From 48350a3686092d1949138e4a18efe442d3e7b1b0 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 21 Sep 2026 11:02:59 -0400 Subject: [PATCH] A report may carry a screenshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 🐞 form takes a picture beside its words — PNG, JPEG, or WebP, under 2.5 MB, previewed before sending. The socket's message cap is far too small for one, so the picture goes through a plain HTTP door after the report is filed, addressed to the report's id, one per report within the hour, checked by its own first bytes, and kept in feedback-images/ beside the reports with a line on the report saying so. The desk's skill knows where to look. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- .claude/skills/wizwar-reports/SKILL.md | 4 ++ packages/server/src/index.ts | 56 ++++++++++++++++++++++++-- packages/server/src/store.ts | 11 ++++- packages/web/src/App.svelte | 24 ++++++++++- packages/web/src/net.svelte.ts | 19 ++++++++- 5 files changed, 106 insertions(+), 8 deletions(-) diff --git a/.claude/skills/wizwar-reports/SKILL.md b/.claude/skills/wizwar-reports/SKILL.md index 88532b1..0bb606f 100644 --- a/.claude/skills/wizwar-reports/SKILL.md +++ b/.claude/skills/wizwar-reports/SKILL.md @@ -23,6 +23,10 @@ One JSONL line per entry: A report is ANSWERED when any reply line names it; never re-answer one unless Eric asks for a revision. +- Picture: `{reportId, image: ".png", at}` — the player attached a + screenshot; the file is `/var/lib/wizwar/feedback-images/.`. + `scp` it to the scratchpad and Read it: it is usually the whole story. + Fetch: `ssh root@104.236.96.198 'cat /var/lib/wizwar/feedback.jsonl'` ## a) Fetch and b) display diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 262008a..bda1f6e 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -66,7 +66,8 @@ import { callKeeper, callRematch, abandonRoom, } from "./rooms"; import { engagementStats, recordHotseat } from "./stats"; -import { appendFeedback, readFeedback, readClips, clipAssetPath, CLIP_SLUG } from "./store"; +import { appendFeedback, readFeedback, readClips, clipAssetPath, CLIP_SLUG, feedbackImageDir } from "./store"; +import { mkdirSync, writeFileSync } from "node:fs"; import { clipsIndexHtml, clipPageHtml } from "./clips"; import { SlidingLimit, clientAddress } from "./ratelimit"; import { getShare, loadShares, mintShare } from "./shares"; @@ -86,6 +87,48 @@ const MAX_ROOMS_PER_CONN = 10; // rooms one connection may create // Per-address limits, held across reconnects: a table of friends never // nears them; a script filling the vault or the reports desk does. const roomsPerAddress = new SlidingLimit(12, 60 * 60 * 1000); +/** Screenshots for the desk: a few per address per hour, like reports. */ +const imagesPerAddress = new SlidingLimit(6, 60 * 60 * 1000); +const IMAGE_MAX_BYTES = 2_500_000; + +/** A picture for a report, POSTed right after the report is filed: one + * per report, within the hour, PNG/JPEG/WebP by its own first bytes, kept + * beside the reports and noted on the report's line. */ +function receiveFeedbackImage(req: IncomingMessage, res: ServerResponse, reportId: string): void { + const address = clientAddress(req.headers, req.socket.remoteAddress); + if (!imagesPerAddress.allow(address)) { res.writeHead(429).end("enough pictures for now"); return; } + const report = readFeedback().find((r) => r.id === reportId); + if (!report) { res.writeHead(404).end("no such report"); return; } + if (report.image) { res.writeHead(409).end("that report has its picture"); return; } + if (Date.now() - Date.parse(report.at) > 60 * 60 * 1000) { res.writeHead(410).end("too late for a picture"); return; } + const declared = Number(req.headers["content-length"] ?? 0); + if (declared > IMAGE_MAX_BYTES) { res.writeHead(413).end("2.5 MB at most"); return; } + const chunks: Buffer[] = []; + let size = 0; + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > IMAGE_MAX_BYTES) { res.writeHead(413).end("2.5 MB at most"); req.destroy(); return; } + chunks.push(chunk); + }); + req.on("end", () => { + if (res.writableEnded) return; + const body = Buffer.concat(chunks); + const ext = + body.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47])) ? "png" + : body.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff])) ? "jpg" + : body.subarray(0, 4).toString("ascii") === "RIFF" && body.subarray(8, 12).toString("ascii") === "WEBP" ? "webp" + : null; + if (!ext) { res.writeHead(415).end("a PNG, JPEG, or WebP"); return; } + const dir = feedbackImageDir(); + mkdirSync(dir, { recursive: true }); + const name = `${reportId}.${ext}`; + writeFileSync(join(dir, name), body); + appendFeedback({ reportId, image: name, at: new Date().toISOString() }); + res.writeHead(204).end(); + }); + req.on("error", () => { if (!res.writableEnded) res.writeHead(400).end(); }); +} + /** Calls to the keeper: a real person's phone rings for each. */ const challengesPerAddress = new SlidingLimit(3, 60 * 60 * 1000); /** The keeper of this table: the wizard a lobby may challenge. */ @@ -300,6 +343,12 @@ const OG_PNG_CACHE_MAX = 200; const httpServer = createServer((req, res) => { try { + if (req.method === "POST") { + const picture = (req.url ?? "").split("?")[0]!.match(/^\/api\/feedback-image\/([0-9a-f]{8})$/); + if (picture) { receiveFeedbackImage(req, res, picture[1]!); return; } + res.writeHead(404).end(); + return; + } if (req.method !== "GET" && req.method !== "HEAD") { res.writeHead(405).end(); return; @@ -1003,8 +1052,9 @@ wss.on("connection", (socket, req) => { if (!reportsPerAddress.allow(session.address)) { return send(socket, { type: "error", message: "the desk has plenty from you for now — more in an hour" }); } + const reportId = randomBytes(4).toString("hex"); appendFeedback({ - id: randomBytes(4).toString("hex"), + id: reportId, at: new Date().toISOString(), roomId: room.id, player: session.playerId ?? "(gallery)", @@ -1014,7 +1064,7 @@ wss.on("connection", (socket, req) => { happened, expected: clean(msg.expected), }); - send(socket, { type: "feedbackReceived" }); + send(socket, { type: "feedbackReceived", id: reportId }); break; } case "myFeedback": { diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index 6d8f8d2..f679153 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -221,6 +221,13 @@ export interface FeedbackReport { happened: string; expected: string; reply?: { at: string; text: string; status: string }; + /** A screenshot's file name under feedback-images/, when one was sent. */ + image?: string; +} + +/** Where players' screenshots rest, beside the reports. */ +export function feedbackImageDir(): string { + return join(dataRoot(), "feedback-images"); } export function readFeedback(): FeedbackReport[] { @@ -233,7 +240,9 @@ export function readFeedback(): FeedbackReport[] { try { line = JSON.parse(raw); } catch { continue; } if (typeof line.reportId === "string") { const report = reports.get(line.reportId); - if (report) { + if (report && typeof line.image === "string") { + report.image = line.image; + } else if (report) { report.reply = { at: String(line.at ?? ""), text: String(line.text ?? ""), status: String(line.status ?? "resolved"), }; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 4d5bab4..5f2b2c0 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1517,14 +1517,27 @@ let fbHappened = $state(""); let fbExpected = $state(""); let fbSent = $state(false); + /** A screenshot for the desk, chosen with the report. */ + let fbImage = $state(null); + let fbPreview = $state(null); + function pickImage(e: Event) { + const file = (e.currentTarget as HTMLInputElement).files?.[0] ?? null; + if (fbPreview) URL.revokeObjectURL(fbPreview); + if (file && file.size > 2_500_000) { net.flash("That picture is over 2.5 MB — a smaller one, please"); fbImage = null; fbPreview = null; return; } + fbImage = file; + fbPreview = file ? URL.createObjectURL(file) : null; + } function submitFeedback() { - net.sendFeedback(fbHappened.trim(), fbExpected.trim()); + net.sendFeedback(fbHappened.trim(), fbExpected.trim(), fbImage); fbSent = true; setTimeout(() => { feedbackOpen = false; fbSent = false; fbHappened = ""; fbExpected = ""; + if (fbPreview) URL.revokeObjectURL(fbPreview); + fbImage = null; + fbPreview = null; }, 1600); } @@ -2068,7 +2081,12 @@ -

The room code and move number ride along automatically.

+ +

The room code and move number ride along automatically; a picture is worth a thousand of them.

@@ -3663,6 +3681,8 @@ resize: vertical; } .fb-note { font-size: 0.8rem; color: #6b5a41; font-style: italic; } + .fb-picture input { font-size: 0.85rem; color: #43331f; } + .fb-preview { display: block; max-width: 100%; max-height: 9rem; margin-top: 0.4rem; border: 1px solid #b3a687; border-radius: 3px; } .fb-thanks { color: #43331f; padding: 0.6rem 0; } .respond-card { display: inline-block; vertical-align: middle; margin-left: 0.4rem; zoom: 0.72; } .chronicle.tucked { display: none; } diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index a0f8263..f4ea00f 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -4,6 +4,10 @@ import type { Command, GameEvent, GameView, Side } from "@wizwar/engine"; import { cardDef } from "@wizwar/engine"; import { receiptFor } from "./receipt"; +/** The same server over plain HTTP, for the one door a socket cannot carry: a picture. */ +const API_URL = + import.meta.env.VITE_WIZWAR_SERVER ? String(import.meta.env.VITE_WIZWAR_SERVER).replace(/^ws/, "http") + : location.port === "5173" ? `http://${location.hostname}:8787` : ""; const SERVER_URL = import.meta.env.VITE_WIZWAR_SERVER ?? (location.port === "5173" // the vite dev server; anything else serves its own socket @@ -659,9 +663,17 @@ class Net { case "feedbackList": this.feedbackReports = msg.reports ?? []; break; - case "feedbackReceived": + case "feedbackReceived": { + const image = this.pendingImage; + this.pendingImage = null; + if (image && typeof msg.id === "string") { + fetch(`${API_URL}/api/feedback-image/${msg.id}`, { method: "POST", headers: { "content-type": image.type }, body: image }) + .then((r) => { if (!r.ok) this.flash("The words went through; the picture did not — a PNG or JPEG under 2.5 MB, please"); }) + .catch(() => this.flash("The words went through; the picture did not")); + } this.refreshFeedback(); break; + } case "games": { this.games = msg.games; for (const g of msg.games as GameSummary[]) { @@ -773,7 +785,10 @@ class Net { } /** A surprise report: the server pins it to the room and move number. */ - sendFeedback(happened: string, expected: string): void { + /** A screenshot waiting for its report's id, then posted beside it. */ + private pendingImage: File | null = null; + sendFeedback(happened: string, expected: string, image: File | null = null): void { + this.pendingImage = image; this.send({ type: "feedback", happened, expected }); }