import { Hono } from "hono"; import { randomUUID } from "node:crypto"; import { prisma } from "../lib/prisma"; import { getClientIp, isFree } from "../lib/ip"; import { storeFile, absolutePathFor } from "../lib/storage"; const uploads = new Hono(); const FREE_LIMIT = 1_000_000; // 1 Mo for the free tier (README) const ABSOLUTE_MAX = 50_000_000; // hard cap even for paid, to protect the dev box async function ownsNoFileLimit(ip: string): Promise { if (isFree(ip)) return true; const rows = await prisma.entitlement.findMany({ where: { ip, kind: "no-file-limit", active: true }, }); return rows.length > 0; } // POST /api/uploads (multipart) — store a file, return its metadata. uploads.post("/", async (c) => { const ip = getClientIp(c); let body: Record; try { body = await c.req.parseBody(); } catch { return c.json({ error: "Upload invalide" }, 400); } const file = body["file"]; if (!(file instanceof File)) { return c.json({ error: "Aucun fichier" }, 400); } if (file.size > ABSOLUTE_MAX) { return c.json({ error: "Fichier trop volumineux (50 Mo max absolu)" }, 413); } if (file.size > FREE_LIMIT && !(await ownsNoFileLimit(ip))) { return c.json( { error: "Fichier > 1 Mo : débloque « Fichiers illimités » dans le Shop 💸" }, 413 ); } const id = randomUUID(); let stored; try { stored = await storeFile(id, file); } catch { return c.json({ error: "Échec d'écriture" }, 500); } const attachment = await prisma.attachment.create({ data: { id, ip, filename: file.name || "fichier", mimeType: file.type || "application/octet-stream", size: file.size, storagePath: stored.storagePath, }, select: { id: true, filename: true, mimeType: true, size: true }, }); return c.json(attachment, 201); }); // GET /uploads/:id — serve the stored bytes. Images inline; everything else is // forced to download (never rendered same-origin, never executed). uploads.get("/:id", async (c) => { const id = c.req.param("id"); const att = await prisma.attachment.findUnique({ where: { id } }); if (!att) return c.json({ error: "Introuvable" }, 404); let file; try { file = Bun.file(absolutePathFor(att.storagePath)); } catch { return c.json({ error: "Introuvable" }, 404); } if (!(await file.exists())) return c.json({ error: "Introuvable" }, 404); const isImage = att.mimeType.startsWith("image/"); const headers: Record = { // Images may render inline; anything else downloads. Never serve as HTML. "Content-Type": isImage ? att.mimeType : "application/octet-stream", "Content-Disposition": `${isImage ? "inline" : "attachment"}; filename="${att.filename.replace(/"/g, "")}"`, "X-Content-Type-Options": "nosniff", }; return new Response(file, { headers }); }); export default uploads;