diff --git a/README.md b/README.md index 5f8ab28..bb7787a 100644 --- a/README.md +++ b/README.md @@ -27,4 +27,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). ## License -[AGPL-3.0](LICENSE) +**Code:** [AGPL-3.0](LICENSE) + +**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. diff --git a/src/lib/access.ts b/src/lib/access.ts index cc8906d..f9378da 100644 --- a/src/lib/access.ts +++ b/src/lib/access.ts @@ -98,6 +98,22 @@ export async function resolveWikiContext( return ctx as WikiRequestContext; } +/** + * Like resolveWikiContext but never throws -- returns the full context even when + * access is insufficient. Throws NotFoundError if wiki doesn't exist. + * Used for pages that render a custom access-denied view. + */ +export async function resolveWikiContextSoft( + request: Request, + wikiSlug: string, +): Promise { + const ctx = await resolveRequestContext(request, wikiSlug); + if (!ctx.wiki) { + throw new NotFoundError("Wiki not found"); + } + return ctx as WikiRequestContext; +} + export async function resolveRequestContext( request: Request, wikiSlug?: string, diff --git a/src/server/db/queries/index.ts b/src/server/db/queries/index.ts index 7870228..e9d00d3 100644 --- a/src/server/db/queries/index.ts +++ b/src/server/db/queries/index.ts @@ -43,7 +43,6 @@ export { capSnapshots, getBacklinks, getSnapshots, - updateBacklinks, } from "./revision.ts"; export { deleteWikiByAtUri, diff --git a/src/server/db/queries/note.ts b/src/server/db/queries/note.ts index ae88cd5..4bb0b3c 100644 --- a/src/server/db/queries/note.ts +++ b/src/server/db/queries/note.ts @@ -1,8 +1,7 @@ import { createDiff } from "../../../lib/diff.ts"; -import { extractWikilinks } from "../../../lib/markdown.ts"; import { getDb } from "../index.ts"; import type { CurrentNoteRow, NoteRow } from "../types.ts"; -import { capSnapshots } from "./revision.ts"; +import { appendRevisionTx, capSnapshots } from "./revision.ts"; export interface NoteWithCurrent { note: NoteRow; @@ -120,52 +119,6 @@ export function deleteNoteByAtUri(atUri: string): void { })(); } -/** - * Append a revision to an existing note (revisions + current_note + backlinks + snapshot). - * Must be called inside an outer db.transaction(). - */ -function appendRevisionTx( - db: ReturnType, - noteAtUri: string, - wikiSlug: string, - did: string, - revisionAtUri: string, - parentRevisionUri: string | null, - oldContent: string, - newContent: string, - message: string | undefined, -): void { - const diff = createDiff(oldContent, newContent); - const slugs = extractWikilinks(newContent); - - db.run( - `INSERT INTO revisions (note_at_uri, did, at_uri, parent_revision_uri, diff, message) - VALUES (?, ?, ?, ?, ?, ?)`, - [noteAtUri, did, revisionAtUri, parentRevisionUri, diff, message ?? null], - ); - db.run( - `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) - VALUES (?, ?, ?, datetime('now')) - ON CONFLICT(note_at_uri) DO UPDATE SET - content = excluded.content, - latest_revision_uri = excluded.latest_revision_uri, - updated_at = excluded.updated_at`, - [noteAtUri, newContent, revisionAtUri], - ); - // Inline backlinks update to stay within the outer transaction - db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [noteAtUri]); - for (const slug of slugs) { - db.run( - "INSERT OR IGNORE INTO backlinks (source_note_uri, target_note_slug, wiki_slug) VALUES (?, ?, ?)", - [noteAtUri, slug, wikiSlug], - ); - } - db.run( - "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", - [noteAtUri, revisionAtUri, newContent], - ); -} - /** * Create a new note with its first revision. * The caller is responsible for generating noteAtUri and revisionAtUri (e.g. from PDS write TIDs). @@ -181,23 +134,23 @@ export function createNote( message?: string, ): void { const db = getDb(); + const diff = createDiff("", initialContent); db.transaction(() => { db.run( "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", [noteSlug, wikiSlug, title, did, noteAtUri], ); - appendRevisionTx( - db, + appendRevisionTx(db, { noteAtUri, wikiSlug, did, revisionAtUri, - null, - "", - initialContent, - message, - ); + parentRevisionUri: null, + diff, + message: message ?? null, + newContent: initialContent, + }); })(); capSnapshots(noteAtUri); @@ -234,6 +187,7 @@ export function saveNoteEdit( const oldContent = current?.content ?? ""; const parentRevisionUri = current?.latest_revision_uri ?? null; + const diff = createDiff(oldContent, newContent); db.transaction(() => { if (newTitle) { @@ -243,17 +197,16 @@ export function saveNoteEdit( noteSlug, ]); } - appendRevisionTx( - db, - note.at_uri, + appendRevisionTx(db, { + noteAtUri: note.at_uri, wikiSlug, did, revisionAtUri, parentRevisionUri, - oldContent, + diff, + message: message ?? null, newContent, - message, - ); + }); })(); capSnapshots(note.at_uri); diff --git a/src/server/db/queries/revision.ts b/src/server/db/queries/revision.ts index 25b70d1..040891e 100644 --- a/src/server/db/queries/revision.ts +++ b/src/server/db/queries/revision.ts @@ -3,21 +3,69 @@ import { extractWikilinks } from "../../../lib/markdown.ts"; import { getDb } from "../index.ts"; import type { BacklinkRow, SnapshotRow } from "../types.ts"; -export function updateBacklinks( - noteAtUri: string, - wikiSlug: string, - slugs: string[], +interface AppendRevisionParams { + noteAtUri: string; + wikiSlug: string; + did: string; + revisionAtUri: string; + parentRevisionUri: string | null; + diff: string; + message: string | null; + newContent: string; + blobs?: { cid: string; mimeType: string }[]; +} + +/** + * Core revision pipeline: insert revision, upsert current_note, sync backlinks, + * insert snapshot, persist blobs. Must be called inside db.transaction(). + */ +export function appendRevisionTx( + db: ReturnType, + params: AppendRevisionParams, ): void { - const db = getDb(); - db.transaction(() => { - db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [noteAtUri]); - for (const slug of slugs) { + const slugs = extractWikilinks(params.newContent); + + db.run( + `INSERT INTO revisions (note_at_uri, did, at_uri, parent_revision_uri, diff, message) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + params.noteAtUri, + params.did, + params.revisionAtUri, + params.parentRevisionUri, + params.diff, + params.message, + ], + ); + db.run( + `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) + VALUES (?, ?, ?, datetime('now')) + ON CONFLICT(note_at_uri) DO UPDATE SET + content = excluded.content, + latest_revision_uri = excluded.latest_revision_uri, + updated_at = excluded.updated_at`, + [params.noteAtUri, params.newContent, params.revisionAtUri], + ); + db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [params.noteAtUri]); + for (const slug of slugs) { + db.run( + "INSERT OR IGNORE INTO backlinks (source_note_uri, target_note_slug, wiki_slug) VALUES (?, ?, ?)", + [params.noteAtUri, slug, params.wikiSlug], + ); + } + db.run( + "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", + [params.noteAtUri, params.revisionAtUri, params.newContent], + ); + if (params.blobs) { + for (const blob of params.blobs) { db.run( - "INSERT OR IGNORE INTO backlinks (source_note_uri, target_note_slug, wiki_slug) VALUES (?, ?, ?)", - [noteAtUri, slug, wikiSlug], + `INSERT OR IGNORE INTO blobs (cid, revision_at_uri, mime_type, storage_key) + VALUES (?, ?, ?, ?)`, + [blob.cid, params.revisionAtUri, blob.mimeType, blob.cid], ); } - })(); + } } export function getBacklinks( @@ -61,7 +109,7 @@ export function applyRevisionFromFirehose( revisionAtUri: string, parentRevisionUri: string | null, diff: string, - diffFormat: string, + _diffFormat: string, message: string | null, blobs: { cid: string; mimeType: string }[], ): void { @@ -73,49 +121,19 @@ export function applyRevisionFromFirehose( const oldContent = current?.content ?? ""; const newContent = applyDiff(oldContent, diff); - const slugs = extractWikilinks(newContent); db.transaction(() => { - db.run( - `INSERT INTO revisions (note_at_uri, did, at_uri, parent_revision_uri, diff, diff_format, message) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [ - noteAtUri, - did, - revisionAtUri, - parentRevisionUri, - diff, - diffFormat, - message, - ], - ); - db.run( - `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) - VALUES (?, ?, ?, datetime('now')) - ON CONFLICT(note_at_uri) DO UPDATE SET - content = excluded.content, - latest_revision_uri = excluded.latest_revision_uri, - updated_at = excluded.updated_at`, - [noteAtUri, newContent, revisionAtUri], - ); - db.run( - "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", - [noteAtUri, revisionAtUri, newContent], - ); - db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [noteAtUri]); - for (const slug of slugs) { - db.run( - "INSERT OR IGNORE INTO backlinks (source_note_uri, target_note_slug, wiki_slug) VALUES (?, ?, ?)", - [noteAtUri, slug, wikiSlug], - ); - } - for (const blob of blobs) { - db.run( - `INSERT OR IGNORE INTO blobs (cid, revision_at_uri, mime_type, storage_key) - VALUES (?, ?, ?, ?)`, - [blob.cid, revisionAtUri, blob.mimeType, blob.cid], - ); - } + appendRevisionTx(db, { + noteAtUri, + wikiSlug, + did, + revisionAtUri, + parentRevisionUri, + diff, + message, + newContent, + blobs, + }); })(); capSnapshots(noteAtUri); diff --git a/src/server/db/queries/wiki.ts b/src/server/db/queries/wiki.ts index 5b4ce12..19cf88c 100644 --- a/src/server/db/queries/wiki.ts +++ b/src/server/db/queries/wiki.ts @@ -1,5 +1,6 @@ import { getDb } from "../index.ts"; import type { WikiRow } from "../types.ts"; +import { deleteNoteByAtUri } from "./note.ts"; export function listWikis(): WikiRow[] { const db = getDb(); @@ -98,12 +99,8 @@ export function deleteWikiByAtUri(atUri: string): void { .query("SELECT at_uri FROM notes WHERE wiki_slug = ?") .all(wiki.slug) as { at_uri: string }[]; for (const note of notes) { - db.run("DELETE FROM current_note WHERE note_at_uri = ?", [note.at_uri]); - db.run("DELETE FROM revisions WHERE note_at_uri = ?", [note.at_uri]); - db.run("DELETE FROM snapshots WHERE note_at_uri = ?", [note.at_uri]); - db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [note.at_uri]); + deleteNoteByAtUri(note.at_uri); } - db.run("DELETE FROM notes WHERE wiki_slug = ?", [wiki.slug]); db.run("DELETE FROM memberships WHERE wiki_slug = ?", [wiki.slug]); db.run("DELETE FROM requests WHERE wiki_slug = ?", [wiki.slug]); db.run("DELETE FROM wikis WHERE slug = ?", [wiki.slug]); diff --git a/src/server/index.ts b/src/server/index.ts index 6c0ab0f..903f705 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -6,10 +6,10 @@ import { AppError } from "../lib/errors.ts"; import { getDb } from "./db/index.ts"; import { blobRoutes } from "./routes/blob.ts"; import { homeRoute } from "./routes/home.ts"; +import { localeRoutes } from "./routes/locale.ts"; import { membershipRoutes } from "./routes/membership.ts"; import { noteRoutes } from "./routes/note.ts"; import { searchRoutes } from "./routes/search.ts"; -import { settingsRoutes } from "./routes/settings.ts"; import { wikiRoutes } from "./routes/wiki.ts"; // Initialize database on startup @@ -32,7 +32,7 @@ const app = new Elysia() .use(staticPlugin({ prefix: "/public", assets: "public" })) .use(atprotoRoutes()) .use(blobRoutes) - .use(settingsRoutes) + .use(localeRoutes) .use(homeRoute) .use(searchRoutes) .use(membershipRoutes) diff --git a/src/server/routes/settings.ts b/src/server/routes/locale.ts similarity index 92% rename from src/server/routes/settings.ts rename to src/server/routes/locale.ts index 2e0daac..5aa05cf 100644 --- a/src/server/routes/settings.ts +++ b/src/server/routes/locale.ts @@ -1,7 +1,7 @@ import { Elysia } from "elysia"; import { type Locale, SUPPORTED_LOCALES } from "../../lib/i18n/index.ts"; -export const settingsRoutes = new Elysia().post( +export const localeRoutes = new Elysia().post( "/set-locale", async ({ request }) => { const formData = await request.formData(); diff --git a/src/server/routes/wiki.ts b/src/server/routes/wiki.ts index e129ed5..bfff5cc 100644 --- a/src/server/routes/wiki.ts +++ b/src/server/routes/wiki.ts @@ -3,6 +3,7 @@ import { canRead, resolveRequestContext, resolveWikiContext, + resolveWikiContextSoft, } from "../../lib/access.ts"; import { VIZ_SCRIPTS } from "../../lib/constants.ts"; import { ValidationError } from "../../lib/errors.ts"; @@ -91,10 +92,7 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) return redirect("/"); }) .get("/:wikiSlug", async ({ params, request }) => { - const ctx = await resolveRequestContext(request, params.wikiSlug); - if (!ctx.wiki) { - return new Response("Wiki not found", { status: 404 }); - } + const ctx = await resolveWikiContextSoft(request, params.wikiSlug); if (!canRead(ctx.access)) { return htmlResponse(