From 597151b478dabc73e58b6ffaf7834081af6db826 Mon Sep 17 00:00:00 2001 From: juprodh Date: Tue, 2 Jun 2026 10:14:35 +0800 Subject: [PATCH] Delete some comments --- public/editor/editor.ts | 13 ++----- public/editor/preview.ts | 6 ++-- public/editor/upload.ts | 1 - public/editor/wikilink-autocomplete.ts | 13 ++----- public/style.css | 2 +- public/viz/viz-hydrate.ts | 1 - src/atproto/env.ts | 7 +--- src/atproto/session.ts | 10 ++---- src/firehose/handlers.ts | 30 ++++------------ src/firehose/index.ts | 9 ++--- src/lib/assets.ts | 23 ++---------- src/lib/csrf.ts | 27 +++----------- src/lib/diff-render.ts | 14 +++----- src/lib/headings.ts | 7 ++-- src/lib/identity.ts | 4 --- src/lib/image.ts | 8 ++--- src/lib/import-export/export.ts | 19 ++-------- src/lib/import-export/import.ts | 10 ++---- src/lib/import-export/markdown-transform.ts | 32 +++-------------- src/lib/import-export/zip-parse.ts | 14 ++------ src/lib/limits.ts | 28 ++++----------- src/lib/markdown.ts | 9 +---- src/lib/markdown/base-plugins.ts | 11 +----- src/lib/markdown/comment-plugin.ts | 4 +-- src/lib/markdown/heading-anchor-plugin.ts | 6 +--- src/lib/markdown/highlight-plugin.ts | 5 +-- src/lib/markdown/sanitize.ts | 10 +----- src/lib/markdown/youtube-plugin.ts | 10 ++---- src/lib/note-validation.ts | 1 - src/lib/og-card.ts | 28 +++------------ src/lib/orchestrators/helpers.ts | 5 +-- src/lib/orchestrators/membership.ts | 28 ++------------- src/lib/orchestrators/note.ts | 23 ++---------- src/lib/orchestrators/wiki.ts | 39 ++++----------------- src/lib/pds-fetch.ts | 18 ++-------- src/lib/profile.ts | 27 +++----------- src/lib/rate-limit.ts | 6 +--- src/lib/response.ts | 8 +---- src/server/app.ts | 5 +-- src/server/canonical-handle-plugin.ts | 8 ++--- src/server/context-plugin.ts | 11 +----- src/server/db/queries/index.ts | 1 - src/server/db/queries/note.ts | 15 ++------ src/server/db/queries/profile-cache.ts | 5 +-- src/server/db/queries/revision.ts | 13 ++----- src/server/db/queries/wiki.ts | 4 +-- src/server/db/schema.ts | 1 - src/server/db/types.ts | 3 +- src/server/routes/blob.ts | 4 +-- src/server/routes/note.ts | 6 ++-- src/server/routes/og.ts | 6 ++-- src/server/routes/params.ts | 3 +- src/server/routes/search.ts | 4 +-- src/server/routes/seo.ts | 4 +-- src/server/routes/wiki.ts | 13 ++----- src/views/edit-note.ts | 6 +--- src/views/edit-sidebar.ts | 10 ++---- src/views/history.ts | 13 ++----- src/views/icons.ts | 3 +- src/views/layout.ts | 15 +++----- src/views/members.ts | 3 +- src/views/new-note.ts | 4 +-- src/views/theme/apply.ts | 30 +++------------- src/views/theme/resolve.ts | 7 +--- src/views/view-options.ts | 13 +------ 65 files changed, 134 insertions(+), 592 deletions(-) diff --git a/public/editor/editor.ts b/public/editor/editor.ts index 336e047..942addf 100644 --- a/public/editor/editor.ts +++ b/public/editor/editor.ts @@ -59,7 +59,6 @@ function initEditor(root: Document | Element = document): void { textarea.style.display = "none"; - // Create split-pane container const wrapper = document.createElement("div"); wrapper.style.display = "flex"; wrapper.style.gap = "1rem"; @@ -92,7 +91,7 @@ function initEditor(root: Document | Element = document): void { wrapper.appendChild(preview); textarea.parentElement?.appendChild(wrapper); - // --- CRITICAL: Force scrollable editor with CSS --- + // CodeMirror won't scroll without explicit height/flex !important. const styleId = "lichen-editor-scroll-fix"; if (!document.getElementById(styleId)) { const style = document.createElement("style"); @@ -111,7 +110,6 @@ function initEditor(root: Document | Element = document): void { `; document.head.appendChild(style); } - // ------------------------------------------------ let debounceTimer: ReturnType; @@ -245,11 +243,7 @@ function initEditor(root: Document | Element = document): void { }); } -/** - * Set up the single-file import handler on the New Note page. - * Reads a .md file and populates the title and content fields. - * If CodeMirror is already mounted, updates its state directly. - */ +// New Note import: read a .md file into the title/content fields (and CodeMirror if mounted). function initFileImport(): void { const fileInput = document.getElementById( "import-file", @@ -288,7 +282,6 @@ function initFileImport(): void { textarea.value = content; } - // If CodeMirror is mounted, update its state if (activeView) { activeView.dispatch({ changes: { @@ -304,7 +297,7 @@ function initFileImport(): void { }); } -// Initial hydration — script loads in , so wait for DOM +// Script loads in , so wait for DOM. document.addEventListener("DOMContentLoaded", () => { initEditor(); initFileImport(); diff --git a/public/editor/preview.ts b/public/editor/preview.ts index a8819ac..c0108b9 100644 --- a/public/editor/preview.ts +++ b/public/editor/preview.ts @@ -11,16 +11,14 @@ const PURIFY_CONFIG = { ADD_ATTR: ["allow", "allowfullscreen", "loading"], }; -// Defence in depth: lock embedded iframes to the youtube-nocookie host (the -// only iframe source our markdown produces), mirroring the server sanitizer. +// Defence in depth: lock iframes to the youtube-nocookie host (mirrors the server sanitizer). DOMPurify.addHook("uponSanitizeElement", (node, data) => { if (data.tagName !== "iframe" || !(node instanceof Element)) return; const src = node.getAttribute("src") ?? ""; if (!src.startsWith("https://www.youtube-nocookie.com/")) node.remove(); }); -// Editor URLs are /@{handle}/{wikiSlug}/... — recover both so wikilinks in the -// preview resolve to the same hrefs the server would render. +// Editor URLs are /@{handle}/{wikiSlug}/… — recover both so preview wikilinks match server hrefs. function getWikiContext(): { ownerHandle?: string; wikiSlug?: string } { const match = window.location.pathname.match(/^\/@([^/]+)\/([^/]+)/); if (!match) return {}; diff --git a/public/editor/upload.ts b/public/editor/upload.ts index ef754c9..5ae784a 100644 --- a/public/editor/upload.ts +++ b/public/editor/upload.ts @@ -50,7 +50,6 @@ export async function uploadImage( } const replacement = `![image](${json.url})`; - // Find the placeholder in current doc state const doc = view.state.doc.toString(); const placeholderIdx = doc.indexOf(placeholder, insertPos); if (placeholderIdx >= 0) { diff --git a/public/editor/wikilink-autocomplete.ts b/public/editor/wikilink-autocomplete.ts index 7c76234..7bd264b 100644 --- a/public/editor/wikilink-autocomplete.ts +++ b/public/editor/wikilink-autocomplete.ts @@ -12,13 +12,10 @@ interface HeadingItem { level: number; } -/** Matches the text inside an unclosed `[[` up to the cursor. */ +// Matches the text inside an unclosed `[[` up to the cursor. const TRIGGER_RE = /\[\[([^[\]\n]*)$/; -/** - * CodeMirror autocomplete source: triggers inside `[[...`. Returns note titles - * by default; after the user types `#`, returns headings for the named note. - */ +// Autocomplete inside `[[…`: note titles, or a note's headings after a `#`. export function wikilinkCompletion( ownerHandle: string, wikiSlug: string, @@ -66,11 +63,7 @@ export function wikilinkCompletion( }; } -/** - * Insert `target` at `[from, to]`. If `closeBrackets` already placed a `]]` - * just past `to`, leave it and park the cursor before it — so typing `#` next - * stays inside the link and re-triggers the heading completion. - */ +// If closeBrackets already added `]]` past `to`, park the cursor before it so typing `#` re-triggers heading completion. function applyLink(target: string) { return ( view: EditorView, diff --git a/public/style.css b/public/style.css index 1d01c05..d962ff6 100644 --- a/public/style.css +++ b/public/style.css @@ -129,7 +129,7 @@ /* ==highlight== markup */ mark { - background-color: var(--accent-soft); + background-color: var(--accent-soft-border); color: inherit; padding: 0.1em 0.2em; border-radius: 2px; diff --git a/public/viz/viz-hydrate.ts b/public/viz/viz-hydrate.ts index 25549be..7a877d8 100644 --- a/public/viz/viz-hydrate.ts +++ b/public/viz/viz-hydrate.ts @@ -29,7 +29,6 @@ function hydrateViz(root: Document | Element = document): void { } } -// Initial hydration hydrateViz(); // Re-hydrate after HTMX partial swaps diff --git a/src/atproto/env.ts b/src/atproto/env.ts index f4337fb..be6e028 100644 --- a/src/atproto/env.ts +++ b/src/atproto/env.ts @@ -44,12 +44,7 @@ const DEFAULT_DEV_ACCOUNTS: Record = { }, }; -/** - * Dev-mode accounts. When OAuth is not configured, the app falls back to - * cookie-only impersonation against this list. Returns null when OAuth is - * configured (production); otherwise reads DEV_ACCOUNTS env or hands back - * the baked-in alice/bob defaults. - */ +// Cookie-only impersonation list used when OAuth is off; null in production. export function getDevAccounts(): Record | null { if (getAtprotoEnv() !== null) return null; const raw = process.env["DEV_ACCOUNTS"]; diff --git a/src/atproto/session.ts b/src/atproto/session.ts index ffae4fa..27d06a7 100644 --- a/src/atproto/session.ts +++ b/src/atproto/session.ts @@ -37,10 +37,7 @@ export async function getSession( } } -/** - * Cookie-only dev session. The DID cookie is trusted because OAuth is not - * configured; only valid in dev where DEV_ACCOUNTS lists the impersonatable users. - */ +// Dev only: the DID cookie is trusted because OAuth isn't configured (no real auth). export function getDevSession( cookieHeader: string | undefined, ): Session | null { @@ -59,10 +56,7 @@ export function getDevSession( return { did, handle: account.handle, avatar: null }; } -/** - * Get an RPC client authenticated for the session. Returns null in dev mode — - * dev has no PDS, so orchestrators skip PDS writes and go straight to DB. - */ +// null in dev mode — no PDS there, so orchestrators skip PDS writes and go straight to the DB. export function getAgent(session: Session): Client | null { if (session.oauthSession) { return new Client({ handler: session.oauthSession }); diff --git a/src/firehose/handlers.ts b/src/firehose/handlers.ts index b259445..87e444a 100644 --- a/src/firehose/handlers.ts +++ b/src/firehose/handlers.ts @@ -68,12 +68,7 @@ interface BookmarkRecord { createdAt: string; } -// --- Size guards --- -// -// Other PDSes are not trusted to enforce our lexicon maxima. Records that -// exceed the declared limits are logged and skipped — never throw, so one bad -// record can't kill the subscriber. - +// Other PDSes don't enforce our lexicon maxima; oversized records are logged and skipped, never thrown. function logDrop(atUri: string, reason: string): void { console.warn(`[firehose] dropping record ${atUri}: ${reason}`); } @@ -145,8 +140,6 @@ function membershipExceedsLimits(atUri: string, r: MembershipRecord): boolean { return false; } -// --- Type guards --- - type Rec = Record; function hasString(r: Rec, key: string): boolean { @@ -196,19 +189,13 @@ function isBookmarkRecord(r: Rec): r is Rec & BookmarkRecord { return hasString(r, "wikiRef") && hasString(r, "createdAt"); } -// --- Event dispatch --- - -/** - * Normalized commit event consumed by the handler. Built from a jetstream - * message in production; integration tests invoke this handler directly - * after writing to the test PDS, since asynchrony adds nothing testable. - */ +// Built from a jetstream message in prod; tests invoke handleCommitEvent directly. export interface FirehoseCommit { did: string; collection: string; rkey: string; operation: "create" | "update" | "delete"; - /** The record body. Required for create/update; ignored for delete. */ + // Record body: required for create/update, ignored for delete. record?: unknown; } @@ -250,8 +237,7 @@ export function handleCommitEvent(evt: FirehoseCommit): void { } } -// --- Handlers (validation already done by type guards) --- - +// Handlers — records are already validated by the type guards above. function handleWiki( did: string, rkey: string, @@ -269,9 +255,7 @@ function handleWiki( record.description ?? "", ); - // Theme sync from PDS. A known theme name means the wiki enforces that - // preset. Absence (or an unknown name from a future Lichen build / another - // app's preset) falls back to reader mode so visitors keep their own theme. + // Known theme name = enforce that preset; anything else falls back to reader mode. const theme = record.theme; if (typeof theme === "string" && theme in themes) { setWikiTheme(did, rkey, "enforce", theme); @@ -367,9 +351,7 @@ function handleDelete(atUri: string, collection: string): void { deleteNoteByAtUri(atUri); break; case COLLECTIONS.noteRevision: - // Revisions form a diff chain -- deleting one would break the chain - // for all subsequent revisions. We intentionally retain the appview - // copy even if the PDS record is deleted. + // Retained even if deleted on the PDS: removing one would break the diff chain. break; case COLLECTIONS.membership: deleteMembershipByUri(atUri); diff --git a/src/firehose/index.ts b/src/firehose/index.ts index 0c14f9d..bc05669 100644 --- a/src/firehose/index.ts +++ b/src/firehose/index.ts @@ -5,8 +5,7 @@ import { getCursor, setCursor } from "../server/db/queries/index.ts"; import { type FirehoseCommit, handleCommitEvent } from "./handlers.ts"; const jetstreamUrl = getJetstreamUrl(); -// Dev mode has no real users on the network; persisting a cursor would only ask -// jetstream for events older than its retention window on the next run. +// No cursor in dev: there are no real network users, only events past jetstream's retention. const isDevMode = !isAuthEnabled(); const savedCursor = isDevMode ? null : getCursor(); @@ -21,8 +20,7 @@ const subscription = new JetstreamSubscription({ ...(savedCursor != null && { cursor: savedCursor }), }); -// Persist the cursor every 5s so a restart resumes near where we left off. -// Jetstream cursors are microsecond timestamps. +// Persist the cursor (a microsecond timestamp) every 5s so a restart resumes near where we stopped. const cursorInterval = setInterval(() => { if (!isDevMode && subscription.cursor != null) { setCursor(subscription.cursor); @@ -68,8 +66,7 @@ function shutdown(): void { if (!isDevMode && subscription.cursor != null) { setCursor(subscription.cursor); } - // JetstreamSubscription closes its WebSocket when the async iterator exits; - // process.exit triggers that via teardown. + // The subscription closes its WebSocket when the iterator exits, which process.exit triggers. process.exit(0); } diff --git a/src/lib/assets.ts b/src/lib/assets.ts index 72a21f4..37056c7 100644 --- a/src/lib/assets.ts +++ b/src/lib/assets.ts @@ -2,16 +2,6 @@ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -/** - * Content-addressed URLs for our shipped static assets, so a deploy that - * changes a file invalidates CDN + browser caches automatically (no manual - * purge). External URLs (CDNs) and URLs with an existing query string pass - * through unchanged. - * - * Hashes are computed lazily on first request and cached for the lifetime of - * the process — server restart picks up new content. - */ - const PUBLIC_DIR = join(import.meta.dir, "..", "..", "public"); const hashCache = new Map(); @@ -25,14 +15,6 @@ function computeHash(localPath: string): string | null { } } -/** - * Returns a versioned URL for an asset path. Pass paths that begin with - * `/public/` or are external URLs; both are handled. - * - * Examples: - * assetUrl("/public/dist.css") → "/public/dist.css?v=abcd1234ef" - * assetUrl("https://cdn.example/foo.js") → "https://cdn.example/foo.js" (unchanged) - */ export const VIZ_SCRIPTS = [ "https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js", "/public/viz/dist.js", @@ -42,15 +24,14 @@ export const EDITOR_SCRIPTS = ["/public/editor/dist.js"]; export const KATEX_STYLESHEETS = ["/public/katex.css"]; +// Appends a content hash (?v=…) so a deploy busts CDN/browser caches automatically. export function assetUrl(path: string): string { if (/^https?:\/\//.test(path)) return path; if (path.includes("?")) return path; if (!path.startsWith("/public/")) return path; const localPath = path.slice("/public/".length); - // Defence in depth: never let a path escape the public dir via `..`. - // Inputs are static constants today, but this keeps the disk read safe. - if (localPath.includes("..")) return path; + if (localPath.includes("..")) return path; // never let a path escape the public dir let hash = hashCache.get(localPath); if (hash === undefined) { hash = computeHash(localPath) ?? ""; diff --git a/src/lib/csrf.ts b/src/lib/csrf.ts index 46d8875..30a66e3 100644 --- a/src/lib/csrf.ts +++ b/src/lib/csrf.ts @@ -1,16 +1,8 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { ForbiddenError } from "./errors.ts"; -// Stateless CSRF: token = HMAC(secret, did). Bound to the session DID so a -// cross-origin attacker without read access to the session cookie can't forge -// a valid token. SameSite=Lax on the session cookie already blocks the basic -// CSRF case — this is the defense-in-depth layer the project's known-improvements -// list called out. -// -// The secret is read from CSRF_SECRET if provided, otherwise generated per -// process. A per-process secret means tokens invalidate across restarts, which -// is fine: the cost is a single resubmit on the rare destructive form open -// across a deploy. Set CSRF_SECRET to share state across processes. +// Stateless CSRF: token = HMAC(secret, did), bound to the session DID (defense-in-depth over SameSite=Lax). +// Per-process secret unless CSRF_SECRET is set, so tokens reset on restart — costs only a rare form resubmit. const CSRF_SECRET = process.env["CSRF_SECRET"] ?? randomBytes(32).toString("hex"); const CSRF_FIELD = "_csrf"; @@ -19,10 +11,6 @@ export function csrfTokenFor(did: string): string { return createHmac("sha256", CSRF_SECRET).update(did).digest("base64url"); } -/** - * Verify a CSRF token. Throws ForbiddenError on mismatch. - * Call from destructive POST handlers after the session has been resolved. - */ export function verifyCsrfToken(submitted: string | null, did: string): void { if (!submitted) throw new ForbiddenError("Missing CSRF token"); const expected = csrfTokenFor(did); @@ -33,12 +21,7 @@ export function verifyCsrfToken(submitted: string | null, did: string): void { } } -/** - * Read form data and verify CSRF. Accepts the token from either the hidden - * _csrf form field (standard forms) or the X-CSRF-Token header (HTMX requests - * and bodyless POSTs). Throws ForbiddenError if `did` is null so route - * handlers don't need to non-null-assert their session. - */ +// Token comes from the _csrf field or the X-CSRF-Token header (HTMX/bodyless POSTs). export async function verifyCsrfForm(request: Request, did: string | null) { if (!did) throw new ForbiddenError(); const form = await readFormData(request); @@ -49,9 +32,7 @@ export async function verifyCsrfForm(request: Request, did: string | null) { 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. +// Empty FormData (not a throw) for bodyless POSTs that verify via header only. async function readFormData(request: Request) { try { return await request.formData(); diff --git a/src/lib/diff-render.ts b/src/lib/diff-render.ts index 39063b2..66330d4 100644 --- a/src/lib/diff-render.ts +++ b/src/lib/diff-render.ts @@ -20,8 +20,7 @@ function renderEqual(text: string): string { return `${escapeHtml(text)}`; } -// Keep the ins/del background off leading/trailing whitespace — the padding -// would otherwise show as a stray colored gap. +// Keep ins/del backgrounds off edge whitespace, else padding shows as a stray colored gap. function splitWhitespaceEdges(text: string): { lead: string; body: string; @@ -101,9 +100,7 @@ function toTuples(changes: DiffLib.Change[]): [number, string][] { }); } -// Only refine a removed/added pair to word-level if the two sides share enough -// content; otherwise common stopwords (the, you, is) turn a paragraph rewrite -// into red/green zebra striping. +// Below this shared-content ratio, word-level refinement turns a rewrite into zebra striping. const REFINE_SIMILARITY = 0.4; function shouldRefine( @@ -119,8 +116,7 @@ function shouldRefine( return equal / (before.length + after.length) >= REFINE_SIMILARITY; } -// Pair line-level removed+added blocks, then word-diff inside each pair when -// the two sides are similar enough to read as an edit (not a rewrite). +// Pair removed+added lines, then word-diff inside each pair when similar enough to read as an edit. function lineThenWord( before: string, after: string, @@ -153,9 +149,7 @@ export function renderContentDiff(before: string, after: string): string { return renderDiffs(lineThenWord(beforeLF, afterLF, getDiff())); } -// Fallback for revisions older than the snapshot cap: render a stored dmp -// patch directly. Less readable than the snapshot path since patches only -// preserve a tiny context window. +// Fallback for revisions older than the snapshot cap: render a stored dmp patch directly. export function renderDiffInline(patchText: string): string { if (!patchText.trim()) return ""; let patches: { diffs: [number, string][] }[]; diff --git a/src/lib/headings.ts b/src/lib/headings.ts index 85e6c37..ee692e6 100644 --- a/src/lib/headings.ts +++ b/src/lib/headings.ts @@ -6,10 +6,7 @@ interface Heading { anchor: string; } -/** - * Allocate an anchor for `text`, disambiguating repeats by suffixing -1, -2, ... - * `seen` tracks how many times each base slug has been used so far in the doc. - */ +// Disambiguates repeated slugs with -1, -2, …; `seen` counts uses per base slug. export function makeAnchor(text: string, seen: Map): string { const base = slugify(text); if (!base) return base; @@ -21,7 +18,7 @@ export function makeAnchor(text: string, seen: Map): string { const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/; const FENCE_RE = /^(`{3,}|~{3,})/; -/** Extract ATX headings from markdown, skipping fenced code blocks. */ +// Extracts ATX headings, skipping fenced code blocks. export function extractHeadings(md: string): Heading[] { const out: Heading[] = []; const seen = new Map(); diff --git a/src/lib/identity.ts b/src/lib/identity.ts index 59b81e8..70db04d 100644 --- a/src/lib/identity.ts +++ b/src/lib/identity.ts @@ -42,10 +42,6 @@ function matchesAtprotoPds(type: string | string[]): boolean { return type.includes("AtprotoPersonalDataServer"); } -/** - * Resolve a DID to its PDS service endpoint URL. - * Returns null if the DID or PDS service cannot be found. - */ export async function resolvePdsEndpoint(did: string): Promise { let didDoc: Awaited>; try { diff --git a/src/lib/image.ts b/src/lib/image.ts index 7fe8b59..78366fa 100644 --- a/src/lib/image.ts +++ b/src/lib/image.ts @@ -27,7 +27,7 @@ const SHARP_FORMAT_TO_MIME: Record = { webp: "image/webp", }; -/** GIF magic bytes: "GIF87a" or "GIF89a" */ +// GIF magic bytes: "GIF87a" or "GIF89a". function isGifData(data: Buffer): boolean { return data.length >= 6 && data.toString("ascii", 0, 3) === "GIF"; } @@ -49,8 +49,7 @@ export async function processImage( ); } - // GIFs: validate magic bytes, then pass through - // (sharp's animated GIF support is limited, GIFs rarely have EXIF) + // GIFs pass through after a magic-byte check: sharp's animated-GIF support is limited. if (mimeType === "image/gif") { if (!isGifData(data)) { throw new ImageValidationError("File content is not a valid GIF"); @@ -58,8 +57,7 @@ export async function processImage( return { data, mimeType }; } - // Auto-orient (applies EXIF rotation) and strip metadata. - // sharp validates magic bytes internally -- throws on non-image data. + // rotate() applies EXIF orientation then strips metadata; sharp throws on non-image data. const { default: sharp } = await import("sharp"); const instance = sharp(data); const meta = await instance.metadata(); diff --git a/src/lib/import-export/export.ts b/src/lib/import-export/export.ts index 03ef088..894ecac 100644 --- a/src/lib/import-export/export.ts +++ b/src/lib/import-export/export.ts @@ -11,11 +11,7 @@ import { rewriteForExport } from "./markdown-transform.ts"; const INVALID_FILENAME_CHARS = /[/\\:*?"<>|]/g; -/** - * Export a wiki as a zip file containing .md files and an attachments/ folder. - * Wikilinks are converted to Obsidian-compatible [[Note Title]] format. - * Blob images are fetched and saved as attachments/{cid}.{ext}. - */ +// Wiki → zip of Obsidian-style .md files plus an attachments/ folder of fetched blobs. export async function exportWikiZip(wikiAtUri: string): Promise { const notes = listNotesWithContent(wikiAtUri); const slugToTitle = new Map(); @@ -28,7 +24,6 @@ export async function exportWikiZip(wikiAtUri: string): Promise { } } - // Rewrite content and collect blob refs const allBlobRefs: { did: string; cid: string }[] = []; const rewrittenNotes = new Map(); @@ -41,7 +36,6 @@ export async function exportWikiZip(wikiAtUri: string): Promise { allBlobRefs.push(...blobRefs); } - // Deduplicate blob refs const uniqueBlobs = new Map(); for (const ref of allBlobRefs) { if (!uniqueBlobs.has(ref.cid)) { @@ -49,7 +43,6 @@ export async function exportWikiZip(wikiAtUri: string): Promise { } } - // Look up blob mime types from DB const blobCids = [...uniqueBlobs.keys()]; const blobRows = getBlobsByCids(blobCids); const blobMimeMap = new Map(); @@ -57,7 +50,6 @@ export async function exportWikiZip(wikiAtUri: string): Promise { blobMimeMap.set(row.cid, row.mime_type); } - // Fetch blob data from PDS const blobData = new Map(); const warnings: string[] = []; @@ -70,7 +62,6 @@ export async function exportWikiZip(wikiAtUri: string): Promise { } } - // Build zip entries const zipEntries: Record = {}; const usedFilenames = new Set(); @@ -82,14 +73,12 @@ export async function exportWikiZip(wikiAtUri: string): Promise { zipEntries[`${filename}.md`] = new TextEncoder().encode(content); } - // Add blob attachments for (const [cid, data] of blobData) { const mime = blobMimeMap.get(cid) ?? "application/octet-stream"; const ext = MIME_TO_EXT[mime] ?? "bin"; zipEntries[`attachments/${cid}.${ext}`] = data; } - // Add warnings file if any if (warnings.length > 0) { zipEntries["_export_warnings.txt"] = new TextEncoder().encode( warnings.join("\n"), @@ -114,11 +103,7 @@ function deduplicateFilename(name: string, used: Set): string { return `${name}-${counter}`; } -/** - * Export a single note as markdown text. - * Wikilinks are converted to Obsidian-compatible [[Note Title]] format. - * Blob image refs are left as-is (no attachments folder for single-note export). - */ +// Single note as Obsidian markdown; blob refs stay as-is (no attachments folder). export function exportNoteMd( wikiAtUri: string, noteSlug: string, diff --git a/src/lib/import-export/import.ts b/src/lib/import-export/import.ts index bd00393..0fcbd5a 100644 --- a/src/lib/import-export/import.ts +++ b/src/lib/import-export/import.ts @@ -18,11 +18,7 @@ interface ImportFields extends WikiFormFields { zipBuffer: ArrayBuffer; } -/** - * Full lifecycle for creating a wiki from an imported zip: - * parse zip → create wiki → upload images → create notes. - * Throws ImportError, ValidationError, PdsWriteError on failure. - */ +// Lifecycle: parse zip → create wiki → upload images → create notes. export async function importWikiAction( ctx: RequestContext, fields: ImportFields, @@ -36,13 +32,13 @@ export async function importWikiAction( msg, ); - // Build slug map for wikilink rewriting: title -> slug + // title → slug, for wikilink rewriting. const slugMap = new Map(); for (const note of notes) { slugMap.set(note.title, note.slug); } - // Upload images and build mapping: filename -> blob URL + // filename → blob URL. const imageMap = new Map(); const blobMeta: Record = {}; diff --git a/src/lib/import-export/markdown-transform.ts b/src/lib/import-export/markdown-transform.ts index d9e1c8a..2354240 100644 --- a/src/lib/import-export/markdown-transform.ts +++ b/src/lib/import-export/markdown-transform.ts @@ -6,14 +6,7 @@ interface ExportResult { blobRefs: { did: string; cid: string }[]; } -/** - * Rewrite Obsidian-style markdown to Lichen format. - * - [[Note Title]] -> [[slug]] - * - [[Note Title|display]] -> [[slug|display]] - * - ![[image.png]] -> ![image](blobUrl) - * - ![alt](relative/image.png) -> ![alt](blobUrl) (basename match) - * - Absolute URLs and /blob/ refs left untouched - */ +// Obsidian → Lichen: note-title wikilinks become slugs, local image refs become blob URLs. export function rewriteForImport( content: string, slugMap: Map, @@ -68,12 +61,7 @@ export function rewriteForImport( return result; } -/** - * Rewrite Lichen markdown to Obsidian-compatible format. - * - [[slug]] -> [[Note Title]] - * - [[slug|display]] -> [[Note Title|display]] - * - ![alt](/blob/{did}/{cid}) -> ![alt](attachments/{cid}.ext), collects blob refs - */ +// Lichen → Obsidian: slug wikilinks become note titles, /blob/ refs become attachments/ paths. export function rewriteForExport( content: string, slugToTitle: Map, @@ -113,11 +101,7 @@ export function rewriteForExport( return { content: result, blobRefs }; } -/** - * Extract local image references from markdown content. - * Finds ![[filename]] and ![...](relative-path) where path is not a URL or /blob/ ref. - * Returns basenames only (deduplicated). - */ +// Returns deduplicated basenames of local (non-URL, non-/blob/) image refs. export function extractLocalImageRefs(content: string): string[] { const refs = new Set(); @@ -154,17 +138,13 @@ function isImageFilename(name: string): boolean { return IMAGE_EXTENSIONS.has(ext); } -/** - * Case-insensitive lookup in imageMap by basename. - */ +// Exact match, then case-insensitive fallback. function findImageUrl( imageMap: Map, filename: string, ): string | undefined { - // Try exact match first const exact = imageMap.get(filename); if (exact) return exact; - // Case-insensitive fallback const lower = filename.toLowerCase(); for (const [key, value] of imageMap) { if (key.toLowerCase() === lower) return value; @@ -172,9 +152,7 @@ function findImageUrl( return undefined; } -/** - * Look up slug by note title. Tries exact match, then case-insensitive. - */ +// Exact match, then case-insensitive fallback. function findSlug( slugMap: Map, title: string, diff --git a/src/lib/import-export/zip-parse.ts b/src/lib/import-export/zip-parse.ts index f333083..d4a3147 100644 --- a/src/lib/import-export/zip-parse.ts +++ b/src/lib/import-export/zip-parse.ts @@ -8,10 +8,6 @@ import { isValidSlug, slugify } from "../slug.ts"; import { extractLocalImageRefs } from "./markdown-transform.ts"; import type { ImportedImage, ImportedNote, ImportResult } from "./types.ts"; -/** - * Parse an import zip file, extracting markdown notes and referenced images. - * Throws ImportError on validation failures. - */ export function parseImportZip(buffer: ArrayBuffer): ImportResult { let entries: Record; try { @@ -39,7 +35,6 @@ export function parseImportZip(buffer: ArrayBuffer): ImportResult { ); } - // Check total uncompressed size let totalSize = 0; for (const data of Object.values(entries)) { totalSize += data.length; @@ -55,12 +50,11 @@ export function parseImportZip(buffer: ArrayBuffer): ImportResult { const imageFiles: Map = new Map(); // lowercase basename -> data for (const [path, data] of Object.entries(entries)) { - // Skip directories (entries ending with /) if (path.endsWith("/")) continue; const name = basename(path); - // Skip hidden files/directories + // Skip hidden files and anything under a dotfolder. if ( name.startsWith(".") || path.split("/").some((p) => p.startsWith(".")) @@ -99,7 +93,6 @@ export function parseImportZip(buffer: ArrayBuffer): ImportResult { ); } - // Collect all image references from all markdown files const referencedImages = new Set(); for (const { content } of mdFiles) { for (const ref of extractLocalImageRefs(content)) { @@ -107,7 +100,7 @@ export function parseImportZip(buffer: ArrayBuffer): ImportResult { } } - // Filter images to only referenced ones, preserving original filename casing + // Keep only referenced images. const images: ImportedImage[] = []; for (const [lowerName, data] of imageFiles) { if (referencedImages.has(lowerName)) { @@ -120,11 +113,10 @@ export function parseImportZip(buffer: ArrayBuffer): ImportResult { } } - // Build notes with slug deduplication const notes: ImportedNote[] = []; const usedSlugs = new Set(); - // Sort so that home.md/Home.md comes first + // Sort home.md first so it claims the "home" slug. mdFiles.sort((a, b) => { const aIsHome = isHomeName(a.name); const bIsHome = isHomeName(b.name); diff --git a/src/lib/limits.ts b/src/lib/limits.ts index 2995136..b8e686e 100644 --- a/src/lib/limits.ts +++ b/src/lib/limits.ts @@ -1,24 +1,11 @@ -/** - * Central size limits, mirroring the `maxLength` / `maxSize` values declared in - * the `wiki.lichen.*` lexicons. These are the hard ceilings enforced by a - * conforming PDS; we re-declare them here so the appview can: - * - * - surface friendly validation errors before hitting the PDS - * - reject oversized records at firehose ingestion (other PDSes may be lax) - * - later introduce per-tier sub-limits (paid users get higher ceilings, - * lexicon max remains the absolute cap) - * - * Keep in sync with `lexicons/wiki.lichen.*.json`. - */ +// Mirrors lexicon maxLength/maxSize (lexicons/wiki.lichen.*.json) — keep in sync. export const LIMITS = { wiki: { name: 256, visibility: 32, language: 16, description: 300, - // Appview-only — no lexicon constraint. Generous cap; matches a long - // markdown page. Tighten later if abuse surfaces. - sidebar: 100_000, + sidebar: 100_000, // appview-only, no lexicon constraint }, note: { slug: 256, @@ -50,7 +37,6 @@ export const LIMITS = { snippetRadius: 60, profileCacheHours: 24, sessionMaxAgeSecs: 604800, // 7 days - /** Per-IP rate limits: { max requests, window in ms } */ rateLimit: { upload: { limit: 10, windowMs: 60_000 }, blobProxy: { limit: 60, windowMs: 60_000 }, @@ -66,10 +52,10 @@ export const LIMITS = { maxMdFiles: 100, }, cacheTtl: { - ogCard: 3600, - sitemap: 3600, - blobLocal: 86400, - blobImmutable: 31536000, - cookie: 31536000, + ogCard: 3600, // 1h + sitemap: 3600, // 1h + blobLocal: 86400, // 1d + blobImmutable: 31536000, // 1y + cookie: 31536000, // 1y }, } as const; diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index ceff602..2207aa8 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -21,9 +21,6 @@ interface MarkdownEnv extends VizPluginEnv, WikilinkEnv, KatexPluginEnv {} const md = new MarkdownIt({ html: false, linkify: true }); useBaseMarkdownPlugins(md); -// Server-only additions. Both wrap the fence renderer and chain through the -// previous one, so viz blocks bypass the highlighter and vice versa. Kept out -// of the shared base set so they never reach the browser editor bundle. md.use(highlightPlugin); md.use(vizPlugin); @@ -40,11 +37,7 @@ export function renderMarkdown( return { html, hasViz: env.hasViz === true, hasMath: env.hasMath === true }; } -/** - * Extract unique wikilink targets from markdown content. - * Matches [[slug]], [[slug|label]], [[slug#heading]], [[wiki/slug]], and [[wiki/slug|label]]. - * Heading suffix is dropped from dedup keys since backlinks are per-note. - */ +// Dedup key ignores the #heading suffix — backlinks are per-note, not per-heading. export function extractWikilinks( content: string, currentWikiSlug?: string, diff --git a/src/lib/markdown/base-plugins.ts b/src/lib/markdown/base-plugins.ts index 6cd9f62..7c16617 100644 --- a/src/lib/markdown/base-plugins.ts +++ b/src/lib/markdown/base-plugins.ts @@ -9,16 +9,7 @@ import { katexPlugin } from "./katex-plugin.ts"; import { wikilinkPlugin } from "./wikilink-plugin.ts"; import { youtubePlugin } from "./youtube-plugin.ts"; -/** - * Registers the markdown plugins shared by the server renderer (`markdown.ts`) - * and the client-side editor preview (`public/editor/preview.ts`), keeping the - * two pipelines from drifting apart. - * - * Deliberately excludes the syntax highlighter (shiki ships WASM grammars — far - * too heavy for the browser bundle) and the viz plugin (renders through a - * separate D3 bundle not loaded in the editor). Both are server-only and added - * in `markdown.ts` after this base set. - */ +// shiki/viz are deliberately server-only (heavy WASM grammars / separate D3 bundle), so they live in markdown.ts, not here. export function useBaseMarkdownPlugins(md: MarkdownIt): void { md.use(commentPlugin); md.use(wikilinkPlugin); diff --git a/src/lib/markdown/comment-plugin.ts b/src/lib/markdown/comment-plugin.ts index f6d848a..d805994 100644 --- a/src/lib/markdown/comment-plugin.ts +++ b/src/lib/markdown/comment-plugin.ts @@ -38,9 +38,7 @@ function commentBlock( const restOfLine = state.src.slice(startPos + 2, startMax); const closeOnSameLine = restOfLine.indexOf("%%"); if (closeOnSameLine !== -1) { - // Only consume the line as a block comment when nothing visible follows - // the closing %%. Otherwise defer to the inline rule, which strips just - // the %%...%% span and keeps the trailing text. + // Whole-line comment only; if text follows the close, let the inline rule strip just the span. if (restOfLine.slice(closeOnSameLine + 2).trim() !== "") return false; if (silent) return true; state.line = startLine + 1; diff --git a/src/lib/markdown/heading-anchor-plugin.ts b/src/lib/markdown/heading-anchor-plugin.ts index ccd7799..9c4d301 100644 --- a/src/lib/markdown/heading-anchor-plugin.ts +++ b/src/lib/markdown/heading-anchor-plugin.ts @@ -1,11 +1,7 @@ import type MarkdownIt from "markdown-it"; import { makeAnchor } from "../headings.ts"; -/** - * Adds an `id` attribute to every rendered heading, slugified from its text. - * Matches the anchor scheme used by the wikilink plugin so `[[slug#heading]]` - * lands on the right element. - */ +// Anchor ids must match the wikilink plugin's scheme so [[slug#heading]] resolves. export function headingAnchorPlugin(mdi: MarkdownIt): void { mdi.core.ruler.push("heading_anchor", (state) => { const seen = new Map(); diff --git a/src/lib/markdown/highlight-plugin.ts b/src/lib/markdown/highlight-plugin.ts index 9ba7763..7ac2c5d 100644 --- a/src/lib/markdown/highlight-plugin.ts +++ b/src/lib/markdown/highlight-plugin.ts @@ -48,10 +48,7 @@ function highlightCode(code: string, lang: string): string | null { return highlighter.codeToHtml(code, { lang, themes: { light: "github-light", dark: "github-dark" }, - // Emit both palettes as CSS vars (--shiki-light/--shiki-dark) instead - // of a baked-in `color`, so the active theme is chosen in CSS via - // `light-dark()` keyed off `color-scheme` — matching Lichen's theme - // system rather than the OS `prefers-color-scheme`. + // Emit both palettes as vars so CSS light-dark() follows Lichen's theme, not the OS. defaultColor: false, }); } catch { diff --git a/src/lib/markdown/sanitize.ts b/src/lib/markdown/sanitize.ts index 4da901a..7f2747c 100644 --- a/src/lib/markdown/sanitize.ts +++ b/src/lib/markdown/sanitize.ts @@ -1,12 +1,5 @@ import sanitizeHtml from "sanitize-html"; -// Allowlist sized for our markdown pipeline: -// - markdown-it core elements -// - katex math output (lots of nested spans with classes/styles) -// - heading anchors (id) -// - viz containers (data-viz-type, data-viz) -// - wikilinks (class="wikilink") -// - youtube-nocookie iframes from the youtube embed plugin const SCHEMES = ["http", "https", "mailto"]; const SANITIZE_OPTIONS: sanitizeHtml.IOptions = { @@ -119,8 +112,7 @@ const SANITIZE_OPTIONS: sanitizeHtml.IOptions = { allowProtocolRelative: false, // iframe src is locked to youtube-nocookie.com (matches the markdown-it youtube plugin) allowedIframeHostnames: ["www.youtube-nocookie.com"], - // katex inline styles are needed for math layout; we accept the trade-off - // because the markdown source can't inject arbitrary HTML (markdown-it `html: false`). + // katex needs inline styles for math layout; safe because the source can't inject HTML (`html: false`). allowedStyles: { "*": { color: [/^[\w#().,%\-\s]+$/], diff --git a/src/lib/markdown/youtube-plugin.ts b/src/lib/markdown/youtube-plugin.ts index ae98765..2cc81fd 100644 --- a/src/lib/markdown/youtube-plugin.ts +++ b/src/lib/markdown/youtube-plugin.ts @@ -3,11 +3,7 @@ import type MarkdownIt from "markdown-it"; const YOUTUBE_RE = /^(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[?&]\S*)?$/; -/** - * Replaces paragraphs containing only a YouTube link with a responsive iframe - * embed (privacy-enhanced `youtube-nocookie` mode). The video id is constrained - * to 11 url-safe chars by the regex, so the interpolated src can't break out. - */ +// videoId is regex-bound to 11 url-safe chars, so the interpolated iframe src can't break out. export function youtubePlugin(mdi: MarkdownIt): void { mdi.core.ruler.push("youtube_embed", (state) => { const tokens = state.tokens; @@ -25,7 +21,6 @@ export function youtubePlugin(mdi: MarkdownIt): void { ) continue; - // Check if the inline content is a single autolinked or plain URL const text = inline.content.trim(); const match = YOUTUBE_RE.exec(text); if (!match) continue; @@ -34,8 +29,7 @@ export function youtubePlugin(mdi: MarkdownIt): void { const htmlToken = new state.Token("html_block", "", 0); htmlToken.content = `
\n`; - // Replace the 3 tokens (p_open, inline, p_close) with the html_block - tokens.splice(i, 3, htmlToken); + tokens.splice(i, 3, htmlToken); // p_open + inline + p_close → html_block } }); } diff --git a/src/lib/note-validation.ts b/src/lib/note-validation.ts index 36eca59..0e399e8 100644 --- a/src/lib/note-validation.ts +++ b/src/lib/note-validation.ts @@ -4,7 +4,6 @@ import { fmt } from "./i18n/index.ts"; import { LIMITS } from "./limits.ts"; import { isValidSlug, slugify } from "./slug.ts"; -/** Validates title and generates a unique slug for a new note. */ export function validateNewNote( wikiAtUri: string, title: string, diff --git a/src/lib/og-card.ts b/src/lib/og-card.ts index 68d7ae4..7bffa3b 100644 --- a/src/lib/og-card.ts +++ b/src/lib/og-card.ts @@ -3,8 +3,7 @@ import { escapeHtml } from "./html.ts"; const OG_CARD_WIDTH = 1200; const OG_CARD_HEIGHT = 630; -// OG cards always render against the light palette so previews look the same -// in every Bluesky/Discord/Twitter feed regardless of the viewer's device theme. +// Always the light palette, so previews look identical across feeds regardless of device theme. const PALETTE = { bg: "#fafaf9", surface: "#ffffff", @@ -19,16 +18,11 @@ const PALETTE = { const FONT_STACK = "system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif"; -// Geometry copied verbatim from public/logo.svg so the card mark matches the -// favicon and nav logo. Re-centred to 0,0 with a viewBox the size of the path. +// Geometry copied verbatim from public/logo.svg — keep in sync with favicon/nav logo. const LICHEN_LOGO_SVG = (size: number, color: string): string => ``; -/** - * Greedy word-wrap on character count. Approximate but works fine for sans-serif - * at the sizes we use (a real metric-aware wrapper would need font loading and - * isn't worth the complexity for an OG card). - */ +// Greedy char-count word-wrap — approximate, but fine for sans-serif at these sizes. function wrapText( text: string, maxCharsPerLine: number, @@ -141,11 +135,7 @@ export function buildWikiCardSvg(data: WikiCardData): string { `; } -/** - * Fetch a Bluesky-CDN avatar and shape it into a circular PNG sized for the - * card footer. Returns null on any failure so the card still renders without - * the avatar (a placeholder circle is drawn into the SVG underneath). - */ +// Returns null on any failure; the card still renders with the placeholder circle drawn underneath. async function fetchAvatarCircle( url: string, diameter: number, @@ -171,10 +161,6 @@ async function fetchAvatarCircle( } } -/** - * Render an OG card SVG to PNG, optionally compositing a circular avatar at - * the wiki card's owner-avatar slot. - */ export async function renderCardPng( svg: string, avatar?: { url: string; diameter: number; left: number; top: number } | null, @@ -189,11 +175,7 @@ export async function renderCardPng( .toBuffer(); } -/** - * Pixel position of the wiki card avatar circle (matches the placeholder - * circle drawn in buildWikiCardSvg: g.translate(contentRight-72, footerY-36) - * + circle cx=36 cy=24 r=30 → top-left at (contentRight-66, footerY-42)). - */ +// Must match the placeholder circle drawn in buildWikiCardSvg (translate + cx/cy/r). export function wikiCardAvatarSlot(): { diameter: number; left: number; diff --git a/src/lib/orchestrators/helpers.ts b/src/lib/orchestrators/helpers.ts index 140f9ca..b958cfb 100644 --- a/src/lib/orchestrators/helpers.ts +++ b/src/lib/orchestrators/helpers.ts @@ -1,9 +1,6 @@ import { formatError, PdsWriteError } from "../errors.ts"; -/** - * Run a PDS write operation, wrapping any error in a PdsWriteError. - * Use for all PDS writes in orchestrators to avoid copy-pasted try/catch blocks. - */ +// Wraps a PDS write so any failure surfaces as a PdsWriteError. export async function withPdsError( label: string, fn: () => Promise, diff --git a/src/lib/orchestrators/membership.ts b/src/lib/orchestrators/membership.ts index a6b4365..ce3e205 100644 --- a/src/lib/orchestrators/membership.ts +++ b/src/lib/orchestrators/membership.ts @@ -20,10 +20,6 @@ import { ForbiddenError, NotFoundError, ValidationError } from "../errors.ts"; import { t } from "../i18n/index.ts"; import { withPdsError } from "./helpers.ts"; -/** - * Request access to a wiki. PDS write → DB write. - * Throws ForbiddenError if not logged in, PdsWriteError on PDS failure. - */ export async function requestAccessAction( ctx: WikiRequestContext, ): Promise { @@ -52,10 +48,6 @@ export async function requestAccessAction( upsertRequest(ctx.wiki.at_uri, ctx.wiki.slug, session.did, atUri, now); } -/** - * Approve a membership request. PDS write → DB write. - * Throws PdsWriteError on PDS failure. - */ export async function approveMemberAction( ctx: WikiRequestContext, memberDid: string, @@ -95,12 +87,6 @@ export async function approveMemberAction( ); } -/** - * Change a member's role. PDS: delete old record (best-effort) + write new → DB update. - * Throws ValidationError if trying to change owner's role, - * NotFoundError if membership not found, - * PdsWriteError on PDS write failure. - */ export async function changeMemberRoleAction( ctx: WikiRequestContext, memberDid: string, @@ -127,7 +113,7 @@ export async function changeMemberRoleAction( const session = ctx.session; const agent = session ? getAgent(session) : null; if (agent && session) { - // Best-effort: delete old PDS record if we own it + // Best-effort: delete the old PDS record if we own it. const parsed = parseAtUri(existing.at_uri); if (parsed && parsed.did === session.did) { try { @@ -165,11 +151,7 @@ export async function changeMemberRoleAction( ); } -/** - * Add a member directly (admin-initiated, no request required). PDS write → DB write. - * Idempotent: re-adding an existing member updates their role. - * Throws PdsWriteError on PDS failure. - */ +// Admin-initiated (no request needed); idempotent — re-adding a member updates their role. export async function addMemberAction( ctx: WikiRequestContext, memberDid: string, @@ -201,12 +183,6 @@ export async function addMemberAction( upsertMembership(ctx.wiki.at_uri, ctx.wiki.slug, memberDid, role, atUri, now); } -/** - * Remove a member. PDS delete → DB delete. - * Throws ValidationError if trying to remove owner, - * NotFoundError if membership not found, - * PdsWriteError on PDS failure. - */ export async function deleteMemberAction( ctx: WikiRequestContext, memberDid: string, diff --git a/src/lib/orchestrators/note.ts b/src/lib/orchestrators/note.ts index 339084e..0d0c8d7 100644 --- a/src/lib/orchestrators/note.ts +++ b/src/lib/orchestrators/note.ts @@ -60,8 +60,7 @@ function validateRevisionFields( message: string | undefined, msg: Messages, ): void { - // The diff stored in the revision is almost always shorter than its content; - // capping content against the diff ceiling fails fast for oversized pastes. + // Cap content against the diff ceiling — the stored diff is rarely larger than the content. if (content.length > LIMITS.revision.diff) { throw new ValidationError( fmt(msg.error.contentTooLong, { max: String(LIMITS.revision.diff) }), @@ -74,9 +73,6 @@ function validateRevisionFields( } } -/** - * Write a revision record to the PDS. Shared between create and edit. - */ async function writePdsRevision( agent: NonNullable>, did: string, @@ -103,11 +99,6 @@ async function writePdsRevision( ); } -/** - * Full lifecycle for creating a note: validate → PDS write → DB write. - * Throws ValidationError, PdsWriteError on failure. - * Returns the created note slug. - */ export async function createNoteAction( ctx: WikiRequestContext, fields: NoteFormFields, @@ -126,7 +117,7 @@ export async function createNoteAction( const did = ctx.did; if (!did) throw new ForbiddenError(); - // Generate TIDs once — shared between PDS and DB writes + // One TID set, shared by the PDS and DB writes. const noteTid = TID.now(); const revisionTid = TID.now(); const noteAtUri = `at://${did}/wiki.lichen.note/${noteTid}`; @@ -182,10 +173,6 @@ export async function createNoteAction( return { noteSlug }; } -/** - * Full lifecycle for editing a note: validate → PDS write → DB write. - * Throws NotFoundError, ValidationError, PdsWriteError on failure. - */ export async function editNoteAction( ctx: WikiRequestContext, noteSlug: string, @@ -215,7 +202,7 @@ export async function editNoteAction( const blobs = buildBlobsForContent(fields.content, fields.blobMeta); const currentNote = getCurrentNote(ctx.wiki.at_uri, noteSlug); - // Generate revision TID once — shared between PDS and DB writes + // One TID, shared by the PDS and DB writes. const revisionTid = TID.now(); const revisionAtUri = `at://${did}/wiki.lichen.noteRevision/${revisionTid}`; const currentContent = currentNote?.content ?? ""; @@ -265,10 +252,6 @@ export async function editNoteAction( ); } -/** - * Delete a note: PDS cleanup → DB cascade delete. - * Throws NotFoundError if note not found. - */ export async function deleteNoteAction( ctx: WikiRequestContext, noteSlug: string, diff --git a/src/lib/orchestrators/wiki.ts b/src/lib/orchestrators/wiki.ts index 64668c6..9585506 100644 --- a/src/lib/orchestrators/wiki.ts +++ b/src/lib/orchestrators/wiki.ts @@ -39,11 +39,7 @@ export interface WikiFormFields { description: string; } -/** - * Resolve the theme value to write on PDS. Returns the theme name when the - * wiki enforces a theme; undefined when in "reader" mode so the field is - * omitted from the record entirely. - */ +// Theme name when enforced; undefined in reader mode so the PDS record omits the field. function themePayloadFor( themeMode: string, themeKey: string, @@ -61,10 +57,7 @@ interface WikiCoreResult { now: string; } -/** - * Core wiki creation: validate → PDS write wiki + membership → DB write. - * Does NOT create the home note. Used by both createWikiAction and importWikiAction. - */ +// Shared by createWikiAction and importWikiAction; does NOT create the home note. export async function createWikiCore( ctx: RequestContext, fields: WikiFormFields, @@ -143,7 +136,7 @@ export async function createWikiCore( }); } - // DB writes after all PDS writes succeed + // DB writes only after every PDS write succeeds. try { insertWiki( slug, @@ -166,11 +159,6 @@ export async function createWikiCore( return { wikiSlug: slug, wikiAtUri: atUri, agent, did, now }; } -/** - * Full lifecycle for creating a wiki: validate → PDS write → DB write → auto-admin → home note. - * Throws ValidationError, PdsWriteError on failure. - * Returns the created wiki slug. - */ export async function createWikiAction( ctx: RequestContext, fields: WikiFormFields, @@ -182,7 +170,7 @@ export async function createWikiAction( msg, ); - // Create home note — TIDs shared between PDS and DB writes + // Home note; one TID set shared by the PDS and DB writes. const homeContent = `# Welcome to ${fields.name}\n\nThis is the home page of your wiki. Edit it to get started.`; const noteTid = TID.now(); const revisionTid = TID.now(); @@ -228,10 +216,7 @@ export async function createWikiAction( return { wikiSlug }; } -/** - * Update a wiki's name and description. Admin only (route-gated). - * Slug, visibility, language, and createdAt are preserved. - */ +// Updates name + description only; slug/visibility/language/createdAt are preserved. export async function editWikiAction( ctx: WikiRequestContext, fields: { name: string; description: string }, @@ -255,8 +240,7 @@ export async function editWikiAction( const agent = ctx.session ? getAgent(ctx.session) : null; if (agent) { - // Carry the wiki's current theme through PDS update so unrelated edits - // to name/description don't accidentally strip an enforced theme. + // Carry the current theme through, so a name/description edit can't strip an enforced theme. const theme = themePayloadFor(ctx.wiki.theme_mode, ctx.wiki.theme); await withPdsError("edit wiki", async () => { await writeWikiRecord( @@ -285,11 +269,7 @@ export async function editWikiAction( ); } -/** - * Update a wiki's theme settings. Admin only (route-gated). - * Writes the wiki record on PDS with the chosen theme (or strips it for - * "reader" mode), then updates the local DB. - */ +// Writes the theme to the PDS record (stripped in reader mode), then the DB. export async function setWikiThemeAction( ctx: WikiRequestContext, fields: { themeMode: string; theme: string }, @@ -326,11 +306,6 @@ export async function setWikiThemeAction( setWikiTheme(ctx.wiki.did, ctx.wiki.slug, fields.themeMode, fields.theme); } -/** - * Delete a wiki. Owner only. - * PDS cleanup (wiki record + membership records) → DB cascade delete. - * Throws ForbiddenError if caller is not the wiki owner. - */ export async function deleteWikiAction(ctx: WikiRequestContext): Promise { const did = ctx.did; if (!did) throw new ForbiddenError(); diff --git a/src/lib/pds-fetch.ts b/src/lib/pds-fetch.ts index 1f85112..dc707d5 100644 --- a/src/lib/pds-fetch.ts +++ b/src/lib/pds-fetch.ts @@ -27,21 +27,6 @@ interface VerifiedBlob { mimeType: string; } -/** - * Fetch a blob from its owning PDS and verify it. - * - * The PDS is resolved from the DID, then `com.atproto.sync.getBlob` is called - * with these guarantees: - * - * - SSRF guard: only HTTPS endpoints, except localhost for dev - * - Timeout: aborts after `LIMITS.blobProxy.timeoutMs` - * - Size cap: rejects on Content-Length, then enforces during streaming - * (the streaming check is the actual security boundary — a malicious PDS - * can lie about Content-Length) - * - CID verification: hashes the bytes and rejects if they don't match the - * requested CID (a malicious PDS could otherwise serve arbitrary content - * for any CID) - */ export async function fetchVerifiedBlob( did: string, cid: string, @@ -74,6 +59,7 @@ export async function fetchVerifiedBlob( const pdsUrl = new URL(pdsEndpoint); const isLocal = pdsUrl.hostname === "localhost" || pdsUrl.hostname === "127.0.0.1"; + // SSRF guard: HTTPS only (localhost allowed for dev). if (pdsUrl.protocol !== "https:" && !isLocal) { throw new BlobFetchError( "non-https-pds", @@ -130,6 +116,7 @@ export async function fetchVerifiedBlob( const { done, value } = await reader.read(); if (done) break; total += value.byteLength; + // Real size boundary: a malicious PDS can lie about Content-Length. if (total > LIMITS.blobProxy.maxBytes) { await reader.cancel().catch(() => {}); throw new BlobFetchError("too-large", 413, "Blob too large"); @@ -148,6 +135,7 @@ export async function fetchVerifiedBlob( offset += chunk.byteLength; } + // Verify bytes hash to the requested CID, else a PDS could serve anything for any CID. const computed = await CID.create(0x55, data); if (!CID.equals(computed, expected)) { throw new BlobFetchError( diff --git a/src/lib/profile.ts b/src/lib/profile.ts index 0eedfea..ce48dcc 100644 --- a/src/lib/profile.ts +++ b/src/lib/profile.ts @@ -12,18 +12,12 @@ export interface ProfileInfo { avatar: string | null; } -// Bluesky's CDN serves a ~150px variant at /img/avatar_thumbnail/. We display -// avatars at 16–64px, so the full 1000px asset is wasted bytes (~75 KiB each). +// Bluesky's /img/avatar_thumbnail/ ~150px variant; we render at 16–64px, so the full asset is wasted bytes. export function thumbnailAvatar(url: string | null): string | null { if (!url) return null; return url.replace("/img/avatar/", "/img/avatar_thumbnail/"); } -/** - * Resolve a handle or DID string to a DID. - * If input already starts with "did:", returns it unchanged. - * Returns null if the handle cannot be resolved. - */ export async function resolveHandleToDid( handleOrDid: string, ): Promise { @@ -31,7 +25,7 @@ export async function resolveHandleToDid( ? handleOrDid.slice(1) : handleOrDid; if (normalized.startsWith("did:")) return normalized; - // Dev mode: resolve known test handles without network calls + // Dev mode: resolve known test handles without network calls. const devAccounts = getDevAccounts(); if (devAccounts) { const account = Object.values(devAccounts).find( @@ -46,15 +40,7 @@ export async function resolveHandleToDid( } } -/** - * Resolve a DID to profile info (handle, displayName, avatar). - * Fetches the DID document for the handle, then queries the Bluesky AppView - * for avatar and display name. Returns nulls on any failure — never throws. - * - * NOTE: The Bluesky AppView endpoint is AT Protocol's current de-facto standard - * for profile data. This can be swapped when the community standardizes profile - * resolution across appviews. - */ +// Profile data comes from the Bluesky AppView (the de-facto standard); never throws. export async function resolveProfile( did: string, fetchFn: typeof fetch = fetch, @@ -68,7 +54,7 @@ export async function resolveProfile( } } - // Check cache first (skip for injected fetchFn, i.e. tests) + // Skip the cache when fetchFn is injected (tests). if (fetchFn === fetch) { const cached = getCachedProfile(did); if (cached) { @@ -86,7 +72,6 @@ export async function resolveProfile( const atHandle = alsoKnownAs.find((uri) => uri.startsWith("at://")); const handle = atHandle ? atHandle.slice("at://".length) : null; - // Fetch display name and avatar from Bluesky public AppView const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`; const res = await fetchFn(url, { signal: AbortSignal.timeout(5000) }); if (!res.ok) { @@ -116,10 +101,6 @@ export async function resolveProfile( } } -/** - * Resolve multiple DIDs in parallel. - * Returns a map from DID → ProfileInfo. - */ export async function resolveProfiles( dids: string[], fetchFn: typeof fetch = fetch, diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 72e286c..386f111 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -5,7 +5,6 @@ interface WindowEntry { const windows = new Map(); -// Purge expired entries every 60s setInterval(() => { const now = Date.now(); for (const [key, entry] of windows) { @@ -13,10 +12,7 @@ setInterval(() => { } }, 60_000).unref(); -/** - * Fixed-window rate limiter. Returns null if allowed, - * or a 429 Response if the limit is exceeded. - */ +// Fixed-window rate limiter: null when allowed, 429 Response when exceeded. export function rateLimit( key: string, limit: number, diff --git a/src/lib/response.ts b/src/lib/response.ts index 20b1b21..bf5a4a1 100644 --- a/src/lib/response.ts +++ b/src/lib/response.ts @@ -3,13 +3,7 @@ interface HtmlResponseOptions { edgeCacheSeconds?: number; } -// Strict CSP — no 'unsafe-inline' for scripts. All client behaviors live in -// /public/*.js. Inline