From d84ba19007e082fd5635c5369053eb196a9e6649 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 06:49:09 +0000 Subject: [PATCH 1/5] =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=E2=80=94=20103?= =?UTF-8?q?=EB=B0=94=ED=80=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LpyJh1iKuFEjeQNTDfds3L From d92a9bc6e907063342ba8e5581a940b8a8eb2459 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 07:06:29 +0000 Subject: [PATCH 2/5] web: take the workshop out of the community The creator asked on 26 October for the workshop to go. Its pages, API, components and helpers are removed, the menu, phone tab and deck dock no longer link to it, and its old addresses redirect to /community. The comment, alert and report code keeps only its pattern and deck branches. The database tables and their rows stay. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LpyJh1iKuFEjeQNTDfds3L --- PATTERN_GUIDE.md | 4 - web/ARCHITECTURE.md | 6 +- web/next.config.ts | 10 + web/package.json | 3 +- web/scripts/moderation-smoke.ts | 4 +- web/scripts/notify-smoke.ts | 50 +- web/scripts/workshop-smoke.ts | 349 ---- .../api/community/attachments/[id]/route.ts | 119 -- .../app/api/community/attachments/route.ts | 215 --- .../app/api/community/comments/[id]/route.ts | 26 +- web/src/app/api/community/nullBody.test.ts | 14 - .../community/patterns/[id]/comments/route.ts | 1 - .../community/posts/[id]/comments/route.ts | 74 - .../app/api/community/posts/[id]/pin/route.ts | 56 - web/src/app/api/community/posts/[id]/route.ts | 105 -- web/src/app/api/community/posts/route.ts | 114 -- web/src/app/api/community/presence/route.ts | 123 -- .../community/territories/[code]/pin/route.ts | 127 -- .../api/community/territories/[code]/route.ts | 187 -- .../app/api/community/territories/route.ts | 101 -- web/src/app/community/atlas/page.tsx | 9 +- web/src/app/community/notifications/page.tsx | 4 +- .../community/p/[id]/PatternDetailClient.tsx | 2 +- web/src/app/community/t/[id]/page.tsx | 22 - web/src/app/community/territories/page.tsx | 53 - .../app/community/workshop/[code]/page.tsx | 176 -- .../community/workshop/[code]/t/[id]/page.tsx | 117 -- web/src/app/community/workshop/page.tsx | 116 -- .../components/community/AttachmentList.tsx | 135 -- web/src/components/community/BodyComposer.tsx | 2 +- .../components/community/CommentSection.tsx | 135 +- .../components/community/Community.module.css | 1542 +---------------- web/src/components/community/CommunityNav.tsx | 19 +- web/src/components/community/DeckDock.tsx | 8 +- web/src/components/community/MobileTabBar.tsx | 14 +- .../components/community/NewThreadButton.tsx | 50 - .../components/community/NewThreadModal.tsx | 291 ---- .../community/NotificationsList.tsx | 15 +- web/src/components/community/PostBody.tsx | 11 +- .../components/community/PostDetailClient.tsx | 403 ----- .../components/community/PresenceLayer.tsx | 422 ----- web/src/components/community/ReportQueue.tsx | 5 +- .../components/community/TerritoryEditor.tsx | 560 ------ .../components/community/WorkshopClient.tsx | 683 -------- .../components/guide/desk/community/pages.tsx | 3 - web/src/lib/atlas/data.ts | 3 +- web/src/lib/community/server/attachments.ts | 56 +- web/src/lib/community/server/notify.ts | 73 +- web/src/lib/community/server/queries.ts | 3 +- web/src/lib/community/server/queries/atlas.ts | 42 + .../community/server/queries/moderation.ts | 31 +- .../community/server/queries/notifications.ts | 15 +- .../lib/community/server/queries/workshop.ts | 430 ----- web/src/lib/community/server/schema.ts | 4 + web/src/lib/community/validate.ts | 20 +- web/src/lib/community/workshop.ts | 268 --- web/src/lib/hooks/useDragPan.ts | 329 ---- 57 files changed, 236 insertions(+), 7523 deletions(-) delete mode 100644 web/scripts/workshop-smoke.ts delete mode 100644 web/src/app/api/community/attachments/[id]/route.ts delete mode 100644 web/src/app/api/community/attachments/route.ts delete mode 100644 web/src/app/api/community/posts/[id]/comments/route.ts delete mode 100644 web/src/app/api/community/posts/[id]/pin/route.ts delete mode 100644 web/src/app/api/community/posts/[id]/route.ts delete mode 100644 web/src/app/api/community/posts/route.ts delete mode 100644 web/src/app/api/community/presence/route.ts delete mode 100644 web/src/app/api/community/territories/[code]/pin/route.ts delete mode 100644 web/src/app/api/community/territories/[code]/route.ts delete mode 100644 web/src/app/api/community/territories/route.ts delete mode 100644 web/src/app/community/t/[id]/page.tsx delete mode 100644 web/src/app/community/territories/page.tsx delete mode 100644 web/src/app/community/workshop/[code]/page.tsx delete mode 100644 web/src/app/community/workshop/[code]/t/[id]/page.tsx delete mode 100644 web/src/app/community/workshop/page.tsx delete mode 100644 web/src/components/community/AttachmentList.tsx delete mode 100644 web/src/components/community/NewThreadButton.tsx delete mode 100644 web/src/components/community/NewThreadModal.tsx delete mode 100644 web/src/components/community/PostDetailClient.tsx delete mode 100644 web/src/components/community/PresenceLayer.tsx delete mode 100644 web/src/components/community/TerritoryEditor.tsx delete mode 100644 web/src/components/community/WorkshopClient.tsx create mode 100644 web/src/lib/community/server/queries/atlas.ts delete mode 100644 web/src/lib/community/server/queries/workshop.ts delete mode 100644 web/src/lib/community/workshop.ts delete mode 100644 web/src/lib/hooks/useDragPan.ts 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) => ( - -