diff --git a/PATTERN_GUIDE.md b/PATTERN_GUIDE.md index 3ba06a3da..d9f50fb37 100644 --- a/PATTERN_GUIDE.md +++ b/PATTERN_GUIDE.md @@ -122,10 +122,6 @@ chapters use all of this. ![A pattern's detail page](docs/images/pattern-guide/10-pattern-detail.png) -One more thing in the top nav — **Workshop** is where directions get discussed -and decided. It's under heavy construction, so this guide leaves it alone for -now. - --- ## 3. Putting someone else's pattern on your device diff --git a/web/ARCHITECTURE.md b/web/ARCHITECTURE.md index 21ae5fe7c..cb45e5055 100644 --- a/web/ARCHITECTURE.md +++ b/web/ARCHITECTURE.md @@ -14,7 +14,7 @@ The `web/` app is the Patternflow site at [patternflow.work](https://patternflow | `/guide` · `/guide/build` · `/guide/play` · `/guide/make` · `/guide/audio` (each + `/ko`) | The interactive guide: a hub and four guides on one continuous 3D stage (`components/guide/`), with the firmware's input logic and screens ported to `lib/guide/` and the real console and audio editor in windows (`public/guide/console-demo/`, `audio-editor/`, generated by `firmware/toolchain/console_demo.py` and `editor_demo.py`). Words in `components/guide/copy.ts` and `copy/`; old `/guide#flash`-style anchors land on `/guide/play` (`legacy.ts`) | | `/inside/[build]` | One community build's page | | `/pattern-lab` | The pattern workspace — see below | -| `/community/**` | The community: feed, pattern pages (`/p/[id]`), profiles (`/u/[username]`), decks (`/d/[id]`, `/decks`), the Workshop (`/workshop/[code]`), territories and the atlas, notifications, featured, reports. Renders a pointer to the community host unless `COMMUNITY_ENABLED=1` | +| `/community/**` | The community: feed, pattern pages (`/p/[id]`), profiles (`/u/[username]`), decks (`/d/[id]`, `/decks`), the atlas, notifications, featured, reports. The workshop came down on 26 October 2026, and its old addresses redirect to `/community` (`next.config.ts`). Renders a pointer to the community host unless `COMMUNITY_ENABLED=1` | | `/editions` | The edition shelf: every firmware you can put on a panel, official and community, with one-click install. `/variants` (the URL until 2026-09, baked into shipped console pages) redirects here permanently | | `/features` | The catalogue: every feature on its own — what it needs, which firmware carries it, and a reel of it running (Instagram embeds by permalink, lazy). The other axis of the shelf; hand-curated in `features-data.ts`. A feature that is not on the shelf can carry a frozen try-out image (`tryOut`), installed the way the shelf installs | | `/update` | The device's firmware-update handoff: the browser downloads an image and POSTs it to the panel over the LAN, because the panel cannot fetch over TLS | @@ -23,7 +23,7 @@ The `web/` app is the Patternflow site at [patternflow.work](https://patternflow | `/roadmap` | Roadmap rendered from `roadmap-data.ts` plus GitHub issue progress via `/api/roadmap` | | `/compliance` | Printed on the box — never move, redirect or noindex it | | `/terms` · `/business` · `/contact` | Static pages | -| `/api/community/**` | The community's JSON API (30-odd routes: patterns, decks, posts, comments, builds, workshop, moderation…). Every route guards on `communityEnabled()`, and `src/proxy.ts` closes the whole prefix with a 404 on a deployment without the community, so a route that forgets is still closed | +| `/api/community/**` | The community's JSON API (20-odd routes: patterns, decks, comments, builds, the atlas, moderation…). Every route guards on `communityEnabled()`, and `src/proxy.ts` closes the whole prefix with a 404 on a deployment without the community, so a route that forgets is still closed | | `/api/auth/[...all]` | Better Auth | | `/api/variant-bin/[...path]` | Serves hosted edition images from `VARIANT_BIN_DIR` with the CORS headers `/update` needs | | `/api/roadmap` | Open issues + sub-issue progress (10 min revalidate; optional `GITHUB_TOKEN`) | @@ -81,7 +81,7 @@ Exports flatten the visible stack to one standalone pattern (`flatten.ts`); `hEx ## Community (`src/lib/community/` + `src/components/community/`) -SQLite via Drizzle (`server/schema.ts`; migrations in `web/drizzle/`, applied on first open by `server/db.ts`), Better Auth (`server/auth.ts`), reads in `server/queries/` — one file per domain (patterns, decks, workshop, moderation, users, notifications, plus the shared sub-selects), re-exported by `server/queries.ts` — attachments and thumbnails on disk under `communityDataDir()` (default `web/data/`, gitignored — set `COMMUNITY_DB_PATH` on a real deployment). **`lib/community/server/` is the server side** — everything that touches SQLite, the filesystem or a session — and ESLint refuses a value import of it from `components/` or a `*Client.tsx` (type imports are fine; a value crosses through an API route). The rest of `lib/community/` is either `"use client"` (`auth-client`, `deviceHost`, `download`, `handoff`, `knobs`, `thumbs`) or pure and usable anywhere (`validate`, `license`, `deck`, `workshop`, `cors`, …). +SQLite via Drizzle (`server/schema.ts`; migrations in `web/drizzle/`, applied on first open by `server/db.ts`), Better Auth (`server/auth.ts`), reads in `server/queries/` — one file per domain (patterns, decks, atlas, moderation, users, notifications, plus the shared sub-selects), re-exported by `server/queries.ts` — attachments and thumbnails on disk under `communityDataDir()` (default `web/data/`, gitignored — set `COMMUNITY_DB_PATH` on a real deployment). **`lib/community/server/` is the server side** — everything that touches SQLite, the filesystem or a session — and ESLint refuses a value import of it from `components/` or a `*Client.tsx` (type imports are fine; a value crosses through an API route). The rest of `lib/community/` is either `"use client"` (`auth-client`, `deviceHost`, `download`, `handoff`, `knobs`, `thumbs`) or pure and usable anywhere (`validate`, `license`, `deck`, `cors`, …). Patterns render in a sandboxed iframe (`public/pattern-sandbox.html`, driven by `components/community/SandboxPreview.tsx`). Pattern Lab hands off to the community through `handoff.ts` (fork vs. edit-in-place), and publishes through `components/community/PublishModal.tsx`. diff --git a/web/next.config.ts b/web/next.config.ts index 0fd9de6f4..109aacd0b 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -90,6 +90,16 @@ const nextConfig: NextConfig = { // to the guide's Flash chapter, where the button and the steps are. Only // the exact path: /flash/manifest.json and /flash/bin/* are untouched. { source: "/flash", destination: "/guide/play#flash", permanent: false }, + // The community's workshop came down on 26 October 2026: the map of + // directions, its threads, and the moderators' territory editor. Its + // rows stay in the database. A link to any of it, from an old alert, a + // post elsewhere or a bookmark, lands on the community's front page. One + // line per page that was there. + { source: "/community/workshop", destination: "/community", permanent: false }, + { source: "/community/workshop/:code", destination: "/community", permanent: false }, + { source: "/community/workshop/:code/t/:id", destination: "/community", permanent: false }, + { source: "/community/t/:id", destination: "/community", permanent: false }, + { source: "/community/territories", destination: "/community", permanent: false }, // From the journal's first post on 29 April 2026 until 11 May, its own // links asked for English with ?lang=en. English moved to /en paths that // day (c44b0d7a), and these two send the old links there. The query goes diff --git a/web/package.json b/web/package.json index a44da120c..5361f02ef 100644 --- a/web/package.json +++ b/web/package.json @@ -31,7 +31,6 @@ "check:notify": "tsx scripts/notify-smoke.ts", "check:ports": "tsx scripts/ports-smoke.ts", "seed:map": "tsx scripts/seed-territories.ts", - "check:workshop": "tsx scripts/workshop-smoke.ts", "check:sandbox": "tsx scripts/sandbox-smoke.ts", "check:capture": "tsx scripts/lab-capture-smoke.ts", "check:thumbs": "tsx scripts/thumbs-smoke.ts", @@ -43,7 +42,7 @@ "check:pfstvectors": "tsx scripts/pfst-v2-vectors-smoke.ts", "check:ramp": "tsx scripts/ramp-oklab-smoke.ts", "check:panels": "vitest run", - "check:ci": "npm run check:license && npm run check:moderation && npm run check:headermod && npm run check:hidemod && npm run check:retention && npm run check:deck && npm run check:pack && npm run check:sharing && npm run check:publish && npm run check:notify && npm run check:ports && npm run check:workshop && npm run check:sandbox && npm run check:capture && npm run check:thumbs && npm run check:flatten && npm run check:labedit && npm run check:performance && npm run check:pixelcode && npm run check:director && npm run check:pfstvectors && npm run check:ramp && npm run check:panels && npm run check:sandbox-sync && npm run check:modcache && npm run check:drift", + "check:ci": "npm run check:license && npm run check:moderation && npm run check:headermod && npm run check:hidemod && npm run check:retention && npm run check:deck && npm run check:pack && npm run check:sharing && npm run check:publish && npm run check:notify && npm run check:ports && npm run check:sandbox && npm run check:capture && npm run check:thumbs && npm run check:flatten && npm run check:labedit && npm run check:performance && npm run check:pixelcode && npm run check:director && npm run check:pfstvectors && npm run check:ramp && npm run check:panels && npm run check:sandbox-sync && npm run check:modcache && npm run check:drift", "build:sandbox": "tsx scripts/build-sandbox.ts", "check:sandbox-sync": "tsx scripts/build-sandbox.ts --check", "gen:shared": "tsx scripts/gen-shared.ts", diff --git a/web/scripts/moderation-smoke.ts b/web/scripts/moderation-smoke.ts index dda6baf1e..3e489ef54 100644 --- a/web/scripts/moderation-smoke.ts +++ b/web/scripts/moderation-smoke.ts @@ -192,7 +192,9 @@ check("a non-string is rejected", cleanModerationReason(42), undefined); console.log("\n── report input ──"); check("pattern is a valid target", isReportTargetType("pattern"), true); -check("post is a valid target", isReportTargetType("post"), true); +// A workshop thread was a target until the workshop came down on 26 October 2026. +check("post is no longer a target", isReportTargetType("post"), false); +check("deck is a valid target", isReportTargetType("deck"), true); check("comment is a valid target", isReportTargetType("comment"), true); check("user is not a target", isReportTargetType("user"), false); check("non-string is not a target", isReportTargetType(42), false); diff --git a/web/scripts/notify-smoke.ts b/web/scripts/notify-smoke.ts index 304b9fdf0..2680d64bd 100644 --- a/web/scripts/notify-smoke.ts +++ b/web/scripts/notify-smoke.ts @@ -75,7 +75,8 @@ async function main() { updatedAt: ago(10), }, ]); - // Threads live on the map now, so the fixture needs somewhere to put one. + // The workshop came down on 26 October 2026 and its tables stayed, so the + // fixture keeps a thread for the alert below that still points at one. await db.insert(schema.territories).values({ id: "z1", code: "A1", @@ -102,7 +103,6 @@ async function main() { createdAt: NOW, }); await notify.notifyCommentAdded({ - on: "pattern", targetId: "p1", targetTitle: "Alice One", ownerId: "alice", @@ -132,28 +132,26 @@ async function main() { check("thread rows say so", bobRows.map((row) => row.type), ["thread", "thread"]); check("and carry the snippet", bobRows[0]?.snippet, "thanks both"); - console.log("\n── post comments use the same path ──"); - await db.insert(schema.postComments).values({ - id: "pc1", - postId: "t1", - userId: "bob", - body: "post reply", - createdAt: NOW, - }); - await notify.notifyCommentAdded({ - on: "post", + console.log("\n── a workshop alert stays off the list ──"); + await db.insert(schema.notifications).values({ + id: "n-post", + userId: "cara", + type: "thread", + actorId: "bob", + targetType: "post", targetId: "t1", targetTitle: "Alice Thread", - ownerId: "alice", - actorId: "bob", - commentId: "pc1", - body: "post reply", + createdAt: NOW, }); - check("the post's author hears it", await queries.countUnreadNotifications("alice"), 3); check( - "the row points at the post", - (await queries.listNotifications("alice"))[0]?.targetType, - "post", + "a row about a workshop thread is not listed", + (await queries.listNotifications("cara")).map((row) => row.targetType), + ["pattern"], + ); + check( + "not for a moderator either", + (await queries.listNotifications("cara", { moderator: true })).map((row) => row.targetType), + ["pattern"], ); console.log("\n── forks ──"); @@ -178,7 +176,7 @@ async function main() { forkVisibility: "public", actorId: "bob", }); - check("a public fork tells the parent's author", await queries.countUnreadNotifications("alice"), 4); + check("a public fork tells the parent's author", await queries.countUnreadNotifications("alice"), 3); await notify.notifyForkPublished({ parentOwnerId: "alice", parentTitle: "Alice One", @@ -186,7 +184,7 @@ async function main() { forkVisibility: "private", actorId: "bob", }); - check("a private fork tells nobody — its page would 404", await queries.countUnreadNotifications("alice"), 4); + check("a private fork tells nobody — its page would 404", await queries.countUnreadNotifications("alice"), 3); await notify.notifyForkPublished({ parentOwnerId: "alice", parentTitle: "Alice One", @@ -194,7 +192,7 @@ async function main() { forkVisibility: "public", actorId: "alice", }); - check("forking your own work tells nobody", await queries.countUnreadNotifications("alice"), 4); + check("forking your own work tells nobody", await queries.countUnreadNotifications("alice"), 3); console.log("\n── deck inclusion ──"); await db.insert(schema.decks).values({ @@ -215,7 +213,7 @@ async function main() { actorId: "bob", patterns: deckPatterns, }); - check("the pattern's author hears it", await queries.countUnreadNotifications("alice"), 5); + check("the pattern's author hears it", await queries.countUnreadNotifications("alice"), 4); check("the deck owner's own pattern tells nobody", await queries.countUnreadNotifications("bob"), 2); await notify.notifyDeckInclusion({ deckId: "d1", @@ -223,12 +221,12 @@ async function main() { actorId: "bob", patterns: deckPatterns, }); - check("repeating the event while unread is absorbed", await queries.countUnreadNotifications("alice"), 5); + check("repeating the event while unread is absorbed", await queries.countUnreadNotifications("alice"), 4); console.log("\n── reading ──"); await queries.markNotificationsRead("alice"); check("opening the page reads everything", await queries.countUnreadNotifications("alice"), 0); - check("the rows themselves stay", (await queries.listNotifications("alice")).length, 5); + check("the rows themselves stay", (await queries.listNotifications("alice")).length, 4); console.log("\n── the read path respects visibility ──"); await db.update(schema.patterns).set({ visibility: "private" }).where(eq(schema.patterns.id, "p1")); diff --git a/web/scripts/workshop-smoke.ts b/web/scripts/workshop-smoke.ts deleted file mode 100644 index 73b5a21df..000000000 --- a/web/scripts/workshop-smoke.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** - * Workshop smoke test — `npm run check:workshop`. - * - * Against a throwaway SQLite file. What this guards: a pin is a subscription - * (new thread notifies exactly the pinned, never the author), re-pinning edits - * the note without restarting "since", the filename sanitiser stops traversal - * and header injection, the byte sniffer only ever says "image" about real - * raster images, and a deleted thread's attachment bytes actually leave the - * disk once the sweep runs. - */ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -// The database is a lazy singleton keyed off this variable, so it has to be set -// before anything imports it — hence the dynamic imports below. -const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-workshop-")); -process.env.COMMUNITY_DB_PATH = path.join(tmp, "test.db"); -process.env.COMMUNITY_ENABLED = "1"; - -const DAY = 24 * 60 * 60 * 1000; -const NOW = new Date("2026-08-01T12:00:00Z"); -const ago = (days: number) => new Date(NOW.getTime() - days * DAY); - -let failures = 0; - -function check(label: string, actual: unknown, expected: unknown) { - const ok = JSON.stringify(actual) === JSON.stringify(expected); - if (!ok) failures += 1; - console.log( - `${ok ? " ok " : "FAIL "}${label}${ok ? "" : `\n got ${JSON.stringify(actual)}\n want ${JSON.stringify(expected)}`}`, - ); -} - -async function main() { - const { eq } = await import("drizzle-orm"); - const { getDb } = await import("../src/lib/community/server/db"); - const schema = await import("../src/lib/community/server/schema"); - const queries = await import("../src/lib/community/server/queries"); - const notify = await import("../src/lib/community/server/notify"); - const workshop = await import("../src/lib/community/workshop"); - const { attachmentDir, attachmentPath, sniffImage } = await import( - "../src/lib/community/server/attachments" - ); - const { sweepOrphanAttachments } = await import("../src/lib/community/server/retention"); - - const db = getDb(); - - // ── Seed ─────────────────────────────────────────────────────────────────── - const person = (id: string) => ({ - id, - name: id, - email: `${id}@patternflow.local`, - emailVerified: false, - createdAt: ago(30), - updatedAt: ago(30), - username: id, - displayUsername: id, - }); - await db.insert(schema.user).values([person("alice"), person("bob"), person("cara")]); - await db.insert(schema.territories).values({ - id: "z1", - code: "A1", - title: "Wired control", - createdAt: ago(20), - }); - await db.insert(schema.territoryPins).values([ - { id: "pin-a", territoryId: "z1", userId: "alice", note: "OSC bridge", createdAt: ago(5) }, - { id: "pin-b", territoryId: "z1", userId: "bob", note: null, createdAt: ago(3) }, - ]); - - // ── Pin = subscription ──────────────────────────────────────────────────── - await db.insert(schema.posts).values({ - id: "t1", - territoryId: "z1", - userId: "alice", - title: "First bytes over the wire", - body: "hello", - createdAt: NOW, - updatedAt: NOW, - }); - await notify.notifyNewThread({ - territoryId: "z1", - territoryLabel: "A1 · Wired control", - postId: "t1", - postTitle: "First bytes over the wire", - actorId: "alice", - }); - - const rows = await db.select().from(schema.notifications); - check("new thread notifies exactly the other pinned person", rows.length, 1); - check("…and it is bob", rows[0]?.userId, "bob"); - check("…as a territory row", rows[0]?.type, "territory"); - check("…carrying where", rows[0]?.snippet, "A1 · Wired control"); - check("…pointing at the thread", [rows[0]?.targetType, rows[0]?.targetId], ["post", "t1"]); - - // Unpinned bystanders hear nothing; the author never hears about themselves. - check( - "cara (not pinned) got nothing", - rows.some((row) => row.userId === "cara"), - false, - ); - - // ── Correlated counts ───────────────────────────────────────────────────── - // These shipped as zero once: drizzle renders `${table.column}` unqualified, - // and SQLite bound the bare `id` to the INNER table's own id column. The - // node counts are the workshop's entire face, so they get asserted against - // known contents. - const zones = await queries.listTerritories(); - check("territory counts see the pins", zones[0]?.pinCount, 2); - check("…and the thread", zones[0]?.threadCount, 1); - const drawerThreads = await queries.listPosts({ territoryId: "z1", limit: 6 }); - check("reply count starts at zero, not undefined", drawerThreads[0]?.commentCount, 0); - await db.insert(schema.postComments).values({ - id: "c1", - postId: "t1", - userId: "bob", - body: "tried it — works at 115200", - createdAt: NOW, - }); - const withReply = await queries.listPosts({ territoryId: "z1", limit: 6 }); - check("…and counts the reply", withReply[0]?.commentCount, 1); - const recent = await queries.listRecentThreads(); - check("the latest strip carries where", recent[0]?.territoryCode, "A1"); - - // ── Re-pin edits the note, keeps "since" ────────────────────────────────── - await db - .insert(schema.territoryPins) - .values({ id: "pin-a2", territoryId: "z1", userId: "alice", note: "MIDI too", createdAt: NOW }) - .onConflictDoUpdate({ - target: [schema.territoryPins.territoryId, schema.territoryPins.userId], - set: { note: "MIDI too" }, - }); - const alicePin = ( - await db - .select() - .from(schema.territoryPins) - .where(eq(schema.territoryPins.userId, "alice")) - )[0]; - check("re-pin updates the note", alicePin?.note, "MIDI too"); - check("…without restarting since", alicePin?.createdAt.getTime(), ago(5).getTime()); - - // ── Filename sanitiser ──────────────────────────────────────────────────── - check("traversal keeps only the basename", workshop.cleanFilename("..\\..\\front.dxf"), "front.dxf"); - check("unix traversal too", workshop.cleanFilename("../../etc/passwd"), "passwd"); - check("leading dots come off", workshop.cleanFilename("...hidden.png"), "hidden.png"); - check( - "quotes and controls come out", - workshop.cleanFilename('to\u0000ler"an\u001fces.md'), - "tolerances.md", - ); - check("nothing left means null", workshop.cleanFilename('"\u0001.'), null); - check("allowlist takes dxf", workshop.attachmentAllowed("front.dxf"), true); - check("allowlist takes uppercase PNG", workshop.attachmentAllowed("shot.PNG"), true); - check("allowlist refuses exe", workshop.attachmentAllowed("run.exe"), false); - check("svg is a file, not an image", workshop.isImageFilename("logo.svg"), false); - check("jpeg is an image", workshop.isImageFilename("bench.JPG"), true); - - // ── Territory validators ────────────────────────────────────────────────── - // The admin editor is the only way to draw the map now, so its cleaners are - // the only thing between a typo and a broken front page. - check("code accepts A1", workshop.cleanTerritoryCode("a1"), "A1"); - check("code accepts two digits", workshop.cleanTerritoryCode("B12"), "B12"); - check("code refuses a word", workshop.cleanTerritoryCode("hardware"), null); - check("code refuses a bare letter", workshop.cleanTerritoryCode("A"), null); - check("title collapses whitespace", workshop.cleanTerritoryTitle(" Wired control "), "Wired control"); - check("empty title is refused", workshop.cleanTerritoryTitle(" "), undefined); - check("blank description is null", workshop.cleanTerritoryDescription(" "), null); - check("span 2 is fine", workshop.cleanSpan("2"), 2); - check("span 7 is not", workshop.cleanSpan(7), undefined); - check("span 1 is not", workshop.cleanSpan(1), undefined); - check("coords clamp rather than refuse", workshop.cleanStageCoord(99999, "x"), 1440); - check("…on both ends", workshop.cleanStageCoord(-40, "y"), 0); - check("coords refuse nonsense", workshop.cleanStageCoord("over there", "x"), undefined); - check( - "questions keep at most four lines", - workshop.cleanQuestions("a\nb\n\nc\nd\ne"), - "a\nb\nc\nd", - ); - check("no questions is null", workshop.cleanQuestions(""), null); - - // A question three characters over the cap used to 400 the whole save — the - // description edit in the same body went with it, and the reason landed in a - // banner at the top of a list you had scrolled past. The cap is still a cap; - // what changed is that the editor knows it, and can say which line. - const long = "x".repeat(workshop.QUESTION_MAX + 1); - check("a question at the cap is fine", workshop.cleanQuestions("x".repeat(workshop.QUESTION_MAX)) !== undefined, true); - check("one over is refused", workshop.cleanQuestions(long), undefined); - check("…and names the line", workshop.overlongQuestion(`ok\n${long}`), long); - check("nothing over means nothing to name", workshop.overlongQuestion("ok\nfine"), null); - check( - "a long fifth line is dropped, not fatal", - workshop.cleanQuestions(`a\nb\nc\nd\n${long}`), - "a\nb\nc\nd", - ); - check("the editor's cap is the route's cap", workshop.QUESTION_MAX, 60); - - // ── Presence ────────────────────────────────────────────────────────────── - // The map's people layer. Presence is not a pin — walking somewhere must - // never touch subscriptions — so it gets its own table and its own checks. - await db.insert(schema.presence).values({ - userId: "alice", - x: 900, - y: 200, - status: "soldering", - updatedAt: NOW, - }); - const standing = await queries.listPresence(); - check("presence carries the handle", standing[0]?.username, "alice"); - check("…and the spot", [standing[0]?.x, standing[0]?.y], [900, 200]); - check("…and the words", standing[0]?.status, "soldering"); - check("the unmoved are everyone else", await queries.countUnmoved(), 2); - - // Moving again is an update, not a second body. - await db - .insert(schema.presence) - .values({ userId: "alice", x: 300, y: 500, status: null, updatedAt: NOW }) - .onConflictDoUpdate({ - target: schema.presence.userId, - set: { x: 300, y: 500, updatedAt: NOW }, - }); - const walked = await queries.listPresence(); - check("walking moves the one square", walked.length, 1); - check("…to the new spot", [walked[0]?.x, walked[0]?.y], [300, 500]); - check("…without dropping the words", walked[0]?.status, "soldering"); - - check("status collapses whitespace", workshop.cleanStatus(" taking a walk "), "taking a walk"); - check("empty status clears to null", workshop.cleanStatus(" "), null); - check("a long status is refused", workshop.cleanStatus("x".repeat(61)), undefined); - check("at the cap it fits", workshop.cleanStatus("x".repeat(60)), "x".repeat(60)); - - // ── Markdown links ──────────────────────────────────────────────────────── - // Posts render as markdown now, so every href in one was typed by a member. - // react-markdown has its own urlTransform; this is ours, and it is the one - // PostBody actually calls. - const { safeHref } = await import("../src/lib/community/markdown"); - check("https passes through", safeHref("https://example.com/a"), "https://example.com/a"); - check("http too", safeHref("http://example.com"), "http://example.com"); - check("javascript: is refused", safeHref("javascript:alert(1)"), null); - check("…with padding", safeHref(" JaVaScRiPt:alert(1)"), null); - check("…and with a tab inside the scheme", safeHref("java\tscript:alert(1)"), null); - check("…and with a NUL inside it", safeHref("java\u0000script:alert(1)"), null); - check("data: is refused", safeHref("data:text/html;base64,PHNjcmlwdD4="), null); - check("vbscript: is refused", safeHref("vbscript:msgbox"), null); - check("file: is refused", safeHref("file:///etc/passwd"), null); - check("protocol-relative is refused, not upgraded", safeHref("//evil.example/x"), null); - check("a relative path is fine", safeHref("/community/patterns"), "/community/patterns"); - check("an anchor is fine", safeHref("#results"), "#results"); - check("a colon after a slash is a path, not a scheme", safeHref("notes/a:b"), "notes/a:b"); - check("empty is nothing", safeHref(""), null); - check("undefined is nothing", safeHref(undefined), null); - - // ── Storage quotas ──────────────────────────────────────────────────────── - // The per-parent cap bounds one thread and nothing else — an account just - // makes more threads. Without a per-user and a whole-community ceiling the - // only limit is the disk the server boots from, and when that fills SQLite - // stops writing: the community does not degrade, it stops. - check("a fresh account is storing nothing", await queries.attachmentBytesByUser("cara"), 0); - await db.insert(schema.posts).values({ - id: "quota-thread", - territoryId: "z1", - userId: "cara", - title: "Quota", - body: "x", - createdAt: NOW, - updatedAt: NOW, - }); - await db.insert(schema.postAttachments).values([ - { id: "cccccccccccccccc", postId: "quota-thread", commentId: null, userId: "cara", - filename: "a.png", bytes: 900_000, createdAt: NOW }, - { id: "dddddddddddddddd", postId: "quota-thread", commentId: null, userId: "cara", - filename: "b.png", bytes: 100_000, createdAt: NOW }, - ]); - check("a person's usage is the sum of their files", await queries.attachmentBytesByUser("cara"), 1_000_000); - check("…and is not charged to anybody else", await queries.attachmentBytesByUser("bob"), 0); - check("the community total counts everyone", await queries.attachmentBytesTotal(), 1_000_000); - - check("per-user ceiling is 100 MB", workshop.ATTACHMENT_MAX_PER_USER_BYTES, 100 * 1024 * 1024); - check("community ceiling is 2 GB", workshop.ATTACHMENT_MAX_TOTAL_BYTES, 2 * 1024 * 1024 * 1024); - // The body cap has to leave room for the largest legitimate upload, or a - // valid five-file post gets a 413 instead of an attachment. - check( - "the request cap admits a full five-file upload", - workshop.ATTACHMENT_MAX_REQUEST_BYTES > - workshop.ATTACHMENT_MAX_PER_PARENT * workshop.ATTACHMENT_MAX_BYTES, - true, - ); - - // ── Byte sniffing ───────────────────────────────────────────────────────── - const png = Buffer.concat([ - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - Buffer.alloc(16), - ]); - check("png magic sniffs as png", sniffImage(png), "image/png"); - check("text never sniffs as an image", sniffImage(Buffer.from("")), null); - check("short buffers are nothing", sniffImage(Buffer.from([0x89, 0x50])), null); - - // ── Cascade + orphan sweep ──────────────────────────────────────────────── - const fileId = "aaaaaaaaaaaaaaaa"; - const orphanId = "bbbbbbbbbbbbbbbb"; - fs.mkdirSync(attachmentDir(), { recursive: true }); - fs.writeFileSync(attachmentPath(fileId), png); - fs.writeFileSync(attachmentPath(orphanId), Buffer.from("leftover")); - await db.insert(schema.postAttachments).values({ - id: fileId, - postId: "t1", - commentId: null, - userId: "alice", - filename: "shot.png", - bytes: png.length, - createdAt: NOW, - }); - - // Deleting the thread takes its attachment ROW with it (foreign keys)… - await db.delete(schema.posts).where(eq(schema.posts.id, "t1")); - // Scoped to the thread that was deleted: this is asking whether ITS rows went - // with it, not whether the table is empty — other threads' files are supposed - // to survive, and asserting on the whole table quietly made this test depend - // on nothing else in the fixture ever having an attachment. - const remainingRows = await db - .select() - .from(schema.postAttachments) - .where(eq(schema.postAttachments.postId, "t1")); - check("deleting the thread cascades the attachment row", remainingRows.length, 0); - check( - "…and leaves another thread's files alone", - (await db.select().from(schema.postAttachments).where(eq(schema.postAttachments.postId, "quota-thread"))).length, - 2, - ); - - // …and the sweep takes the BYTES, once they are past the grace window. - // The files' mtimes are pinned to the fixture clock — the real clock is - // days ahead of NOW, and the grace check compares against mtime. - fs.utimesSync(attachmentPath(fileId), NOW, NOW); - fs.utimesSync(attachmentPath(orphanId), NOW, NOW); - const early = await sweepOrphanAttachments(NOW); - check("inside the grace window nothing is touched", early.deleted, 0); - fs.utimesSync(attachmentPath(fileId), ago(2), ago(2)); - fs.utimesSync(attachmentPath(orphanId), ago(2), ago(2)); - const late = await sweepOrphanAttachments(NOW); - check("past it, both orphans go", late.deleted, 2); - check("…and the directory is actually empty", fs.readdirSync(attachmentDir()).length, 0); - check("…with no errors", late.errors, []); - - console.log(failures === 0 ? "\nAll workshop checks passed." : `\n${failures} FAILED`); - process.exit(failures === 0 ? 0 : 1); -} - -void main(); diff --git a/web/src/app/api/community/attachments/[id]/route.ts b/web/src/app/api/community/attachments/[id]/route.ts deleted file mode 100644 index 10060f177..000000000 --- a/web/src/app/api/community/attachments/[id]/route.ts +++ /dev/null @@ -1,119 +0,0 @@ -import fs from "node:fs/promises"; -import { eq } from "drizzle-orm"; -import { isAdminSession } from "@/lib/community/server/admin"; -import { attachmentPath, sniffImage } from "@/lib/community/server/attachments"; -import { getAuth } from "@/lib/community/server/auth"; -import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; -import { getAttachment } from "@/lib/community/server/queries"; -import { postAttachments } from "@/lib/community/server/schema"; - -// GET /api/community/attachments/[id] — hand back a file from a thread. -// DELETE the same — take one back off. -// -// Two paths out, decided by the BYTES rather than the uploaded name: -// -// - Verified raster images (png/jpeg/gif/webp by magic number) are served -// inline with their real content type, so a build photo shows up IN the -// thread instead of landing in Downloads. `CSP: sandbox` rides along as -// the belt to that brace: even if something were mis-sniffed, the response -// renders with no scripts, no origin, no cookies. -// -// - Everything else is application/octet-stream + attachment, whatever it -// claims to be. An .svg or .html hung on a thread is somebody else's -// markup on our origin — served inline it would run with our cookies, -// which is stored XSS. SVG stays on this path forever: it is a document, -// and there is no magic number that makes a document safe. -// -// No auth check: threads are public, so their files are too. The id is the -// capability, and it is not guessable. - -export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) { - if (!communityEnabled()) { - return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); - } - - const { id } = await context.params; - const row = await getAttachment(id); - if (!row) { - return Response.json({ error: "File not found." }, { status: 404 }); - } - - let bytes: Buffer; - try { - bytes = await fs.readFile(attachmentPath(row.id)); - } catch { - // The row outlived its file — a restored database against a fresh disk, - // say. A 404 is the honest answer. - return Response.json({ error: "File not found." }, { status: 404 }); - } - - // The stored name is already stripped of quotes and control characters - // (cleanFilename), so it is safe to put in the header; filename* carries the - // non-ASCII form for browsers that want it. - const encoded = encodeURIComponent(row.filename); - const image = sniffImage(bytes); - - return new Response(new Uint8Array(bytes), { - headers: { - "Content-Type": image ?? "application/octet-stream", - "Content-Length": String(bytes.length), - "Content-Disposition": `${image ? "inline" : "attachment"}; filename="${row.filename}"; filename*=UTF-8''${encoded}`, - // A thread's file never changes — the row is immutable once written. - "Cache-Control": "public, max-age=31536000, immutable", - "X-Content-Type-Options": "nosniff", - // For the inline path: no scripts, no origin, no cookies, even if the - // sniff were somehow wrong. Harmless on downloads. - "Content-Security-Policy": "sandbox", - }, - }); -} - -export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handleDelete(request, context)); -} - -export const OPTIONS = preflight; - -/** - * Unattach a file. - * - * Attaching used to be one-way: pick the wrong file and it was on the thread - * for good, under your name, with a permanent URL. That is a bad thing to do to - * somebody over a misclick, and worse when the wrong file is one they did not - * mean to publish at all. - * - * Only the person who uploaded it, or a moderator. The uploader is always the - * author of the thread or reply it hangs on (the POST route enforces that), so - * this is the same permission as editing the thing it is attached to. - * - * Row first, then bytes. A row pointing at a missing file already 404s - * gracefully above; bytes with no row are invisible and permanent, so if the - * unlink fails the sweep is what cleans up — never the other way round. - */ -async function handleDelete(request: Request, context: { params: Promise<{ id: string }> }) { - if (!communityEnabled()) { - return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); - } - - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!session) { - return Response.json({ error: "Sign in first." }, { status: 401 }); - } - - const { id } = await context.params; - const row = await getAttachment(id); - if (!row) { - return Response.json({ error: "File not found." }, { status: 404 }); - } - if (row.userId !== session.user.id && !isAdminSession(session)) { - return Response.json({ error: "That is not your file." }, { status: 403 }); - } - - await getDb().delete(postAttachments).where(eq(postAttachments.id, row.id)); - await fs.rm(attachmentPath(row.id), { force: true }).catch(() => undefined); - - return Response.json({ ok: true }); -} diff --git a/web/src/app/api/community/attachments/route.ts b/web/src/app/api/community/attachments/route.ts deleted file mode 100644 index effb4bda5..000000000 --- a/web/src/app/api/community/attachments/route.ts +++ /dev/null @@ -1,215 +0,0 @@ -import fs from "node:fs/promises"; -import { getAuth } from "@/lib/community/server/auth"; -import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; -import { attachmentDir, attachmentPath } from "@/lib/community/server/attachments"; -import { - ATTACHMENT_MAX_BYTES, - ATTACHMENT_MAX_PER_PARENT, - ATTACHMENT_MAX_PER_USER_BYTES, - ATTACHMENT_MAX_REQUEST_BYTES, - ATTACHMENT_MAX_TOTAL_BYTES, - attachmentAllowed, - cleanFilename, - formatBytes, -} from "@/lib/community/workshop"; -import { - attachmentBytesByUser, - attachmentBytesTotal, - countAttachments, - getPostCommentStub, - getPostStub, - newId, -} from "@/lib/community/server/queries"; -import { rateLimit } from "@/lib/community/ratelimit"; -import { postAttachments } from "@/lib/community/server/schema"; - -// POST /api/community/attachments — hang files on a thread or one of its -// replies. multipart/form-data: postId, optional commentId, and `files`. -// -// Called AFTER the thread or reply exists, not with it. That ordering means an -// attachment always has a parent — there is no upload session to expire, no -// orphaned bytes to sweep — and a failed upload leaves the writing intact for -// the author to retry rather than losing the post with it. -// -// This is not a file host and it runs on a Raspberry Pi: five files per parent, -// two megabytes each, and an extension allowlist. See lib/community/workshop.ts. - -export async function POST(request: Request) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handlePost(request)); -} - -export const OPTIONS = preflight; - -async function handlePost(request: Request) { - if (!communityEnabled()) { - return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); - } - - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!session) { - return Response.json({ error: "Sign in to attach a file." }, { status: 401 }); - } - - if (!rateLimit(`attach:${session.user.id}`, 20, 60_000)) { - return Response.json({ error: "Too many uploads — wait a minute." }, { status: 429 }); - } - - // Before parsing anything. formData() pulls the entire multipart body into - // memory, and every size check below runs on what it produces — so without - // this the per-file cap is enforced only after a gigabyte has already been - // read. A reverse proxy usually also caps this, but the app cannot see - // whether one is in front of it today, let alone after the next move. - const declared = Number(request.headers.get("content-length") ?? "0"); - if (Number.isFinite(declared) && declared > ATTACHMENT_MAX_REQUEST_BYTES) { - return Response.json( - { error: `That request is ${formatBytes(declared)}; the limit is ${formatBytes(ATTACHMENT_MAX_REQUEST_BYTES)}.` }, - { status: 413 }, - ); - } - - let form: FormData; - try { - form = await request.formData(); - } catch { - return Response.json({ error: "Expected a multipart form." }, { status: 400 }); - } - - const postId = String(form.get("postId") ?? ""); - const rawCommentId = form.get("commentId"); - const commentId = typeof rawCommentId === "string" && rawCommentId.length > 0 ? rawCommentId : null; - - // Only the person who wrote the thing may hang files on it — the file reads - // as theirs, so it has to be. - if (commentId) { - const comment = await getPostCommentStub(commentId); - if (!comment || comment.postId !== postId) { - return Response.json({ error: "Reply not found." }, { status: 404 }); - } - if (comment.userId !== session.user.id) { - return Response.json({ error: "That is not your reply." }, { status: 403 }); - } - } else { - const post = await getPostStub(postId); - if (!post) { - return Response.json({ error: "Thread not found." }, { status: 404 }); - } - if (post.userId !== session.user.id) { - return Response.json({ error: "That is not your thread." }, { status: 403 }); - } - } - - const files = form.getAll("files").filter((entry): entry is File => entry instanceof File); - if (files.length === 0) { - return Response.json({ error: "No files in the request." }, { status: 400 }); - } - - const already = await countAttachments(postId, commentId); - if (already + files.length > ATTACHMENT_MAX_PER_PARENT) { - return Response.json( - { error: `Up to ${ATTACHMENT_MAX_PER_PARENT} files each — this one has ${already}.` }, - { status: 400 }, - ); - } - - // Validate everything before writing anything: a half-attached post is worse - // than a rejected one. - const accepted: { id: string; filename: string; bytes: Buffer }[] = []; - for (const file of files) { - const filename = cleanFilename(file.name); - if (!filename) { - return Response.json({ error: "A file had no usable name." }, { status: 400 }); - } - if (!attachmentAllowed(filename)) { - return Response.json( - { error: `“${filename}” is not a file type we take. Link it from a repo instead.` }, - { status: 400 }, - ); - } - if (file.size > ATTACHMENT_MAX_BYTES) { - return Response.json( - { - error: `“${filename}” is ${formatBytes(file.size)} — the limit is ${formatBytes( - ATTACHMENT_MAX_BYTES, - )}.`, - }, - { status: 400 }, - ); - } - accepted.push({ - id: newId(), - filename, - bytes: Buffer.from(await file.arrayBuffer()), - }); - } - - // Quotas, checked once the real sizes are known. The per-parent count above - // bounds a thread; these bound an account and the disk. - const incoming = accepted.reduce((sum, item) => sum + item.bytes.length, 0); - const [mine, everyone] = await Promise.all([ - attachmentBytesByUser(session.user.id), - attachmentBytesTotal(), - ]); - if (mine + incoming > ATTACHMENT_MAX_PER_USER_BYTES) { - return Response.json( - { - error: - `You are storing ${formatBytes(mine)} of ${formatBytes(ATTACHMENT_MAX_PER_USER_BYTES)}. ` + - "Remove a file from an older thread to make room — the × on any attachment.", - }, - { status: 413 }, - ); - } - if (everyone + incoming > ATTACHMENT_MAX_TOTAL_BYTES) { - // Nothing the uploader can do about this one, so it says so plainly - // rather than blaming them. - return Response.json( - { error: "The community's file storage is full. This is being looked at — try again later." }, - { status: 507 }, - ); - } - - await fs.mkdir(attachmentDir(), { recursive: true }); - const now = new Date(); - const written: string[] = []; - try { - for (const item of accepted) { - await fs.writeFile(attachmentPath(item.id), item.bytes); - written.push(item.id); - } - await getDb() - .insert(postAttachments) - .values( - accepted.map((item) => ({ - id: item.id, - postId, - commentId, - userId: session.user.id, - filename: item.filename, - bytes: item.bytes.length, - createdAt: now, - })), - ); - } catch { - // Bytes on disk with no row pointing at them are invisible and permanent, - // so a failed write takes its own files back out. - await Promise.all( - written.map((id) => fs.rm(attachmentPath(id), { force: true }).catch(() => undefined)), - ); - return Response.json({ error: "Could not store the files." }, { status: 500 }); - } - - return Response.json( - { - ok: true, - files: accepted.map((item) => ({ - id: item.id, - filename: item.filename, - bytes: item.bytes.length, - })), - }, - { status: 201 }, - ); -} diff --git a/web/src/app/api/community/comments/[id]/route.ts b/web/src/app/api/community/comments/[id]/route.ts index cf3b030bb..06d395899 100644 --- a/web/src/app/api/community/comments/[id]/route.ts +++ b/web/src/app/api/community/comments/[id]/route.ts @@ -7,18 +7,18 @@ import { communityEnabled, getDb } from "@/lib/community/server/db"; import { clearNotificationsFor } from "@/lib/community/server/notify"; import { getCommentStub } from "@/lib/community/server/queries"; import { rateLimit } from "@/lib/community/ratelimit"; -import { comments, postComments } from "@/lib/community/server/schema"; +import { comments } from "@/lib/community/server/schema"; import { COMMENT_MAX, cleanComment } from "@/lib/community/validate"; -// DELETE /api/community/comments/[id]?on=pattern|post +// DELETE /api/community/comments/[id]?on=pattern // // Comments previously had no removal path at all — not for their author, not // for anyone. That left "report this comment" with no remedy behind it, so the // report button and this route ship together. // -// `on` says which thread the comment belongs to. Pattern comments and post -// comments live in separate tables (see schema.ts), and guessing by trying both -// would make the route's behaviour depend on id collisions. +// `on` says which thread the comment belongs to. Until 26 October 2026 it could +// also be "post", a reply in the workshop. The workshop came down that day and +// its replies stay in their own table, untouched by this route. export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) { const blocked = originBlocked(request); @@ -52,12 +52,12 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st } const on = new URL(request.url).searchParams.get("on"); - if (on !== "pattern" && on !== "post") { + if (on !== "pattern") { return Response.json({ error: "Unknown comment thread." }, { status: 400 }); } const { id } = await context.params; - const comment = await getCommentStub(on, id); + const comment = await getCommentStub(id); if (!comment) { return Response.json({ error: "Comment not found." }, { status: 404 }); } @@ -76,14 +76,13 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st ); } - const table = on === "pattern" ? comments : postComments; // `editedAt` is stamped so the thread shows the comment changed. Replies sit // under it, and a comment that quietly becomes something else makes them read // as answers to something never said. await getDb() - .update(table) + .update(comments) .set({ body: next, editedAt: new Date() }) - .where(eq(table.id, id)); + .where(eq(comments.id, id)); return Response.json({ ok: true }); } @@ -99,12 +98,12 @@ async function handleDelete(request: Request, context: { params: Promise<{ id: s } const on = new URL(request.url).searchParams.get("on"); - if (on !== "pattern" && on !== "post") { + if (on !== "pattern") { return Response.json({ error: "Unknown comment thread." }, { status: 400 }); } const { id } = await context.params; - const comment = await getCommentStub(on, id); + const comment = await getCommentStub(id); if (!comment) { return Response.json({ error: "Comment not found." }, { status: 404 }); } @@ -113,8 +112,7 @@ async function handleDelete(request: Request, context: { params: Promise<{ id: s return Response.json({ error: "You can only delete your own comments." }, { status: 403 }); } - const table = on === "pattern" ? comments : postComments; - await getDb().delete(table).where(eq(table.id, id)); + await getDb().delete(comments).where(eq(comments.id, id)); // The notifications this comment caused go with it. await clearNotificationsFor({ sourceId: id }); return Response.json({ ok: true }); diff --git a/web/src/app/api/community/nullBody.test.ts b/web/src/app/api/community/nullBody.test.ts index 0c4a4c807..23db9be67 100644 --- a/web/src/app/api/community/nullBody.test.ts +++ b/web/src/app/api/community/nullBody.test.ts @@ -9,9 +9,6 @@ import { afterAll, beforeAll, expect, it } from "vitest"; // Pattern Lab on another origin could not read even the status. Every route // below is driven with a signed-in moderator and the Lab's Origin, on a // database of its own, with something for each id in the path to find. -// -// territories/[code]/pin is not here: its body is optional, and a `null` one -// already pins with no note. const LAB_ORIGIN = "https://lab.patternflow.test"; const ENV_KEYS = [ @@ -44,15 +41,8 @@ const ROUTES: [string, string, () => Promise, Record import("./patterns/[id]/header/route"), { id: "p1" }], ["POST", "patterns/p1/performance", () => import("./patterns/[id]/performance/route"), { id: "p1" }], ["PATCH", "ports/h1", () => import("./ports/[id]/route"), { id: "h1" }], - ["POST", "posts", () => import("./posts/route"), {}], - ["PATCH", "posts/s1", () => import("./posts/[id]/route"), { id: "s1" }], - ["POST", "posts/s1/comments", () => import("./posts/[id]/comments/route"), { id: "s1" }], - ["POST", "posts/s1/pin", () => import("./posts/[id]/pin/route"), { id: "s1" }], - ["POST", "presence", () => import("./presence/route"), {}], ["POST", "reports", () => import("./reports/route"), {}], ["PATCH", "reports/r1", () => import("./reports/[id]/route"), { id: "r1" }], - ["POST", "territories", () => import("./territories/route"), {}], - ["PATCH", "territories/A1", () => import("./territories/[code]/route"), { code: "A1" }], ]; beforeAll(async () => { @@ -95,10 +85,6 @@ beforeAll(async () => { await db.insert(schema.patternHeaders).values({ id: "h1", patternId: "p1", userId, codeCpp: "#pragma once\n", createdAt: now, }); - await db.insert(schema.territories).values({ id: "t1", code: "A1", title: "Workshop", createdAt: now }); - await db.insert(schema.posts).values({ - id: "s1", territoryId: "t1", userId, title: "ada's post", body: "hello", createdAt: now, updatedAt: now, - }); await db.insert(schema.reports).values({ id: "r1", targetType: "pattern", targetId: "p1", reporterId: userId, reason: "other", createdAt: now, }); diff --git a/web/src/app/api/community/patterns/[id]/comments/route.ts b/web/src/app/api/community/patterns/[id]/comments/route.ts index 16bf38756..c4235d8b9 100644 --- a/web/src/app/api/community/patterns/[id]/comments/route.ts +++ b/web/src/app/api/community/patterns/[id]/comments/route.ts @@ -67,7 +67,6 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str }); await notifyCommentAdded({ - on: "pattern", targetId: patternId, targetTitle: pattern.title, ownerId: pattern.userId, diff --git a/web/src/app/api/community/posts/[id]/comments/route.ts b/web/src/app/api/community/posts/[id]/comments/route.ts deleted file mode 100644 index e38d30af3..000000000 --- a/web/src/app/api/community/posts/[id]/comments/route.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { getAuth } from "@/lib/community/server/auth"; -import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { readJsonObject } from "@/lib/community/jsonBody"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; -import { notifyCommentAdded } from "@/lib/community/server/notify"; -import { getPostStub, newId } from "@/lib/community/server/queries"; -import { rateLimit } from "@/lib/community/ratelimit"; -import { postComments } from "@/lib/community/server/schema"; -import { cleanComment } from "@/lib/community/validate"; - -// POST /api/community/posts/[id]/comments — reply to a discussion thread (login -// required). Same rules as pattern comments: plain text in, React escapes out. - -export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handlePost(request, context)); -} - -export const OPTIONS = preflight; - -async function handlePost(request: Request, context: { params: Promise<{ id: string }> }) { - if (!communityEnabled()) { - return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); - } - - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!session) { - return Response.json({ error: "Sign in to comment." }, { status: 401 }); - } - - // Shares the pattern-comment budget on purpose: it is one person's comment - // rate, not a per-surface allowance. - if (!rateLimit(`comment:${session.user.id}`, 10, 60_000)) { - return Response.json({ error: "Too many comments — wait a minute and try again." }, { status: 429 }); - } - - const { id: postId } = await context.params; - const post = await getPostStub(postId); - if (!post) { - return Response.json({ error: "Post not found." }, { status: 404 }); - } - - const payload = await readJsonObject(request); - if (payload instanceof Response) return payload; - - const text = cleanComment(payload.body); - if (!text) { - return Response.json({ error: "Comment is empty or over 2000 chars." }, { status: 400 }); - } - - const commentId = newId(); - await getDb().insert(postComments).values({ - id: commentId, - postId, - userId: session.user.id, - body: text, - createdAt: new Date(), - }); - - await notifyCommentAdded({ - on: "post", - targetId: postId, - targetTitle: post.title, - ownerId: post.userId, - actorId: session.user.id, - commentId, - body: text, - }); - - // The id goes back because attachments are a second request: the reply - // composer uploads its files against this comment right after this returns. - return Response.json({ ok: true, id: commentId }, { status: 201 }); -} diff --git a/web/src/app/api/community/posts/[id]/pin/route.ts b/web/src/app/api/community/posts/[id]/pin/route.ts deleted file mode 100644 index 10c216b56..000000000 --- a/web/src/app/api/community/posts/[id]/pin/route.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { eq, isNotNull } from "drizzle-orm"; -import { isAdminSession } from "@/lib/community/server/admin"; -import { getAuth } from "@/lib/community/server/auth"; -import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { readJsonObject } from "@/lib/community/jsonBody"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; -import { getPostStub } from "@/lib/community/server/queries"; -import { posts } from "@/lib/community/server/schema"; - -// POST /api/community/posts/[id]/pin — moderators set (or clear) THE notice. -// -// Not part of the post PATCH on purpose: pinning is curation, not editing, and -// the edit route is author-only by a rule worth keeping intact. One slot: -// pinning a post un-pins whatever held the spot before. - -export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handlePost(request, context)); -} - -export const OPTIONS = preflight; - -async function handlePost(request: Request, context: { params: Promise<{ id: string }> }) { - if (!communityEnabled()) { - return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); - } - - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!session || !isAdminSession(session)) { - // 404, not 403 — the pin control is moderator furniture; everyone else - // has no reason to learn the endpoint exists. - return Response.json({ error: "Not found." }, { status: 404 }); - } - - const { id } = await context.params; - const post = await getPostStub(id); - if (!post) return Response.json({ error: "Post not found." }, { status: 404 }); - - const body = await readJsonObject(request); - if (body instanceof Response) return body; - const pinned = body.pinned; - if (typeof pinned !== "boolean") { - return Response.json({ error: "Expected { pinned: true | false }." }, { status: 400 }); - } - - const db = getDb(); - if (pinned) { - await db.update(posts).set({ pinnedAt: null }).where(isNotNull(posts.pinnedAt)); - await db.update(posts).set({ pinnedAt: new Date() }).where(eq(posts.id, id)); - } else { - await db.update(posts).set({ pinnedAt: null }).where(eq(posts.id, id)); - } - - return Response.json({ ok: true }); -} diff --git a/web/src/app/api/community/posts/[id]/route.ts b/web/src/app/api/community/posts/[id]/route.ts deleted file mode 100644 index 36ee7b0eb..000000000 --- a/web/src/app/api/community/posts/[id]/route.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { eq } from "drizzle-orm"; -import { isAdminSession } from "@/lib/community/server/admin"; -import { getAuth } from "@/lib/community/server/auth"; -import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { readJsonObject } from "@/lib/community/jsonBody"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; -import { clearNotificationsFor } from "@/lib/community/server/notify"; -import { getPostStub } from "@/lib/community/server/queries"; -import { rateLimit } from "@/lib/community/ratelimit"; -import { posts } from "@/lib/community/server/schema"; -import { cleanPostBody, cleanTitle } from "@/lib/community/validate"; - -// PATCH / DELETE /api/community/posts/[id] — the author edits or removes their -// own thread. Deleting takes its comments with it (they cascade). - -export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handlePatch(request, context)); -} - -export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handleDelete(request, context)); -} - -export const OPTIONS = preflight; - -/** Shared gate: enabled, signed in, post exists, and it belongs to the caller. */ -async function authorize(request: Request, id: string, verb: string) { - if (!communityEnabled()) { - return { - error: Response.json( - { error: "Community is not enabled on this deployment." }, - { status: 503 }, - ), - }; - } - - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!session) { - return { error: Response.json({ error: `Sign in to ${verb} your post.` }, { status: 401 }) }; - } - - const post = await getPostStub(id); - if (!post) { - return { error: Response.json({ error: "Post not found." }, { status: 404 }) }; - } - // Moderators can take a post down, but not rewrite it: removing someone's - // post is a moderation act, editing it in their name is not. - const allowed = - post.userId === session.user.id || (verb === "delete" && isAdminSession(session)); - if (!allowed) { - // 403, not 404 — the post is public, it just isn't theirs to change. - return { - error: Response.json({ error: `You can only ${verb} your own posts.` }, { status: 403 }), - }; - } - - return { session }; -} - -async function handleDelete(request: Request, context: { params: Promise<{ id: string }> }) { - const { id } = await context.params; - const gate = await authorize(request, id, "delete"); - if (gate.error) return gate.error; - - await getDb().delete(posts).where(eq(posts.id, id)); - await clearNotificationsFor({ targetType: "post", targetId: id }); - return Response.json({ ok: true }); -} - -async function handlePatch(request: Request, context: { params: Promise<{ id: string }> }) { - const { id } = await context.params; - const gate = await authorize(request, id, "edit"); - if (gate.error) return gate.error; - - if (!rateLimit(`postpatch:${gate.session.user.id}`, 20, 60_000)) { - return Response.json({ error: "Too many edits — wait a minute and try again." }, { status: 429 }); - } - - const raw = await readJsonObject(request); - if (raw instanceof Response) return raw; - - // Partial update: only the fields present in the body change. - const patch: { title?: string; body?: string; updatedAt: Date } = { updatedAt: new Date() }; - - if (raw.title !== undefined) { - const title = cleanTitle(raw.title); - if (!title) return Response.json({ error: "Title is empty or too long." }, { status: 400 }); - patch.title = title; - } - if (raw.body !== undefined) { - const body = cleanPostBody(raw.body); - if (!body) return Response.json({ error: "Body is empty or too long." }, { status: 400 }); - patch.body = body; - } - if (patch.title === undefined && patch.body === undefined) { - return Response.json({ error: "Nothing to update." }, { status: 400 }); - } - - await getDb().update(posts).set(patch).where(eq(posts.id, id)); - return Response.json({ ok: true }); -} diff --git a/web/src/app/api/community/posts/route.ts b/web/src/app/api/community/posts/route.ts deleted file mode 100644 index 45f3d970b..000000000 --- a/web/src/app/api/community/posts/route.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { getAuth } from "@/lib/community/server/auth"; -import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { readJsonObject } from "@/lib/community/jsonBody"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; -import { cleanPinNote, cleanTerritoryCode } from "@/lib/community/workshop"; -import { notifyNewThread } from "@/lib/community/server/notify"; -import { getTerritoryByCode, newId } from "@/lib/community/server/queries"; -import { rateLimit } from "@/lib/community/ratelimit"; -import { posts, territoryPins } from "@/lib/community/server/schema"; -import { cleanPostBody, cleanTitle } from "@/lib/community/validate"; - -// POST /api/community/posts — start a thread inside a territory (login -// required). Title and body are plain text, escaped by React on output. -// -// A thread always belongs to a direction on the map: there is no general list -// any more, because a question about a direction belongs beside that -// direction. The caller names the territory by its map code ("A3"). -// -// `pin` piggybacks on the same request — the new-thread modal has "also pin -// me: I'm working here" checked by default, and starting a thread about -// something IS the strongest evidence you are working on it. Pinning is -// idempotent, so a second thread in the same territory changes nothing. - -export async function POST(request: Request) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handlePost(request)); -} - -export const OPTIONS = preflight; - -async function handlePost(request: Request) { - if (!communityEnabled()) { - return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); - } - - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!session) { - return Response.json({ error: "Sign in to post." }, { status: 401 }); - } - - // Tighter than comments: a post is a thread, not a reply. - if (!rateLimit(`post:${session.user.id}`, 5, 60_000)) { - return Response.json({ error: "Too many posts — wait a minute and try again." }, { status: 429 }); - } - - const raw = await readJsonObject(request); - if (raw instanceof Response) return raw; - const title = cleanTitle(raw.title); - if (!title) { - return Response.json({ error: "Title is empty or too long." }, { status: 400 }); - } - const body = cleanPostBody(raw.body); - if (!body) { - return Response.json({ error: "Body is empty or too long." }, { status: 400 }); - } - - const code = cleanTerritoryCode(raw.territoryCode); - if (!code) { - return Response.json({ error: "Pick a place on the map for this." }, { status: 400 }); - } - const territory = await getTerritoryByCode(code); - if (!territory) { - return Response.json({ error: "That territory does not exist." }, { status: 404 }); - } - - const note = cleanPinNote(raw.pinNote); - if (note === undefined) { - return Response.json({ error: "That note is too long." }, { status: 400 }); - } - - const id = newId(); - const now = new Date(); - const db = getDb(); - await db.insert(posts).values({ - id, - territoryId: territory.id, - userId: session.user.id, - title, - body, - createdAt: now, - updatedAt: now, - }); - - if (raw.pin === true) { - // Already pinned there? Leave the original "since" alone — the date is the - // point of the pin, and a new thread does not restart it. - await db - .insert(territoryPins) - .values({ - id: newId(), - territoryId: territory.id, - userId: session.user.id, - note, - createdAt: now, - }) - .onConflictDoNothing(); - } - - // The pin-as-subscription payoff: everyone working in this territory hears - // that something started in it. After the author's own pin above, so a - // brand-new pin does not change who gets told about THIS thread (the actor - // is excluded either way) — but ordering it here keeps that true by - // construction rather than by coincidence. - await notifyNewThread({ - territoryId: territory.id, - territoryLabel: `${territory.code} · ${territory.title}`, - postId: id, - postTitle: title, - actorId: session.user.id, - }); - - return Response.json({ ok: true, id, territoryCode: territory.code }, { status: 201 }); -} diff --git a/web/src/app/api/community/presence/route.ts b/web/src/app/api/community/presence/route.ts deleted file mode 100644 index 0385ef7df..000000000 --- a/web/src/app/api/community/presence/route.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { getAuth } from "@/lib/community/server/auth"; -import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { readJsonObject } from "@/lib/community/jsonBody"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; -import { - STAGE_HEIGHT, - STAGE_WIDTH, - STATUS_MAX, - cleanStageCoord, - cleanStatus, -} from "@/lib/community/workshop"; -import { countUnmoved, listPresence } from "@/lib/community/server/queries"; -import { rateLimit } from "@/lib/community/ratelimit"; -import { presence } from "@/lib/community/server/schema"; - -// GET /api/community/presence — everyone standing on the constellation, -// plus how many accounts are still parked at the core. -// POST — move your own square, or say something. -// -// This is presence, not pins. A pin subscribes you to a territory's threads; -// your square just stands somewhere. The two never touch each other's tables, -// so walking around can never change what you get notified about. - -export async function GET(request: Request) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handleGet()); -} - -export async function POST(request: Request) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handlePost(request)); -} - -export const OPTIONS = preflight; - -async function handleGet() { - if (!communityEnabled()) { - return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); - } - const [people, unmoved] = await Promise.all([listPresence(), countUnmoved()]); - return Response.json({ - people: people.map((person) => ({ - userId: person.userId, - username: person.username, - displayUsername: person.displayUsername, - x: person.x, - y: person.y, - status: person.status, - updatedAt: person.updatedAt.toISOString(), - })), - unmoved, - }); -} - -async function handlePost(request: Request) { - if (!communityEnabled()) { - return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); - } - - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!session) { - return Response.json({ error: "Sign in to stand on the map." }, { status: 401 }); - } - - // Movement saves are debounced client-side to one per pause, so a normal - // walk is a handful a minute; the limit only exists for a stuck key. - if (!rateLimit(`presence:${session.user.id}`, 60, 60_000)) { - return Response.json({ error: "Too many moves — stand still a moment." }, { status: 429 }); - } - - const payload = await readJsonObject(request); - if (payload instanceof Response) return payload; - - const hasPosition = payload.x !== undefined || payload.y !== undefined; - const hasStatus = payload.status !== undefined; - if (!hasPosition && !hasStatus) { - return Response.json({ error: "Nothing to change." }, { status: 400 }); - } - - let x: number | undefined; - let y: number | undefined; - if (hasPosition) { - // One coordinate without the other is a client bug, not a save. - x = cleanStageCoord(payload.x, "x"); - y = cleanStageCoord(payload.y, "y"); - if (x === undefined || y === undefined) { - return Response.json({ error: "Bad position." }, { status: 400 }); - } - } - - const status = hasStatus ? cleanStatus(payload.status) : undefined; - if (hasStatus && status === undefined) { - return Response.json( - { error: `A status fits in ${STATUS_MAX} characters — it stands beside your square.` }, - { status: 400 }, - ); - } - - // Upsert: first write births the square (a status-only save spawns it at the - // core, which is where the person visibly already was). - const now = new Date(); - await getDb() - .insert(presence) - .values({ - userId: session.user.id, - x: x ?? Math.round(STAGE_WIDTH / 2), - y: y ?? Math.round(STAGE_HEIGHT / 2), - status: status ?? null, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: presence.userId, - set: { - ...(hasPosition ? { x, y } : {}), - ...(hasStatus ? { status } : {}), - updatedAt: now, - }, - }); - - return Response.json({ ok: true }); -} diff --git a/web/src/app/api/community/territories/[code]/pin/route.ts b/web/src/app/api/community/territories/[code]/pin/route.ts deleted file mode 100644 index 255096d53..000000000 --- a/web/src/app/api/community/territories/[code]/pin/route.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { and, eq } from "drizzle-orm"; -import { getAuth } from "@/lib/community/server/auth"; -import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; -import { cleanPinNote, cleanTerritoryCode } from "@/lib/community/workshop"; -import { getTerritoryByCode, listTerritoryPins, newId } from "@/lib/community/server/queries"; -import { rateLimit } from "@/lib/community/ratelimit"; -import { territoryPins } from "@/lib/community/server/schema"; - -// POST /api/community/territories/[code]/pin — "I'm working here." -// DELETE — take it back. -// -// The cheapest contribution the site accepts: one click, no artifact. That is -// the point — a direction with three names on it reads completely differently -// from the same direction with none, and asking for a write-up first would mean -// most directions stay at zero forever. -// -// Not a toggle on one verb. Pinning carries a note ("steel front") that the -// pinner can rewrite without unpinning, and unpinning throws away the "since" -// date, so the two need to be separately addressable. - -type Context = { params: Promise<{ code: string }> }; - -export async function POST(request: Request, context: Context) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handlePin(request, context)); -} - -export async function DELETE(request: Request, context: Context) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handleUnpin(request, context)); -} - -export const OPTIONS = preflight; - -/** Everything both verbs need, or the response that says why not. */ -type Resolved = - | { error: Response } - | { error?: undefined; userId: string; territory: { id: string; code: string } }; - -async function resolve(request: Request, context: Context): Promise { - if (!communityEnabled()) { - return { - error: Response.json( - { error: "Community is not enabled on this deployment." }, - { status: 503 }, - ), - }; - } - - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!session) { - return { error: Response.json({ error: "Sign in to pin yourself." }, { status: 401 }) }; - } - - if (!rateLimit(`pin:${session.user.id}`, 30, 60_000)) { - return { - error: Response.json({ error: "Too many changes — slow down a moment." }, { status: 429 }), - }; - } - - const { code: rawCode } = await context.params; - const code = cleanTerritoryCode(rawCode); - const territory = code ? await getTerritoryByCode(code) : null; - if (!territory) { - return { error: Response.json({ error: "Territory not found." }, { status: 404 }) }; - } - - return { userId: session.user.id, territory }; -} - -async function handlePin(request: Request, context: Context) { - const resolved = await resolve(request, context); - if (resolved.error) return resolved.error; - const { userId, territory } = resolved; - - let note: string | null | undefined = null; - // A note is optional, and so is the body carrying it. - try { - const payload = (await request.json()) as Record; - note = cleanPinNote(payload.note); - } catch { - note = null; - } - if (note === undefined) { - return Response.json({ error: "That note is too long." }, { status: 400 }); - } - - const db = getDb(); - // Re-pinning is how the note gets edited, so an existing row is updated in - // place — and keeps its original createdAt, because "since" is the part of a - // pin that means anything. - await db - .insert(territoryPins) - .values({ - id: newId(), - territoryId: territory.id, - userId, - note, - createdAt: new Date(), - }) - .onConflictDoUpdate({ - target: [territoryPins.territoryId, territoryPins.userId], - set: { note }, - }); - - return Response.json({ pinned: true, pins: await listTerritoryPins(territory.id) }); -} - -async function handleUnpin(request: Request, context: Context) { - const resolved = await resolve(request, context); - if (resolved.error) return resolved.error; - const { userId, territory } = resolved; - - await getDb() - .delete(territoryPins) - .where( - and( - eq(territoryPins.territoryId, territory.id), - eq(territoryPins.userId, userId), - ), - ); - - return Response.json({ pinned: false, pins: await listTerritoryPins(territory.id) }); -} diff --git a/web/src/app/api/community/territories/[code]/route.ts b/web/src/app/api/community/territories/[code]/route.ts deleted file mode 100644 index 7e554b835..000000000 --- a/web/src/app/api/community/territories/[code]/route.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { eq } from "drizzle-orm"; -import { isAdminSession } from "@/lib/community/server/admin"; -import { getAuth } from "@/lib/community/server/auth"; -import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { readJsonObject } from "@/lib/community/jsonBody"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; -import { - QUESTION_MAX, - TERRITORY_DESC_MAX, - cleanQuestions, - cleanSpan, - cleanStageCoord, - cleanTerritoryCode, - cleanTerritoryDescription, - cleanTerritoryTitle, - overlongQuestion, -} from "@/lib/community/workshop"; -import { getTerritoryByCode } from "@/lib/community/server/queries"; -import { territories } from "@/lib/community/server/schema"; - -// PATCH /api/community/territories/[code] — edit one, or archive/restore it. -// DELETE /api/community/territories/[code] — only when nothing hangs off it. -// -// Archiving is the normal way to retire a direction: the threads written in it -// are still worth reading, and the codes are in their URLs. Deleting is for -// the case archiving does not cover — a typo'd territory nobody used — and is -// refused the moment it would take somebody's writing with it. -// -// The code itself is NOT editable. It is the identity: it appears in every -// thread URL under this territory, and renaming it would break every link -// anybody had shared. - -type Context = { params: Promise<{ code: string }> }; - -export async function PATCH(request: Request, context: Context) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handlePatch(request, context)); -} - -export async function DELETE(request: Request, context: Context) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handleDelete(request, context)); -} - -export const OPTIONS = preflight; - -/** The territory, or the response saying why not. Spelled out rather than - * inferred so `resolved.error` narrows for the callers. */ -type Resolved = - | { error: Response; territory?: undefined } - | { error?: undefined; territory: NonNullable>> }; - -async function resolve(request: Request, context: Context): Promise { - if (!communityEnabled()) { - return { - error: Response.json( - { error: "Community is not enabled on this deployment." }, - { status: 503 }, - ), - }; - } - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!isAdminSession(session)) { - return { error: Response.json({ error: "Not found." }, { status: 404 }) }; - } - const { code: rawCode } = await context.params; - const code = cleanTerritoryCode(rawCode); - const territory = code ? await getTerritoryByCode(code) : null; - if (!territory) { - return { error: Response.json({ error: "Territory not found." }, { status: 404 }) }; - } - return { territory }; -} - -async function handlePatch(request: Request, context: Context) { - const resolved = await resolve(request, context); - if (resolved.error) return resolved.error; - const { territory } = resolved; - - const raw = await readJsonObject(request); - if (raw instanceof Response) return raw; - - // Every field is optional — the editor sends what changed, and archiving - // arrives as its own one-key body. - const patch: Record = {}; - - if (raw.title !== undefined) { - const title = cleanTerritoryTitle(raw.title); - if (!title) return Response.json({ error: "A title is required." }, { status: 400 }); - patch.title = title; - } - if (raw.description !== undefined) { - const description = cleanTerritoryDescription(raw.description); - if (description === undefined) { - return Response.json( - { - error: - `A description is at most ${TERRITORY_DESC_MAX} characters — that one is ` + - `${typeof raw.description === "string" ? raw.description.trim().length : "?"}.`, - }, - { status: 400 }, - ); - } - patch.description = description; - } - if (raw.questions !== undefined) { - const questions = cleanQuestions(raw.questions); - if (questions === undefined) { - // Say WHICH line. The editor sends every field in one body, so a refusal - // here takes the description edit down with it, and "questions are one - // short line each" leaves you re-reading four lines to find the culprit. - const offender = overlongQuestion(raw.questions); - return Response.json( - { - error: offender - ? `“${offender.slice(0, 24)}…” is ${offender.length} characters. ` + - `A question hangs off the node as a chip, so it has to fit in ${QUESTION_MAX} — ` + - "put the long version in a thread." - : `Questions are one line each, ${QUESTION_MAX} characters at most.`, - }, - { status: 400 }, - ); - } - patch.questions = questions; - } - if (raw.span !== undefined) { - const span = cleanSpan(raw.span); - if (span === undefined) return Response.json({ error: "Width is 2 to 6 columns." }, { status: 400 }); - patch.span = span; - } - if (raw.x !== undefined) { - const x = cleanStageCoord(raw.x, "x"); - if (x === undefined) return Response.json({ error: "Bad map position." }, { status: 400 }); - patch.x = x; - } - if (raw.y !== undefined) { - const y = cleanStageCoord(raw.y, "y"); - if (y === undefined) return Response.json({ error: "Bad map position." }, { status: 400 }); - patch.y = y; - } - if (raw.position !== undefined) { - const position = Math.round(Number(raw.position)); - if (!Number.isFinite(position) || position < 0) { - return Response.json({ error: "Bad position." }, { status: 400 }); - } - patch.position = position; - } - if (raw.shippingNext !== undefined) { - patch.shippingNext = raw.shippingNext === true; - } - if (raw.archived !== undefined) { - patch.archivedAt = raw.archived === true ? new Date() : null; - } - - if (Object.keys(patch).length === 0) { - return Response.json({ error: "Nothing to change." }, { status: 400 }); - } - - await getDb().update(territories).set(patch).where(eq(territories.id, territory.id)); - return Response.json({ ok: true, code: territory.code }); -} - -async function handleDelete(request: Request, context: Context) { - const resolved = await resolve(request, context); - if (resolved.error) return resolved.error; - const { territory } = resolved; - - // The counts ride on the row already (see territoryColumns), so this costs - // nothing to check — and it is the difference between removing a mistake and - // deleting other people's writing, which the foreign key would cascade away - // without asking. - if (territory.threadCount > 0 || territory.pinCount > 0) { - return Response.json( - { - error: - `${territory.code} has ${territory.threadCount} thread(s) and ${territory.pinCount} ` + - "pin(s). Archive it instead — the threads stay readable and their links keep working.", - }, - { status: 409 }, - ); - } - - await getDb().delete(territories).where(eq(territories.id, territory.id)); - return Response.json({ ok: true }); -} diff --git a/web/src/app/api/community/territories/route.ts b/web/src/app/api/community/territories/route.ts deleted file mode 100644 index eb5e3b08a..000000000 --- a/web/src/app/api/community/territories/route.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { isAdminSession } from "@/lib/community/server/admin"; -import { getAuth } from "@/lib/community/server/auth"; -import { originBlocked, preflight, withCors } from "@/lib/community/cors"; -import { readJsonObject } from "@/lib/community/jsonBody"; -import { communityEnabled, getDb } from "@/lib/community/server/db"; -import { - cleanQuestions, - cleanSpan, - cleanStageCoord, - cleanTerritoryCode, - cleanTerritoryDescription, - cleanTerritoryTitle, -} from "@/lib/community/workshop"; -import { getTerritoryByCode, listTerritoriesForAdmin, newId } from "@/lib/community/server/queries"; -import { territories } from "@/lib/community/server/schema"; - -// POST /api/community/territories — draw a new direction on the map. -// -// Moderators only. Territories were seed-script-only at first, which meant -// adding one took an SSH session, a TypeScript edit and a redeploy — and the -// workshop's own empty state told the person running the project that someone -// ought to draw some, with no way to do it. This is that way. - -export async function POST(request: Request) { - const blocked = originBlocked(request); - if (blocked) return blocked; - return withCors(request, await handlePost(request)); -} - -export const OPTIONS = preflight; - -async function handlePost(request: Request) { - if (!communityEnabled()) { - return Response.json({ error: "Community is not enabled on this deployment." }, { status: 503 }); - } - - const session = await getAuth().api.getSession({ headers: request.headers }); - if (!isAdminSession(session)) { - // Same answer as a missing route, like the report queue. - return Response.json({ error: "Not found." }, { status: 404 }); - } - - const raw = await readJsonObject(request); - if (raw instanceof Response) return raw; - - const code = cleanTerritoryCode(raw.code); - if (!code) { - return Response.json( - { error: "A code is a letter and a number, like A1 or B3." }, - { status: 400 }, - ); - } - // The code is the identity: it is in every thread URL under this territory, - // so a duplicate would quietly attach new threads to the wrong direction. - if (await getTerritoryByCode(code)) { - return Response.json({ error: `${code} already exists.` }, { status: 409 }); - } - - const title = cleanTerritoryTitle(raw.title); - if (!title) { - return Response.json({ error: "A title is required." }, { status: 400 }); - } - const description = cleanTerritoryDescription(raw.description); - if (description === undefined) { - return Response.json({ error: "That description is too long." }, { status: 400 }); - } - const questions = cleanQuestions(raw.questions); - if (questions === undefined) { - return Response.json({ error: "Questions are one short line each." }, { status: 400 }); - } - const span = cleanSpan(raw.span ?? 2); - if (span === undefined) { - return Response.json({ error: "Width is 2 to 6 columns." }, { status: 400 }); - } - const x = cleanStageCoord(raw.x ?? 720, "x"); - const y = cleanStageCoord(raw.y ?? 320, "y"); - if (x === undefined || y === undefined) { - return Response.json({ error: "Bad map position." }, { status: 400 }); - } - - // New directions land at the end of the map rather than the front: the order - // is somebody's arrangement, and arriving should not disturb it. - const existing = await listTerritoriesForAdmin(); - const position = existing.reduce((max, row) => Math.max(max, row.position), -1) + 1; - - await getDb().insert(territories).values({ - id: newId(), - code, - title, - description, - span, - position, - x, - y, - shippingNext: raw.shippingNext === true, - questions, - createdAt: new Date(), - }); - - return Response.json({ ok: true, code }, { status: 201 }); -} diff --git a/web/src/app/community/atlas/page.tsx b/web/src/app/community/atlas/page.tsx index 803ddb2e7..b661edaa8 100644 --- a/web/src/app/community/atlas/page.tsx +++ b/web/src/app/community/atlas/page.tsx @@ -6,11 +6,10 @@ import { communityEnabled } from "@/lib/community/server/db"; import { listAtlasPins, listPatternsByUser } from "@/lib/community/server/queries"; import AtlasClient from "@/components/community/AtlasClient"; -// The atlas: a map of pattern-technique space, sibling to the workshop map. -// The workshop charts where the HARDWARE could go (territories = project -// directions); this charts where PATTERNS could go — verified continents, -// open frontier, invented hypotheses, and, pinned between them, the actual -// published work: each pin is a live pattern placed where it belongs. +// The atlas: a map of pattern-technique space. It charts where PATTERNS could +// go — verified continents, open frontier, invented hypotheses, and, pinned +// between them, the actual published work: each pin is a live pattern placed +// where it belongs. // // Technique entries live in lib/atlas/data.ts and are edited like the seed // script. Pins live in the atlas_pins table. diff --git a/web/src/app/community/notifications/page.tsx b/web/src/app/community/notifications/page.tsx index 5695633ac..6874befec 100644 --- a/web/src/app/community/notifications/page.tsx +++ b/web/src/app/community/notifications/page.tsx @@ -16,7 +16,7 @@ export const dynamic = "force-dynamic"; export const metadata: Metadata = { title: "Alerts / Patternflow Community", - description: "Comments, forks and deck inclusions on your patterns and posts.", + description: "Comments, forks and deck inclusions on your patterns.", }; export default async function CommunityNotificationsPage() { @@ -26,7 +26,7 @@ export default async function CommunityNotificationsPage() { if (!session) { return (
- Sign in to see alerts about your patterns, posts and comments. + Sign in to see alerts about your patterns and comments.
); } diff --git a/web/src/app/community/p/[id]/PatternDetailClient.tsx b/web/src/app/community/p/[id]/PatternDetailClient.tsx index 1d8f6fe51..573f62308 100644 --- a/web/src/app/community/p/[id]/PatternDetailClient.tsx +++ b/web/src/app/community/p/[id]/PatternDetailClient.tsx @@ -765,7 +765,7 @@ export default function PatternDetailClient({ /> - + {headerModalOpen && ( }) { - if (!communityEnabled()) return null; // layout already rendered the notice - - const { id } = await props.params; - const post = await getPost(id); - if (!post) notFound(); - - redirect(`/community/workshop/${post.territoryCode.toLowerCase()}/t/${post.id}`); -} diff --git a/web/src/app/community/territories/page.tsx b/web/src/app/community/territories/page.tsx deleted file mode 100644 index 93c053ea3..000000000 --- a/web/src/app/community/territories/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { headers } from "next/headers"; -import { notFound } from "next/navigation"; -import type { Metadata } from "next"; -import { isAdminSession } from "@/lib/community/server/admin"; -import { getAuth } from "@/lib/community/server/auth"; -import { communityEnabled } from "@/lib/community/server/db"; -import { listTerritoriesForAdmin } from "@/lib/community/server/queries"; -import TerritoryEditor from "@/components/community/TerritoryEditor"; - -// Drawing the map — the directions the workshop is made of. -// -// Moderators only. This existed as a seed script first, which meant adding a -// direction took an SSH session and a redeploy, and the workshop's own empty -// state told the person running the project that somebody ought to draw some -// while giving them nowhere to do it. `npm run seed:map` is still there for -// first setup; this is for every change after. - -export const dynamic = "force-dynamic"; - -export const metadata: Metadata = { - title: "Territories / Patternflow Community", - robots: { index: false, follow: false }, -}; - -export default async function TerritoriesPage() { - if (!communityEnabled()) return null; // layout already rendered the notice - - const session = await getAuth().api.getSession({ headers: await headers() }); - // 404 rather than "you are not a moderator", same as the report queue. - if (!isAdminSession(session)) notFound(); - - const rows = await listTerritoriesForAdmin(); - - return ( - ({ - id: row.id, - code: row.code, - title: row.title, - description: row.description, - span: row.span, - position: row.position, - x: row.x, - y: row.y, - shippingNext: row.shippingNext, - questions: row.questions, - pinCount: row.pinCount, - threadCount: row.threadCount, - archived: row.archivedAt !== null, - }))} - /> - ); -} diff --git a/web/src/app/community/workshop/[code]/page.tsx b/web/src/app/community/workshop/[code]/page.tsx deleted file mode 100644 index dc014e9b5..000000000 --- a/web/src/app/community/workshop/[code]/page.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import Link from "next/link"; -import { notFound } from "next/navigation"; -import type { Metadata } from "next"; -import { communityEnabled } from "@/lib/community/server/db"; -import { formatSince } from "@/lib/community/workshop"; -import { - countPosts, - getTerritoryByCode, - listPosts, - listTerritories, - listTerritoryPins, -} from "@/lib/community/server/queries"; -import NewThreadButton from "@/components/community/NewThreadButton"; -import styles from "@/components/community/Community.module.css"; - -// Every thread in one territory. The workshop drawer shows the first six — -// this is where "all 23" goes, and it pages, because twenty text rows at a -// time is plenty and a pager is honest furniture for an archive. - -export const dynamic = "force-dynamic"; - -const PAGE_SIZE = 20; - -type RouteParams = { - params: Promise<{ code: string }>; - searchParams: Promise<{ page?: string }>; -}; - -export async function generateMetadata(props: RouteParams): Promise { - if (!communityEnabled()) return {}; - const { code } = await props.params; - const territory = await getTerritoryByCode(code); - if (!territory) return {}; - return { - title: `${territory.code} ${territory.title} / Patternflow Community`, - description: territory.description ?? `Threads in ${territory.title}.`, - }; -} - -export default async function TerritoryPage(props: RouteParams) { - if (!communityEnabled()) return null; // layout already rendered the notice - - const { code } = await props.params; - const territory = await getTerritoryByCode(code); - if (!territory) notFound(); - - const { page: rawPage } = await props.searchParams; - const page = Math.max(0, Number.parseInt(rawPage ?? "0", 10) || 0); - - const [threads, total, pins, territories] = await Promise.all([ - listPosts({ territoryId: territory.id, limit: PAGE_SIZE, offset: page * PAGE_SIZE }), - countPosts(territory.id), - listTerritoryPins(territory.id), - // The new-thread modal's territory select. - listTerritories(), - ]); - const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); - - /** One line of the body for the list, with newlines flattened. */ - const preview = (body: string) => { - const flat = body.replace(/\s+/g, " ").trim(); - return flat.length > 160 ? `${flat.slice(0, 160)}…` : flat; - }; - - return ( -
- - ← The workshop - - -
- {territory.code} - {territory.title} - - {territory.pinCount} working · {total} thread{total === 1 ? "" : "s"} - - - -
- - {territory.description &&

{territory.description}

} - - {pins.length > 0 && ( -
- Working here - {pins.map((pin) => ( - -