From 31a821261ed5a2a73d5f97f34bf8e05fe082c890 Mon Sep 17 00:00:00 2001 From: juprodh Date: Thu, 21 May 2026 16:10:15 +0800 Subject: [PATCH] Centralize code duplicates --- src/lib/access.ts | 33 +++- src/lib/csrf.ts | 24 +-- src/server/routes/explore.ts | 6 +- src/server/routes/home.ts | 6 +- src/server/routes/membership.ts | 27 +-- src/server/routes/note.ts | 84 +++------ src/server/routes/profile.ts | 14 +- src/server/routes/wiki.ts | 323 +++++++++----------------------- src/views/view-options.ts | 33 ++++ 9 files changed, 201 insertions(+), 349 deletions(-) create mode 100644 src/views/view-options.ts diff --git a/src/lib/access.ts b/src/lib/access.ts index 42031d0..45d1cac 100644 --- a/src/lib/access.ts +++ b/src/lib/access.ts @@ -70,24 +70,39 @@ export interface RequestContext { csrfToken: string | null; } -/** RequestContext with wiki guaranteed non-null and ownerHandle resolved. */ +/** + * RequestContext with wiki guaranteed non-null and ownerHandle resolved. + * `ownerHandle` is non-null — it falls back to the URL-supplied handle when + * profile resolution returns nothing, so routes never have to repeat the + * `?? urlHandle` fallback. + */ export interface WikiRequestContext extends RequestContext { wiki: WikiRow; - ownerHandle: string | null; + ownerHandle: string; +} + +function narrowWikiContext( + ctx: RequestContext & { ownerHandle?: string | null }, + urlHandle: string, +): WikiRequestContext { + return { + ...ctx, + wiki: ctx.wiki as WikiRow, + ownerHandle: ctx.ownerHandle ?? urlHandle, + }; } /** * 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, + urlHandle: string, wikiSlug: string, requiredAccess: "read" | "edit" | "admin", ): Promise { - const ctx = await resolveRequestContext(request, ownerHandle, wikiSlug); + const ctx = await resolveRequestContext(request, urlHandle, wikiSlug); if (!ctx.wiki) { throw new NotFoundError("Wiki not found", { i18nKey: "wikiNotFound" }); } @@ -98,7 +113,7 @@ export async function resolveWikiContext( if (!check[requiredAccess](ctx.access)) { throw new ForbiddenError(); } - return ctx as WikiRequestContext; + return narrowWikiContext(ctx, urlHandle); } /** @@ -108,14 +123,14 @@ export async function resolveWikiContext( */ export async function resolveWikiContextSoft( request: Request, - ownerHandle: string, + urlHandle: string, wikiSlug: string, ): Promise { - const ctx = await resolveRequestContext(request, ownerHandle, wikiSlug); + const ctx = await resolveRequestContext(request, urlHandle, wikiSlug); if (!ctx.wiki) { throw new NotFoundError("Wiki not found", { i18nKey: "wikiNotFound" }); } - return ctx as WikiRequestContext; + return narrowWikiContext(ctx, urlHandle); } export async function resolveRequestContext( diff --git a/src/lib/csrf.ts b/src/lib/csrf.ts index 38b36be..46d8875 100644 --- a/src/lib/csrf.ts +++ b/src/lib/csrf.ts @@ -41,21 +41,21 @@ export function verifyCsrfToken(submitted: string | null, did: string): void { */ export async function verifyCsrfForm(request: Request, did: string | null) { if (!did) throw new ForbiddenError(); - let form: ReturnType extends Promise< - infer F - > - ? F - : never; - try { - form = await request.formData(); - } catch { - // Bodyless POST — fall back to header-only check. - verifyCsrfToken(request.headers.get("X-CSRF-Token"), did); - return new FormData(); - } + const form = await readFormData(request); const submitted = (form.get(CSRF_FIELD) as string | null) ?? request.headers.get("X-CSRF-Token"); verifyCsrfToken(submitted, did); return form; } + +// Return an empty FormData rather than throwing when the request has no body +// — that path is for routes whose form has only the auto-injected _csrf field +// and falls back to header-only verification. +async function readFormData(request: Request) { + try { + return await request.formData(); + } catch { + return new FormData(); + } +} diff --git a/src/server/routes/explore.ts b/src/server/routes/explore.ts index dd5b84a..1711e90 100644 --- a/src/server/routes/explore.ts +++ b/src/server/routes/explore.ts @@ -3,6 +3,7 @@ import { LIMITS } from "../../lib/limits.ts"; import { parsePage, parseSort } from "../../lib/query-params.ts"; import { htmlResponse } from "../../lib/response.ts"; import { explorePage } from "../../views/explore.ts"; +import { baseLayoutOptions } from "../../views/view-options.ts"; import { contextPlugin } from "../context-plugin.ts"; import { getWikiLanguages, @@ -27,10 +28,7 @@ export const exploreRoutes = new Elysia() return htmlResponse( explorePage(wikis, total, languages, { - session: ctx.session, - locale: ctx.locale, - userTheme: ctx.userTheme, - csrfToken: ctx.csrfToken, + ...baseLayoutOptions(ctx), sort, page, }), diff --git a/src/server/routes/home.ts b/src/server/routes/home.ts index eb6493a..afbcc3b 100644 --- a/src/server/routes/home.ts +++ b/src/server/routes/home.ts @@ -3,6 +3,7 @@ import { getAtprotoEnv } from "../../atproto/env.ts"; import { LIMITS } from "../../lib/limits.ts"; import { htmlResponse } from "../../lib/response.ts"; import { homePage } from "../../views/home.ts"; +import { baseLayoutOptions } from "../../views/view-options.ts"; import { contextPlugin } from "../context-plugin.ts"; import { getWikiLanguages, @@ -21,10 +22,7 @@ export const homeRoute = new Elysia() const publicUrl = getAtprotoEnv()?.publicUrl ?? new URL(request.url).origin; return htmlResponse( homePage(wikis, languages, { - session: ctx.session, - locale: ctx.locale, - userTheme: ctx.userTheme, - csrfToken: ctx.csrfToken, + ...baseLayoutOptions(ctx), ogUrl: `${publicUrl}/`, ogImage: `${publicUrl}/public/og-home.png`, }), diff --git a/src/server/routes/membership.ts b/src/server/routes/membership.ts index 737e6e6..9c674b1 100644 --- a/src/server/routes/membership.ts +++ b/src/server/routes/membership.ts @@ -52,11 +52,9 @@ export const membershipRoutes = new Elysia() if (!ctx.session) throw new ForbiddenError(); await verifyCsrfForm(request, ctx.session.did); - const handle = - (ctx as { ownerHandle?: string | null }).ownerHandle ?? urlHandle; await requestAccessAction(ctx as WikiRequestContext); - return redirect(wikiUrl(handle, ctx.wiki.slug)); + return redirect(wikiUrl(ctx.ownerHandle ?? urlHandle, ctx.wiki.slug)); }) .get("/@:handle/:wikiSlug/-/members", async ({ params }) => { const { handle, wikiSlug } = hwp(params); @@ -76,14 +74,12 @@ export const membershipRoutes = new Elysia() wikiSlug, "admin", ); - const handle = ctx.ownerHandle ?? urlHandle; - const memberDid = decodeURIComponent(encodedDid); const formData = await verifyCsrfForm(request, ctx.did); const role = normalizeRole(formData.get("role") as string | null); - await approveMemberAction(ctx, memberDid, role); + await approveMemberAction(ctx, decodeURIComponent(encodedDid), role); - return redirect(settingsUrl(handle, ctx.wiki.slug)); + return redirect(settingsUrl(ctx.ownerHandle, ctx.wiki.slug)); }, ) .post( @@ -101,12 +97,10 @@ export const membershipRoutes = new Elysia() "admin", ); await verifyCsrfForm(request, ctx.did); - const handle = ctx.ownerHandle ?? urlHandle; - const memberDid = decodeURIComponent(encodedDid); - await deleteMemberAction(ctx, memberDid); + await deleteMemberAction(ctx, decodeURIComponent(encodedDid)); - return redirect(settingsUrl(handle, ctx.wiki.slug)); + return redirect(settingsUrl(ctx.ownerHandle, ctx.wiki.slug)); }, ) .post( @@ -123,21 +117,18 @@ export const membershipRoutes = new Elysia() wikiSlug, "admin", ); - const handle = ctx.ownerHandle ?? urlHandle; - const memberDid = decodeURIComponent(encodedDid); const formData = await verifyCsrfForm(request, ctx.did); const role = normalizeRole(formData.get("role") as string | null); - await changeMemberRoleAction(ctx, memberDid, role); + await changeMemberRoleAction(ctx, decodeURIComponent(encodedDid), role); - return redirect(settingsUrl(handle, ctx.wiki.slug)); + return redirect(settingsUrl(ctx.ownerHandle, ctx.wiki.slug)); }, ) .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 formData = await verifyCsrfForm(request, ctx.did); const handleOrDid = ((formData.get("did") as string | null) ?? "").trim(); const role = normalizeRole(formData.get("role") as string | null); @@ -149,5 +140,5 @@ export const membershipRoutes = new Elysia() await addMemberAction(ctx, memberDid, role); - return redirect(settingsUrl(handle, ctx.wiki.slug)); + return redirect(settingsUrl(ctx.ownerHandle, ctx.wiki.slug)); }); diff --git a/src/server/routes/note.ts b/src/server/routes/note.ts index 75ad598..735cc10 100644 --- a/src/server/routes/note.ts +++ b/src/server/routes/note.ts @@ -30,6 +30,7 @@ import { import { newNotePage } from "../../views/new-note.ts"; import { notePage } from "../../views/note.ts"; import { shareOnBlueskyButton } from "../../views/share.ts"; +import { wikiLayoutOptions } from "../../views/view-options.ts"; import { getCachedProfilesByDids, getNoteWithCurrent, @@ -62,29 +63,26 @@ function hwnp(params: { return params as unknown as HandleWikiNoteParams; } +const EDITOR_LAYOUT_EXTRAS = { + scripts: EDITOR_SCRIPTS, + stylesheets: KATEX_STYLESHEETS, +}; + 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, handle, ctx.wiki.slug, { - session: ctx.session, - scripts: EDITOR_SCRIPTS, - stylesheets: KATEX_STYLESHEETS, - locale: ctx.locale, - userTheme: ctx.userTheme, - csrfToken: ctx.csrfToken, - ownerHandle: handle, - wikiAtUri: ctx.wiki.at_uri, + newNotePage(ctx.wiki.name, ctx.ownerHandle, ctx.wiki.slug, { + ...wikiLayoutOptions(ctx), + ...EDITOR_LAYOUT_EXTRAS, }), ); }) .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 verifyCsrfForm(request, ctx.did); @@ -92,19 +90,13 @@ export const noteRoutes = new Elysia() try { const { noteSlug } = await createNoteAction(ctx, fields, msg); - return redirect(noteUrl(handle, ctx.wiki.slug, noteSlug)); + return redirect(noteUrl(ctx.ownerHandle, ctx.wiki.slug, noteSlug)); } catch (err) { if (err instanceof ValidationError) { return htmlResponse( - newNotePage(ctx.wiki.name, handle, ctx.wiki.slug, { - session: ctx.session, - scripts: EDITOR_SCRIPTS, - stylesheets: KATEX_STYLESHEETS, - locale: ctx.locale, - userTheme: ctx.userTheme, - csrfToken: ctx.csrfToken, - ownerHandle: handle, - wikiAtUri: ctx.wiki.at_uri, + newNotePage(ctx.wiki.name, ctx.ownerHandle, ctx.wiki.slug, { + ...wikiLayoutOptions(ctx), + ...EDITOR_LAYOUT_EXTRAS, error: err.message, titleValue: fields.title, contentValue: fields.content, @@ -117,7 +109,6 @@ export const noteRoutes = new Elysia() .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(ctx.wiki.at_uri, noteSlug); if (!data) @@ -126,36 +117,25 @@ export const noteRoutes = new Elysia() return htmlResponse( editNotePage( ctx.wiki.name, - handle, + ctx.ownerHandle, ctx.wiki.slug, noteSlug, data.note.title, data.current.content, - { - session: ctx.session, - scripts: EDITOR_SCRIPTS, - stylesheets: KATEX_STYLESHEETS, - locale: ctx.locale, - userTheme: ctx.userTheme, - csrfToken: ctx.csrfToken, - ownerHandle: handle, - wikiAtUri: ctx.wiki.at_uri, - }, + { ...wikiLayoutOptions(ctx), ...EDITOR_LAYOUT_EXTRAS }, ), ); }) .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 verifyCsrfForm(request, ctx.did); const fields = parseNoteFormFields(formData); await editNoteAction(ctx, noteSlug, fields, msg); - return redirect(noteUrl(handle, ctx.wiki.slug, noteSlug)); + return redirect(noteUrl(ctx.ownerHandle, ctx.wiki.slug, noteSlug)); }) .get( "/@:handle/:wikiSlug/:noteSlug/-/export", @@ -190,7 +170,6 @@ export const noteRoutes = new Elysia() wikiSlug, "read", ); - const handle = ctx.ownerHandle ?? urlHandle; const data = getNoteWithCurrent(ctx.wiki.at_uri, noteSlug); if (!data) @@ -243,7 +222,7 @@ export const noteRoutes = new Elysia() // "View more" button can swap itself out for the new entries + button. const isHxRequest = request.headers.get("HX-Request") === "true"; if (isHxRequest) { - const historyUrl = `/@${handle}/${ctx.wiki.slug}/${noteSlug}/-/history`; + const historyUrl = `/@${ctx.ownerHandle}/${ctx.wiki.slug}/${noteSlug}/-/history`; return htmlResponse( historyEntriesFragment( entries, @@ -257,23 +236,13 @@ export const noteRoutes = new Elysia() return htmlResponse( historyPage( ctx.wiki.name, - handle, + ctx.ownerHandle, ctx.wiki.slug, noteSlug, data.note.title, entries, hasMore, - { - session: ctx.session, - locale: ctx.locale, - userTheme: ctx.userTheme, - csrfToken: ctx.csrfToken, - accessLevel: ctx.access, - ownerHandle: handle, - wikiAtUri: ctx.wiki.at_uri, - wikiThemeMode: ctx.wiki.theme_mode, - wikiTheme: ctx.wiki.theme, - }, + wikiLayoutOptions(ctx), ), ); }, @@ -282,16 +251,15 @@ export const noteRoutes = new Elysia() const { handle: urlHandle, wikiSlug, noteSlug } = hwnp(params); const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "edit"); await verifyCsrfForm(request, ctx.did); - const handle = ctx.ownerHandle ?? urlHandle; await deleteNoteAction(ctx, noteSlug); - return redirect(wikiUrl(handle, ctx.wiki.slug)); + return redirect(wikiUrl(ctx.ownerHandle, ctx.wiki.slug)); }) .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 handle = ctx.ownerHandle; const data = getNoteWithCurrent(ctx.wiki.at_uri, noteSlug); if (!data) @@ -324,26 +292,18 @@ export const noteRoutes = new Elysia() data.note.title, html, { + ...wikiLayoutOptions(ctx), ...(hasViz && { scripts: VIZ_SCRIPTS }), ...(hasMath && { stylesheets: KATEX_STYLESHEETS }), - session: ctx.session, sidebarNotes, sidebarMarkdown: ctx.wiki.sidebar, currentNoteSlug: noteSlug, - locale: ctx.locale, - userTheme: ctx.userTheme, - csrfToken: ctx.csrfToken, - accessLevel: ctx.access, bookmarkHtml: bmHtml, shareHtml, - ownerHandle: handle, - wikiAtUri: ctx.wiki.at_uri, ogTitle: `${data.note.title} - ${ctx.wiki.name}`, ogUrl: canonicalUrl, ogImage, ...ogDescription, - wikiThemeMode: ctx.wiki.theme_mode, - wikiTheme: ctx.wiki.theme, }, ctx.wiki.language, ), diff --git a/src/server/routes/profile.ts b/src/server/routes/profile.ts index 49aca6c..342eb9c 100644 --- a/src/server/routes/profile.ts +++ b/src/server/routes/profile.ts @@ -4,6 +4,7 @@ import { resolveHandleToDid, resolveProfile } from "../../lib/profile.ts"; import { htmlResponse } from "../../lib/response.ts"; import { profileUrl, redirect } from "../../lib/urls.ts"; import { profilePage } from "../../views/profile.ts"; +import { baseLayoutOptions } from "../../views/view-options.ts"; import { contextPlugin } from "../context-plugin.ts"; import { getBookmarksForUser, @@ -38,12 +39,13 @@ export const profileRoutes = new Elysia() const bookmarks = getBookmarksForUser(did); return htmlResponse( - profilePage(profile, ownedWikis, collaboratingWikis, bookmarks, { - session: ctx.session, - locale: ctx.locale, - userTheme: ctx.userTheme, - csrfToken: ctx.csrfToken, - }), + profilePage( + profile, + ownedWikis, + collaboratingWikis, + bookmarks, + baseLayoutOptions(ctx), + ), ); }) .get("/profile-redirect", ({ ctx }) => { diff --git a/src/server/routes/wiki.ts b/src/server/routes/wiki.ts index 0a8b3aa..7db26ec 100644 --- a/src/server/routes/wiki.ts +++ b/src/server/routes/wiki.ts @@ -5,6 +5,7 @@ import { resolveRequestContext, resolveWikiContext, resolveWikiContextSoft, + type WikiRequestContext, } from "../../lib/access.ts"; import { EDITOR_SCRIPTS, @@ -39,6 +40,10 @@ import { newWikiPage } from "../../views/new-wiki.ts"; import { notePage } from "../../views/note.ts"; import { settingsPage } from "../../views/settings.ts"; import { shareOnBlueskyButton } from "../../views/share.ts"; +import { + baseLayoutOptions, + wikiLayoutOptions, +} from "../../views/view-options.ts"; import { wikiPage } from "../../views/wiki.ts"; import { getCurrentNote, @@ -70,19 +75,55 @@ async function loadSettingsData(wikiAtUri: string) { return { members, requests, profiles, notes }; } +/** + * Render the settings page. Success-path callers pass `query` so saved-banner + * flags survive the redirect. Validation-error callers pass `error` (and + * sometimes user-supplied name/description to echo back) plus `status: 400`. + */ +async function renderSettings( + ctx: WikiRequestContext, + query: Record = {}, + overrides: { + wikiName?: string; + wikiDescription?: string; + error?: string; + status?: number; + } = {}, +): Promise { + const { members, requests, profiles, notes } = await loadSettingsData( + ctx.wiki.at_uri, + ); + return htmlResponse( + settingsPage( + overrides.wikiName ?? ctx.wiki.name, + ctx.ownerHandle, + ctx.wiki.slug, + ctx.did === ctx.wiki.did, + members, + requests, + profiles, + { + ...wikiLayoutOptions(ctx), + wikiDid: ctx.wiki.did, + wikiHomeSlug: ctx.wiki.home_slug, + wikiNotes: notes, + wikiDescription: overrides.wikiDescription ?? ctx.wiki.description, + detailsSaved: query["saved"] === "1", + themeSaved: query["themeSaved"] === "1", + homepageSaved: query["homepageSaved"] === "1", + ...(overrides.error ? { error: overrides.error } : {}), + }, + ), + overrides.status ?? 200, + ); +} + const LINK_SUGGEST_LIMIT = 20; export const wikiCreationRoutes = new Elysia({ prefix: "/wiki" }) .get("/new", async ({ request }) => { const ctx = await resolveRequestContext(request); - return htmlResponse( - newWikiPage({ - session: ctx.session, - locale: ctx.locale, - userTheme: ctx.userTheme, - csrfToken: ctx.csrfToken, - }), - ); + return htmlResponse(newWikiPage(baseLayoutOptions(ctx))); }) .post("/new", async ({ request }) => { const ctx = await resolveRequestContext(request); @@ -101,10 +142,7 @@ export const wikiCreationRoutes = new Elysia({ prefix: "/wiki" }) const renderError = (error: string) => htmlResponse( newWikiPage({ - session: ctx.session, - locale: ctx.locale, - userTheme: ctx.userTheme, - csrfToken: ctx.csrfToken, + ...baseLayoutOptions(ctx), error, nameValue: name, languageValue: language, @@ -145,46 +183,11 @@ 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, notes } = await loadSettingsData( - ctx.wiki.at_uri, - ); - - return htmlResponse( - settingsPage( - ctx.wiki.name, - handle, - ctx.wiki.slug, - isOwner, - members, - requests, - profiles, - { - session: ctx.session, - locale: ctx.locale, - userTheme: ctx.userTheme, - csrfToken: ctx.csrfToken, - accessLevel: ctx.access, - ownerHandle: handle, - wikiAtUri: ctx.wiki.at_uri, - wikiDid: ctx.wiki.did, - wikiHomeSlug: ctx.wiki.home_slug, - wikiNotes: notes, - wikiDescription: ctx.wiki.description, - wikiThemeMode: ctx.wiki.theme_mode, - wikiTheme: ctx.wiki.theme, - detailsSaved: query["saved"] === "1", - themeSaved: query["themeSaved"] === "1", - homepageSaved: query["homepageSaved"] === "1", - }, - ), - ); + return renderSettings(ctx, query); }) .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 verifyCsrfForm(request, ctx.did); const name = (formData.get("name") as string | null) ?? ""; @@ -192,40 +195,18 @@ export const wikiRoutes = new Elysia() try { await editWikiAction(ctx, { name, description }, msg); - return redirect(`${settingsUrl(handle, ctx.wiki.slug)}?saved=1`); + return redirect(`${settingsUrl(ctx.ownerHandle, ctx.wiki.slug)}?saved=1`); } catch (err) { if (err instanceof ValidationError) { - const isOwner = ctx.did === ctx.wiki.did; - const { members, requests, profiles, notes } = await loadSettingsData( - ctx.wiki.at_uri, - ); - return htmlResponse( - settingsPage( - name || ctx.wiki.name, - handle, - ctx.wiki.slug, - isOwner, - members, - requests, - profiles, - { - session: ctx.session, - locale: ctx.locale, - userTheme: ctx.userTheme, - csrfToken: ctx.csrfToken, - accessLevel: ctx.access, - ownerHandle: handle, - wikiAtUri: ctx.wiki.at_uri, - wikiDid: ctx.wiki.did, - wikiHomeSlug: ctx.wiki.home_slug, - wikiNotes: notes, - wikiDescription: description, - wikiThemeMode: ctx.wiki.theme_mode, - wikiTheme: ctx.wiki.theme, - error: err.message, - }, - ), - 400, + return renderSettings( + ctx, + {}, + { + wikiName: name || ctx.wiki.name, + wikiDescription: description, + error: err.message, + status: 400, + }, ); } throw err; @@ -234,7 +215,6 @@ export const wikiRoutes = new Elysia() .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 verifyCsrfForm(request, ctx.did); const choice = (formData.get("theme_choice") as string | null) ?? "default"; @@ -243,41 +223,12 @@ export const wikiRoutes = new Elysia() try { await setWikiThemeAction(ctx, { themeMode, theme }, msg); - return redirect(`${settingsUrl(handle, ctx.wiki.slug)}?themeSaved=1`); + return redirect( + `${settingsUrl(ctx.ownerHandle, ctx.wiki.slug)}?themeSaved=1`, + ); } catch (err) { if (err instanceof ValidationError) { - const isOwner = ctx.did === ctx.wiki.did; - const { members, requests, profiles, notes } = await loadSettingsData( - ctx.wiki.at_uri, - ); - return htmlResponse( - settingsPage( - ctx.wiki.name, - handle, - ctx.wiki.slug, - isOwner, - members, - requests, - profiles, - { - session: ctx.session, - locale: ctx.locale, - userTheme: ctx.userTheme, - csrfToken: ctx.csrfToken, - accessLevel: ctx.access, - ownerHandle: handle, - wikiAtUri: ctx.wiki.at_uri, - wikiDid: ctx.wiki.did, - wikiHomeSlug: ctx.wiki.home_slug, - wikiNotes: notes, - wikiDescription: ctx.wiki.description, - wikiThemeMode: ctx.wiki.theme_mode, - wikiTheme: ctx.wiki.theme, - error: err.message, - }, - ), - 400, - ); + return renderSettings(ctx, {}, { error: err.message, status: 400 }); } throw err; } @@ -285,77 +236,38 @@ export const wikiRoutes = new Elysia() .post("/@:handle/:wikiSlug/-/home", 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 verifyCsrfForm(request, ctx.did); const raw = ((formData.get("home_slug") as string | null) ?? "").trim(); const homeSlug = raw === "" ? null : raw; - if (homeSlug !== null) { - const exists = getNoteWithCurrent(ctx.wiki.at_uri, homeSlug); - if (!exists) { - const isOwner = ctx.did === ctx.wiki.did; - const { members, requests, profiles, notes } = await loadSettingsData( - ctx.wiki.at_uri, - ); - return htmlResponse( - settingsPage( - ctx.wiki.name, - handle, - ctx.wiki.slug, - isOwner, - members, - requests, - profiles, - { - session: ctx.session, - locale: ctx.locale, - userTheme: ctx.userTheme, - csrfToken: ctx.csrfToken, - accessLevel: ctx.access, - ownerHandle: handle, - wikiAtUri: ctx.wiki.at_uri, - wikiDid: ctx.wiki.did, - wikiHomeSlug: ctx.wiki.home_slug, - wikiNotes: notes, - wikiDescription: ctx.wiki.description, - wikiThemeMode: ctx.wiki.theme_mode, - wikiTheme: ctx.wiki.theme, - error: msg.settings.homepageInvalidNote, - }, - ), - 400, - ); - } + if (homeSlug !== null && !getNoteWithCurrent(ctx.wiki.at_uri, homeSlug)) { + return renderSettings( + ctx, + {}, + { error: msg.settings.homepageInvalidNote, status: 400 }, + ); } setWikiHomeSlug(ctx.wiki.did, ctx.wiki.slug, homeSlug); - return redirect(`${settingsUrl(handle, ctx.wiki.slug)}?homepageSaved=1`); + return redirect( + `${settingsUrl(ctx.ownerHandle, ctx.wiki.slug)}?homepageSaved=1`, + ); }) .get("/@:handle/:wikiSlug/-/sidebar/edit", async ({ params, request }) => { const { handle: urlHandle, wikiSlug } = hp(params); const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "admin"); - const handle = ctx.ownerHandle ?? urlHandle; - const sidebarNotes = getSidebarNotes(ctx.wiki.at_uri); return htmlResponse( editSidebarPage( ctx.wiki.name, - handle, + ctx.ownerHandle, ctx.wiki.slug, ctx.wiki.sidebar ?? "", - sidebarNotes, + getSidebarNotes(ctx.wiki.at_uri), { - session: ctx.session, + ...wikiLayoutOptions(ctx), scripts: EDITOR_SCRIPTS, stylesheets: KATEX_STYLESHEETS, - locale: ctx.locale, - userTheme: ctx.userTheme, - csrfToken: ctx.csrfToken, - accessLevel: ctx.access, - ownerHandle: handle, - wikiAtUri: ctx.wiki.at_uri, - wikiThemeMode: ctx.wiki.theme_mode, - wikiTheme: ctx.wiki.theme, }, ), ); @@ -363,32 +275,22 @@ export const wikiRoutes = new Elysia() .post("/@:handle/:wikiSlug/-/sidebar/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 verifyCsrfForm(request, ctx.did); const content = ((formData.get("content") as string | null) ?? "").trim(); if (content.length > LIMITS.wiki.sidebar) { - const sidebarNotes = getSidebarNotes(ctx.wiki.at_uri); return htmlResponse( editSidebarPage( ctx.wiki.name, - handle, + ctx.ownerHandle, ctx.wiki.slug, content, - sidebarNotes, + getSidebarNotes(ctx.wiki.at_uri), { - session: ctx.session, + ...wikiLayoutOptions(ctx), scripts: EDITOR_SCRIPTS, stylesheets: KATEX_STYLESHEETS, - locale: ctx.locale, - userTheme: ctx.userTheme, - csrfToken: ctx.csrfToken, - accessLevel: ctx.access, - ownerHandle: handle, - wikiAtUri: ctx.wiki.at_uri, - wikiThemeMode: ctx.wiki.theme_mode, - wikiTheme: ctx.wiki.theme, error: msg.wiki.sidebarTooLong, }, ), @@ -402,46 +304,18 @@ export const wikiRoutes = new Elysia() ctx.wiki.slug, content === "" ? null : content, ); - return redirect(wikiUrl(handle, ctx.wiki.slug)); + return redirect(wikiUrl(ctx.ownerHandle, ctx.wiki.slug)); }) .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 verifyCsrfForm(request, ctx.did); const confirm = (formData.get("confirm") as string | null) ?? ""; if (confirm !== ctx.wiki.name) { - const isOwner = ctx.did === ctx.wiki.did; - const { members, requests, profiles, notes } = await loadSettingsData( - ctx.wiki.at_uri, - ); - return htmlResponse( - settingsPage( - ctx.wiki.name, - handle, - ctx.wiki.slug, - isOwner, - members, - requests, - profiles, - { - session: ctx.session, - locale: ctx.locale, - userTheme: ctx.userTheme, - csrfToken: ctx.csrfToken, - accessLevel: ctx.access, - ownerHandle: handle, - wikiAtUri: ctx.wiki.at_uri, - wikiDid: ctx.wiki.did, - wikiHomeSlug: ctx.wiki.home_slug, - wikiNotes: notes, - wikiDescription: ctx.wiki.description, - wikiThemeMode: ctx.wiki.theme_mode, - wikiTheme: ctx.wiki.theme, - error: "Wiki name does not match.", - }, - ), - 400, + return renderSettings( + ctx, + {}, + { error: "Wiki name does not match.", status: 400 }, ); } await deleteWikiAction(ctx); @@ -495,15 +369,12 @@ export const wikiRoutes = new Elysia() .get("/@:handle/:wikiSlug", async ({ params, request }) => { const { handle: urlHandle, wikiSlug } = hp(params); const ctx = await resolveWikiContextSoft(request, urlHandle, wikiSlug); - const handle = ctx.ownerHandle ?? urlHandle; + const handle = ctx.ownerHandle; if (!canRead(ctx.access)) { return htmlResponse( accessDeniedPage(ctx.wiki.name, handle, ctx.wiki.slug, { - session: ctx.session, - locale: ctx.locale, - userTheme: ctx.userTheme, - csrfToken: ctx.csrfToken, + ...baseLayoutOptions(ctx), hasPendingRequest: ctx.hasPendingRequest, }), 403, @@ -556,26 +427,18 @@ export const wikiRoutes = new Elysia() homeData.note.title, html, { + ...wikiLayoutOptions(ctx), ...(hasViz && { scripts: VIZ_SCRIPTS }), ...(hasMath && { stylesheets: KATEX_STYLESHEETS }), - session: ctx.session, sidebarNotes, sidebarMarkdown: ctx.wiki.sidebar, currentNoteSlug: homeData.note.slug, - locale: ctx.locale, - userTheme: ctx.userTheme, - csrfToken: ctx.csrfToken, - accessLevel: ctx.access, bookmarkHtml: bmHtml, shareHtml, - ownerHandle: handle, - wikiAtUri: ctx.wiki.at_uri, ogTitle: ctx.wiki.name, ogUrl: canonicalUrl, ogImage, ...ogDescription, - wikiThemeMode: ctx.wiki.theme_mode, - wikiTheme: ctx.wiki.theme, }, ctx.wiki.language, ), @@ -589,23 +452,15 @@ export const wikiRoutes = new Elysia() ctx.wiki.slug, handle, { - session: ctx.session, + ...wikiLayoutOptions(ctx), sidebarNotes, sidebarMarkdown: ctx.wiki.sidebar, - locale: ctx.locale, - userTheme: ctx.userTheme, - csrfToken: ctx.csrfToken, - accessLevel: ctx.access, bookmarkHtml: bmHtml, - ownerHandle: handle, - wikiAtUri: ctx.wiki.at_uri, wikiSlug: ctx.wiki.slug, ogTitle: ctx.wiki.name, ogUrl: `${publicUrl}${wikiUrl(handle, ctx.wiki.slug)}`, ogImage, ...ogDescription, - wikiThemeMode: ctx.wiki.theme_mode, - wikiTheme: ctx.wiki.theme, }, ctx.wiki.language, ), diff --git a/src/views/view-options.ts b/src/views/view-options.ts new file mode 100644 index 0000000..753d242 --- /dev/null +++ b/src/views/view-options.ts @@ -0,0 +1,33 @@ +import type { RequestContext, WikiRequestContext } from "../lib/access.ts"; + +/** + * Common subset of LayoutOptions derived purely from the request context. + * Every page renders these — having a helper avoids spelling the same four + * fields at twenty call sites. The return type is inferred so callers can + * spread it into option objects that demand `NonNullable<...>` for these + * fields (e.g. SettingsPageOptions). + */ +export function baseLayoutOptions(ctx: RequestContext) { + return { + session: ctx.session, + locale: ctx.locale, + userTheme: ctx.userTheme, + csrfToken: ctx.csrfToken, + }; +} + +/** + * baseLayoutOptions + the wiki-scoped fields (access level, owner handle, + * wiki at-uri, per-wiki theme). Use from any route that calls + * `resolveWikiContext`. + */ +export function wikiLayoutOptions(ctx: WikiRequestContext) { + return { + ...baseLayoutOptions(ctx), + accessLevel: ctx.access, + ownerHandle: ctx.ownerHandle, + wikiAtUri: ctx.wiki.at_uri, + wikiThemeMode: ctx.wiki.theme_mode, + wikiTheme: ctx.wiki.theme, + }; +} -- 2.51.2