From 22e83aaf103ea7a44de8a2ee9984512fde21257c Mon Sep 17 00:00:00 2001 From: juprodh Date: Fri, 21 Aug 2026 13:14:30 +0800 Subject: [PATCH] Resolve AT-URIs to their Lichen pages Signed-off-by: juprodh --- src/server/app.ts | 2 + src/server/db/queries/index.ts | 1 + src/server/db/queries/revision.ts | 8 ++ src/server/routes/at-uri.ts | 95 ++++++++++++++++++ tests/server/routes/at-uri.test.ts | 153 +++++++++++++++++++++++++++++ 5 files changed, 259 insertions(+) create mode 100644 src/server/routes/at-uri.ts create mode 100644 tests/server/routes/at-uri.test.ts diff --git a/src/server/app.ts b/src/server/app.ts index 27f110a..0c3f255 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -9,6 +9,7 @@ import { removedPage } from "../views/removed.ts"; import { canonicalHandlePlugin } from "./canonical-handle-plugin.ts"; import { getDb } from "./db/index.ts"; import { pruneExpiredAppSessions } from "./db/queries/index.ts"; +import { atUriRoutes } from "./routes/at-uri.ts"; import { blobRoutes } from "./routes/blob.ts"; import { bookmarkRoutes } from "./routes/bookmark.ts"; import { collabRoutes } from "./routes/collab.ts"; @@ -93,6 +94,7 @@ export function buildApp() { .use(ogRoutes) .use(homeRoute) .use(seoRoutes) + .use(atUriRoutes) .use(pwaRoutes) .use(exploreRoutes) .use(searchRoutes) diff --git a/src/server/db/queries/index.ts b/src/server/db/queries/index.ts index 54bfee0..2268f0c 100644 --- a/src/server/db/queries/index.ts +++ b/src/server/db/queries/index.ts @@ -65,6 +65,7 @@ export { applyRevisionFromFirehose, capSnapshots, getBacklinks, + getRevisionNoteUri, getSnapshots, listRevisions, reconstructContent, diff --git a/src/server/db/queries/revision.ts b/src/server/db/queries/revision.ts index f80f237..0cbb879 100644 --- a/src/server/db/queries/revision.ts +++ b/src/server/db/queries/revision.ts @@ -181,6 +181,14 @@ export function reconstructContent( return content; } +export function getRevisionNoteUri(atUri: string): string | null { + const db = getDb(); + const row = db + .query("SELECT note_at_uri FROM revisions WHERE at_uri = ?") + .get(atUri) as { note_at_uri: string } | null; + return row?.note_at_uri ?? null; +} + export function getSnapshots(noteAtUri: string): SnapshotRow[] { const db = getDb(); return db diff --git a/src/server/routes/at-uri.ts b/src/server/routes/at-uri.ts new file mode 100644 index 0000000..76ef196 --- /dev/null +++ b/src/server/routes/at-uri.ts @@ -0,0 +1,95 @@ +import { Elysia } from "elysia"; +import { getSessionFromRequest } from "../../atproto/session.ts"; +import { canRead, getAccessLevel } from "../../lib/access.ts"; +import { COLLECTIONS } from "../../lib/collections.ts"; +import { NotFoundError, WikiRemovedError } from "../../lib/errors.ts"; +import { resolveProfile } from "../../lib/profile.ts"; +import { historyNoteUrl, noteUrl, wikiUrl } from "../../lib/urls.ts"; +import { + getMemberRole, + getNoteByAtUri, + getRevisionNoteUri, + getWikiByAtUri, + isWikiHidden, +} from "../db/queries/index.ts"; +import type { WikiRow } from "../db/types.ts"; + +interface Target { + wiki: WikiRow; + noteSlug: string | null; + history: boolean; +} + +// Note slugs live in the record body and rkeys are TIDs, so only our DB can map +// an AT-URI to a page — no static URL template can. +function resolveTarget(atUri: string, collection: string): Target | null { + if (collection === COLLECTIONS.wiki) { + const wiki = getWikiByAtUri(atUri); + return wiki ? { wiki, noteSlug: null, history: false } : null; + } + + const noteAtUri = + collection === COLLECTIONS.note + ? atUri + : collection === COLLECTIONS.noteRevision + ? getRevisionNoteUri(atUri) + : null; + if (!noteAtUri) return null; + + const note = getNoteByAtUri(noteAtUri); + if (!note) return null; + // The wiki owner, not note.did — a contributor's note lives at the owner's URL. + const wiki = getWikiByAtUri(note.wiki_at_uri); + if (!wiki) return null; + + return { + wiki, + noteSlug: note.slug, + history: collection === COLLECTIONS.noteRevision, + }; +} + +export const atUriRoutes = new Elysia().get( + "/at/:did/:collection/:rkey", + async ({ params, request }) => { + const { did, collection, rkey } = params as unknown as { + did: string; + collection: string; + rkey: string; + }; + + const target = resolveTarget( + `at://${did}/${collection}/${rkey}`, + collection, + ); + if (!target) { + throw new NotFoundError("Record not found", { i18nKey: "routeNotFound" }); + } + const { wiki, noteSlug, history } = target; + + if (isWikiHidden(wiki)) { + throw new WikiRemovedError(undefined, { i18nKey: "wikiRemoved" }); + } + + // This route reaches the wiki by at_uri, so resolveRequestContext's checks + // never run. Without one here the redirect hands a non-member the wiki and + // note slugs of a private wiki. + const session = await getSessionFromRequest(request); + const viewerDid = session?.did ?? null; + const role = viewerDid ? getMemberRole(wiki.at_uri, viewerDid) : null; + if (!canRead(getAccessLevel(wiki, viewerDid, role))) { + throw new NotFoundError("Record not found", { i18nKey: "routeNotFound" }); + } + + const ownerRef = (await resolveProfile(wiki.did)).handle ?? wiki.did; + const location = + noteSlug === null + ? wikiUrl(ownerRef, wiki.slug) + : history + ? historyNoteUrl(ownerRef, wiki.slug, noteSlug) + : noteUrl(ownerRef, wiki.slug, noteSlug); + + // 302: the target embeds a handle, which its owner can change at any time. + return new Response(null, { status: 302, headers: { Location: location } }); + }, +); diff --git a/tests/server/routes/at-uri.test.ts b/tests/server/routes/at-uri.test.ts new file mode 100644 index 0000000..4c589b6 --- /dev/null +++ b/tests/server/routes/at-uri.test.ts @@ -0,0 +1,153 @@ +import { afterAll, describe, expect, mock, test } from "bun:test"; +import { COLLECTIONS } from "../../../src/lib/collections.ts"; +import { hideWiki, unhideWiki } from "../../../src/server/db/queries/index.ts"; +import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; +import { + ALICE, + BOB, + createDiff, + emitMembership, + emitNote, + emitRevision, + emitWiki, +} from "../../integration/helpers.ts"; + +// Other test files mock `lib/profile.ts` process-globally with a stub returning +// `handle = did`. Re-apply our own so the redirect targets are the handle form. +const realProfile = await import("../../../src/lib/profile.ts"); +const devHandles: Record = { + [ALICE.did]: ALICE.handle, + [BOB.did]: BOB.handle, +}; +mock.module("../../../src/lib/profile.ts", () => ({ + ...realProfile, + resolveProfile: async (did: string) => ({ + handle: devHandles[did] ?? did, + displayName: null, + avatar: null, + }), +})); + +const { fetch, loginCookie } = await import( + "../../integration/http-helpers.ts" +); + +const SLUG = "at-uri-wiki"; +const PRIVATE_SLUG = "at-uri-private"; +const WIKI_URI = emitWiki(ALICE.did, "AT-URI Wiki", "public", SLUG).uri; +const NOTE_URI = emitNote(ALICE.did, "risotto", "Risotto", WIKI_URI).uri; +const REVISION_URI = emitRevision( + ALICE.did, + NOTE_URI, + createDiff("", "# Risotto\n"), +).uri; + +function atPath(uri: string): string { + return `/at/${uri.slice("at://".length)}`; +} + +afterAll(() => { + cleanupWikiAndDependents(SLUG); + cleanupWikiAndDependents(PRIVATE_SLUG); +}); + +describe("GET /at/:did/:collection/:rkey", () => { + test("wiki record redirects to the wiki home", async () => { + const res = await fetch("GET", atPath(WIKI_URI)); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe(`/@${ALICE.handle}/${SLUG}`); + }); + + test("note record redirects to the note page", async () => { + const res = await fetch("GET", atPath(NOTE_URI)); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe( + `/@${ALICE.handle}/${SLUG}/risotto`, + ); + }); + + test("revision record redirects to the note history", async () => { + const res = await fetch("GET", atPath(REVISION_URI)); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe( + `/@${ALICE.handle}/${SLUG}/risotto/-/history`, + ); + }); + + test("a contributor's note redirects to the wiki owner, not the author", async () => { + emitMembership(ALICE.did, BOB.did, WIKI_URI, "contributor"); + const bobNote = emitNote(BOB.did, "bob-note", "Bob Note", WIKI_URI).uri; + + const res = await fetch("GET", atPath(bobNote)); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe( + `/@${ALICE.handle}/${SLUG}/bob-note`, + ); + }); + + test("unknown collection 404s", async () => { + const res = await fetch( + "GET", + `/at/${ALICE.did}/app.bsky.feed.post/3k7qw2abc`, + ); + expect(res.status).toBe(404); + }); + + test("unknown rkey 404s", async () => { + const res = await fetch( + "GET", + `/at/${ALICE.did}/${COLLECTIONS.note}/nosuchrkey`, + ); + expect(res.status).toBe(404); + }); + + test("a moderated wiki 410s instead of redirecting", async () => { + hideWiki(WIKI_URI, "spam", "tester"); + try { + const wikiRes = await fetch("GET", atPath(WIKI_URI)); + expect(wikiRes.status).toBe(410); + const noteRes = await fetch("GET", atPath(NOTE_URI)); + expect(noteRes.status).toBe(410); + } finally { + unhideWiki(WIKI_URI); + } + }); + + describe("private wikis", () => { + const privateWikiUri = emitWiki( + ALICE.did, + "Private", + "private", + PRIVATE_SLUG, + ).uri; + const privateNoteUri = emitNote( + ALICE.did, + "secret-plans", + "Secret Plans", + privateWikiUri, + ).uri; + + test("404s for a visitor, without disclosing the note slug", async () => { + const res = await fetch("GET", atPath(privateNoteUri)); + expect(res.status).toBe(404); + expect(await res.text()).not.toContain("secret-plans"); + }); + + test("404s for a logged-in non-member", async () => { + const res = await fetch("GET", atPath(privateNoteUri), { + cookie: await loginCookie(BOB.handle), + }); + expect(res.status).toBe(404); + }); + + test("redirects for the owner", async () => { + const res = await fetch("GET", atPath(privateNoteUri), { + cookie: await loginCookie(ALICE.handle), + }); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe( + `/@${ALICE.handle}/${PRIVATE_SLUG}/secret-plans`, + ); + }); + }); +}); -- 2.51.2