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
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user