🌿 Collaborative wiki on ATProto
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Refactor and extract functions

juprodh 704dfec7 456e92fc

+100 -98
+1
src/atproto/routes.ts
··· 132 132 const message = err instanceof Error ? err.message : String(err); 133 133 return new Response(`OAuth callback failed: ${message}`, { 134 134 status: 500, 135 + headers: { "Content-Type": "text/plain" }, 135 136 }); 136 137 } 137 138 })
-16
src/atproto/session.ts
··· 53 53 return { did, handle: account.handle }; 54 54 } 55 55 56 - export function createAgent(session: Session): Agent { 57 - if (session.oauthSession) { 58 - return new Agent(session.oauthSession); 59 - } 60 - 61 - // Dev-mode: create agent via app password login 62 - const devPdsUrl = getDevPdsUrl(); 63 - if (!devPdsUrl) { 64 - throw new Error("Cannot create agent: no OAuth session and no DEV_PDS_URL"); 65 - } 66 - 67 - const agent = new Agent(devPdsUrl); 68 - // The agent needs to be logged in before use. Callers should await loginDevAgent(). 69 - return agent; 70 - } 71 - 72 56 /** 73 57 * Get a fully authenticated agent for the session. 74 58 * In production: wraps the OAuth session.
+4 -5
src/firehose/index.ts
··· 1 1 import "../lib/ws-polyfill.ts"; 2 2 3 - import { IdResolver } from "@atproto/identity"; 4 3 import { type Event, Firehose, MemoryRunner } from "@atproto/sync"; 5 - import { getDevPdsUrl, getDevPlcUrl, getRelayUrl } from "../atproto/env.ts"; 4 + import { getDevPdsUrl, getRelayUrl } from "../atproto/env.ts"; 6 5 import { COLLECTIONS } from "../lib/constants.ts"; 6 + import { getIdResolver } from "../lib/identity.ts"; 7 7 import { getCursor, setCursor } from "../server/db/queries/index.ts"; 8 8 import { handleCommitEvent } from "./handlers.ts"; 9 9 10 10 const relayUrl = getRelayUrl(); 11 - // In dev mode the PDS is ephemeral — each run starts fresh from seq 0. 11 + // In dev mode the PDS is ephemeral -- each run starts fresh from seq 0. 12 12 // Persisting the cursor across sessions causes "FutureCursor" errors. 13 13 const isDevMode = !!getDevPdsUrl(); 14 14 const savedCursor = isDevMode ? null : getCursor(); ··· 18 18 console.log(`Resuming from cursor ${savedCursor}`); 19 19 } 20 20 21 - const plcUrl = getDevPlcUrl(); 22 - const idResolver = new IdResolver(plcUrl ? { plcUrl } : {}); 21 + const idResolver = getIdResolver(); 23 22 24 23 const runner = new MemoryRunner({ 25 24 ...(savedCursor != null && { startCursor: savedCursor }),
+2 -7
src/lib/access.ts
··· 86 86 if (!ctx.wiki) { 87 87 throw new NotFoundError("Wiki not found"); 88 88 } 89 - if (requiredAccess === "admin" && !canManage(ctx.access)) { 90 - throw new ForbiddenError(); 91 - } 92 - if (requiredAccess === "edit" && !canEdit(ctx.access)) { 93 - throw new ForbiddenError(); 94 - } 95 - if (requiredAccess === "read" && !canRead(ctx.access)) { 89 + const check = { read: canRead, edit: canEdit, admin: canManage }; 90 + if (!check[requiredAccess](ctx.access)) { 96 91 throw new ForbiddenError(); 97 92 } 98 93 return ctx as WikiRequestContext;
+15
src/lib/constants.ts
··· 21 21 22 22 export const EDITOR_SCRIPTS = ["/public/editor/dist.js"]; 23 23 24 + export const MIME_TO_EXT: Record<string, string> = { 25 + "image/jpeg": "jpg", 26 + "image/png": "png", 27 + "image/gif": "gif", 28 + "image/webp": "webp", 29 + }; 30 + 31 + export const IMAGE_EXTENSIONS = new Set([ 32 + ".jpg", 33 + ".jpeg", 34 + ".png", 35 + ".gif", 36 + ".webp", 37 + ]); 38 + 24 39 export const WIKI_LANGUAGES = [ 25 40 { code: "en", label: "English" }, 26 41 { code: "fr", label: "Francais" },
+6 -1
src/lib/html.ts
··· 3 3 .replace(/&/g, "&amp;") 4 4 .replace(/</g, "&lt;") 5 5 .replace(/>/g, "&gt;") 6 - .replace(/"/g, "&quot;"); 6 + .replace(/"/g, "&quot;") 7 + .replace(/'/g, "&#39;"); 8 + } 9 + 10 + export function escapeLikePattern(query: string): string { 11 + return query.replace(/[%_\\]/g, (c) => `\\${c}`); 7 12 }
+1
src/lib/i18n/en.ts
··· 101 101 noMembers: "No members yet.", 102 102 noRequests: "No pending requests.", 103 103 cannotRemoveOwner: "Cannot remove the wiki owner.", 104 + cannotChangeOwnerRole: "Cannot change the wiki owner's role.", 104 105 roleAdmin: "Admin", 105 106 roleContributor: "Contributor", 106 107 roleViewer: "Viewer",
+2
src/lib/i18n/fr.ts
··· 102 102 noMembers: "Aucun membre pour le moment.", 103 103 noRequests: "Aucune demande en attente.", 104 104 cannotRemoveOwner: "Impossible de supprimer le propriétaire du wiki.", 105 + cannotChangeOwnerRole: 106 + "Impossible de changer le rôle du propriétaire du wiki.", 105 107 roleAdmin: "Administrateur", 106 108 roleContributor: "Contributeur", 107 109 roleViewer: "Lecteur",
+9 -1
src/lib/i18n/index.ts
··· 101 101 noMembers: string; 102 102 noRequests: string; 103 103 cannotRemoveOwner: string; 104 + cannotChangeOwnerRole: string; 104 105 roleAdmin: string; 105 106 roleContributor: string; 106 107 roleViewer: string; ··· 175 176 } 176 177 177 178 export function fmt(template: string, vars: Record<string, string>): string { 178 - return template.replace(/\{(\w+)\}/g, (_, key) => vars[key] ?? ""); 179 + return template.replace(/\{(\w+)\}/g, (_, key) => { 180 + if (!(key in vars)) { 181 + console.warn( 182 + `i18n: missing variable "{${key}}" in template "${template}"`, 183 + ); 184 + } 185 + return vars[key] ?? ""; 186 + }); 179 187 } 180 188 181 189 export function resolveLocale(
+12
src/lib/identity.ts
··· 1 + import { IdResolver } from "@atproto/identity"; 2 + import { getDevPlcUrl } from "../atproto/env.ts"; 3 + 4 + let instance: IdResolver | null = null; 5 + 6 + export function getIdResolver(): IdResolver { 7 + if (!instance) { 8 + const plcUrl = getDevPlcUrl(); 9 + instance = new IdResolver(plcUrl ? { plcUrl } : {}); 10 + } 11 + return instance; 12 + }
+3 -11
src/lib/import-export/export.ts
··· 1 - import { IdResolver } from "@atproto/identity"; 2 1 import { zipSync } from "fflate"; 3 2 import { getBlobsByCids } from "../../server/db/queries/blob.ts"; 4 3 import { getCurrentNote, listNotes } from "../../server/db/queries/index.ts"; 4 + import { MIME_TO_EXT } from "../constants.ts"; 5 + import { getIdResolver } from "../identity.ts"; 5 6 import { rewriteForExport } from "./markdown-transform.ts"; 6 - 7 - const idResolver = new IdResolver(); 8 - 9 - const MIME_TO_EXT: Record<string, string> = { 10 - "image/jpeg": "jpg", 11 - "image/png": "png", 12 - "image/gif": "gif", 13 - "image/webp": "webp", 14 - }; 15 7 16 8 const INVALID_FILENAME_CHARS = /[/\\:*?"<>|]/g; 17 9 ··· 110 102 did: string, 111 103 cid: string, 112 104 ): Promise<Uint8Array | null> { 113 - const didDoc = await idResolver.did.resolve(did); 105 + const didDoc = await getIdResolver().did.resolve(did); 114 106 if (!didDoc) return null; 115 107 116 108 const service = didDoc.service?.find(
+1 -2
src/lib/import-export/markdown-transform.ts
··· 1 1 import { basename } from "node:path"; 2 + import { IMAGE_EXTENSIONS } from "../constants.ts"; 2 3 3 4 interface ExportResult { 4 5 content: string; ··· 141 142 s.startsWith("http://") || s.startsWith("https://") || s.startsWith("//") 142 143 ); 143 144 } 144 - 145 - const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]); 146 145 147 146 function isImageFilename(name: string): boolean { 148 147 const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
+1 -1
src/lib/import-export/zip-parse.ts
··· 1 1 import { basename } from "node:path"; 2 2 import { unzipSync } from "fflate"; 3 + import { IMAGE_EXTENSIONS } from "../constants.ts"; 3 4 import { ImportError } from "../errors.ts"; 4 5 import { isValidSlug, slugify } from "../slug.ts"; 5 6 import { extractLocalImageRefs } from "./markdown-transform.ts"; ··· 9 10 const MAX_ZIP_ENTRIES = 500; 10 11 const MAX_MD_FILES = 100; 11 12 12 - const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]); 13 13 const MIME_BY_EXT: Record<string, string> = { 14 14 ".jpg": "image/jpeg", 15 15 ".jpeg": "image/jpeg",
+8 -5
src/lib/orchestrators/membership.ts
··· 16 16 import type { MemberRole } from "../constants.ts"; 17 17 import { COLLECTIONS } from "../constants.ts"; 18 18 import { ForbiddenError, NotFoundError, ValidationError } from "../errors.ts"; 19 + import { t } from "../i18n/index.ts"; 19 20 import { currentTimestamp, generateTid } from "../tid.ts"; 20 21 import { withPdsError } from "./helpers.ts"; 21 22 ··· 93 94 memberDid: string, 94 95 newRole: MemberRole, 95 96 ): Promise<void> { 97 + const msg = t(ctx.locale); 96 98 if (memberDid === ctx.wiki.did) { 97 - throw new ValidationError("Cannot change the wiki owner's role"); 99 + throw new ValidationError(msg.access.cannotChangeOwnerRole); 98 100 } 99 101 100 102 const existing = getMembership(ctx.wiki.slug, memberDid); ··· 107 109 108 110 if (ctx.session) { 109 111 const session = ctx.session; 112 + const agent = await getAgent(session); 113 + 110 114 // Best-effort: delete old PDS record if we own it 111 115 const parsed = parseAtUri(existing.at_uri); 112 116 if (parsed && parsed.did === session.did) { 113 117 try { 114 - const agent = await getAgent(session); 115 118 await deleteRecord( 116 119 agent, 117 120 parsed.did, ··· 119 122 parsed.rkey, 120 123 ); 121 124 } catch { 122 - // ignore — old record stays on PDS but DB is authoritative 125 + // ignore -- old record stays on PDS but DB is authoritative 123 126 } 124 127 } 125 128 126 129 await withPdsError("change member role", async () => { 127 - const agent = await getAgent(session); 128 130 await writeMembershipRecord( 129 131 agent, 130 132 session.did, ··· 189 191 ctx: WikiRequestContext, 190 192 memberDid: string, 191 193 ): Promise<void> { 194 + const msg = t(ctx.locale); 192 195 if (memberDid === ctx.wiki.did) { 193 - throw new ValidationError("Cannot remove wiki owner"); 196 + throw new ValidationError(msg.access.cannotRemoveOwner); 194 197 } 195 198 196 199 const atUri = deleteMembership(ctx.wiki.slug, memberDid);
+16 -16
src/lib/orchestrators/wiki.ts
··· 19 19 import { COLLECTIONS } from "../constants.ts"; 20 20 import { createDiff } from "../diff.ts"; 21 21 import { ForbiddenError, ValidationError } from "../errors.ts"; 22 - import type { Messages } from "../i18n/index.ts"; 23 - import { fmt } from "../i18n/index.ts"; 22 + import { fmt, type Messages, t } from "../i18n/index.ts"; 24 23 import { isValidSlug, slugify } from "../slug.ts"; 25 24 import { currentTimestamp, generateTid } from "../tid.ts"; 26 25 import { withPdsError } from "./helpers.ts"; ··· 96 95 }); 97 96 } 98 97 99 - upsertWiki( 100 - slug, 101 - did, 102 - fields.name, 103 - validVisibility, 104 - atUri, 105 - now, 106 - fields.language, 107 - description, 108 - ); 109 - 110 - // Auto-admin: make creator an admin member 111 98 const membershipTid = generateTid(); 112 99 const membershipAtUri = `at://${did}/wiki.lichen.membership/${membershipTid}`; 113 - upsertMembership(slug, did, "admin", membershipAtUri, now); 114 100 115 101 if (agent) { 116 102 await withPdsError("create wiki membership", async () => { ··· 126 112 }); 127 113 } 128 114 115 + // DB writes after all PDS writes succeed 116 + upsertWiki( 117 + slug, 118 + did, 119 + fields.name, 120 + validVisibility, 121 + atUri, 122 + now, 123 + fields.language, 124 + description, 125 + ); 126 + upsertMembership(slug, did, "admin", membershipAtUri, now); 127 + 129 128 return { wikiSlug: slug, wikiAtUri: atUri, agent, did, now }; 130 129 } 131 130 ··· 197 196 */ 198 197 export async function deleteWikiAction(ctx: WikiRequestContext): Promise<void> { 199 198 if (ctx.effectiveDid !== ctx.wiki.did) { 200 - throw new ForbiddenError("Only the wiki owner can delete this wiki"); 199 + const msg = t(ctx.locale); 200 + throw new ForbiddenError(msg.settings.deleteWikiOwnerOnly); 201 201 } 202 202 203 203 if (ctx.session) {
+1 -8
src/lib/profile.ts
··· 1 - import { IdResolver } from "@atproto/identity"; 2 1 import { 3 2 getCachedProfile, 4 3 setCachedProfile, 5 4 } from "../server/db/queries/index.ts"; 5 + import { getIdResolver } from "./identity.ts"; 6 6 7 7 export interface ProfileInfo { 8 8 handle: string | null; 9 9 displayName: string | null; 10 10 avatar: string | null; 11 - } 12 - 13 - let idResolver: IdResolver | null = null; 14 - 15 - function getIdResolver(): IdResolver { 16 - if (!idResolver) idResolver = new IdResolver(); 17 - return idResolver; 18 11 } 19 12 20 13 /**
+3 -2
src/server/db/queries/note.ts
··· 1 1 import { createDiff } from "../../../lib/diff.ts"; 2 + import { escapeLikePattern } from "../../../lib/html.ts"; 2 3 import { getDb } from "../index.ts"; 3 4 import type { CurrentNoteRow, NoteRow } from "../types.ts"; 4 5 import { appendRevisionTx, capSnapshots } from "./revision.ts"; ··· 46 47 wikiSlug?: string, 47 48 ): NoteSearchResult[] { 48 49 const db = getDb(); 49 - const pattern = `%${query}%`; 50 + const pattern = `%${escapeLikePattern(query)}%`; 50 51 const wikiFilter = wikiSlug ? "n.wiki_slug = ? AND" : ""; 51 52 const params = wikiSlug ? [wikiSlug, pattern, pattern] : [pattern, pattern]; 52 53 const rows = db ··· 55 56 FROM notes n 56 57 JOIN wikis w ON w.slug = n.wiki_slug 57 58 LEFT JOIN current_note c ON c.note_at_uri = n.at_uri 58 - WHERE ${wikiFilter} (n.title LIKE ? OR c.content LIKE ?) 59 + WHERE ${wikiFilter} (n.title LIKE ? ESCAPE '\\' OR c.content LIKE ? ESCAPE '\\') 59 60 ORDER BY n.created_at DESC LIMIT 20`, 60 61 ) 61 62 .all(...params) as (NoteRow & {
+3
src/server/db/queries/revision.ts
··· 57 57 "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", 58 58 [params.noteAtUri, params.revisionAtUri, params.newContent], 59 59 ); 60 + db.run("UPDATE wikis SET updated_at = datetime('now') WHERE slug = ?", [ 61 + params.wikiSlug, 62 + ]); 60 63 if (params.blobs) { 61 64 for (const blob of params.blobs) { 62 65 db.run(
+5 -2
src/server/db/queries/wiki.ts
··· 1 + import { escapeLikePattern } from "../../../lib/html.ts"; 1 2 import { getDb } from "../index.ts"; 2 3 import type { WikiRow } from "../types.ts"; 3 4 import { deleteNoteByAtUri } from "./note.ts"; ··· 36 37 } 37 38 38 39 if (options.query) { 39 - const pattern = `%${options.query}%`; 40 - conditions.push("(w.name LIKE ? OR w.slug LIKE ? OR w.description LIKE ?)"); 40 + const pattern = `%${escapeLikePattern(options.query)}%`; 41 + conditions.push( 42 + "(w.name LIKE ? ESCAPE '\\' OR w.slug LIKE ? ESCAPE '\\' OR w.description LIKE ? ESCAPE '\\')", 43 + ); 41 44 params.push(pattern, pattern, pattern); 42 45 } 43 46
+5 -13
src/server/routes/blob.ts
··· 1 1 import { existsSync, mkdirSync } from "node:fs"; 2 2 import { join, resolve } from "node:path"; 3 - import { IdResolver } from "@atproto/identity"; 4 3 import { Elysia } from "elysia"; 5 - import { createAgent, getSessionFromRequest } from "../../atproto/session.ts"; 4 + import { getAgent, getSessionFromRequest } from "../../atproto/session.ts"; 5 + import { MIME_TO_EXT } from "../../lib/constants.ts"; 6 6 import { formatError } from "../../lib/errors.ts"; 7 + import { getIdResolver } from "../../lib/identity.ts"; 7 8 import { processImage } from "../../lib/image.ts"; 8 9 9 10 const LOCAL_BLOB_DIR = "data/blobs"; 10 - 11 - const idResolver = new IdResolver(); 12 - 13 - const MIME_TO_EXT: Record<string, string> = { 14 - "image/jpeg": "jpg", 15 - "image/png": "png", 16 - "image/gif": "gif", 17 - "image/webp": "webp", 18 - }; 19 11 20 12 function ensureBlobDir(): void { 21 13 if (!existsSync(LOCAL_BLOB_DIR)) { ··· 52 44 53 45 if (session) { 54 46 // ATProto mode: upload to PDS 55 - const agent = createAgent(session); 47 + const agent = await getAgent(session); 56 48 const response = await agent.com.atproto.repo.uploadBlob(processed.data, { 57 49 encoding: processed.mimeType, 58 50 }); ··· 108 100 109 101 let pdsEndpoint: string; 110 102 try { 111 - const didDoc = await idResolver.did.resolve(did); 103 + const didDoc = await getIdResolver().did.resolve(did); 112 104 if (!didDoc) { 113 105 return new Response("DID not found", { status: 404 }); 114 106 }
-1
src/server/routes/wiki.ts
··· 211 211 wikiPage( 212 212 ctx.wiki.name, 213 213 ctx.wiki.slug, 214 - sidebarNotes, 215 214 { 216 215 session: ctx.session, 217 216 sidebarNotes,
+1 -1
src/views/layout.ts
··· 150 150 <head> 151 151 <meta charset="UTF-8"> 152 152 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 153 - <title>${title} — Lichen</title> 153 + <title>${escapeHtml(title)} — Lichen</title> 154 154 <link rel="stylesheet" href="/public/dist.css"> 155 155 <script src="https://unpkg.com/htmx.org@2.0.4"></script> 156 156 ${extraScripts}
+1 -6
src/views/wiki.ts
··· 3 3 import { type LayoutOptions, layout } from "./layout.ts"; 4 4 import { primarySmallButtonClass, THEME } from "./theme.ts"; 5 5 6 - interface NoteSummary { 7 - slug: string; 8 - title: string; 9 - } 10 - 11 6 export function wikiPage( 12 7 wikiName: string, 13 8 wikiSlug: string, 14 - notes: NoteSummary[], 15 9 options?: LayoutOptions, 16 10 wikiLanguage?: string, 17 11 ): string { 18 12 const locale = options?.locale ?? "en"; 19 13 const msg = t(locale); 14 + const notes = options?.sidebarNotes ?? []; 20 15 21 16 const noteList = notes 22 17 .map(