diff --git a/bun.lock b/bun.lock index a1b1fb5..a1307b4 100644 --- a/bun.lock +++ b/bun.lock @@ -106,7 +106,7 @@ "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="], - "@codemirror/commands": ["@codemirror/commands@6.10.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ=="], + "@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="], "@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="], @@ -120,7 +120,7 @@ "@codemirror/lint": ["@codemirror/lint@6.9.5", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA=="], - "@codemirror/search": ["@codemirror/search@6.6.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw=="], + "@codemirror/search": ["@codemirror/search@6.7.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg=="], "@codemirror/state": ["@codemirror/state@6.5.4", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw=="], @@ -666,6 +666,10 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@codemirror/commands/@codemirror/state": ["@codemirror/state@6.6.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="], + + "@codemirror/search/@codemirror/state": ["@codemirror/state@6.6.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], diff --git a/public/editor/editor.ts b/public/editor/editor.ts index b4236ae..dc41c56 100644 --- a/public/editor/editor.ts +++ b/public/editor/editor.ts @@ -106,9 +106,15 @@ function initEditor(root: Document | Element = document): void { fileInput.style.display = "none"; const wikiSlug = textarea.dataset["wikiSlug"]; - const autocompleteExt = wikiSlug - ? [autocompletion({ override: [wikilinkCompletion(wikiSlug)] })] - : []; + const ownerHandle = textarea.dataset["ownerHandle"]; + const autocompleteExt = + wikiSlug && ownerHandle + ? [ + autocompletion({ + override: [wikilinkCompletion(ownerHandle, wikiSlug)], + }), + ] + : []; const view = new EditorView({ state: EditorState.create({ diff --git a/public/editor/wikilink-autocomplete.ts b/public/editor/wikilink-autocomplete.ts index de81a23..3076f5d 100644 --- a/public/editor/wikilink-autocomplete.ts +++ b/public/editor/wikilink-autocomplete.ts @@ -19,8 +19,11 @@ const TRIGGER_RE = /\[\[([^[\]\n]*)$/; * CodeMirror autocomplete source: triggers inside `[[...`. Returns note titles * by default; after the user types `#`, returns headings for the named note. */ -export function wikilinkCompletion(wikiSlug: string): CompletionSource { - const endpoint = `/wiki/${encodeURIComponent(wikiSlug)}/-/link-suggest`; +export function wikilinkCompletion( + ownerHandle: string, + wikiSlug: string, +): CompletionSource { + const endpoint = `/@${encodeURIComponent(ownerHandle)}/${encodeURIComponent(wikiSlug)}/-/link-suggest`; return async (context) => { const match = context.matchBefore(TRIGGER_RE); diff --git a/src/firehose/handlers.ts b/src/firehose/handlers.ts index 039e2a2..09e915e 100644 --- a/src/firehose/handlers.ts +++ b/src/firehose/handlers.ts @@ -274,9 +274,9 @@ function handleWiki( // app's preset) falls back to reader mode so visitors keep their own theme. const theme = record.theme; if (typeof theme === "string" && theme in themes) { - setWikiTheme(rkey, "enforce", theme); + setWikiTheme(did, rkey, "enforce", theme); } else { - setWikiTheme(rkey, "reader", "light"); + setWikiTheme(did, rkey, "reader", "light"); } } @@ -285,6 +285,7 @@ function handleNote(did: string, atUri: string, record: NoteRecord): void { if (!wiki) return; upsertNote( + wiki.at_uri, wiki.slug, record.slug, record.title, @@ -326,6 +327,7 @@ function handleMembership( if (wiki.did !== did) return; upsertMembership( + wiki.at_uri, wiki.slug, record.memberDid, normalizeRole(record.role), @@ -342,7 +344,7 @@ function handleMemberRequest( const wiki = getWikiByAtUri(record.wikiRef); if (!wiki) return; - upsertRequest(wiki.slug, did, atUri, record.createdAt); + upsertRequest(wiki.at_uri, wiki.slug, did, atUri, record.createdAt); } function handleBookmark( diff --git a/src/lib/access.ts b/src/lib/access.ts index 6115bfd..6fd9ee5 100644 --- a/src/lib/access.ts +++ b/src/lib/access.ts @@ -8,6 +8,7 @@ import { import { resolveUserTheme, type UserTheme } from "../views/theme/index.ts"; import { ForbiddenError, NotFoundError } from "./errors.ts"; import { type Locale, resolveLocale } from "./i18n/index.ts"; +import { resolveHandleToDid, resolveProfile } from "./profile.ts"; export type AccessLevel = "none" | "read" | "edit" | "admin"; @@ -66,21 +67,24 @@ export interface RequestContext { hasPendingRequest: boolean; } -/** RequestContext with wiki guaranteed non-null. */ +/** RequestContext with wiki guaranteed non-null and ownerHandle resolved. */ export interface WikiRequestContext extends RequestContext { wiki: WikiRow; + ownerHandle: string | null; } /** * Resolve context, check wiki exists, and verify access level in one call. + * ownerHandle is the handle from the URL — resolved to DID to find the wiki. * Returns a narrowed WikiRequestContext on success, throws on failure. */ export async function resolveWikiContext( request: Request, + ownerHandle: string, wikiSlug: string, requiredAccess: "read" | "edit" | "admin", ): Promise { - const ctx = await resolveRequestContext(request, wikiSlug); + const ctx = await resolveRequestContext(request, ownerHandle, wikiSlug); if (!ctx.wiki) { throw new NotFoundError("Wiki not found"); } @@ -101,9 +105,10 @@ export async function resolveWikiContext( */ export async function resolveWikiContextSoft( request: Request, + ownerHandle: string, wikiSlug: string, ): Promise { - const ctx = await resolveRequestContext(request, wikiSlug); + const ctx = await resolveRequestContext(request, ownerHandle, wikiSlug); if (!ctx.wiki) { throw new NotFoundError("Wiki not found"); } @@ -112,8 +117,9 @@ export async function resolveWikiContextSoft( export async function resolveRequestContext( request: Request, + ownerHandle?: string, wikiSlug?: string, -): Promise { +): Promise { const session = await getSessionFromRequest(request); const did = session?.did ?? null; const cookieHeader = request.headers.get("cookie"); @@ -123,7 +129,7 @@ export async function resolveRequestContext( ); const userTheme = resolveUserTheme(cookieHeader); - if (!wikiSlug) { + if (!ownerHandle || !wikiSlug) { return { session, wiki: null, @@ -135,7 +141,20 @@ export async function resolveRequestContext( }; } - const wiki = getWiki(wikiSlug); + const ownerDid = await resolveHandleToDid(ownerHandle); + if (!ownerDid) { + return { + session, + wiki: null, + did, + access: "none", + locale, + userTheme, + hasPendingRequest: false, + }; + } + + const wiki = getWiki(ownerDid, wikiSlug); if (!wiki) { return { session, @@ -148,13 +167,17 @@ export async function resolveRequestContext( }; } - const role = did ? getMemberRole(wiki.slug, did) : null; + const role = did ? getMemberRole(wiki.at_uri, did) : null; const access = getAccessLevel(wiki, did, role); const hasPendingRequest = - access === "none" && did ? getRequest(wiki.slug, did) !== null : false; + access === "none" && did ? getRequest(wiki.at_uri, did) !== null : false; + + const profile = await resolveProfile(wiki.did); + return { session, wiki, + ownerHandle: profile.handle, did, access, locale, diff --git a/src/lib/import-export/export.ts b/src/lib/import-export/export.ts index 181c430..5ad27ed 100644 --- a/src/lib/import-export/export.ts +++ b/src/lib/import-export/export.ts @@ -16,8 +16,8 @@ const INVALID_FILENAME_CHARS = /[/\\:*?"<>|]/g; * Wikilinks are converted to Obsidian-compatible [[Note Title]] format. * Blob images are fetched and saved as attachments/{cid}.{ext}. */ -export async function exportWikiZip(wikiSlug: string): Promise { - const notes = listNotesWithContent(wikiSlug); +export async function exportWikiZip(wikiAtUri: string): Promise { + const notes = listNotesWithContent(wikiAtUri); const slugToTitle = new Map(); const noteContents = new Map(); @@ -120,14 +120,14 @@ function deduplicateFilename(name: string, used: Set): string { * Blob image refs are left as-is (no attachments folder for single-note export). */ export function exportNoteMd( - wikiSlug: string, + wikiAtUri: string, noteSlug: string, ): string | null { - const current = getCurrentNote(wikiSlug, noteSlug); + const current = getCurrentNote(wikiAtUri, noteSlug); if (!current) return null; const slugToTitle = new Map(); - for (const note of getSidebarNotes(wikiSlug)) { + for (const note of getSidebarNotes(wikiAtUri)) { slugToTitle.set(note.slug, note.title); } diff --git a/src/lib/import-export/import.ts b/src/lib/import-export/import.ts index d8c5c18..bd00393 100644 --- a/src/lib/import-export/import.ts +++ b/src/lib/import-export/import.ts @@ -89,6 +89,7 @@ export async function importWikiAction( createNote( noteAtUri, revisionAtUri, + wikiAtUri, wikiSlug, note.slug, note.title, diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 6c51e8c..9af1dbe 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -65,8 +65,12 @@ md.use(youtubePlugin); export function renderMarkdown( source: string, wikiSlug?: string, + ownerHandle?: string, ): RenderResult { - const env: MarkdownEnv = wikiSlug ? { wikiSlug } : {}; + const env: MarkdownEnv = { + ...(wikiSlug ? { wikiSlug } : {}), + ...(ownerHandle ? { ownerHandle } : {}), + }; const html = md.render(source, env); return { html, hasViz: env.hasViz === true, hasMath: env.hasMath === true }; } diff --git a/src/lib/markdown/wikilink-plugin.ts b/src/lib/markdown/wikilink-plugin.ts index a0927e6..5606468 100644 --- a/src/lib/markdown/wikilink-plugin.ts +++ b/src/lib/markdown/wikilink-plugin.ts @@ -6,9 +6,11 @@ import { noteUrl, wikiUrl } from "../urls.ts"; export interface WikilinkEnv { wikiSlug?: string; + ownerHandle?: string; } export interface WikilinkTarget { + ownerHandle?: string; wikiSlug?: string; noteSlug: string; label: string; @@ -18,6 +20,7 @@ export interface WikilinkTarget { export function parseWikilinkTarget( content: string, currentWikiSlug?: string, + currentOwnerHandle?: string, ): WikilinkTarget | null { const trimmed = content.trim(); if (!trimmed) return null; @@ -28,7 +31,6 @@ export function parseWikilinkTarget( ).trim(); const rawLabel = (pipeIdx >= 0 ? trimmed.slice(pipeIdx + 1) : "").trim(); - // Split path from section fragment const hashIdx = beforePipe.indexOf("#"); const rawTarget = ( hashIdx >= 0 ? beforePipe.slice(0, hashIdx) : beforePipe @@ -36,7 +38,7 @@ export function parseWikilinkTarget( const section = hashIdx >= 0 ? beforePipe.slice(hashIdx + 1).trim() : undefined; - // Anchor-only link [[#section]] + // [[#section]] — anchor-only link in the same note if (!rawTarget && section) { return { noteSlug: "", label: rawLabel || section, section }; } @@ -45,25 +47,47 @@ export function parseWikilinkTarget( const sectionSuffix = section ? `#${section}` : ""; - // Cross-wiki detection: wiki/note or wiki/ - const separatorIdx = rawTarget.indexOf("/"); - if (separatorIdx > 0) { - const wikiSlug = rawTarget.slice(0, separatorIdx).trim(); - const noteSlug = rawTarget.slice(separatorIdx + 1).trim(); - if (wikiSlug) { - const defaultLabel = noteSlug - ? `${wikiSlug}/${noteSlug}${sectionSuffix}` - : wikiSlug; + // [[@handle/wiki/note]] or [[@handle/wiki/]] — explicit cross-user link + if (rawTarget.startsWith("@")) { + const withoutAt = rawTarget.slice(1); + const firstSlash = withoutAt.indexOf("/"); + if (firstSlash > 0) { + const handle = withoutAt.slice(0, firstSlash); + const rest = withoutAt.slice(firstSlash + 1); + const secondSlash = rest.indexOf("/"); + if (secondSlash > 0) { + const wikiSlug = rest.slice(0, secondSlash); + const noteSlug = rest.slice(secondSlash + 1).trim(); + const defaultLabel = noteSlug + ? `@${handle}/${wikiSlug}/${noteSlug}${sectionSuffix}` + : `@${handle}/${wikiSlug}`; + return { + ownerHandle: handle, + wikiSlug, + noteSlug, + label: rawLabel || defaultLabel, + ...(section ? { section } : {}), + }; + } + // [[@handle/wiki]] — wiki home + const wikiSlug = rest.trim(); return { + ownerHandle: handle, wikiSlug, - noteSlug, - label: rawLabel || defaultLabel, + noteSlug: "", + label: rawLabel || `@${handle}/${wikiSlug}`, ...(section ? { section } : {}), }; } + return null; } + // [[note]] or [[note#section]] — note in the same wiki + // Slugs never contain "/"; reject anything with a slash (use [[@handle/wiki/note]] instead) + if (rawTarget.includes("/")) return null; + return { + ...(currentOwnerHandle ? { ownerHandle: currentOwnerHandle } : {}), ...(currentWikiSlug ? { wikiSlug: currentWikiSlug } : {}), noteSlug: rawTarget, label: rawLabel || `${rawTarget}${sectionSuffix}`, @@ -86,6 +110,7 @@ export function wikilinkPlugin(mdi: MarkdownIt): void { const target = parseWikilinkTarget( src.slice(pos + 2, closeIdx), env.wikiSlug, + env.ownerHandle, ); if (!target) return false; @@ -94,14 +119,16 @@ export function wikilinkPlugin(mdi: MarkdownIt): void { if (!target.wikiSlug && !target.noteSlug && target.section) { // [[#section]] — anchor only href = anchor; - } else if (!target.noteSlug && target.wikiSlug) { - // [[wiki/]] — wiki home page - href = wikiUrl(target.wikiSlug) + anchor; - } else if (target.wikiSlug) { - // [[wiki/note]] or [[wiki/note#section]] - href = noteUrl(target.wikiSlug, target.noteSlug) + anchor; + } else if (!target.noteSlug && target.wikiSlug && target.ownerHandle) { + // [[@handle/wiki/]] — wiki home page + href = wikiUrl(target.ownerHandle, target.wikiSlug) + anchor; + } else if (target.wikiSlug && target.ownerHandle) { + // [[@handle/wiki/note]] + href = + noteUrl(target.ownerHandle, target.wikiSlug, target.noteSlug) + + anchor; } else { - // [[note]] or [[note#section]] (bare slug, no current wiki) + // [[note]] or [[note#section]] — bare slug in same wiki href = target.noteSlug + anchor; } diff --git a/src/lib/note-validation.ts b/src/lib/note-validation.ts index 876b06f..36eca59 100644 --- a/src/lib/note-validation.ts +++ b/src/lib/note-validation.ts @@ -6,7 +6,7 @@ import { isValidSlug, slugify } from "./slug.ts"; /** Validates title and generates a unique slug for a new note. */ export function validateNewNote( - wikiSlug: string, + wikiAtUri: string, title: string, msg: Messages, ): { noteSlug: string } | { error: string } { @@ -19,7 +19,7 @@ export function validateNewNote( const noteSlug = slugify(title); if (!isValidSlug(noteSlug)) return { error: fmt(msg.error.invalidSlug, { title }) }; - const existing = getNoteBySlug(wikiSlug, noteSlug); + const existing = getNoteBySlug(wikiAtUri, noteSlug); if (existing) return { error: fmt(msg.error.slugExists, { slug: noteSlug }) }; return { noteSlug }; } diff --git a/src/lib/orchestrators/membership.ts b/src/lib/orchestrators/membership.ts index 613679f..f1e40ba 100644 --- a/src/lib/orchestrators/membership.ts +++ b/src/lib/orchestrators/membership.ts @@ -50,7 +50,7 @@ export async function requestAccessAction( }); } - upsertRequest(ctx.wiki.slug, session.did, atUri, now); + upsertRequest(ctx.wiki.at_uri, ctx.wiki.slug, session.did, atUri, now); } /** @@ -85,8 +85,15 @@ export async function approveMemberAction( }); } - deleteRequest(ctx.wiki.slug, memberDid); - upsertMembership(ctx.wiki.slug, memberDid, role, membershipAtUri, now); + deleteRequest(ctx.wiki.at_uri, memberDid); + upsertMembership( + ctx.wiki.at_uri, + ctx.wiki.slug, + memberDid, + role, + membershipAtUri, + now, + ); } /** @@ -105,7 +112,7 @@ export async function changeMemberRoleAction( throw new ValidationError(msg.access.cannotChangeOwnerRole); } - const existing = getMembership(ctx.wiki.slug, memberDid); + const existing = getMembership(ctx.wiki.at_uri, memberDid); if (!existing) { throw new NotFoundError("Membership not found"); } @@ -148,6 +155,7 @@ export async function changeMemberRoleAction( } upsertMembership( + ctx.wiki.at_uri, ctx.wiki.slug, memberDid, newRole, @@ -189,7 +197,7 @@ export async function addMemberAction( }); } - upsertMembership(ctx.wiki.slug, memberDid, role, atUri, now); + upsertMembership(ctx.wiki.at_uri, ctx.wiki.slug, memberDid, role, atUri, now); } /** @@ -207,7 +215,7 @@ export async function deleteMemberAction( throw new ValidationError(msg.access.cannotRemoveOwner); } - const atUri = getMembershipAtUri(ctx.wiki.slug, memberDid); + const atUri = getMembershipAtUri(ctx.wiki.at_uri, memberDid); if (!atUri) { throw new NotFoundError("Membership not found"); } @@ -228,5 +236,5 @@ export async function deleteMemberAction( } } - deleteMembership(ctx.wiki.slug, memberDid); + deleteMembership(ctx.wiki.at_uri, memberDid); } diff --git a/src/lib/orchestrators/note.ts b/src/lib/orchestrators/note.ts index 85fcaa9..8ff5b15 100644 --- a/src/lib/orchestrators/note.ts +++ b/src/lib/orchestrators/note.ts @@ -107,7 +107,7 @@ export async function createNoteAction( fields: NoteFormFields, msg: Messages, ): Promise<{ noteSlug: string }> { - const validation = validateNewNote(ctx.wiki.slug, fields.title, msg); + const validation = validateNewNote(ctx.wiki.at_uri, fields.title, msg); if ("error" in validation) { throw new ValidationError(validation.error); } @@ -156,6 +156,7 @@ export async function createNoteAction( createNote( noteAtUri, revisionAtUri, + ctx.wiki.at_uri, ctx.wiki.slug, noteSlug, fields.title, @@ -178,7 +179,7 @@ export async function editNoteAction( fields: NoteFormFields, msg: Messages, ): Promise { - const note = getNoteBySlug(ctx.wiki.slug, noteSlug); + const note = getNoteBySlug(ctx.wiki.at_uri, noteSlug); if (!note) { throw new NotFoundError("Note not found"); } @@ -199,7 +200,7 @@ export async function editNoteAction( const newTitle = fields.title !== note.title ? fields.title : undefined; const blobs = buildBlobsForContent(fields.content, fields.blobMeta); - const currentNote = getCurrentNote(ctx.wiki.slug, noteSlug); + const currentNote = getCurrentNote(ctx.wiki.at_uri, noteSlug); // Generate revision TID once — shared between PDS and DB writes const revisionTid = TID.now(); @@ -241,7 +242,7 @@ export async function editNoteAction( saveNoteEdit( revisionAtUri, - ctx.wiki.slug, + ctx.wiki.at_uri, noteSlug, fields.content, did, @@ -259,7 +260,7 @@ export async function deleteNoteAction( ctx: WikiRequestContext, noteSlug: string, ): Promise { - const note = getNoteBySlug(ctx.wiki.slug, noteSlug); + const note = getNoteBySlug(ctx.wiki.at_uri, noteSlug); if (!note) { throw new NotFoundError("Note not found"); } diff --git a/src/lib/orchestrators/wiki.ts b/src/lib/orchestrators/wiki.ts index c662e46..1c17ee4 100644 --- a/src/lib/orchestrators/wiki.ts +++ b/src/lib/orchestrators/wiki.ts @@ -87,17 +87,17 @@ export async function createWikiCore( ); } - const existing = getWiki(slug); - if (existing) { - throw new ValidationError(fmt(msg.error.wikiSlugExists, { slug })); - } - const validVisibility = fields.visibility === "private" ? "private" : ("public" as const); const now = new Date().toISOString(); const did = ctx.did; if (!did) throw new ForbiddenError(); + const existing = getWiki(did, slug); + if (existing) { + throw new ValidationError(fmt(msg.error.wikiSlugExists, { slug })); + } + let atUri = `at://${did}/wiki.lichen.wiki/${slug}`; const agent = ctx.session ? getAgent(ctx.session) : null; @@ -149,7 +149,7 @@ export async function createWikiCore( fields.language, description, ); - upsertMembership(slug, did, "admin", membershipAtUri, now); + upsertMembership(atUri, slug, did, "admin", membershipAtUri, now); return { wikiSlug: slug, wikiAtUri: atUri, agent, did, now }; } @@ -205,6 +205,7 @@ export async function createWikiAction( createNote( noteAtUri, revisionAtUri, + wikiAtUri, wikiSlug, "home", "Home", @@ -310,7 +311,7 @@ export async function setWikiThemeAction( }); } - setWikiTheme(ctx.wiki.slug, fields.themeMode, fields.theme); + setWikiTheme(ctx.wiki.did, ctx.wiki.slug, fields.themeMode, fields.theme); } /** diff --git a/src/lib/profile.ts b/src/lib/profile.ts index b707e6d..0eedfea 100644 --- a/src/lib/profile.ts +++ b/src/lib/profile.ts @@ -31,6 +31,14 @@ export async function resolveHandleToDid( ? handleOrDid.slice(1) : handleOrDid; if (normalized.startsWith("did:")) return normalized; + // Dev mode: resolve known test handles without network calls + const devAccounts = getDevAccounts(); + if (devAccounts) { + const account = Object.values(devAccounts).find( + (a) => a.handle === normalized, + ); + if (account) return account.did; + } try { return await getHandleResolver().resolve(normalized as Handle); } catch { diff --git a/src/lib/urls.ts b/src/lib/urls.ts index 635aea6..28e0758 100644 --- a/src/lib/urls.ts +++ b/src/lib/urls.ts @@ -1,33 +1,45 @@ -export function wikiUrl(wikiSlug: string): string { - return `/wiki/${wikiSlug}`; +export function wikiUrl(handle: string, wikiSlug: string): string { + return `/@${handle}/${wikiSlug}`; } -export function noteUrl(wikiSlug: string, noteSlug: string): string { - return `/wiki/${wikiSlug}/${noteSlug}`; +export function noteUrl( + handle: string, + wikiSlug: string, + noteSlug: string, +): string { + return `/@${handle}/${wikiSlug}/${noteSlug}`; } -export function editNoteUrl(wikiSlug: string, noteSlug: string): string { - return `/wiki/${wikiSlug}/${noteSlug}/edit`; +export function editNoteUrl( + handle: string, + wikiSlug: string, + noteSlug: string, +): string { + return `/@${handle}/${wikiSlug}/${noteSlug}/edit`; } -export function newNoteUrl(wikiSlug: string): string { - return `/wiki/${wikiSlug}/new`; +export function newNoteUrl(handle: string, wikiSlug: string): string { + return `/@${handle}/${wikiSlug}/new`; } -export function settingsUrl(wikiSlug: string): string { - return `/wiki/${wikiSlug}/-/settings`; +export function settingsUrl(handle: string, wikiSlug: string): string { + return `/@${handle}/${wikiSlug}/-/settings`; } export function profileUrl(didOrHandle: string): string { return `/@${didOrHandle}`; } -export function exportUrl(wikiSlug: string): string { - return `/wiki/${wikiSlug}/-/export`; +export function exportUrl(handle: string, wikiSlug: string): string { + return `/@${handle}/${wikiSlug}/-/export`; } -export function exportNoteUrl(wikiSlug: string, noteSlug: string): string { - return `/wiki/${wikiSlug}/${noteSlug}/-/export`; +export function exportNoteUrl( + handle: string, + wikiSlug: string, + noteSlug: string, +): string { + return `/@${handle}/${wikiSlug}/${noteSlug}/-/export`; } export function redirect(url: string): Response { diff --git a/src/server/app.ts b/src/server/app.ts index 92d5305..622016c 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -16,7 +16,7 @@ import { profileRoutes } from "./routes/profile.ts"; import { searchRoutes } from "./routes/search.ts"; import { seoRoutes } from "./routes/seo.ts"; import { themeRoutes } from "./routes/theme.ts"; -import { wikiRoutes } from "./routes/wiki.ts"; +import { wikiCreationRoutes, wikiRoutes } from "./routes/wiki.ts"; // Dev and prod auth must not coexist: the dev-session fallback trusts a `did=` // cookie without a password check. A prod instance that also sets DEV_ACCOUNTS @@ -55,5 +55,6 @@ export function buildApp() { .use(searchRoutes) .use(membershipRoutes) .use(noteRoutes) + .use(wikiCreationRoutes) .use(wikiRoutes); } diff --git a/src/server/db/queries/membership.ts b/src/server/db/queries/membership.ts index 0e0b8ef..9a09502 100644 --- a/src/server/db/queries/membership.ts +++ b/src/server/db/queries/membership.ts @@ -2,6 +2,7 @@ import { getDb } from "../index.ts"; import type { MembershipRow, RequestRow } from "../types.ts"; export function upsertMembership( + wikiAtUri: string, wikiSlug: string, did: string, role: string, @@ -10,11 +11,11 @@ export function upsertMembership( ): void { const db = getDb(); db.run( - `INSERT INTO memberships (wiki_slug, did, role, at_uri, created_at) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(wiki_slug, did) DO UPDATE SET + `INSERT INTO memberships (wiki_at_uri, wiki_slug, did, role, at_uri, created_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(wiki_at_uri, did) DO UPDATE SET role = excluded.role`, - [wikiSlug, did, role, atUri, createdAt], + [wikiAtUri, wikiSlug, did, role, atUri, createdAt], ); } @@ -24,6 +25,7 @@ export function deleteMembershipByUri(atUri: string): void { } export function upsertRequest( + wikiAtUri: string, wikiSlug: string, did: string, atUri: string, @@ -31,11 +33,11 @@ export function upsertRequest( ): void { const db = getDb(); db.run( - `INSERT INTO requests (wiki_slug, did, at_uri, created_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(wiki_slug, did) DO UPDATE SET + `INSERT INTO requests (wiki_at_uri, wiki_slug, did, at_uri, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(wiki_at_uri, did) DO UPDATE SET at_uri = excluded.at_uri`, - [wikiSlug, did, atUri, createdAt], + [wikiAtUri, wikiSlug, did, atUri, createdAt], ); } @@ -45,71 +47,73 @@ export function deleteRequestByUri(atUri: string): void { } export function getMembership( - wikiSlug: string, + wikiAtUri: string, did: string, ): MembershipRow | null { const db = getDb(); return ( (db - .query("SELECT * FROM memberships WHERE wiki_slug = ? AND did = ?") - .get(wikiSlug, did) as MembershipRow) ?? null + .query("SELECT * FROM memberships WHERE wiki_at_uri = ? AND did = ?") + .get(wikiAtUri, did) as MembershipRow) ?? null ); } -export function getMemberRole(wikiSlug: string, did: string): string | null { +export function getMemberRole(wikiAtUri: string, did: string): string | null { const db = getDb(); const row = db - .query("SELECT role FROM memberships WHERE wiki_slug = ? AND did = ?") - .get(wikiSlug, did) as { role: string } | null; + .query("SELECT role FROM memberships WHERE wiki_at_uri = ? AND did = ?") + .get(wikiAtUri, did) as { role: string } | null; return row?.role ?? null; } -export function listMembers(wikiSlug: string): MembershipRow[] { +export function listMembers(wikiAtUri: string): MembershipRow[] { const db = getDb(); return db - .query("SELECT * FROM memberships WHERE wiki_slug = ? ORDER BY created_at") - .all(wikiSlug) as MembershipRow[]; + .query( + "SELECT * FROM memberships WHERE wiki_at_uri = ? ORDER BY created_at", + ) + .all(wikiAtUri) as MembershipRow[]; } -export function listRequests(wikiSlug: string): RequestRow[] { +export function listRequests(wikiAtUri: string): RequestRow[] { const db = getDb(); return db - .query("SELECT * FROM requests WHERE wiki_slug = ? ORDER BY created_at") - .all(wikiSlug) as RequestRow[]; + .query("SELECT * FROM requests WHERE wiki_at_uri = ? ORDER BY created_at") + .all(wikiAtUri) as RequestRow[]; } -export function getRequest(wikiSlug: string, did: string): RequestRow | null { +export function getRequest(wikiAtUri: string, did: string): RequestRow | null { const db = getDb(); return ( (db - .query("SELECT * FROM requests WHERE wiki_slug = ? AND did = ?") - .get(wikiSlug, did) as RequestRow) ?? null + .query("SELECT * FROM requests WHERE wiki_at_uri = ? AND did = ?") + .get(wikiAtUri, did) as RequestRow) ?? null ); } export function getMembershipAtUri( - wikiSlug: string, + wikiAtUri: string, did: string, ): string | null { const db = getDb(); const row = db - .query("SELECT at_uri FROM memberships WHERE wiki_slug = ? AND did = ?") - .get(wikiSlug, did) as { at_uri: string } | null; + .query("SELECT at_uri FROM memberships WHERE wiki_at_uri = ? AND did = ?") + .get(wikiAtUri, did) as { at_uri: string } | null; return row?.at_uri ?? null; } -export function deleteMembership(wikiSlug: string, did: string): void { +export function deleteMembership(wikiAtUri: string, did: string): void { const db = getDb(); - db.run("DELETE FROM memberships WHERE wiki_slug = ? AND did = ?", [ - wikiSlug, + db.run("DELETE FROM memberships WHERE wiki_at_uri = ? AND did = ?", [ + wikiAtUri, did, ]); } -export function deleteRequest(wikiSlug: string, did: string): void { +export function deleteRequest(wikiAtUri: string, did: string): void { const db = getDb(); - db.run("DELETE FROM requests WHERE wiki_slug = ? AND did = ?", [ - wikiSlug, + db.run("DELETE FROM requests WHERE wiki_at_uri = ? AND did = ?", [ + wikiAtUri, did, ]); } diff --git a/src/server/db/queries/note.ts b/src/server/db/queries/note.ts index 6f35d3d..ef85a7d 100644 --- a/src/server/db/queries/note.ts +++ b/src/server/db/queries/note.ts @@ -28,19 +28,19 @@ interface NoteWithCurrent { } export function getSidebarNotes( - wikiSlug: string, + wikiAtUri: string, ): { slug: string; title: string }[] { const db = getDb(); return db .query( - "SELECT slug, title FROM notes WHERE wiki_slug = ? ORDER BY created_at DESC", + "SELECT slug, title FROM notes WHERE wiki_at_uri = ? ORDER BY created_at DESC", ) - .all(wikiSlug) as { slug: string; title: string }[]; + .all(wikiAtUri) as { slug: string; title: string }[]; } /** Fetch all notes with their current content in a single query. */ export function listNotesWithContent( - wikiSlug: string, + wikiAtUri: string, ): { slug: string; title: string; content: string | null }[] { const db = getDb(); return db @@ -48,36 +48,43 @@ export function listNotesWithContent( `SELECT n.slug, n.title, c.content FROM notes n LEFT JOIN current_note c ON c.note_at_uri = n.at_uri - WHERE n.wiki_slug = ? + WHERE n.wiki_at_uri = ? ORDER BY n.created_at DESC`, ) - .all(wikiSlug) as { slug: string; title: string; content: string | null }[]; + .all(wikiAtUri) as { + slug: string; + title: string; + content: string | null; + }[]; } export interface NoteSearchResult extends NoteRow { wiki_name: string; + wiki_handle: string | null; snippet: string | null; } export function searchNotes( query: string, - wikiSlug?: string, + wikiAtUri?: string, ): NoteSearchResult[] { const db = getDb(); const pattern = `%${escapeLikePattern(query)}%`; - const wikiFilter = wikiSlug ? "n.wiki_slug = ? AND" : ""; - const params = wikiSlug ? [wikiSlug, pattern, pattern] : [pattern, pattern]; + const wikiFilter = wikiAtUri ? "n.wiki_at_uri = ? AND" : ""; + const params = wikiAtUri ? [wikiAtUri, pattern, pattern] : [pattern, pattern]; const rows = db .query( - `SELECT n.*, w.name AS wiki_name, c.content + `SELECT n.*, w.name AS wiki_name, pc.handle AS wiki_handle, c.content FROM notes n - JOIN wikis w ON w.slug = n.wiki_slug + JOIN wikis w ON w.at_uri = n.wiki_at_uri + LEFT JOIN profile_cache pc ON pc.did = w.did LEFT JOIN current_note c ON c.note_at_uri = n.at_uri WHERE ${wikiFilter} (n.title LIKE ? ESCAPE '\\' OR c.content LIKE ? ESCAPE '\\') ORDER BY n.created_at DESC LIMIT 20`, ) .all(...params) as (NoteRow & { wiki_name: string; + wiki_handle: string | null; content: string | null; })[]; @@ -88,13 +95,13 @@ export function searchNotes( } export function getCurrentNote( - wikiSlug: string, + wikiAtUri: string, noteSlug: string, ): CurrentNoteRow | null { const db = getDb(); const note = db - .query("SELECT at_uri FROM notes WHERE wiki_slug = ? AND slug = ?") - .get(wikiSlug, noteSlug) as { at_uri: string } | null; + .query("SELECT at_uri FROM notes WHERE wiki_at_uri = ? AND slug = ?") + .get(wikiAtUri, noteSlug) as { at_uri: string } | null; if (!note) return null; return ( @@ -105,14 +112,14 @@ export function getCurrentNote( } export function getNoteBySlug( - wikiSlug: string, + wikiAtUri: string, noteSlug: string, ): NoteRow | null { const db = getDb(); return ( (db - .query("SELECT * FROM notes WHERE wiki_slug = ? AND slug = ?") - .get(wikiSlug, noteSlug) as NoteRow) ?? null + .query("SELECT * FROM notes WHERE wiki_at_uri = ? AND slug = ?") + .get(wikiAtUri, noteSlug) as NoteRow) ?? null ); } @@ -126,17 +133,18 @@ export function getNoteByAtUri(atUri: string): NoteRow | null { /** Fetch note + current content in one call. Returns null if either is missing. */ export function getNoteWithCurrent( - wikiSlug: string, + wikiAtUri: string, noteSlug: string, ): NoteWithCurrent | null { - const note = getNoteBySlug(wikiSlug, noteSlug); + const note = getNoteBySlug(wikiAtUri, noteSlug); if (!note) return null; - const current = getCurrentNote(wikiSlug, noteSlug); + const current = getCurrentNote(wikiAtUri, noteSlug); if (!current) return null; return { note, current }; } export function upsertNote( + wikiAtUri: string, wikiSlug: string, slug: string, title: string, @@ -146,11 +154,11 @@ export function upsertNote( ): void { const db = getDb(); db.run( - `INSERT INTO notes (slug, wiki_slug, title, did, at_uri, created_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(wiki_slug, slug) DO UPDATE SET + `INSERT INTO notes (slug, wiki_at_uri, wiki_slug, title, did, at_uri, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(wiki_at_uri, slug) DO UPDATE SET title = excluded.title`, - [slug, wikiSlug, title, did, atUri, createdAt], + [slug, wikiAtUri, wikiSlug, title, did, atUri, createdAt], ); } @@ -171,6 +179,7 @@ export function deleteNoteByAtUri(atUri: string): void { export function createNote( noteAtUri: string, revisionAtUri: string, + wikiAtUri: string, wikiSlug: string, noteSlug: string, title: string, @@ -184,8 +193,8 @@ export function createNote( db.transaction(() => { db.run( - "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", - [noteSlug, wikiSlug, title, did, noteAtUri], + "INSERT INTO notes (slug, wiki_at_uri, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?, ?)", + [noteSlug, wikiAtUri, wikiSlug, title, did, noteAtUri], ); appendRevisionTx(db, { noteAtUri, @@ -209,7 +218,7 @@ export function createNote( */ export function saveNoteEdit( revisionAtUri: string, - wikiSlug: string, + wikiAtUri: string, noteSlug: string, newContent: string, did: string, @@ -220,9 +229,11 @@ export function saveNoteEdit( const db = getDb(); const note = db - .query("SELECT at_uri FROM notes WHERE wiki_slug = ? AND slug = ?") - .get(wikiSlug, noteSlug) as { at_uri: string } | null; - if (!note) throw new Error(`Note not found: ${wikiSlug}/${noteSlug}`); + .query( + "SELECT at_uri, wiki_slug FROM notes WHERE wiki_at_uri = ? AND slug = ?", + ) + .get(wikiAtUri, noteSlug) as { at_uri: string; wiki_slug: string } | null; + if (!note) throw new Error(`Note not found: ${wikiAtUri}/${noteSlug}`); const current = db .query( @@ -239,15 +250,15 @@ export function saveNoteEdit( db.transaction(() => { if (newTitle) { - db.run("UPDATE notes SET title = ? WHERE wiki_slug = ? AND slug = ?", [ + db.run("UPDATE notes SET title = ? WHERE wiki_at_uri = ? AND slug = ?", [ newTitle, - wikiSlug, + wikiAtUri, noteSlug, ]); } appendRevisionTx(db, { noteAtUri: note.at_uri, - wikiSlug, + wikiSlug: note.wiki_slug, did, revisionAtUri, parentRevisionUri, diff --git a/src/server/db/queries/revision.ts b/src/server/db/queries/revision.ts index 72ec6e8..3777b95 100644 --- a/src/server/db/queries/revision.ts +++ b/src/server/db/queries/revision.ts @@ -51,7 +51,7 @@ export function appendRevisionTx( for (const target of targets) { const targetWikiSlug = target.wikiSlug ?? params.wikiSlug; const targetWikiExists = db - .query("SELECT 1 FROM wikis WHERE slug = ?") + .query("SELECT 1 FROM wikis WHERE slug = ? LIMIT 1") .get(targetWikiSlug); if (!targetWikiExists) continue; @@ -64,9 +64,11 @@ export function appendRevisionTx( "INSERT INTO snapshots (note_at_uri, revision_at_uri, content) VALUES (?, ?, ?)", [params.noteAtUri, params.revisionAtUri, params.newContent], ); - db.run("UPDATE wikis SET updated_at = datetime('now') WHERE slug = ?", [ - params.wikiSlug, - ]); + db.run( + `UPDATE wikis SET updated_at = datetime('now') + WHERE at_uri = (SELECT wiki_at_uri FROM notes WHERE at_uri = ?)`, + [params.noteAtUri], + ); if (params.blobs) { for (const blob of params.blobs) { const cid = blob.ref.$link; diff --git a/src/server/db/queries/wiki.ts b/src/server/db/queries/wiki.ts index 32fcfe1..5a18245 100644 --- a/src/server/db/queries/wiki.ts +++ b/src/server/db/queries/wiki.ts @@ -14,7 +14,7 @@ const WIKI_WITH_COUNTS = ` SELECT w.*, COUNT(n.slug) as note_count, pc.handle as owner_handle, pc.avatar as owner_avatar FROM wikis w - LEFT JOIN notes n ON n.wiki_slug = w.slug + LEFT JOIN notes n ON n.wiki_at_uri = w.at_uri LEFT JOIN profile_cache pc ON pc.did = w.did`; export function listPublicWikisPaginated(options: { @@ -55,7 +55,7 @@ export function listPublicWikisPaginated(options: { .query( `${WIKI_WITH_COUNTS} WHERE ${where} - GROUP BY w.slug + GROUP BY w.at_uri ORDER BY ${orderCol} DESC LIMIT ? OFFSET ?`, ) @@ -78,11 +78,13 @@ export function getWikiLanguages(): string[] { interface SitemapWikiEntry { slug: string; + owner_handle: string | null; updated_at: string; } interface SitemapNoteEntry { wiki_slug: string; + owner_handle: string | null; note_slug: string; updated_at: string; } @@ -92,9 +94,11 @@ export function listPublicWikisForSitemap(): SitemapWikiEntry[] { const db = getDb(); return db .query( - `SELECT slug, updated_at FROM wikis - WHERE visibility = 'public' - ORDER BY updated_at DESC`, + `SELECT w.slug, pc.handle as owner_handle, w.updated_at + FROM wikis w + LEFT JOIN profile_cache pc ON pc.did = w.did + WHERE w.visibility = 'public' + ORDER BY w.updated_at DESC`, ) .all() as SitemapWikiEntry[]; } @@ -104,21 +108,23 @@ export function listPublicNotesForSitemap(): SitemapNoteEntry[] { const db = getDb(); return db .query( - `SELECT n.wiki_slug, n.slug as note_slug, + `SELECT n.wiki_slug, pc.handle as owner_handle, n.slug as note_slug, COALESCE(c.updated_at, n.created_at) as updated_at FROM notes n - INNER JOIN wikis w ON w.slug = n.wiki_slug AND w.visibility = 'public' + INNER JOIN wikis w ON w.at_uri = n.wiki_at_uri AND w.visibility = 'public' + LEFT JOIN profile_cache pc ON pc.did = w.did LEFT JOIN current_note c ON c.note_at_uri = n.at_uri ORDER BY updated_at DESC`, ) .all() as SitemapNoteEntry[]; } -export function getWiki(slug: string): WikiRow | null { +export function getWiki(did: string, slug: string): WikiRow | null { const db = getDb(); return ( - (db.query("SELECT * FROM wikis WHERE slug = ?").get(slug) as WikiRow) ?? - null + (db + .query("SELECT * FROM wikis WHERE did = ? AND slug = ?") + .get(did, slug) as WikiRow) ?? null ); } @@ -128,7 +134,7 @@ export function listOwnedWikis(did: string): WikiWithNoteCount[] { .query( `${WIKI_WITH_COUNTS} WHERE w.did = ? - GROUP BY w.slug + GROUP BY w.at_uri ORDER BY w.updated_at DESC`, ) .all(did) as WikiWithNoteCount[]; @@ -139,9 +145,9 @@ export function listCollaboratingWikis(did: string): WikiWithNoteCount[] { return db .query( `${WIKI_WITH_COUNTS} - INNER JOIN memberships m ON m.wiki_slug = w.slug AND m.did = ? + INNER JOIN memberships m ON m.wiki_at_uri = w.at_uri AND m.did = ? WHERE w.did != ? - GROUP BY w.slug + GROUP BY w.at_uri ORDER BY w.updated_at DESC`, ) .all(did, did) as WikiWithNoteCount[]; @@ -169,7 +175,7 @@ export function upsertWiki( db.run( `INSERT INTO wikis (slug, did, name, visibility, language, description, at_uri, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) - ON CONFLICT(slug) DO UPDATE SET + ON CONFLICT(did, slug) DO UPDATE SET name = excluded.name, visibility = excluded.visibility, language = excluded.language, @@ -180,6 +186,7 @@ export function upsertWiki( } export function setWikiTheme( + did: string, slug: string, themeMode: "reader" | "enforce", theme: string, @@ -188,16 +195,16 @@ export function setWikiTheme( db.run( `UPDATE wikis SET theme_mode = ?, theme = ?, updated_at = datetime('now') - WHERE slug = ?`, - [themeMode, theme, slug], + WHERE did = ? AND slug = ?`, + [themeMode, theme, did, slug], ); } export function deleteWikiByAtUri(atUri: string): void { const db = getDb(); const wiki = db - .query("SELECT slug FROM wikis WHERE at_uri = ?") - .get(atUri) as { slug: string } | null; + .query("SELECT did, slug FROM wikis WHERE at_uri = ?") + .get(atUri) as { did: string; slug: string } | null; if (!wiki) return; // Backlinks use source_note_uri (not a FK target), so clean up before cascade. @@ -205,11 +212,11 @@ export function deleteWikiByAtUri(atUri: string): void { // blobs, memberships, requests, and backlinks (via wiki_slug FK). db.transaction(() => { const noteUris = db - .query("SELECT at_uri FROM notes WHERE wiki_slug = ?") - .all(wiki.slug) as { at_uri: string }[]; + .query("SELECT at_uri FROM notes WHERE wiki_at_uri = ?") + .all(atUri) as { at_uri: string }[]; for (const n of noteUris) { db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [n.at_uri]); } - db.run("DELETE FROM wikis WHERE slug = ?", [wiki.slug]); + db.run("DELETE FROM wikis WHERE at_uri = ?", [atUri]); })(); } diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 5b215b8..6ec4f85 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -5,9 +5,11 @@ export function initSchema(db: Database): void { db.run("PRAGMA foreign_keys = ON"); db.run("PRAGMA busy_timeout = 5000"); + migrateSchemaIfNeeded(db); + db.run(` CREATE TABLE IF NOT EXISTS wikis ( - slug TEXT PRIMARY KEY, + slug TEXT NOT NULL, did TEXT NOT NULL, name TEXT NOT NULL, visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public', 'private')), @@ -17,33 +19,21 @@ export function initSchema(db: Database): void { theme TEXT NOT NULL DEFAULT 'light', at_uri TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (did, slug) ) `); - // Migrations for existing DBs that predate the theme columns. - const wikiCols = db.query("PRAGMA table_info(wikis)").all() as { - name: string; - }[]; - const hasCol = (name: string) => wikiCols.some((c) => c.name === name); - if (!hasCol("theme_mode")) { - db.run( - "ALTER TABLE wikis ADD COLUMN theme_mode TEXT NOT NULL DEFAULT 'reader'", - ); - } - if (!hasCol("theme")) { - db.run("ALTER TABLE wikis ADD COLUMN theme TEXT NOT NULL DEFAULT 'light'"); - } - db.run(` CREATE TABLE IF NOT EXISTS notes ( slug TEXT NOT NULL, - wiki_slug TEXT NOT NULL REFERENCES wikis(slug) ON DELETE CASCADE, + wiki_at_uri TEXT NOT NULL REFERENCES wikis(at_uri) ON DELETE CASCADE, + wiki_slug TEXT NOT NULL, title TEXT NOT NULL, did TEXT NOT NULL, at_uri TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL DEFAULT (datetime('now')), - PRIMARY KEY (wiki_slug, slug) + PRIMARY KEY (wiki_at_uri, slug) ) `); @@ -83,23 +73,25 @@ export function initSchema(db: Database): void { db.run(` CREATE TABLE IF NOT EXISTS memberships ( id INTEGER PRIMARY KEY AUTOINCREMENT, - wiki_slug TEXT NOT NULL REFERENCES wikis(slug) ON DELETE CASCADE, + wiki_at_uri TEXT NOT NULL REFERENCES wikis(at_uri) ON DELETE CASCADE, + wiki_slug TEXT NOT NULL, did TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('admin', 'contributor', 'viewer')), at_uri TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE (wiki_slug, did) + UNIQUE (wiki_at_uri, did) ) `); db.run(` CREATE TABLE IF NOT EXISTS requests ( id INTEGER PRIMARY KEY AUTOINCREMENT, - wiki_slug TEXT NOT NULL REFERENCES wikis(slug) ON DELETE CASCADE, + wiki_at_uri TEXT NOT NULL REFERENCES wikis(at_uri) ON DELETE CASCADE, + wiki_slug TEXT NOT NULL, did TEXT NOT NULL, at_uri TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE (wiki_slug, did) + UNIQUE (wiki_at_uri, did) ) `); @@ -118,7 +110,7 @@ export function initSchema(db: Database): void { CREATE TABLE IF NOT EXISTS backlinks ( source_note_uri TEXT NOT NULL, target_note_slug TEXT NOT NULL, - wiki_slug TEXT NOT NULL REFERENCES wikis(slug) ON DELETE CASCADE, + wiki_slug TEXT NOT NULL, PRIMARY KEY (source_note_uri, target_note_slug) ) `); @@ -174,14 +166,159 @@ export function initSchema(db: Database): void { db.run( "CREATE INDEX IF NOT EXISTS idx_snapshots_note ON snapshots(note_at_uri)", ); - db.run("CREATE INDEX IF NOT EXISTS idx_notes_wiki ON notes(wiki_slug)"); + db.run("CREATE INDEX IF NOT EXISTS idx_notes_wiki ON notes(wiki_at_uri)"); + db.run( + "CREATE INDEX IF NOT EXISTS idx_memberships_wiki ON memberships(wiki_at_uri)", + ); db.run( - "CREATE INDEX IF NOT EXISTS idx_memberships_wiki ON memberships(wiki_slug)", + "CREATE INDEX IF NOT EXISTS idx_requests_wiki ON requests(wiki_at_uri)", ); - db.run("CREATE INDEX IF NOT EXISTS idx_requests_wiki ON requests(wiki_slug)"); db.run( "CREATE INDEX IF NOT EXISTS idx_backlinks_target ON backlinks(wiki_slug, target_note_slug)", ); db.run("CREATE INDEX IF NOT EXISTS idx_bookmarks_did ON bookmarks(did)"); db.run("CREATE INDEX IF NOT EXISTS idx_wikis_did ON wikis(did)"); } + +/** + * Detect the old schema (wikis.slug as sole primary key) and migrate to the + * new composite-primary-key schema. Safe to call on already-migrated DBs. + */ +function migrateSchemaIfNeeded(db: Database): void { + // Check if wikis table exists at all + const wikisExists = db + .query("SELECT 1 FROM sqlite_master WHERE type='table' AND name='wikis'") + .get(); + if (!wikisExists) return; + + // Detect old schema: slug is the sole PK (pk=1, did has pk=0) + const wikiCols = db.query("PRAGMA table_info(wikis)").all() as { + name: string; + pk: number; + }[]; + const slugCol = wikiCols.find((c) => c.name === "slug"); + const didCol = wikiCols.find((c) => c.name === "did"); + + // Old schema: slug.pk=1 and did.pk=0 (not part of PK) + const isOldSchema = slugCol?.pk === 1 && (didCol?.pk ?? 0) === 0; + if (!isOldSchema) return; + + // Run migration inside a transaction with FK enforcement disabled + db.run("PRAGMA foreign_keys = OFF"); + db.transaction(() => { + // Rebuild wikis with composite PK + db.run(` + CREATE TABLE wikis_new ( + slug TEXT NOT NULL, + did TEXT NOT NULL, + name TEXT NOT NULL, + visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public', 'private')), + language TEXT NOT NULL DEFAULT 'en', + description TEXT NOT NULL DEFAULT '', + theme_mode TEXT NOT NULL DEFAULT 'reader' CHECK (theme_mode IN ('reader', 'enforce')), + theme TEXT NOT NULL DEFAULT 'light', + at_uri TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (did, slug) + ) + `); + db.run("INSERT INTO wikis_new SELECT * FROM wikis"); + db.run("DROP TABLE wikis"); + db.run("ALTER TABLE wikis_new RENAME TO wikis"); + db.run("CREATE INDEX IF NOT EXISTS idx_wikis_did ON wikis(did)"); + + // Rebuild notes: FK on wikis(at_uri), keep wiki_slug as denormalized + db.run(` + CREATE TABLE notes_new ( + slug TEXT NOT NULL, + wiki_at_uri TEXT NOT NULL REFERENCES wikis(at_uri) ON DELETE CASCADE, + wiki_slug TEXT NOT NULL, + title TEXT NOT NULL, + did TEXT NOT NULL, + at_uri TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (wiki_at_uri, slug) + ) + `); + db.run(` + INSERT INTO notes_new (slug, wiki_at_uri, wiki_slug, title, did, at_uri, created_at) + SELECT n.slug, w.at_uri, n.wiki_slug, n.title, n.did, n.at_uri, n.created_at + FROM notes n JOIN wikis w ON w.slug = n.wiki_slug AND w.did = n.did + `); + // Notes whose wiki owner DID doesn't match: try matching by slug only + db.run(` + INSERT OR IGNORE INTO notes_new (slug, wiki_at_uri, wiki_slug, title, did, at_uri, created_at) + SELECT n.slug, w.at_uri, n.wiki_slug, n.title, n.did, n.at_uri, n.created_at + FROM notes n JOIN wikis w ON w.slug = n.wiki_slug + WHERE n.at_uri NOT IN (SELECT at_uri FROM notes_new) + `); + db.run("DROP TABLE notes"); + db.run("ALTER TABLE notes_new RENAME TO notes"); + db.run("CREATE INDEX IF NOT EXISTS idx_notes_wiki ON notes(wiki_at_uri)"); + + // Rebuild memberships + db.run(` + CREATE TABLE memberships_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wiki_at_uri TEXT NOT NULL REFERENCES wikis(at_uri) ON DELETE CASCADE, + wiki_slug TEXT NOT NULL, + did TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('admin', 'contributor', 'viewer')), + at_uri TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (wiki_at_uri, did) + ) + `); + db.run(` + INSERT INTO memberships_new (wiki_at_uri, wiki_slug, did, role, at_uri, created_at) + SELECT w.at_uri, m.wiki_slug, m.did, m.role, m.at_uri, m.created_at + FROM memberships m JOIN wikis w ON w.slug = m.wiki_slug + `); + db.run("DROP TABLE memberships"); + db.run("ALTER TABLE memberships_new RENAME TO memberships"); + db.run( + "CREATE INDEX IF NOT EXISTS idx_memberships_wiki ON memberships(wiki_at_uri)", + ); + + // Rebuild requests + db.run(` + CREATE TABLE requests_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wiki_at_uri TEXT NOT NULL REFERENCES wikis(at_uri) ON DELETE CASCADE, + wiki_slug TEXT NOT NULL, + did TEXT NOT NULL, + at_uri TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (wiki_at_uri, did) + ) + `); + db.run(` + INSERT INTO requests_new (wiki_at_uri, wiki_slug, did, at_uri, created_at) + SELECT w.at_uri, r.wiki_slug, r.did, r.at_uri, r.created_at + FROM requests r JOIN wikis w ON w.slug = r.wiki_slug + `); + db.run("DROP TABLE requests"); + db.run("ALTER TABLE requests_new RENAME TO requests"); + db.run( + "CREATE INDEX IF NOT EXISTS idx_requests_wiki ON requests(wiki_at_uri)", + ); + + // Rebuild backlinks: remove invalid FK on wikis(slug) + db.run(` + CREATE TABLE backlinks_new ( + source_note_uri TEXT NOT NULL, + target_note_slug TEXT NOT NULL, + wiki_slug TEXT NOT NULL, + PRIMARY KEY (source_note_uri, target_note_slug) + ) + `); + db.run("INSERT INTO backlinks_new SELECT * FROM backlinks"); + db.run("DROP TABLE backlinks"); + db.run("ALTER TABLE backlinks_new RENAME TO backlinks"); + db.run( + "CREATE INDEX IF NOT EXISTS idx_backlinks_target ON backlinks(wiki_slug, target_note_slug)", + ); + })(); + db.run("PRAGMA foreign_keys = ON"); +} diff --git a/src/server/db/seed.ts b/src/server/db/seed.ts index c730466..500c249 100644 --- a/src/server/db/seed.ts +++ b/src/server/db/seed.ts @@ -22,8 +22,9 @@ function seedWiki( [slug, owner.did, name, "public", description, wikiAtUri], ); db.run( - "INSERT INTO memberships (wiki_slug, did, role, at_uri) VALUES (?, ?, ?, ?)", + "INSERT INTO memberships (wiki_at_uri, wiki_slug, did, role, at_uri) VALUES (?, ?, ?, ?, ?)", [ + wikiAtUri, slug, owner.did, "admin", @@ -34,8 +35,8 @@ function seedWiki( for (const note of notes) { const noteAtUri = `at://${owner.did}/wiki.lichen.note/${TID.now()}`; db.run( - "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", - [note.slug, slug, note.title, owner.did, noteAtUri], + "INSERT INTO notes (slug, wiki_at_uri, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?, ?)", + [note.slug, wikiAtUri, slug, note.title, owner.did, noteAtUri], ); db.run( `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) @@ -61,6 +62,13 @@ export function seedIfEmpty(db: Database): void { db.run("BEGIN TRANSACTION"); try { + for (const account of Object.values(accounts)) { + db.run( + "INSERT OR IGNORE INTO profile_cache (did, handle) VALUES (?, ?)", + [account.did, account.handle], + ); + } + seedWiki( db, alice, @@ -76,7 +84,7 @@ export function seedIfEmpty(db: Database): void { This is the **home page**. Try editing it, then check the history. - [[hello|Hello]] — a sample note -- [[bobs-notebook/home|Bob's Notebook]] — Alice contributes there too +- [[@bob.test/bobs-notebook/home|Bob's Notebook]] — Alice contributes there too `, }, { @@ -110,8 +118,9 @@ Bob owns this wiki. Alice can edit notes here as a contributor. // Alice is a contributor on Bob's wiki — log in as alice and edit it. db.run( - "INSERT INTO memberships (wiki_slug, did, role, at_uri) VALUES (?, ?, ?, ?)", + "INSERT INTO memberships (wiki_at_uri, wiki_slug, did, role, at_uri) VALUES (?, ?, ?, ?, ?)", [ + `at://${bob.did}/wiki.lichen.wiki/bobs-notebook`, "bobs-notebook", alice.did, "contributor", diff --git a/src/server/db/types.ts b/src/server/db/types.ts index d50f065..04f7b28 100644 --- a/src/server/db/types.ts +++ b/src/server/db/types.ts @@ -19,6 +19,7 @@ export interface WikiRow { export interface NoteRow { slug: string; + wiki_at_uri: string; wiki_slug: string; title: string; did: string; @@ -36,7 +37,6 @@ export interface CurrentNoteRow { export interface BacklinkRow { source_note_uri: string; target_note_slug: string; - // Target wiki slug for the linked note. wiki_slug: string; } @@ -58,6 +58,7 @@ export interface BlobRow { export interface MembershipRow { id: number; + wiki_at_uri: string; wiki_slug: string; did: string; role: string; @@ -67,6 +68,7 @@ export interface MembershipRow { export interface RequestRow { id: number; + wiki_at_uri: string; wiki_slug: string; did: string; at_uri: string; diff --git a/src/server/routes/membership.ts b/src/server/routes/membership.ts index 6dc2310..e273558 100644 --- a/src/server/routes/membership.ts +++ b/src/server/routes/membership.ts @@ -20,77 +20,130 @@ import { import { resolveHandleToDid } from "../../lib/profile.ts"; import { redirect, settingsUrl, wikiUrl } from "../../lib/urls.ts"; -export const membershipRoutes = new Elysia({ prefix: "/wiki" }) - .post("/:wikiSlug/-/request-access", async ({ params, request }) => { - const ctx = await resolveRequestContext(request, params.wikiSlug); - if (!ctx.wiki) { - throw new NotFoundError("Wiki not found"); - } - if (!ctx.session) { - throw new ForbiddenError(); - } +interface HandleWikiParams { + handle: string; + wikiSlug: string; +} +interface HandleWikiMemberParams { + handle: string; + wikiSlug: string; + memberDid: string; +} + +function hwp(params: { wikiSlug: string }): HandleWikiParams { + return params as unknown as HandleWikiParams; +} + +function hwmp(params: { + wikiSlug: string; + memberDid: string; +}): HandleWikiMemberParams { + return params as unknown as HandleWikiMemberParams; +} + +export const membershipRoutes = new Elysia() + .post("/@:handle/:wikiSlug/-/request-access", async ({ params, request }) => { + const { handle: urlHandle, wikiSlug } = hwp(params); + const ctx = await resolveRequestContext(request, urlHandle, wikiSlug); + if (!ctx.wiki) throw new NotFoundError("Wiki not found"); + if (!ctx.session) throw new ForbiddenError(); + + const handle = + (ctx as { ownerHandle?: string | null }).ownerHandle ?? urlHandle; await requestAccessAction(ctx as WikiRequestContext); - return redirect(wikiUrl(params.wikiSlug)); + return redirect(wikiUrl(handle, ctx.wiki.slug)); }) - .get("/:wikiSlug/-/members", async ({ params }) => { - // Redirect to unified settings page - return redirect(settingsUrl(params.wikiSlug)); + .get("/@:handle/:wikiSlug/-/members", async ({ params }) => { + const { handle, wikiSlug } = hwp(params); + return redirect(settingsUrl(handle, wikiSlug)); }) .post( - "/:wikiSlug/-/members/:memberDid/approve", + "/@:handle/:wikiSlug/-/members/:memberDid/approve", async ({ params, request }) => { - const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); - const memberDid = decodeURIComponent(params.memberDid); + const { + handle: urlHandle, + wikiSlug, + memberDid: encodedDid, + } = hwmp(params); + const ctx = await resolveWikiContext( + request, + urlHandle, + wikiSlug, + "admin", + ); + const handle = ctx.ownerHandle ?? urlHandle; + const memberDid = decodeURIComponent(encodedDid); const formData = await request.formData(); const role = normalizeRole(formData.get("role") as string | null); await approveMemberAction(ctx, memberDid, role); - return redirect(settingsUrl(params.wikiSlug)); + return redirect(settingsUrl(handle, ctx.wiki.slug)); }, ) .post( - "/:wikiSlug/-/members/:memberDid/remove", + "/@:handle/:wikiSlug/-/members/:memberDid/remove", async ({ params, request }) => { - const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); - const memberDid = decodeURIComponent(params.memberDid); + const { + handle: urlHandle, + wikiSlug, + memberDid: encodedDid, + } = hwmp(params); + const ctx = await resolveWikiContext( + request, + urlHandle, + wikiSlug, + "admin", + ); + const handle = ctx.ownerHandle ?? urlHandle; + const memberDid = decodeURIComponent(encodedDid); await deleteMemberAction(ctx, memberDid); - return redirect(settingsUrl(params.wikiSlug)); + return redirect(settingsUrl(handle, ctx.wiki.slug)); }, ) .post( - "/:wikiSlug/-/members/:memberDid/change-role", + "/@:handle/:wikiSlug/-/members/:memberDid/change-role", async ({ params, request }) => { - const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); - const memberDid = decodeURIComponent(params.memberDid); + const { + handle: urlHandle, + wikiSlug, + memberDid: encodedDid, + } = hwmp(params); + const ctx = await resolveWikiContext( + request, + urlHandle, + wikiSlug, + "admin", + ); + const handle = ctx.ownerHandle ?? urlHandle; + const memberDid = decodeURIComponent(encodedDid); const formData = await request.formData(); const role = normalizeRole(formData.get("role") as string | null); await changeMemberRoleAction(ctx, memberDid, role); - return redirect(settingsUrl(params.wikiSlug)); + return redirect(settingsUrl(handle, ctx.wiki.slug)); }, ) - .post("/:wikiSlug/-/members/add", async ({ params, request }) => { - const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); + .post("/@:handle/:wikiSlug/-/members/add", async ({ params, request }) => { + const { handle: urlHandle, wikiSlug } = hwp(params); + const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "admin"); + const handle = ctx.ownerHandle ?? urlHandle; const formData = await request.formData(); const handleOrDid = ((formData.get("did") as string | null) ?? "").trim(); const role = normalizeRole(formData.get("role") as string | null); - if (!handleOrDid) { - throw new ValidationError("Handle or DID is required"); - } + if (!handleOrDid) throw new ValidationError("Handle or DID is required"); const memberDid = await resolveHandleToDid(handleOrDid); - if (!memberDid) { + if (!memberDid) throw new ValidationError(`Cannot resolve "${handleOrDid}" to a DID`); - } await addMemberAction(ctx, memberDid, role); - return redirect(settingsUrl(params.wikiSlug)); + return redirect(settingsUrl(handle, ctx.wiki.slug)); }); diff --git a/src/server/routes/note.ts b/src/server/routes/note.ts index 37c2f44..48546d1 100644 --- a/src/server/routes/note.ts +++ b/src/server/routes/note.ts @@ -25,22 +25,52 @@ import { notePage } from "../../views/note.ts"; import { shareOnBlueskyButton } from "../../views/share.ts"; import { getNoteWithCurrent, getSidebarNotes } from "../db/queries/index.ts"; -export const noteRoutes = new Elysia({ prefix: "/wiki" }) - .get("/:wikiSlug/new", async ({ params, request }) => { - const ctx = await resolveWikiContext(request, params.wikiSlug, "edit"); +interface HandleWikiNoteParams { + handle: string; + wikiSlug: string; + noteSlug: string; +} + +interface HandleWikiParams { + handle: string; + wikiSlug: string; +} + +// Elysia's type inference doesn't extract `handle` from `/@:handle/:wikiSlug` +// (the `@` prefix confuses it). These casts recover the actual runtime values. +function hwp(params: { wikiSlug: string }): HandleWikiParams { + return params as unknown as HandleWikiParams; +} + +function hwnp(params: { + wikiSlug: string; + noteSlug: string; +}): HandleWikiNoteParams { + return params as unknown as HandleWikiNoteParams; +} + +export const noteRoutes = new Elysia() + .get("/@:handle/:wikiSlug/new", async ({ params, request }) => { + const { handle: urlHandle, wikiSlug } = hwp(params); + const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "edit"); + const handle = ctx.ownerHandle ?? urlHandle; return htmlResponse( - newNotePage(ctx.wiki.name, params.wikiSlug, { + newNotePage(ctx.wiki.name, handle, ctx.wiki.slug, { session: ctx.session, scripts: EDITOR_SCRIPTS, stylesheets: KATEX_STYLESHEETS, locale: ctx.locale, userTheme: ctx.userTheme, + ownerHandle: handle, + wikiAtUri: ctx.wiki.at_uri, }), ); }) - .post("/:wikiSlug/new", async ({ params, request }) => { - const ctx = await resolveWikiContext(request, params.wikiSlug, "edit"); + .post("/@:handle/:wikiSlug/new", async ({ params, request }) => { + const { handle: urlHandle, wikiSlug } = hwp(params); + const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "edit"); + const handle = ctx.ownerHandle ?? urlHandle; const msg = t(ctx.locale); const formData = await request.formData(); @@ -48,16 +78,18 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) try { const { noteSlug } = await createNoteAction(ctx, fields, msg); - return redirect(noteUrl(params.wikiSlug, noteSlug)); + return redirect(noteUrl(handle, ctx.wiki.slug, noteSlug)); } catch (err) { if (err instanceof ValidationError) { return htmlResponse( - newNotePage(ctx.wiki.name, params.wikiSlug, { + newNotePage(ctx.wiki.name, handle, ctx.wiki.slug, { session: ctx.session, scripts: EDITOR_SCRIPTS, stylesheets: KATEX_STYLESHEETS, locale: ctx.locale, userTheme: ctx.userTheme, + ownerHandle: handle, + wikiAtUri: ctx.wiki.at_uri, error: err.message, titleValue: fields.title, contentValue: fields.content, @@ -67,19 +99,20 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) throw err; } }) - .get("/:wikiSlug/:noteSlug/edit", async ({ params, request }) => { - const ctx = await resolveWikiContext(request, params.wikiSlug, "edit"); + .get("/@:handle/:wikiSlug/:noteSlug/edit", async ({ params, request }) => { + const { handle: urlHandle, wikiSlug, noteSlug } = hwnp(params); + const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "edit"); + const handle = ctx.ownerHandle ?? urlHandle; - const data = getNoteWithCurrent(params.wikiSlug, params.noteSlug); - if (!data) { - throw new NotFoundError("Note not found"); - } + const data = getNoteWithCurrent(ctx.wiki.at_uri, noteSlug); + if (!data) throw new NotFoundError("Note not found"); return htmlResponse( editNotePage( ctx.wiki.name, - params.wikiSlug, - params.noteSlug, + handle, + ctx.wiki.slug, + noteSlug, data.note.title, data.current.content, { @@ -88,65 +121,77 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) stylesheets: KATEX_STYLESHEETS, locale: ctx.locale, userTheme: ctx.userTheme, + ownerHandle: handle, + wikiAtUri: ctx.wiki.at_uri, }, ), ); }) - .post("/:wikiSlug/:noteSlug/edit", async ({ params, request }) => { - const ctx = await resolveWikiContext(request, params.wikiSlug, "edit"); + .post("/@:handle/:wikiSlug/:noteSlug/edit", async ({ params, request }) => { + const { handle: urlHandle, wikiSlug, noteSlug } = hwnp(params); + const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "edit"); + const handle = ctx.ownerHandle ?? urlHandle; const msg = t(ctx.locale); const formData = await request.formData(); const fields = parseNoteFormFields(formData); - await editNoteAction(ctx, params.noteSlug, fields, msg); + await editNoteAction(ctx, noteSlug, fields, msg); - return redirect(noteUrl(params.wikiSlug, params.noteSlug)); + return redirect(noteUrl(handle, ctx.wiki.slug, noteSlug)); }) - .get("/:wikiSlug/:noteSlug/-/export", async ({ params, request }) => { - await resolveWikiContext(request, params.wikiSlug, "read"); - - const content = exportNoteMd(params.wikiSlug, params.noteSlug); - if (!content) { - throw new NotFoundError("Note not found"); - } - - const filename = `${params.noteSlug}.md`; - return new Response(content, { - headers: { - "Content-Type": "text/markdown; charset=utf-8", - "Content-Disposition": `attachment; filename="${filename}"`, - }, - }); - }) - .post("/:wikiSlug/:noteSlug/delete", async ({ params, request }) => { - const ctx = await resolveWikiContext(request, params.wikiSlug, "edit"); + .get( + "/@:handle/:wikiSlug/:noteSlug/-/export", + async ({ params, request }) => { + const { handle: urlHandle, wikiSlug, noteSlug } = hwnp(params); + const ctx = await resolveWikiContext( + request, + urlHandle, + wikiSlug, + "read", + ); + + const content = exportNoteMd(ctx.wiki.at_uri, noteSlug); + if (!content) throw new NotFoundError("Note not found"); + + return new Response(content, { + headers: { + "Content-Type": "text/markdown; charset=utf-8", + "Content-Disposition": `attachment; filename="${noteSlug}.md"`, + }, + }); + }, + ) + .post("/@:handle/:wikiSlug/:noteSlug/delete", async ({ params, request }) => { + const { handle: urlHandle, wikiSlug, noteSlug } = hwnp(params); + const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "edit"); + const handle = ctx.ownerHandle ?? urlHandle; - await deleteNoteAction(ctx, params.noteSlug); + await deleteNoteAction(ctx, noteSlug); - return redirect(wikiUrl(params.wikiSlug)); + return redirect(wikiUrl(handle, ctx.wiki.slug)); }) - .get("/:wikiSlug/:noteSlug", async ({ params, request }) => { - const ctx = await resolveWikiContext(request, params.wikiSlug, "read"); + .get("/@:handle/:wikiSlug/:noteSlug", async ({ params, request }) => { + const { handle: urlHandle, wikiSlug, noteSlug } = hwnp(params); + const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "read"); + const handle = ctx.ownerHandle ?? urlHandle; - const data = getNoteWithCurrent(params.wikiSlug, params.noteSlug); - if (!data) { - throw new NotFoundError("Note not found"); - } + const data = getNoteWithCurrent(ctx.wiki.at_uri, noteSlug); + if (!data) throw new NotFoundError("Note not found"); const { html, hasViz, hasMath } = renderMarkdown( data.current.content, - params.wikiSlug, + ctx.wiki.slug, + handle, ); - const sidebarNotes = getSidebarNotes(params.wikiSlug); + const sidebarNotes = getSidebarNotes(ctx.wiki.at_uri); const msg = t(ctx.locale); const bmHtml = resolveBookmarkHtml(ctx.did, ctx.wiki.at_uri, msg); const publicUrl = getAtprotoEnv()?.publicUrl ?? new URL(request.url).origin; - const canonicalUrl = `${publicUrl}${noteUrl(params.wikiSlug, params.noteSlug)}`; + const canonicalUrl = `${publicUrl}${noteUrl(handle, ctx.wiki.slug, noteSlug)}`; const shareHtml = shareOnBlueskyButton(data.note.title, canonicalUrl, msg); - // Notes inherit the parent wiki's OG card — no per-note image rendered. - const ogImage = `${publicUrl}/og/wiki/${params.wikiSlug}`; + const ogImage = `${publicUrl}/og/@${handle}/${ctx.wiki.slug}`; const ogDescription = ctx.wiki.description ? { ogDescription: ctx.wiki.description } : {}; @@ -156,7 +201,8 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) return htmlResponse( notePage( ctx.wiki.name, - params.wikiSlug, + handle, + ctx.wiki.slug, data.note.title, html, { @@ -164,12 +210,14 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) ...(hasMath && { stylesheets: KATEX_STYLESHEETS }), session: ctx.session, sidebarNotes, - currentNoteSlug: params.noteSlug, + currentNoteSlug: noteSlug, locale: ctx.locale, userTheme: ctx.userTheme, accessLevel: ctx.access, bookmarkHtml: bmHtml, shareHtml, + ownerHandle: handle, + wikiAtUri: ctx.wiki.at_uri, ogTitle: `${data.note.title} - ${ctx.wiki.name}`, ogUrl: canonicalUrl, ogImage, diff --git a/src/server/routes/og.ts b/src/server/routes/og.ts index 724398e..eac10a8 100644 --- a/src/server/routes/og.ts +++ b/src/server/routes/og.ts @@ -4,7 +4,7 @@ import { renderCardPng, wikiCardAvatarSlot, } from "../../lib/og-card.ts"; -import { resolveProfile } from "../../lib/profile.ts"; +import { resolveHandleToDid, resolveProfile } from "../../lib/profile.ts"; import { getSidebarNotes, getWiki } from "../db/queries/index.ts"; const PNG_HEADERS = { @@ -17,13 +17,19 @@ const PNG_HEADERS = { export const ogRoutes = new Elysia({ prefix: "/og" }) // No `.png` suffix on the route param — Elysia would treat it as part of // the param name. Content-Type header tells consumers what they got. - .get("/wiki/:wikiSlug", async ({ params }) => { - const wiki = getWiki(params.wikiSlug); - if (!wiki) { - return new Response("Wiki not found", { status: 404 }); - } + .get("/@:handle/:wikiSlug", async ({ params }) => { + const { handle, wikiSlug } = params as unknown as { + handle: string; + wikiSlug: string; + }; + const ownerDid = await resolveHandleToDid(handle); + if (!ownerDid) return new Response("Not found", { status: 404 }); + + const wiki = getWiki(ownerDid, wikiSlug); + if (!wiki) return new Response("Wiki not found", { status: 404 }); + const profile = await resolveProfile(wiki.did); - const noteCount = getSidebarNotes(wiki.slug).length; + const noteCount = getSidebarNotes(wiki.at_uri).length; const svg = buildWikiCardSvg({ name: wiki.name, description: wiki.description, diff --git a/src/server/routes/search.ts b/src/server/routes/search.ts index 43589b1..2ac2cf0 100644 --- a/src/server/routes/search.ts +++ b/src/server/routes/search.ts @@ -1,5 +1,9 @@ import { Elysia } from "elysia"; -import { canRead, resolveRequestContext } from "../../lib/access.ts"; +import { + canRead, + getAccessLevel, + resolveRequestContext, +} from "../../lib/access.ts"; import { LIMITS } from "../../lib/limits.ts"; import { parsePage, parseSort } from "../../lib/query-params.ts"; import { getClientIp, rateLimit } from "../../lib/rate-limit.ts"; @@ -9,7 +13,12 @@ import { wikiGridWithExploreLink, wikiGridWithPagination, } from "../../views/wiki-list.ts"; -import { listPublicWikisPaginated, searchNotes } from "../db/queries/index.ts"; +import { + getMemberRole, + getWikiByAtUri, + listPublicWikisPaginated, + searchNotes, +} from "../db/queries/index.ts"; function parseLimit(value: unknown): number { const n = Number(value); @@ -29,21 +38,22 @@ export const searchRoutes = new Elysia().get( if (limited) return limited; const q = (query["q"] as string | undefined) ?? ""; - const wikiSlug = (query["wiki"] as string | undefined) || null; + const wikiAtUri = (query["wiki_uri"] as string | undefined) || null; const lang = (query["lang"] as string | undefined) || null; - const ctx = await resolveRequestContext(request, wikiSlug ?? undefined); + const ctx = await resolveRequestContext(request); // Inside a wiki: search notes within that wiki - if (wikiSlug) { - if (!q.trim()) { - return htmlResponse(""); + if (wikiAtUri) { + if (!q.trim()) return htmlResponse(""); + const wiki = getWikiByAtUri(wikiAtUri); + if (wiki) { + const did = ctx.session?.did ?? null; + const role = did ? getMemberRole(wikiAtUri, did) : null; + const access = getAccessLevel(wiki, did, role); + if (!canRead(access)) return htmlResponse(""); } - if (ctx.wiki && !canRead(ctx.access)) { - return htmlResponse(""); - } - - const results = searchNotes(q, wikiSlug); + const results = searchNotes(q, wikiAtUri); return htmlResponse(renderNoteResults(results, q, ctx.locale)); } diff --git a/src/server/routes/seo.ts b/src/server/routes/seo.ts index 05e7883..e07efca 100644 --- a/src/server/routes/seo.ts +++ b/src/server/routes/seo.ts @@ -35,9 +35,9 @@ Disallow: /set-locale Disallow: /set-theme Disallow: /profile-redirect Disallow: /wiki/new -Disallow: /wiki/*/new -Disallow: /wiki/*/edit -Disallow: /wiki/*/-/ +Disallow: /@*/*/new +Disallow: /@*/*/-/ +Disallow: /@*/*/*/edit Sitemap: ${origin}/sitemap.xml `; @@ -60,14 +60,16 @@ Sitemap: ${origin}/sitemap.xml ); for (const w of wikis) { + if (!w.owner_handle) continue; urls.push( - `${escapeHtml(origin + wikiUrl(w.slug))}${toW3C(w.updated_at)}weekly0.7`, + `${escapeHtml(origin + wikiUrl(w.owner_handle, w.slug))}${toW3C(w.updated_at)}weekly0.7`, ); } for (const n of notes) { + if (!n.owner_handle) continue; urls.push( - `${escapeHtml(origin + noteUrl(n.wiki_slug, n.note_slug))}${toW3C(n.updated_at)}weekly0.6`, + `${escapeHtml(origin + noteUrl(n.owner_handle, n.wiki_slug, n.note_slug))}${toW3C(n.updated_at)}weekly0.6`, ); } diff --git a/src/server/routes/wiki.ts b/src/server/routes/wiki.ts index fb4b559..34048b0 100644 --- a/src/server/routes/wiki.ts +++ b/src/server/routes/wiki.ts @@ -25,7 +25,7 @@ import { } from "../../lib/orchestrators/wiki.ts"; import { resolveProfiles } from "../../lib/profile.ts"; import { htmlResponse } from "../../lib/response.ts"; -import { noteUrl, redirect, wikiUrl } from "../../lib/urls.ts"; +import { noteUrl, redirect, settingsUrl, wikiUrl } from "../../lib/urls.ts"; import { accessDeniedPage } from "../../views/access-denied.ts"; import { resolveBookmarkHtml } from "../../views/bookmark.ts"; import { newWikiPage } from "../../views/new-wiki.ts"; @@ -41,17 +41,28 @@ import { listRequests, } from "../db/queries/index.ts"; -const LINK_SUGGEST_LIMIT = 20; +interface HandleWikiParams { + handle: string; + wikiSlug: string; +} -async function loadSettingsData(wikiSlug: string) { - const members = listMembers(wikiSlug); - const requests = listRequests(wikiSlug); +// Elysia's type inference doesn't extract `handle` from `/@:handle/:wikiSlug` +// (the `@` prefix confuses it). This cast recovers the actual runtime value. +function hp(params: { wikiSlug: string }): HandleWikiParams { + return params as unknown as HandleWikiParams; +} + +async function loadSettingsData(wikiAtUri: string) { + const members = listMembers(wikiAtUri); + const requests = listRequests(wikiAtUri); const allDids = [...members.map((m) => m.did), ...requests.map((r) => r.did)]; const profiles = await resolveProfiles(allDids); return { members, requests, profiles }; } -export const wikiRoutes = new Elysia({ prefix: "/wiki" }) +const LINK_SUGGEST_LIMIT = 20; + +export const wikiCreationRoutes = new Elysia({ prefix: "/wiki" }) .get("/new", async ({ request }) => { const ctx = await resolveRequestContext(request); return htmlResponse( @@ -98,7 +109,7 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) { name, language, visibility, description, zipBuffer }, msg, ); - return redirect(wikiUrl(wikiSlug)); + return redirect(wikiUrl(ctx.session.handle, wikiSlug)); } const { wikiSlug } = await createWikiAction( @@ -106,25 +117,30 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) { name, language, visibility, description }, msg, ); - return redirect(wikiUrl(wikiSlug)); + return redirect(wikiUrl(ctx.session.handle, wikiSlug)); } catch (err) { if (err instanceof ValidationError || err instanceof ImportError) { return renderError(err.message); } throw err; } - }) - .get("/:wikiSlug/-/settings", async ({ params, request, query }) => { - const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); + }); + +export const wikiRoutes = new Elysia() + .get("/@:handle/:wikiSlug/-/settings", async ({ params, request, query }) => { + const { handle: urlHandle, wikiSlug } = hp(params); + const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "admin"); + const handle = ctx.ownerHandle ?? urlHandle; const isOwner = ctx.did === ctx.wiki.did; const { members, requests, profiles } = await loadSettingsData( - params.wikiSlug, + ctx.wiki.at_uri, ); return htmlResponse( settingsPage( ctx.wiki.name, - params.wikiSlug, + handle, + ctx.wiki.slug, isOwner, members, requests, @@ -134,6 +150,8 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) locale: ctx.locale, userTheme: ctx.userTheme, accessLevel: ctx.access, + ownerHandle: handle, + wikiAtUri: ctx.wiki.at_uri, wikiDid: ctx.wiki.did, wikiDescription: ctx.wiki.description, wikiThemeMode: ctx.wiki.theme_mode, @@ -144,8 +162,10 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) ), ); }) - .post("/:wikiSlug/-/edit", async ({ params, request }) => { - const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); + .post("/@:handle/:wikiSlug/-/edit", async ({ params, request }) => { + const { handle: urlHandle, wikiSlug } = hp(params); + const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "admin"); + const handle = ctx.ownerHandle ?? urlHandle; const msg = t(ctx.locale); const formData = await request.formData(); const name = (formData.get("name") as string | null) ?? ""; @@ -153,17 +173,18 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) try { await editWikiAction(ctx, { name, description }, msg); - return redirect(`/wiki/${params.wikiSlug}/-/settings?saved=1`); + return redirect(`${settingsUrl(handle, ctx.wiki.slug)}?saved=1`); } catch (err) { if (err instanceof ValidationError) { const isOwner = ctx.did === ctx.wiki.did; const { members, requests, profiles } = await loadSettingsData( - params.wikiSlug, + ctx.wiki.at_uri, ); return htmlResponse( settingsPage( name || ctx.wiki.name, - params.wikiSlug, + handle, + ctx.wiki.slug, isOwner, members, requests, @@ -173,6 +194,8 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) locale: ctx.locale, userTheme: ctx.userTheme, accessLevel: ctx.access, + ownerHandle: handle, + wikiAtUri: ctx.wiki.at_uri, wikiDid: ctx.wiki.did, wikiDescription: description, wikiThemeMode: ctx.wiki.theme_mode, @@ -186,30 +209,30 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) throw err; } }) - .post("/:wikiSlug/-/theme", async ({ params, request }) => { - const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); + .post("/@:handle/:wikiSlug/-/theme", async ({ params, request }) => { + const { handle: urlHandle, wikiSlug } = hp(params); + const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "admin"); + const handle = ctx.ownerHandle ?? urlHandle; const msg = t(ctx.locale); const formData = await request.formData(); - // Single dropdown sends "default" (= reader mode) or a preset name - // (= enforce that preset). Falls back to keeping the current theme key - // when in default so the value isn't lost on later toggles. const choice = (formData.get("theme_choice") as string | null) ?? "default"; const themeMode = choice === "default" ? "reader" : "enforce"; const theme = choice === "default" ? ctx.wiki.theme : choice; try { await setWikiThemeAction(ctx, { themeMode, theme }, msg); - return redirect(`/wiki/${params.wikiSlug}/-/settings?themeSaved=1`); + return redirect(`${settingsUrl(handle, ctx.wiki.slug)}?themeSaved=1`); } catch (err) { if (err instanceof ValidationError) { const isOwner = ctx.did === ctx.wiki.did; const { members, requests, profiles } = await loadSettingsData( - params.wikiSlug, + ctx.wiki.at_uri, ); return htmlResponse( settingsPage( ctx.wiki.name, - params.wikiSlug, + handle, + ctx.wiki.slug, isOwner, members, requests, @@ -219,6 +242,8 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) locale: ctx.locale, userTheme: ctx.userTheme, accessLevel: ctx.access, + ownerHandle: handle, + wikiAtUri: ctx.wiki.at_uri, wikiDid: ctx.wiki.did, wikiDescription: ctx.wiki.description, wikiThemeMode: ctx.wiki.theme_mode, @@ -232,19 +257,22 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) throw err; } }) - .post("/:wikiSlug/-/delete", async ({ params, request }) => { - const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); + .post("/@:handle/:wikiSlug/-/delete", async ({ params, request }) => { + const { handle: urlHandle, wikiSlug } = hp(params); + const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "admin"); + const handle = ctx.ownerHandle ?? urlHandle; const formData = await request.formData(); const confirm = (formData.get("confirm") as string | null) ?? ""; if (confirm !== ctx.wiki.name) { const isOwner = ctx.did === ctx.wiki.did; const { members, requests, profiles } = await loadSettingsData( - params.wikiSlug, + ctx.wiki.at_uri, ); return htmlResponse( settingsPage( ctx.wiki.name, - params.wikiSlug, + handle, + ctx.wiki.slug, isOwner, members, requests, @@ -254,6 +282,8 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) locale: ctx.locale, userTheme: ctx.userTheme, accessLevel: ctx.access, + ownerHandle: handle, + wikiAtUri: ctx.wiki.at_uri, wikiDid: ctx.wiki.did, wikiDescription: ctx.wiki.description, wikiThemeMode: ctx.wiki.theme_mode, @@ -267,53 +297,59 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) await deleteWikiAction(ctx); return redirect("/"); }) - .get("/:wikiSlug/-/link-suggest", async ({ params, request, query }) => { - await resolveWikiContext(request, params.wikiSlug, "read"); - const q = ((query["q"] as string | undefined) ?? "").toLowerCase().trim(); - const noteSlug = (query["note"] as string | undefined) ?? null; + .get( + "/@:handle/:wikiSlug/-/link-suggest", + async ({ params, request, query }) => { + const { handle: urlHandle, wikiSlug } = hp(params); + const ctx = await resolveWikiContext( + request, + urlHandle, + wikiSlug, + "read", + ); + const q = ((query["q"] as string | undefined) ?? "").toLowerCase().trim(); + const noteSlug = (query["note"] as string | undefined) ?? null; - if (noteSlug) { - const current = getCurrentNote(params.wikiSlug, noteSlug); - if (!current) { - return Response.json({ items: [] }); + if (noteSlug) { + const current = getCurrentNote(ctx.wiki.at_uri, noteSlug); + if (!current) return Response.json({ items: [] }); + const headings = extractHeadings(current.content); + const filtered = q + ? headings.filter((h) => h.text.toLowerCase().includes(q)) + : headings; + return Response.json({ items: filtered.slice(0, LINK_SUGGEST_LIMIT) }); } - const headings = extractHeadings(current.content); - const filtered = q - ? headings.filter((h) => h.text.toLowerCase().includes(q)) - : headings; - return Response.json({ - items: filtered.slice(0, LINK_SUGGEST_LIMIT), - }); - } - const notes = getSidebarNotes(params.wikiSlug); - const filtered = q - ? notes.filter( - (n) => - n.title.toLowerCase().includes(q) || - n.slug.toLowerCase().includes(q), - ) - : notes; - return Response.json({ - items: filtered.slice(0, LINK_SUGGEST_LIMIT), - }); - }) - .get("/:wikiSlug/-/export", async ({ params, request }) => { - await resolveWikiContext(request, params.wikiSlug, "read"); - const zipBytes = await exportWikiZip(params.wikiSlug); + const notes = getSidebarNotes(ctx.wiki.at_uri); + const filtered = q + ? notes.filter( + (n) => + n.title.toLowerCase().includes(q) || + n.slug.toLowerCase().includes(q), + ) + : notes; + return Response.json({ items: filtered.slice(0, LINK_SUGGEST_LIMIT) }); + }, + ) + .get("/@:handle/:wikiSlug/-/export", async ({ params, request }) => { + const { handle: urlHandle, wikiSlug } = hp(params); + const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "read"); + const zipBytes = await exportWikiZip(ctx.wiki.at_uri); return new Response(zipBytes, { headers: { "Content-Type": "application/zip", - "Content-Disposition": `attachment; filename="${params.wikiSlug}.zip"`, + "Content-Disposition": `attachment; filename="${ctx.wiki.slug}.zip"`, }, }); }) - .get("/:wikiSlug", async ({ params, request }) => { - const ctx = await resolveWikiContextSoft(request, params.wikiSlug); + .get("/@:handle/:wikiSlug", async ({ params, request }) => { + const { handle: urlHandle, wikiSlug } = hp(params); + const ctx = await resolveWikiContextSoft(request, urlHandle, wikiSlug); + const handle = ctx.ownerHandle ?? urlHandle; if (!canRead(ctx.access)) { return htmlResponse( - accessDeniedPage(ctx.wiki.name, params.wikiSlug, { + accessDeniedPage(ctx.wiki.name, handle, ctx.wiki.slug, { session: ctx.session, locale: ctx.locale, userTheme: ctx.userTheme, @@ -323,15 +359,15 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) ); } - const sidebarNotes = getSidebarNotes(params.wikiSlug); - const homeData = getNoteWithCurrent(params.wikiSlug, "home"); + const sidebarNotes = getSidebarNotes(ctx.wiki.at_uri); + const homeData = getNoteWithCurrent(ctx.wiki.at_uri, "home"); const cacheable = !ctx.session && ctx.wiki.visibility === "public" ? 60 : 0; const msg = t(ctx.locale); const bmHtml = resolveBookmarkHtml(ctx.did, ctx.wiki.at_uri, msg); const publicUrl = getAtprotoEnv()?.publicUrl ?? new URL(request.url).origin; - const ogImage = `${publicUrl}/og/wiki/${params.wikiSlug}`; + const ogImage = `${publicUrl}/og/@${handle}/${ctx.wiki.slug}`; const ogDescription = ctx.wiki.description ? { ogDescription: ctx.wiki.description } : {}; @@ -339,9 +375,10 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) if (homeData) { const { html, hasViz, hasMath } = renderMarkdown( homeData.current.content, - params.wikiSlug, + ctx.wiki.slug, + handle, ); - const canonicalUrl = `${publicUrl}${noteUrl(params.wikiSlug, "home")}`; + const canonicalUrl = `${publicUrl}${noteUrl(handle, ctx.wiki.slug, "home")}`; const shareHtml = shareOnBlueskyButton( homeData.note.title, canonicalUrl, @@ -350,7 +387,8 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) return htmlResponse( notePage( ctx.wiki.name, - params.wikiSlug, + handle, + ctx.wiki.slug, homeData.note.title, html, { @@ -364,6 +402,8 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) accessLevel: ctx.access, bookmarkHtml: bmHtml, shareHtml, + ownerHandle: handle, + wikiAtUri: ctx.wiki.at_uri, ogTitle: ctx.wiki.name, ogUrl: canonicalUrl, ogImage, @@ -381,6 +421,7 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) wikiPage( ctx.wiki.name, ctx.wiki.slug, + handle, { session: ctx.session, sidebarNotes, @@ -388,8 +429,11 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) userTheme: ctx.userTheme, accessLevel: ctx.access, bookmarkHtml: bmHtml, + ownerHandle: handle, + wikiAtUri: ctx.wiki.at_uri, + wikiSlug: ctx.wiki.slug, ogTitle: ctx.wiki.name, - ogUrl: `${publicUrl}${wikiUrl(ctx.wiki.slug)}`, + ogUrl: `${publicUrl}${wikiUrl(handle, ctx.wiki.slug)}`, ogImage, ...ogDescription, wikiThemeMode: ctx.wiki.theme_mode, diff --git a/src/views/access-denied.ts b/src/views/access-denied.ts index 5323816..0ce0f5e 100644 --- a/src/views/access-denied.ts +++ b/src/views/access-denied.ts @@ -1,5 +1,6 @@ import { escapeHtml } from "../lib/html.ts"; import { t } from "../lib/i18n/index.ts"; +import { wikiUrl } from "../lib/urls.ts"; import { type LayoutOptions, layout } from "./layout.ts"; import { primaryButtonClass, THEME } from "./theme/index.ts"; @@ -9,6 +10,7 @@ interface AccessDeniedOptions extends LayoutOptions { export function accessDeniedPage( wikiName: string, + ownerHandle: string, wikiSlug: string, options?: AccessDeniedOptions, ): string { @@ -21,7 +23,7 @@ export function accessDeniedPage( if (hasPending) { actionHtml = `

${msg.access.pendingRequest}

`; } else if (session) { - actionHtml = `
+ actionHtml = `
`; } else { diff --git a/src/views/edit-note.ts b/src/views/edit-note.ts index 1ebbc07..e7a0190 100644 --- a/src/views/edit-note.ts +++ b/src/views/edit-note.ts @@ -1,10 +1,12 @@ import { escapeHtml } from "../lib/html.ts"; import { t } from "../lib/i18n/index.ts"; +import { editNoteUrl, noteUrl } from "../lib/urls.ts"; import { type LayoutOptions, layout } from "./layout.ts"; import { inputClass, primaryButtonClass, THEME } from "./theme/index.ts"; export function editNotePage( wikiName: string, + ownerHandle: string, wikiSlug: string, noteSlug: string, noteTitle: string, @@ -42,7 +44,7 @@ export function editNotePage( `; const formHtml = ` -
+
${escapeHtml(currentContent)}
@@ -80,7 +83,7 @@ export function editNotePage( type="submit" class="${primaryButtonClass}" >${msg.editor.save} - ${msg.editor.cancel} + ${msg.editor.cancel}
- + `; return layout(`${msg.wiki.edit} ${noteTitle}`, scrollFixStyle + formHtml, { ...options, wikiName, wikiSlug, + ownerHandle, noindex: true, }); } diff --git a/src/views/layout.ts b/src/views/layout.ts index 56aa8be..1367162 100644 --- a/src/views/layout.ts +++ b/src/views/layout.ts @@ -1,7 +1,16 @@ import { type AccessLevel, canEdit, canManage } from "../lib/access.ts"; import { escapeHtml } from "../lib/html.ts"; import { type Locale, SUPPORTED_LOCALES, t } from "../lib/i18n/index.ts"; -import { exportNoteUrl, exportUrl, profileUrl } from "../lib/urls.ts"; +import { + editNoteUrl, + exportNoteUrl, + exportUrl, + newNoteUrl, + noteUrl, + profileUrl, + settingsUrl, + wikiUrl, +} from "../lib/urls.ts"; import { ICONS } from "./icons.ts"; import { kbdClass, @@ -29,6 +38,9 @@ export interface LayoutOptions { session?: { did: string; handle: string; avatar?: string | null } | null; wikiName?: string; wikiSlug?: string; + ownerHandle?: string | null; + /** AT-URI for the current wiki — used for within-wiki search. */ + wikiAtUri?: string; sidebarNotes?: { slug: string; title: string }[]; currentNoteSlug?: string; pageTitle?: string; @@ -98,10 +110,15 @@ function renderWithSidebar(body: string, options: LayoutOptions): string { const notes = [...(options.sidebarNotes ?? [])].sort((a, b) => a.title.localeCompare(b.title), ); + const handle = options.ownerHandle ?? ""; + const slug = options.wikiSlug ?? ""; const noteLinks = notes .map((n) => { const isCurrent = n.slug === options.currentNoteSlug; - return `
  • ${escapeHtml(n.title)}
  • `; + const href = handle + ? noteUrl(handle, slug, n.slug) + : `/${slug}/${n.slug}`; + return `
  • ${escapeHtml(n.title)}
  • `; }) .join("\n"); @@ -109,18 +126,18 @@ function renderWithSidebar(body: string, options: LayoutOptions): string { const editLink = options.currentNoteSlug && canEdit(level) - ? `${msg.wiki.edit}` + ? `${msg.wiki.edit}` : ""; const newNoteLink = canEdit(level) - ? `${msg.wiki.newNote}` + ? `${msg.wiki.newNote}` : ""; const dropdownItemClass = `block px-3 py-1.5 text-sm ${THEME.textSecondary} ${THEME.accentSoft} rounded`; const noteExportItem = options.currentNoteSlug - ? `${msg.wiki.exportNote}` + ? `${msg.wiki.exportNote}` : ""; - const wikiExportItem = `${msg.wiki.exportWiki}`; + const wikiExportItem = `${msg.wiki.exportWiki}`; const exportDropdown = `
    - ${msg.editor.cancel} + ${msg.editor.cancel}
    `, - { ...options, wikiName, wikiSlug, noindex: true }, + { ...options, wikiName, wikiSlug, ownerHandle, noindex: true }, ); } diff --git a/src/views/note.ts b/src/views/note.ts index ab85922..474e530 100644 --- a/src/views/note.ts +++ b/src/views/note.ts @@ -2,6 +2,7 @@ import { type LayoutOptions, layout } from "./layout.ts"; export function notePage( wikiName: string, + ownerHandle: string, wikiSlug: string, noteTitle: string, renderedHtml: string, @@ -18,6 +19,7 @@ export function notePage( ...options, wikiName, wikiSlug, + ownerHandle, pageTitle: noteTitle, enableSearchShortcut: true, }); diff --git a/src/views/search-results.ts b/src/views/search-results.ts index bd04d81..1160e6a 100644 --- a/src/views/search-results.ts +++ b/src/views/search-results.ts @@ -1,5 +1,6 @@ import { escapeHtml } from "../lib/html.ts"; import { fmt, type Locale, t } from "../lib/i18n/index.ts"; +import { noteUrl } from "../lib/urls.ts"; import type { NoteSearchResult } from "../server/db/queries/index.ts"; import { THEME } from "./theme/index.ts"; @@ -33,7 +34,7 @@ export function renderNoteResults( const snippetHtml = r.snippet ? `
    ${highlightMatch(r.snippet, query)}
    ` : ""; - return ` + return `
    ${highlightMatch(r.title, query)}
    ${snippetHtml}
    `; diff --git a/src/views/settings.ts b/src/views/settings.ts index 2dc0374..a0e3b56 100644 --- a/src/views/settings.ts +++ b/src/views/settings.ts @@ -1,6 +1,7 @@ import { escapeHtml } from "../lib/html.ts"; import { fmt, t } from "../lib/i18n/index.ts"; import type { ProfileInfo } from "../lib/profile.ts"; +import { wikiUrl } from "../lib/urls.ts"; import type { MembershipRow, RequestRow } from "../server/db/queries/index.ts"; import { ICONS } from "./icons.ts"; import { type LayoutOptions, layout } from "./layout.ts"; @@ -74,16 +75,18 @@ function renderRoleOptions( function renderMemberRow( m: MembershipRow, + ownerHandle: string, wikiSlug: string, wikiDid: string, profile: ProfileInfo | undefined, roleLabel: (r: string) => string, msg: ReturnType, ): string { + const base = wikiUrl(ownerHandle, wikiSlug); const isOwner = m.did === wikiDid; const roleCell = isOwner ? `${roleLabel("owner")}` - : `
    + : ` @@ -91,7 +94,7 @@ function renderMemberRow(
    `; const removeButton = isOwner ? "" - : `
    + : `
    `; return ` @@ -104,16 +107,18 @@ function renderMemberRow( function renderRequestRow( r: RequestRow, + ownerHandle: string, wikiSlug: string, profile: ProfileInfo | undefined, roleLabel: (r: string) => string, msg: ReturnType, ): string { + const base = wikiUrl(ownerHandle, wikiSlug); return ` ${renderIdentity(r.did, profile)} ${escapeHtml(r.created_at)} -
    + renderMemberRow( m, + ownerHandle, wikiSlug, wikiDid, profiles.get(m.did), @@ -177,7 +186,14 @@ function renderMembersSection( const requestRows = requests .map((r) => - renderRequestRow(r, wikiSlug, profiles.get(r.did), roleLabel, msg), + renderRequestRow( + r, + ownerHandle, + wikiSlug, + profiles.get(r.did), + roleLabel, + msg, + ), ) .join("\n"); @@ -218,12 +234,13 @@ function renderMembersSection( : "" } - ${renderAddMemberForm(wikiSlug, roleLabel, msg)} + ${renderAddMemberForm(ownerHandle, wikiSlug, roleLabel, msg)} `; } function renderDangerZone( wikiName: string, + ownerHandle: string, wikiSlug: string, isOwner: boolean, locale: string, @@ -237,12 +254,13 @@ function renderDangerZone( `; } + const base = wikiUrl(ownerHandle, wikiSlug); return `

    ${msg.settings.dangerZone}

    ${msg.settings.deleteWikiDescription}

    -