Some checks failed
Deploy XIP / deploy (push) Failing after 21s
- 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>
94 lines
2.9 KiB
TypeScript
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;
|