Read-it-later social network

init from oauth template

zeu.dev 2c57854d

+631
+6
.env.example
··· 1 + # Replace with your DB credentials! 2 + DATABASE_URL="postgres://user:password@host:port/db-name" 3 + 4 + # Generated using `openssl rand -base64 32` 5 + # Used to encrypt the DID stored in the cookies 6 + ENCRYPTION_PASSWORD=<generated base 64 string here>
+18
.gitignore
··· 1 + node_modules 2 + 3 + # Output 4 + .output 5 + .vercel 6 + /.svelte-kit 7 + /build 8 + 9 + # OS 10 + .DS_Store 11 + Thumbs.db 12 + 13 + # Env 14 + .env 15 + 16 + # Vite 17 + vite.config.js.timestamp-* 18 + vite.config.ts.timestamp-*
+1
.npmrc
··· 1 + engine-strict=true
+6
README.md
··· 1 + # potatonet 2 + 3 + Get started at [potatonet.app](https://potatonet.app) 🥔 4 + 5 + > Special thanks to [pilcrowonpaper](https://pilcrowonpaper.com) for `@oslojs/encoding` library and the 6 + [encryption gist](https://gist.github.com/pilcrowonpaper/353318556029221c8e25f451b91e5f76) that the `encryption.ts` file is based on.
bun.lockb

This is a binary file and will not be displayed.

+14
drizzle.config.ts
··· 1 + import { defineConfig } from 'drizzle-kit'; 2 + if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set'); 3 + 4 + export default defineConfig({ 5 + schema: './src/lib/server/db/schema.ts', 6 + 7 + dbCredentials: { 8 + url: process.env.DATABASE_URL 9 + }, 10 + 11 + verbose: true, 12 + strict: true, 13 + dialect: 'postgresql' 14 + });
+11
drizzle/0000_slippery_sleepwalker.sql
··· 1 + CREATE TABLE "auth_session" ( 2 + "key" text PRIMARY KEY NOT NULL, 3 + "session" json NOT NULL, 4 + CONSTRAINT "auth_session_key_unique" UNIQUE("key") 5 + ); 6 + --> statement-breakpoint 7 + CREATE TABLE "auth_state" ( 8 + "key" text PRIMARY KEY NOT NULL, 9 + "state" json NOT NULL, 10 + CONSTRAINT "auth_state_key_unique" UNIQUE("key") 11 + );
+85
drizzle/meta/0000_snapshot.json
··· 1 + { 2 + "id": "77f27ea2-aa7a-4cfd-80b0-ecb2ea5647b1", 3 + "prevId": "00000000-0000-0000-0000-000000000000", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.auth_session": { 8 + "name": "auth_session", 9 + "schema": "", 10 + "columns": { 11 + "key": { 12 + "name": "key", 13 + "type": "text", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "session": { 18 + "name": "session", 19 + "type": "json", 20 + "primaryKey": false, 21 + "notNull": true 22 + } 23 + }, 24 + "indexes": {}, 25 + "foreignKeys": {}, 26 + "compositePrimaryKeys": {}, 27 + "uniqueConstraints": { 28 + "auth_session_key_unique": { 29 + "name": "auth_session_key_unique", 30 + "nullsNotDistinct": false, 31 + "columns": [ 32 + "key" 33 + ] 34 + } 35 + }, 36 + "policies": {}, 37 + "checkConstraints": {}, 38 + "isRLSEnabled": false 39 + }, 40 + "public.auth_state": { 41 + "name": "auth_state", 42 + "schema": "", 43 + "columns": { 44 + "key": { 45 + "name": "key", 46 + "type": "text", 47 + "primaryKey": true, 48 + "notNull": true 49 + }, 50 + "state": { 51 + "name": "state", 52 + "type": "json", 53 + "primaryKey": false, 54 + "notNull": true 55 + } 56 + }, 57 + "indexes": {}, 58 + "foreignKeys": {}, 59 + "compositePrimaryKeys": {}, 60 + "uniqueConstraints": { 61 + "auth_state_key_unique": { 62 + "name": "auth_state_key_unique", 63 + "nullsNotDistinct": false, 64 + "columns": [ 65 + "key" 66 + ] 67 + } 68 + }, 69 + "policies": {}, 70 + "checkConstraints": {}, 71 + "isRLSEnabled": false 72 + } 73 + }, 74 + "enums": {}, 75 + "schemas": {}, 76 + "sequences": {}, 77 + "roles": {}, 78 + "policies": {}, 79 + "views": {}, 80 + "_meta": { 81 + "columns": {}, 82 + "schemas": {}, 83 + "tables": {} 84 + } 85 + }
+13
drizzle/meta/_journal.json
··· 1 + { 2 + "version": "7", 3 + "dialect": "postgresql", 4 + "entries": [ 5 + { 6 + "idx": 0, 7 + "version": "7", 8 + "when": 1758175146365, 9 + "tag": "0000_slippery_sleepwalker", 10 + "breakpoints": true 11 + } 12 + ] 13 + }
+36
package.json
··· 1 + { 2 + "name": "atproto-oauth-demo", 3 + "version": "0.0.1", 4 + "type": "module", 5 + "scripts": { 6 + "dev": "vite dev", 7 + "build": "vite build", 8 + "preview": "vite preview", 9 + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 11 + "db:push": "drizzle-kit push", 12 + "db:migrate": "drizzle-kit migrate", 13 + "db:studio": "drizzle-kit studio" 14 + }, 15 + "devDependencies": { 16 + "@sveltejs/adapter-auto": "^6.1.0", 17 + "@sveltejs/kit": "^2.42.1", 18 + "@sveltejs/vite-plugin-svelte": "^6.2.0", 19 + "@tailwindcss/typography": "^0.5.16", 20 + "autoprefixer": "^10.4.21", 21 + "drizzle-kit": "^0.31.4", 22 + "svelte": "^5.39.2", 23 + "svelte-check": "^4.3.1", 24 + "tailwindcss": "^4.1.13", 25 + "typescript": "^5.9.2", 26 + "vite": "^7.1.6" 27 + }, 28 + "dependencies": { 29 + "@atproto/api": "^0.16.9", 30 + "@atproto/oauth-client-node": "^0.3.8", 31 + "@oslojs/encoding": "^1.1.0", 32 + "@tailwindcss/vite": "^4.1.13", 33 + "drizzle-orm": "^0.44.5", 34 + "postgres": "^3.4.7" 35 + } 36 + }
+1
src/app.css
··· 1 + @import "tailwindcss";
+23
src/app.d.ts
··· 1 + // See https://svelte.dev/docs/kit/types#app.d.ts 2 + 3 + import type { Agent, AtpBaseClient } from "@atproto/api"; 4 + import type { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 5 + 6 + // for information about these interfaces 7 + declare global { 8 + namespace App { 9 + // interface Error {} 10 + 11 + // set on `hooks.server.ts`, available on server functions 12 + interface Locals { 13 + agent: Agent | AtpBaseClient | undefined; 14 + user: ProfileViewDetailed | undefined; 15 + } 16 + 17 + // interface PageData {} 18 + // interface PageState {} 19 + // interface Platform {} 20 + } 21 + } 22 + 23 + export {};
+12
src/app.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <link rel="icon" href="%sveltekit.assets%/favicon.png" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 7 + %sveltekit.head% 8 + </head> 9 + <body data-sveltekit-preload-data="hover"> 10 + <div style="display: contents">%sveltekit.body%</div> 11 + </body> 12 + </html>
+44
src/hooks.server.ts
··· 1 + import { atclient } from "$lib/atproto"; 2 + import { AtpBaseClient, Agent } from "@atproto/api"; 3 + 4 + import { decryptToString } from "$lib/server/encryption"; 5 + import { decodeBase64, decodeBase64urlIgnorePadding } from "@oslojs/encoding"; 6 + 7 + import type { Handle } from "@sveltejs/kit"; 8 + import { ENCRYPTION_PASSWORD } from "$env/static/private"; 9 + 10 + // runs everytime there's a new request 11 + export const handle: Handle = async ({ event, resolve }) => { 12 + const sid = event.cookies.get("sid"); 13 + 14 + // if there is a session cookie 15 + if (sid) { 16 + // if a user is already authed, skip reauthing 17 + if (event.locals.user) { return resolve(event); } 18 + 19 + // decrypt session cookie 20 + const decoded = decodeBase64urlIgnorePadding(sid); 21 + const key = decodeBase64(ENCRYPTION_PASSWORD); 22 + const decrypted = await decryptToString(key, decoded); 23 + 24 + // get oauth session from client using decrypted cookie 25 + const oauthSession = await atclient.restore(decrypted); 26 + 27 + // set the authed agent 28 + const agent = new Agent(oauthSession); 29 + event.locals.agent = agent; 30 + 31 + // set the authed user with decrypted session DID 32 + const user = await agent.getProfile({ actor: decrypted }); 33 + event.locals.user = user.data; 34 + } 35 + else { 36 + // set public API agent 37 + const agent = new AtpBaseClient({ 38 + service: "https://slingshot.microcosm.blue" 39 + }); 40 + event.locals.agent = agent; 41 + } 42 + 43 + return resolve(event); 44 + }
+30
src/lib/atproto.ts
··· 1 + import { db } from "./server/db"; 2 + import { NodeOAuthClient } from "@atproto/oauth-client-node"; 3 + import { AuthSessionStore, AuthStateStore } from "./stores"; 4 + 5 + import { dev } from "$app/environment"; 6 + 7 + const publicUrl = "https://potatonet.app" 8 + // localhost resolves to either 127.0.0.1 or [::1] (if ipv6) 9 + const url = dev ? "http://[::1]:5173" : publicUrl; 10 + 11 + export const atclient = new NodeOAuthClient({ 12 + stateStore: new AuthStateStore(db), 13 + sessionStore: new AuthSessionStore(db), 14 + clientMetadata: { 15 + client_name: "potatonet-app", 16 + client_id: !dev ? `${publicUrl}/client-metadata.json` 17 + : `http://localhost?redirect_uri=${ 18 + encodeURIComponent(`${url}/oauth/callback`) 19 + }&scope=${ 20 + encodeURIComponent(`atproto transition:generic`) 21 + }`, 22 + client_uri: url, 23 + redirect_uris: [`${url}/oauth/callback`], 24 + scope: "atproto transition:generic", 25 + grant_types: ["authorization_code", "refresh_token"], 26 + application_type: "web", 27 + token_endpoint_auth_method: "none", 28 + dpop_bound_access_tokens: true 29 + } 30 + });
+10
src/lib/server/db/index.ts
··· 1 + import { drizzle } from 'drizzle-orm/postgres-js'; 2 + import postgres from 'postgres'; 3 + import { env } from '$env/dynamic/private'; 4 + import * as schema from "./schema"; 5 + 6 + if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set'); 7 + const client = postgres(env.DATABASE_URL); 8 + 9 + // add schema 10 + export const db = drizzle(client, { schema });
+11
src/lib/server/db/schema.ts
··· 1 + import { pgTable, text, json } from 'drizzle-orm/pg-core'; 2 + 3 + export const AuthState = pgTable('auth_state', { 4 + key: text('key').primaryKey().unique(), 5 + state: json('state').notNull() 6 + }); 7 + 8 + export const AuthSession = pgTable('auth_session', { 9 + key: text('key').primaryKey().unique(), 10 + session: json('session').notNull() 11 + });
+49
src/lib/server/encryption.ts
··· 1 + // Code by @pilcrowonpaper on GitHub: https://gist.github.com/pilcrowonpaper/353318556029221c8e25f451b91e5f76 2 + // AES128 with the Web Crypto API. 3 + async function encrypt(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> { 4 + const iv = new Uint8Array(16); 5 + crypto.getRandomValues(iv); 6 + const cryptoKey = await crypto.subtle.importKey("raw", key, "AES-GCM", false, ["encrypt"]); 7 + const cipher = await crypto.subtle.encrypt( 8 + { 9 + name: "AES-GCM", 10 + iv, 11 + tagLength: 128 12 + }, 13 + cryptoKey, 14 + data 15 + ); 16 + const encrypted = new Uint8Array(iv.byteLength + cipher.byteLength); 17 + encrypted.set(iv); 18 + encrypted.set(new Uint8Array(cipher), iv.byteLength); 19 + return encrypted; 20 + } 21 + 22 + export async function encryptString(key: Uint8Array, data: string): Promise<Uint8Array> { 23 + const encoded = new TextEncoder().encode(data); 24 + const encrypted = await encrypt(key, encoded); 25 + return encrypted; 26 + } 27 + 28 + async function decrypt(key: Uint8Array, encrypted: Uint8Array): Promise<Uint8Array> { 29 + if (encrypted.length < 16) { 30 + throw new Error("Invalid data"); 31 + } 32 + const cryptoKey = await crypto.subtle.importKey("raw", key, "AES-GCM", false, ["decrypt"]); 33 + const decrypted = await crypto.subtle.decrypt( 34 + { 35 + name: "AES-GCM", 36 + iv: encrypted.slice(0, 16), 37 + tagLength: 128 38 + }, 39 + cryptoKey, 40 + encrypted.slice(16) 41 + ); 42 + return new Uint8Array(decrypted); 43 + } 44 + 45 + export async function decryptToString(key: Uint8Array, data: Uint8Array): Promise<string> { 46 + const decrypted = await decrypt(key, data); 47 + const decoded = new TextDecoder().decode(decrypted); 48 + return decoded; 49 + }
+63
src/lib/stores.ts
··· 1 + import { eq } from "drizzle-orm"; 2 + import { db as database } from "./server/db"; 3 + import * as schema from "./server/db/schema"; 4 + import type { NodeSavedSession, NodeSavedSessionStore, NodeSavedState, NodeSavedStateStore } from "@atproto/oauth-client-node"; 5 + 6 + // can be implemented with your preferred DB and ORM 7 + // both stores are the same, only different is 'state' and 'session' 8 + 9 + export class AuthStateStore implements NodeSavedStateStore { 10 + constructor(private db: typeof database) {} 11 + 12 + async get(key: string): Promise<NodeSavedState | undefined> { 13 + const result = await this.db.query.AuthState.findFirst({ 14 + where: eq(schema.AuthState.key, key) 15 + }); 16 + 17 + if (!result) return; 18 + 19 + return result.state as NodeSavedState; 20 + } 21 + 22 + async set(key: string, val: NodeSavedState) { 23 + await this.db.insert(schema.AuthState) 24 + .values({ key, state: val }) 25 + .onConflictDoUpdate({ 26 + target: schema.AuthState.key, 27 + set: { state: val } 28 + }); 29 + } 30 + 31 + async del(key: string) { 32 + await this.db.delete(schema.AuthState) 33 + .where(eq(schema.AuthState.key, key)); 34 + } 35 + } 36 + 37 + export class AuthSessionStore implements NodeSavedSessionStore { 38 + constructor(private db: typeof database) {} 39 + 40 + async get(key: string): Promise<NodeSavedSession | undefined> { 41 + const result = await this.db.query.AuthSession.findFirst({ 42 + where: eq(schema.AuthSession.key, key) 43 + }); 44 + 45 + if (!result) return; 46 + return result.session as NodeSavedSession; 47 + } 48 + 49 + async set(key: string, val: NodeSavedSession) { 50 + await this.db.insert(schema.AuthSession) 51 + .values({ key, session: val }) 52 + .onConflictDoUpdate({ 53 + target: schema.AuthSession.key, 54 + set: { session: val } 55 + }); 56 + } 57 + 58 + async del(key: string) { 59 + await this.db.delete(schema.AuthSession) 60 + .where(eq(schema.AuthSession.key, key)); 61 + } 62 + } 63 +
+6
src/routes/+layout.server.ts
··· 1 + import type { ServerLoadEvent } from "@sveltejs/kit"; 2 + 3 + export async function load({ locals }: ServerLoadEvent) { 4 + // have user available throughout the app via LayoutData 5 + return { user: locals.user }; 6 + }
+6
src/routes/+layout.svelte
··· 1 + <script lang="ts"> 2 + import '../app.css'; 3 + let { children } = $props(); 4 + </script> 5 + 6 + {@render children()}
+32
src/routes/+page.server.ts
··· 1 + import { atclient } from "$lib/atproto"; 2 + import { isValidHandle } from "@atproto/syntax"; 3 + import { error, redirect, type Actions } from "@sveltejs/kit"; 4 + 5 + export const actions: Actions = { 6 + login: async ({ request }) => { 7 + // get handle from form 8 + const formData = await request.formData(); 9 + const handle = formData.get("handle") as string; 10 + 11 + // validate handle using ATProto SDK 12 + if (!isValidHandle(handle)) { 13 + error(400, { message: "Invalid handle" }); 14 + } 15 + 16 + // get oauth authorizing url to redirect to 17 + const redirectUrl = await atclient.authorize(handle, { 18 + scope: "atproto transition:generic" 19 + }); 20 + 21 + if (!redirectUrl) { 22 + error(500, { message: "Unable to authorize" }); 23 + } 24 + 25 + // redirect for user to authorize 26 + redirect(301, redirectUrl.toString()); 27 + }, 28 + logout: async ({ cookies }) => { 29 + cookies.delete("sid", { path: "/" }); 30 + redirect(301, "/"); 31 + } 32 + };
+58
src/routes/+page.svelte
··· 1 + <script lang="ts"> 2 + let { data } = $props(); 3 + const user = data.user; 4 + </script> 5 + 6 + <main class="flex flex-col gap-4 p-8"> 7 + 8 + <h1 class="text-3xl font-black">potatonet.app</h1> 9 + <h2 class="text-xl font-semibold"> 10 + Implemented by 11 + <a href="https://zeu.dev" class="underline">zeu.dev</a> 12 + 💛 13 + </h2> 14 + 15 + <div class="flex gap-4"> 16 + <a href="https://tangled.sh/@zeu.dev/potatonet-app" class="flex gap-2 rounded border border-black px-4 py-2 w-fit"> 17 + 🧶 Tangled Repo 18 + </a> 19 + <a href="https://bsky.app/profile/zeu.dev" class="flex gap-2 rounded border border-black px-4 py-2 w-fit"> 20 + 🦋 Bluesky 21 + </a> 22 + </div> 23 + 24 + <div class="border border-black p-4 rounded shadow-md flex flex-col gap-4"> 25 + {#if user} 26 + <section class="border border-black flex gap-4 items-center px-4 py-2 rounded shadow-md"> 27 + <img src={user.avatar} alt={`${user.handle} avatar`} class="size-20 rounded" /> 28 + <div class="flex flex-col"> 29 + <p>Logged in as:</p> 30 + <h1>Handle: <span class="font-bold">{user.handle}</span></h1> 31 + <h2>Display Name: <span class="font-bold">{user.displayName}</span></h2> 32 + <h3 class="italic">{user.did}</h3> 33 + </div> 34 + </section> 35 + 36 + <form action="/?/logout" method="POST"> 37 + <button type="submit" class="border border-black rounded px-2 py-1"> 38 + Logout 39 + </button> 40 + </form> 41 + {:else} 42 + <p>You're not logged in</p> 43 + 44 + <form action="/?/login" method="POST"> 45 + <input 46 + name="handle" 47 + type="text" 48 + placeholder="Handle (eg: zeu.dev)" 49 + class="border border-black rounded px-2 py-1" 50 + /> 51 + <button type="submit" class="border border-black rounded px-2 py-1"> 52 + Login 53 + </button> 54 + </form> 55 + {/if} 56 + </div> 57 + 58 + </main>
+6
src/routes/client-metadata.json/+server.ts
··· 1 + import { atclient } from "$lib/atproto"; 2 + import { json } from "@sveltejs/kit"; 3 + 4 + export async function GET() { 5 + return json(atclient.clientMetadata); 6 + }
+34
src/routes/oauth/callback/+server.ts
··· 1 + import { atclient } from "$lib/atproto"; 2 + import { encryptString } from "$lib/server/encryption"; 3 + import { decodeBase64, encodeBase64urlNoPadding } from "@oslojs/encoding"; 4 + 5 + import { error, redirect } from "@sveltejs/kit"; 6 + import type { RequestEvent } from "@sveltejs/kit"; 7 + import { ENCRYPTION_PASSWORD } from "$env/static/private"; 8 + 9 + // called on after authorizing OAuth 10 + export async function GET({ request, cookies }: RequestEvent) { 11 + // get parameters set by the callback 12 + const params = new URLSearchParams(request.url.split("?")[1]); 13 + 14 + try { 15 + const { session } = await atclient.callback(params); 16 + const key = decodeBase64(ENCRYPTION_PASSWORD); 17 + 18 + // encrypt the user DID 19 + const encrypted = await encryptString(key, session.did); 20 + const encoded = encodeBase64urlNoPadding(encrypted); 21 + 22 + // set encoded session DID as cookies for auth 23 + cookies.set("sid", encoded, { 24 + path: "/", 25 + maxAge: 60 * 60, 26 + httpOnly: true, 27 + sameSite: "lax" 28 + }); 29 + } catch (err) { 30 + error(500, { message: (err as Error).message }); 31 + } 32 + 33 + redirect(301, "/"); 34 + }
static/favicon.png

This is a binary file and will not be displayed.

+18
svelte.config.js
··· 1 + import adapter from '@sveltejs/adapter-auto'; 2 + import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 + 4 + /** @type {import('@sveltejs/kit').Config} */ 5 + const config = { 6 + // Consult https://svelte.dev/docs/kit/integrations 7 + // for more information about preprocessors 8 + preprocess: vitePreprocess(), 9 + 10 + kit: { 11 + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. 12 + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 13 + // See https://svelte.dev/docs/kit/adapters for more information about adapters. 14 + adapter: adapter() 15 + } 16 + }; 17 + 18 + export default config;
+12
tailwind.config.ts
··· 1 + import typography from "@tailwindcss/typography"; 2 + import type { Config } from 'tailwindcss'; 3 + 4 + export default { 5 + content: ['./src/**/*.{html,js,svelte,ts}'], 6 + 7 + theme: { 8 + extend: {} 9 + }, 10 + 11 + plugins: [typography] 12 + } satisfies Config;
+19
tsconfig.json
··· 1 + { 2 + "extends": "./.svelte-kit/tsconfig.json", 3 + "compilerOptions": { 4 + "allowJs": true, 5 + "checkJs": true, 6 + "esModuleInterop": true, 7 + "forceConsistentCasingInFileNames": true, 8 + "resolveJsonModule": true, 9 + "skipLibCheck": true, 10 + "sourceMap": true, 11 + "strict": true, 12 + "moduleResolution": "bundler" 13 + } 14 + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias 15 + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files 16 + // 17 + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 18 + // from the referenced tsconfig.json - TypeScript does not merge them in 19 + }
+7
vite.config.ts
··· 1 + import { sveltekit } from '@sveltejs/kit/vite'; 2 + import tailwindcss from '@tailwindcss/vite'; 3 + import { defineConfig } from 'vite'; 4 + 5 + export default defineConfig({ 6 + plugins: [sveltekit(), tailwindcss()] 7 + });