Files
rf4-spotter/apps/web/src/pages/api/report.ts
T

56 lines
4.0 KiB
TypeScript

import type { APIRoute } from "astro";
const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
export const POST: APIRoute = async ({ request, redirect, cookies }) => {
let form: FormData;
try {
form = await request.formData();
} catch {
return redirect("/report?state=create_error", 303);
}
const text = (name: string) => String(form.get(name) || "").trim() || null;
const number = (name: string) => text(name) ? Number(text(name)) : null;
const payload = { fish_slug: String(form.get("fish_slug") || ""), waterbody_slug: String(form.get("waterbody_slug") || ""), x: Number(form.get("x")), y: Number(form.get("y")), weight_g: Number(form.get("weight_g")), bait_name: text("bait_name"), fishing_method: text("fishing_method"), retrieve_method: text("retrieve_method"), retrieve_speed: number("retrieve_speed"), player_name: text("player_name"), comment: text("comment"), website: String(form.get("website") || "") };
let createdId: string | null = null;
let uploadToken: string | null = null;
// Keep one key across retries so a timeout cannot create a duplicate report.
let idempotencyKey = cookies.get("rf4-idempotency-key")?.value;
if (!idempotencyKey) {
idempotencyKey = crypto.randomUUID();
cookies.set("rf4-idempotency-key", idempotencyKey, { httpOnly: true, sameSite: "strict", secure: import.meta.env.PROD, path: "/", maxAge: 900 });
}
try {
const response = await fetch(`${base}/api/v1/catch-reports`, { method: "POST", headers: { "content-type": "application/json", "Idempotency-Key": idempotencyKey, "X-Forwarded-For": request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip") || "unknown" }, body: JSON.stringify(payload), signal: AbortSignal.timeout(30_000) });
if (!response.ok) {
if (response.status === 429) return redirect("/report?state=rate_limited", 303);
if (response.status >= 500) return redirect("/report?state=server_error", 303);
return redirect("/report?state=create_error", 303);
}
const created = await response.json() as { id?: unknown; screenshot_upload_token?: unknown };
if (typeof created.id !== "string" || typeof created.screenshot_upload_token !== "string") return redirect("/report?state=create_error", 303);
createdId = created.id;
uploadToken = created.screenshot_upload_token;
const screenshot = form.get("screenshot");
if (screenshot instanceof File && screenshot.size > 0) {
const upload = new FormData(); upload.set("screenshot", screenshot);
const uploaded = await fetch(`${base}/api/v1/catch-reports/${created.id}/screenshot`, { method: "POST", headers: {"X-Upload-Token": created.screenshot_upload_token}, body: upload, signal: AbortSignal.timeout(60_000) });
if (!uploaded.ok) {
cookies.set(`rf4-upload-${created.id}`, created.screenshot_upload_token, {httpOnly:true, sameSite:"strict", secure:import.meta.env.PROD, path:"/", maxAge:3600});
return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(created.id)}`, 303);
}
}
cookies.delete("rf4-idempotency-key", { path: "/" });
return redirect("/report?state=sent", 303);
}
catch (err) {
const isTimeout = err instanceof DOMException && err.name === "TimeoutError" ||
err instanceof TypeError && err.message.toLowerCase().includes("fetch") ||
err instanceof Error && err.message.toLowerCase().includes("abort");
if (isTimeout) {
if (createdId && uploadToken) cookies.set(`rf4-upload-${createdId}`, uploadToken, {httpOnly:true, sameSite:"strict", secure:import.meta.env.PROD, path:"/", maxAge:3600});
return redirect(createdId ? `/report?state=screenshot_error&report_id=${encodeURIComponent(createdId)}` : "/report?state=timeout", 303);
}
if (createdId && uploadToken) cookies.set(`rf4-upload-${createdId}`, uploadToken, {httpOnly:true, sameSite:"strict", secure:import.meta.env.PROD, path:"/", maxAge:3600});
return redirect(createdId ? `/report?state=screenshot_error&report_id=${encodeURIComponent(createdId)}` : "/report?state=create_error", 303);
}
};