🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

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

Refactor: extract some functions for DRY + docs

juprodh 31c916bf a066e949

+111 -128
+3 -1
README.md
··· 27 27 28 28 ## License 29 29 30 - [AGPL-3.0](LICENSE) 30 + **Code:** [AGPL-3.0](LICENSE) 31 + 32 + **Wiki content:** User contributions on lichen.wiki are licensed under [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) by default. Contributors retain copyright and grant reuse rights under this license. Private wikis are still covered by CC BY-SA 4.0, but access is restricted to members.
+16
src/lib/access.ts
··· 98 98 return ctx as WikiRequestContext; 99 99 } 100 100 101 + /** 102 + * Like resolveWikiContext but never throws -- returns the full context even when 103 + * access is insufficient. Throws NotFoundError if wiki doesn't exist. 104 + * Used for pages that render a custom access-denied view. 105 + */ 106 + export async function resolveWikiContextSoft( 107 + request: Request, 108 + wikiSlug: string, 109 + ): Promise<WikiRequestContext> { 110 + const ctx = await resolveRequestContext(request, wikiSlug); 111 + if (!ctx.wiki) { 112 + throw new NotFoundError("Wiki not found"); 113 + } 114 + return ctx as WikiRequestContext; 115 + } 116 + 101 117 export async function resolveRequestContext( 102 118 request: Request, 103 119 wikiSlug?: string,
-1
src/server/db/queries/index.ts
··· 43 43 capSnapshots, 44 44 getBacklinks, 45 45 getSnapshots, 46 - updateBacklinks, 47 46 } from "./revision.ts"; 48 47 export { 49 48 deleteWikiByAtUri,
+14 -61
src/server/db/queries/note.ts
··· 1 1 import { createDiff } from "../../../lib/diff.ts"; 2 - import { extractWikilinks } from "../../../lib/markdown.ts"; 3 2 import { getDb } from "../index.ts"; 4 3 import type { CurrentNoteRow, NoteRow } from "../types.ts"; 5 - import { capSnapshots } from "./revision.ts"; 4 + import { appendRevisionTx, capSnapshots } from "./revision.ts"; 6 5 7 6 export interface NoteWithCurrent { 8 7 note: NoteRow; ··· 121 120 } 122 121 123 122 /** 124 - * Append a revision to an existing note (revisions + current_note + backlinks + snapshot). 125 - * Must be called inside an outer db.transaction(). 126 - */ 127 - function appendRevisionTx( 128 - db: ReturnType<typeof getDb>, 129 - noteAtUri: string, 130 - wikiSlug: string, 131 - did: string, 132 - revisionAtUri: string, 133 - parentRevisionUri: string | null, 134 - oldContent: string, 135 - newContent: string, 136 - message: string | undefined, 137 - ): void { 138 - const diff = createDiff(oldContent, newContent); 139 - const slugs = extractWikilinks(newContent); 140 - 141 - db.run( 142 - `INSERT INTO revisions (note_at_uri, did, at_uri, parent_revision_uri, diff, message) 143 - VALUES (?, ?, ?, ?, ?, ?)`, 144 - [noteAtUri, did, revisionAtUri, parentRevisionUri, diff, message ?? null], 145 - ); 146 - db.run( 147 - `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) 148 - VALUES (?, ?, ?, datetime('now')) 149 - ON CONFLICT(note_at_uri) DO UPDATE SET 150 - content = excluded.content, 151 - latest_revision_uri = excluded.latest_revision_uri, 152 - updated_at = excluded.updated_at`, 153 - [noteAtUri, newContent, revisionAtUri], 154 - ); 155 - // Inline backlinks update to stay within the outer transaction 156 - db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [noteAtUri]); 157 - for (const slug of slugs) { 158 - db.run( 159 - "INSERT OR IGNORE INTO backlinks (source_note_uri, target_note_slug, wiki_slug) VALUES (?, ?, ?)", 160 - [noteAtUri, slug, wikiSlug], 161 - ); 162 - } 163 - db.run( 164 - "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", 165 - [noteAtUri, revisionAtUri, newContent], 166 - ); 167 - } 168 - 169 - /** 170 123 * Create a new note with its first revision. 171 124 * The caller is responsible for generating noteAtUri and revisionAtUri (e.g. from PDS write TIDs). 172 125 */ ··· 181 134 message?: string, 182 135 ): void { 183 136 const db = getDb(); 137 + const diff = createDiff("", initialContent); 184 138 185 139 db.transaction(() => { 186 140 db.run( 187 141 "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", 188 142 [noteSlug, wikiSlug, title, did, noteAtUri], 189 143 ); 190 - appendRevisionTx( 191 - db, 144 + appendRevisionTx(db, { 192 145 noteAtUri, 193 146 wikiSlug, 194 147 did, 195 148 revisionAtUri, 196 - null, 197 - "", 198 - initialContent, 199 - message, 200 - ); 149 + parentRevisionUri: null, 150 + diff, 151 + message: message ?? null, 152 + newContent: initialContent, 153 + }); 201 154 })(); 202 155 203 156 capSnapshots(noteAtUri); ··· 234 187 235 188 const oldContent = current?.content ?? ""; 236 189 const parentRevisionUri = current?.latest_revision_uri ?? null; 190 + const diff = createDiff(oldContent, newContent); 237 191 238 192 db.transaction(() => { 239 193 if (newTitle) { ··· 243 197 noteSlug, 244 198 ]); 245 199 } 246 - appendRevisionTx( 247 - db, 248 - note.at_uri, 200 + appendRevisionTx(db, { 201 + noteAtUri: note.at_uri, 249 202 wikiSlug, 250 203 did, 251 204 revisionAtUri, 252 205 parentRevisionUri, 253 - oldContent, 206 + diff, 207 + message: message ?? null, 254 208 newContent, 255 - message, 256 - ); 209 + }); 257 210 })(); 258 211 259 212 capSnapshots(note.at_uri);
+71 -53
src/server/db/queries/revision.ts
··· 3 3 import { getDb } from "../index.ts"; 4 4 import type { BacklinkRow, SnapshotRow } from "../types.ts"; 5 5 6 - export function updateBacklinks( 7 - noteAtUri: string, 8 - wikiSlug: string, 9 - slugs: string[], 6 + interface AppendRevisionParams { 7 + noteAtUri: string; 8 + wikiSlug: string; 9 + did: string; 10 + revisionAtUri: string; 11 + parentRevisionUri: string | null; 12 + diff: string; 13 + message: string | null; 14 + newContent: string; 15 + blobs?: { cid: string; mimeType: string }[]; 16 + } 17 + 18 + /** 19 + * Core revision pipeline: insert revision, upsert current_note, sync backlinks, 20 + * insert snapshot, persist blobs. Must be called inside db.transaction(). 21 + */ 22 + export function appendRevisionTx( 23 + db: ReturnType<typeof getDb>, 24 + params: AppendRevisionParams, 10 25 ): void { 11 - const db = getDb(); 12 - db.transaction(() => { 13 - db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [noteAtUri]); 14 - for (const slug of slugs) { 26 + const slugs = extractWikilinks(params.newContent); 27 + 28 + db.run( 29 + `INSERT INTO revisions (note_at_uri, did, at_uri, parent_revision_uri, diff, message) 30 + VALUES (?, ?, ?, ?, ?, ?)`, 31 + [ 32 + params.noteAtUri, 33 + params.did, 34 + params.revisionAtUri, 35 + params.parentRevisionUri, 36 + params.diff, 37 + params.message, 38 + ], 39 + ); 40 + db.run( 41 + `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) 42 + VALUES (?, ?, ?, datetime('now')) 43 + ON CONFLICT(note_at_uri) DO UPDATE SET 44 + content = excluded.content, 45 + latest_revision_uri = excluded.latest_revision_uri, 46 + updated_at = excluded.updated_at`, 47 + [params.noteAtUri, params.newContent, params.revisionAtUri], 48 + ); 49 + db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [params.noteAtUri]); 50 + for (const slug of slugs) { 51 + db.run( 52 + "INSERT OR IGNORE INTO backlinks (source_note_uri, target_note_slug, wiki_slug) VALUES (?, ?, ?)", 53 + [params.noteAtUri, slug, params.wikiSlug], 54 + ); 55 + } 56 + db.run( 57 + "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", 58 + [params.noteAtUri, params.revisionAtUri, params.newContent], 59 + ); 60 + if (params.blobs) { 61 + for (const blob of params.blobs) { 15 62 db.run( 16 - "INSERT OR IGNORE INTO backlinks (source_note_uri, target_note_slug, wiki_slug) VALUES (?, ?, ?)", 17 - [noteAtUri, slug, wikiSlug], 63 + `INSERT OR IGNORE INTO blobs (cid, revision_at_uri, mime_type, storage_key) 64 + VALUES (?, ?, ?, ?)`, 65 + [blob.cid, params.revisionAtUri, blob.mimeType, blob.cid], 18 66 ); 19 67 } 20 - })(); 68 + } 21 69 } 22 70 23 71 export function getBacklinks( ··· 61 109 revisionAtUri: string, 62 110 parentRevisionUri: string | null, 63 111 diff: string, 64 - diffFormat: string, 112 + _diffFormat: string, 65 113 message: string | null, 66 114 blobs: { cid: string; mimeType: string }[], 67 115 ): void { ··· 73 121 74 122 const oldContent = current?.content ?? ""; 75 123 const newContent = applyDiff(oldContent, diff); 76 - const slugs = extractWikilinks(newContent); 77 124 78 125 db.transaction(() => { 79 - db.run( 80 - `INSERT INTO revisions (note_at_uri, did, at_uri, parent_revision_uri, diff, diff_format, message) 81 - VALUES (?, ?, ?, ?, ?, ?, ?)`, 82 - [ 83 - noteAtUri, 84 - did, 85 - revisionAtUri, 86 - parentRevisionUri, 87 - diff, 88 - diffFormat, 89 - message, 90 - ], 91 - ); 92 - db.run( 93 - `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) 94 - VALUES (?, ?, ?, datetime('now')) 95 - ON CONFLICT(note_at_uri) DO UPDATE SET 96 - content = excluded.content, 97 - latest_revision_uri = excluded.latest_revision_uri, 98 - updated_at = excluded.updated_at`, 99 - [noteAtUri, newContent, revisionAtUri], 100 - ); 101 - db.run( 102 - "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", 103 - [noteAtUri, revisionAtUri, newContent], 104 - ); 105 - db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [noteAtUri]); 106 - for (const slug of slugs) { 107 - db.run( 108 - "INSERT OR IGNORE INTO backlinks (source_note_uri, target_note_slug, wiki_slug) VALUES (?, ?, ?)", 109 - [noteAtUri, slug, wikiSlug], 110 - ); 111 - } 112 - for (const blob of blobs) { 113 - db.run( 114 - `INSERT OR IGNORE INTO blobs (cid, revision_at_uri, mime_type, storage_key) 115 - VALUES (?, ?, ?, ?)`, 116 - [blob.cid, revisionAtUri, blob.mimeType, blob.cid], 117 - ); 118 - } 126 + appendRevisionTx(db, { 127 + noteAtUri, 128 + wikiSlug, 129 + did, 130 + revisionAtUri, 131 + parentRevisionUri, 132 + diff, 133 + message, 134 + newContent, 135 + blobs, 136 + }); 119 137 })(); 120 138 121 139 capSnapshots(noteAtUri);
+2 -5
src/server/db/queries/wiki.ts
··· 1 1 import { getDb } from "../index.ts"; 2 2 import type { WikiRow } from "../types.ts"; 3 + import { deleteNoteByAtUri } from "./note.ts"; 3 4 4 5 export function listWikis(): WikiRow[] { 5 6 const db = getDb(); ··· 98 99 .query("SELECT at_uri FROM notes WHERE wiki_slug = ?") 99 100 .all(wiki.slug) as { at_uri: string }[]; 100 101 for (const note of notes) { 101 - db.run("DELETE FROM current_note WHERE note_at_uri = ?", [note.at_uri]); 102 - db.run("DELETE FROM revisions WHERE note_at_uri = ?", [note.at_uri]); 103 - db.run("DELETE FROM snapshots WHERE note_at_uri = ?", [note.at_uri]); 104 - db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [note.at_uri]); 102 + deleteNoteByAtUri(note.at_uri); 105 103 } 106 - db.run("DELETE FROM notes WHERE wiki_slug = ?", [wiki.slug]); 107 104 db.run("DELETE FROM memberships WHERE wiki_slug = ?", [wiki.slug]); 108 105 db.run("DELETE FROM requests WHERE wiki_slug = ?", [wiki.slug]); 109 106 db.run("DELETE FROM wikis WHERE slug = ?", [wiki.slug]);
+2 -2
src/server/index.ts
··· 6 6 import { getDb } from "./db/index.ts"; 7 7 import { blobRoutes } from "./routes/blob.ts"; 8 8 import { homeRoute } from "./routes/home.ts"; 9 + import { localeRoutes } from "./routes/locale.ts"; 9 10 import { membershipRoutes } from "./routes/membership.ts"; 10 11 import { noteRoutes } from "./routes/note.ts"; 11 12 import { searchRoutes } from "./routes/search.ts"; 12 - import { settingsRoutes } from "./routes/settings.ts"; 13 13 import { wikiRoutes } from "./routes/wiki.ts"; 14 14 15 15 // Initialize database on startup ··· 32 32 .use(staticPlugin({ prefix: "/public", assets: "public" })) 33 33 .use(atprotoRoutes()) 34 34 .use(blobRoutes) 35 - .use(settingsRoutes) 35 + .use(localeRoutes) 36 36 .use(homeRoute) 37 37 .use(searchRoutes) 38 38 .use(membershipRoutes)
+1 -1
src/server/routes/settings.ts src/server/routes/locale.ts
··· 1 1 import { Elysia } from "elysia"; 2 2 import { type Locale, SUPPORTED_LOCALES } from "../../lib/i18n/index.ts"; 3 3 4 - export const settingsRoutes = new Elysia().post( 4 + export const localeRoutes = new Elysia().post( 5 5 "/set-locale", 6 6 async ({ request }) => { 7 7 const formData = await request.formData();
+2 -4
src/server/routes/wiki.ts
··· 3 3 canRead, 4 4 resolveRequestContext, 5 5 resolveWikiContext, 6 + resolveWikiContextSoft, 6 7 } from "../../lib/access.ts"; 7 8 import { VIZ_SCRIPTS } from "../../lib/constants.ts"; 8 9 import { ValidationError } from "../../lib/errors.ts"; ··· 91 92 return redirect("/"); 92 93 }) 93 94 .get("/:wikiSlug", async ({ params, request }) => { 94 - const ctx = await resolveRequestContext(request, params.wikiSlug); 95 - if (!ctx.wiki) { 96 - return new Response("Wiki not found", { status: 404 }); 97 - } 95 + const ctx = await resolveWikiContextSoft(request, params.wikiSlug); 98 96 99 97 if (!canRead(ctx.access)) { 100 98 return htmlResponse(