// Crash-telemetry sink for JA2 1.13. Takes the POST that sgp::processCrashTelemetry // makes, and forwards the report to a Discord webhook as a file attachment. // // It stores nothing. Reports are only useful next to a PDB, and the PDB lives on a // developer's machine, so there is nothing for a bucket to do here that the channel // we already read bug reports in does not do better. // // The status codes matter — the client acts on them (reportIsSettled()): // 2xx accepted; the client deletes its copy // 400 junk, never going to be accepted; the client deletes its copy // 429 throttled; the client keeps the file and retries next launch // 5xx our problem; the client keeps the file and retries next launch // So never answer a settling 4xx for a failure on our side: that throws the report // away. 429 is the one 4xx that is safe, because the client does not settle it. const MAX_BYTES = 64 * 1024; // reports run 2-8 KB; anything near this is not ours // Pull the header fields the client writes, for a one-line channel summary. // See writeExceptionBacktrace(): "*** CRASH code=... ***", then " ". function summarize(text) { const field = (name) => (text.match(new RegExp(`^\\s+${name} (.+)$`, "m")) || [])[1]?.trim(); const code = (text.match(/code=(\w+)/) || [])[1] || "????????"; const av = (text.match(/access violation: (.+)/) || [])[1]?.trim(); const parts = [`\`${code}\`${av ? ` (${av})` : ""}`]; const build = field("build"); const handle = field("handle"); if (build) parts.push(`build \`${build}\``); if (handle) parts.push(`from **${handle.replace(/[`*_~|@]/g, "")}**`); // no pings, no markdown return parts.join(" · "); } export default { async fetch(request, env) { if (request.method !== "POST") return new Response("POST only\n", { status: 405 }); if (!env.DISCORD_WEBHOOK) { // `wrangler secret put` targets the deployed Worker; `wrangler dev` reads // .dev.vars instead. Missing one of the two is the usual cause of a 503. console.error("DISCORD_WEBHOOK unset (deploy: wrangler secret put, dev: .dev.vars)"); return new Response("not configured\n", { status: 503 }); } // Per-IP cap, checked before the body is read so a flood costs nothing. 429 is // deliberate: reportIsSettled() does not settle it, so a throttled player keeps // the report and it goes out next launch. Answering 400 here would delete it. const ip = request.headers.get("cf-connecting-ip") || "unknown"; const { success } = await env.UPLOAD_LIMITER.limit({ key: ip }); if (!success) return new Response("slow down\n", { status: 429 }); // Trust the declared length only as a cheap early out; the real cap is on the // bytes actually read, since Content-Length can lie or be absent. const declared = Number(request.headers.get("content-length") || 0); if (declared > MAX_BYTES) return new Response("too large\n", { status: 400 }); const text = await request.text(); if (text.length > MAX_BYTES) return new Response("too large\n", { status: 400 }); // The endpoint is public and unauthenticated — the URL ships in every player's // Ja2.ini. This is not security, just a filter that keeps drive-by POSTs and // scanners out of the channel. Anything determined gets through; that is fine, // the blast radius is a message we delete. if (!text.includes("*** CRASH")) return new Response("not a crash report\n", { status: 400 }); const stamp = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15); const form = new FormData(); form.append("payload_json", JSON.stringify({ content: summarize(text), allowed_mentions: { parse: [] }, // a report is attacker-controlled text; never ping })); form.append("files[0]", new Blob([text], { type: "text/plain" }), `crash_${stamp}.txt`); let res; try { res = await fetch(env.DISCORD_WEBHOOK, { method: "POST", body: form }); } catch (e) { console.error("discord unreachable:", e.message); return new Response("upstream unreachable\n", { status: 503 }); } // Discord's own 429 included: not the player's fault, so keep the report alive. if (!res.ok) { console.error("discord rejected:", res.status, (await res.text()).slice(0, 200)); return new Response("upstream rejected\n", { status: 503 }); } return new Response(null, { status: 204 }); }, };