A report may carry a screenshot
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
d76a18d5e6
commit
48350a3686
@@ -23,6 +23,10 @@ One JSONL line per entry:
|
|||||||
A report is ANSWERED when any reply line names it; never re-answer one
|
A report is ANSWERED when any reply line names it; never re-answer one
|
||||||
unless Eric asks for a revision.
|
unless Eric asks for a revision.
|
||||||
|
|
||||||
|
- Picture: `{reportId, image: "<id>.png", at}` — the player attached a
|
||||||
|
screenshot; the file is `/var/lib/wizwar/feedback-images/<id>.<ext>`.
|
||||||
|
`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'`
|
Fetch: `ssh root@104.236.96.198 'cat /var/lib/wizwar/feedback.jsonl'`
|
||||||
|
|
||||||
## a) Fetch and b) display
|
## a) Fetch and b) display
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ import { callKeeper, callRematch,
|
|||||||
abandonRoom,
|
abandonRoom,
|
||||||
} from "./rooms";
|
} from "./rooms";
|
||||||
import { engagementStats, recordHotseat } from "./stats";
|
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 { clipsIndexHtml, clipPageHtml } from "./clips";
|
||||||
import { SlidingLimit, clientAddress } from "./ratelimit";
|
import { SlidingLimit, clientAddress } from "./ratelimit";
|
||||||
import { getShare, loadShares, mintShare } from "./shares";
|
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
|
// Per-address limits, held across reconnects: a table of friends never
|
||||||
// nears them; a script filling the vault or the reports desk does.
|
// nears them; a script filling the vault or the reports desk does.
|
||||||
const roomsPerAddress = new SlidingLimit(12, 60 * 60 * 1000);
|
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. */
|
/** Calls to the keeper: a real person's phone rings for each. */
|
||||||
const challengesPerAddress = new SlidingLimit(3, 60 * 60 * 1000);
|
const challengesPerAddress = new SlidingLimit(3, 60 * 60 * 1000);
|
||||||
/** The keeper of this table: the wizard a lobby may challenge. */
|
/** 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) => {
|
const httpServer = createServer((req, res) => {
|
||||||
try {
|
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") {
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||||
res.writeHead(405).end();
|
res.writeHead(405).end();
|
||||||
return;
|
return;
|
||||||
@@ -1003,8 +1052,9 @@ wss.on("connection", (socket, req) => {
|
|||||||
if (!reportsPerAddress.allow(session.address)) {
|
if (!reportsPerAddress.allow(session.address)) {
|
||||||
return send(socket, { type: "error", message: "the desk has plenty from you for now — more in an hour" });
|
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({
|
appendFeedback({
|
||||||
id: randomBytes(4).toString("hex"),
|
id: reportId,
|
||||||
at: new Date().toISOString(),
|
at: new Date().toISOString(),
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
player: session.playerId ?? "(gallery)",
|
player: session.playerId ?? "(gallery)",
|
||||||
@@ -1014,7 +1064,7 @@ wss.on("connection", (socket, req) => {
|
|||||||
happened,
|
happened,
|
||||||
expected: clean(msg.expected),
|
expected: clean(msg.expected),
|
||||||
});
|
});
|
||||||
send(socket, { type: "feedbackReceived" });
|
send(socket, { type: "feedbackReceived", id: reportId });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "myFeedback": {
|
case "myFeedback": {
|
||||||
|
|||||||
@@ -221,6 +221,13 @@ export interface FeedbackReport {
|
|||||||
happened: string;
|
happened: string;
|
||||||
expected: string;
|
expected: string;
|
||||||
reply?: { at: string; text: string; status: 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[] {
|
export function readFeedback(): FeedbackReport[] {
|
||||||
@@ -233,7 +240,9 @@ export function readFeedback(): FeedbackReport[] {
|
|||||||
try { line = JSON.parse(raw); } catch { continue; }
|
try { line = JSON.parse(raw); } catch { continue; }
|
||||||
if (typeof line.reportId === "string") {
|
if (typeof line.reportId === "string") {
|
||||||
const report = reports.get(line.reportId);
|
const report = reports.get(line.reportId);
|
||||||
if (report) {
|
if (report && typeof line.image === "string") {
|
||||||
|
report.image = line.image;
|
||||||
|
} else if (report) {
|
||||||
report.reply = {
|
report.reply = {
|
||||||
at: String(line.at ?? ""), text: String(line.text ?? ""), status: String(line.status ?? "resolved"),
|
at: String(line.at ?? ""), text: String(line.text ?? ""), status: String(line.status ?? "resolved"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1517,14 +1517,27 @@
|
|||||||
let fbHappened = $state("");
|
let fbHappened = $state("");
|
||||||
let fbExpected = $state("");
|
let fbExpected = $state("");
|
||||||
let fbSent = $state(false);
|
let fbSent = $state(false);
|
||||||
|
/** A screenshot for the desk, chosen with the report. */
|
||||||
|
let fbImage = $state<File | null>(null);
|
||||||
|
let fbPreview = $state<string | null>(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() {
|
function submitFeedback() {
|
||||||
net.sendFeedback(fbHappened.trim(), fbExpected.trim());
|
net.sendFeedback(fbHappened.trim(), fbExpected.trim(), fbImage);
|
||||||
fbSent = true;
|
fbSent = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
feedbackOpen = false;
|
feedbackOpen = false;
|
||||||
fbSent = false;
|
fbSent = false;
|
||||||
fbHappened = "";
|
fbHappened = "";
|
||||||
fbExpected = "";
|
fbExpected = "";
|
||||||
|
if (fbPreview) URL.revokeObjectURL(fbPreview);
|
||||||
|
fbImage = null;
|
||||||
|
fbPreview = null;
|
||||||
}, 1600);
|
}, 1600);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2068,7 +2081,12 @@
|
|||||||
<textarea bind:value={fbExpected} rows="3" maxlength="2000"
|
<textarea bind:value={fbExpected} rows="3" maxlength="2000"
|
||||||
placeholder="e.g. 5 points of damage"></textarea>
|
placeholder="e.g. 5 points of damage"></textarea>
|
||||||
</label>
|
</label>
|
||||||
<p class="fb-note">The room code and move number ride along automatically.</p>
|
<label class="fb-field fb-picture">
|
||||||
|
<span>A screenshot, if you have one</span>
|
||||||
|
<input type="file" accept="image/png,image/jpeg,image/webp" onchange={pickImage} />
|
||||||
|
{#if fbPreview}<img class="fb-preview" src={fbPreview} alt="the screenshot you chose" />{/if}
|
||||||
|
</label>
|
||||||
|
<p class="fb-note">The room code and move number ride along automatically; a picture is worth a thousand of them.</p>
|
||||||
<div class="attack-actions">
|
<div class="attack-actions">
|
||||||
<button class="stamp primary" disabled={!fbHappened.trim()} onclick={submitFeedback}>Send it</button>
|
<button class="stamp primary" disabled={!fbHappened.trim()} onclick={submitFeedback}>Send it</button>
|
||||||
<button class="hint-cancel" onclick={() => (feedbackOpen = false)}>never mind</button>
|
<button class="hint-cancel" onclick={() => (feedbackOpen = false)}>never mind</button>
|
||||||
@@ -3663,6 +3681,8 @@
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
.fb-note { font-size: 0.8rem; color: #6b5a41; font-style: italic; }
|
.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; }
|
.fb-thanks { color: #43331f; padding: 0.6rem 0; }
|
||||||
.respond-card { display: inline-block; vertical-align: middle; margin-left: 0.4rem; zoom: 0.72; }
|
.respond-card { display: inline-block; vertical-align: middle; margin-left: 0.4rem; zoom: 0.72; }
|
||||||
.chronicle.tucked { display: none; }
|
.chronicle.tucked { display: none; }
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import type { Command, GameEvent, GameView, Side } from "@wizwar/engine";
|
|||||||
import { cardDef } from "@wizwar/engine";
|
import { cardDef } from "@wizwar/engine";
|
||||||
import { receiptFor } from "./receipt";
|
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 =
|
const SERVER_URL =
|
||||||
import.meta.env.VITE_WIZWAR_SERVER ??
|
import.meta.env.VITE_WIZWAR_SERVER ??
|
||||||
(location.port === "5173" // the vite dev server; anything else serves its own socket
|
(location.port === "5173" // the vite dev server; anything else serves its own socket
|
||||||
@@ -659,9 +663,17 @@ class Net {
|
|||||||
case "feedbackList":
|
case "feedbackList":
|
||||||
this.feedbackReports = msg.reports ?? [];
|
this.feedbackReports = msg.reports ?? [];
|
||||||
break;
|
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();
|
this.refreshFeedback();
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "games": {
|
case "games": {
|
||||||
this.games = msg.games;
|
this.games = msg.games;
|
||||||
for (const g of msg.games as GameSummary[]) {
|
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. */
|
/** 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 });
|
this.send({ type: "feedback", happened, expected });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user