Files
XIP/backend/src/routes/uploads.ts
Kerboul 024909b162
Some checks failed
Deploy XIP / deploy (push) Failing after 21s
feat(deploy): CI/CD Gitea Actions + stack Docker prod pour xip.kerboul.me
- docker-compose.prod.yml: postgres + redis + backend (bun) + web (nginx single-origin)
- backend/Dockerfile + entrypoint: prisma migrate deploy + seed idempotent au boot
- frontend/Dockerfile: build Vite (VITE_API_URL=https://xip.kerboul.me) servi par nginx
- deploy/nginx.conf: proxy /api + /ws vers le backend, SPA fallback
- .gitea/workflows/deploy.yml: auto-deploy SSH sur push main (runner CT121 -> CT502)
- scripts/deploy.sh: pull + rebuild de la stack
- mode open-bar (XIP_OPEN_BAR): paywall off pour tous en prod, via isFree() centralise

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:14:53 +02:00

94 lines
2.9 KiB
TypeScript

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<boolean> {
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<string, unknown>;
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<string, string> = {
// 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;