import type { FastifyInstance } from "fastify"; import { createHash } from "node:crypto"; import { mkdirSync, writeFileSync, createReadStream, existsSync } from "node:fs"; import { join } from "node:path"; import { pool } from "../db.js"; import { requireScope } from "../auth.js"; const DATA_DIR = process.env.DATA_DIR ?? "/data"; export async function assetRoutes(app: FastifyInstance): Promise { app.post("/api/assets", { preHandler: requireScope("author") }, async (req, reply) => { const file = await req.file({ limits: { fileSize: 50 * 1024 * 1024 } }); if (!file) return reply.code(400).send({ error: "multipart file required" }); const buf = await file.toBuffer(); const sha = createHash("sha256").update(buf).digest("hex"); // content-addressed dedup const existing = await pool.query("SELECT id, path, mime FROM assets WHERE sha256=$1", [sha]); if (existing.rowCount) return existing.rows[0]; const kind = file.mimetype.startsWith("image/") ? "image" : "other"; const dir = join(DATA_DIR, "assets", sha.slice(0, 2)); mkdirSync(dir, { recursive: true }); const path = join(dir, sha); writeFileSync(path, buf); const r = await pool.query( "INSERT INTO assets (kind, path, mime, bytes, sha256) VALUES ($1,$2,$3,$4,$5) RETURNING id, path, mime", [kind, path, file.mimetype, buf.length, sha], ); return r.rows[0]; }); // Public asset serving (assets are content-addressed; ids are unguessable UUIDs) app.get("/assets/:id", async (req, reply) => { const { id } = req.params as { id: string }; const r = await pool.query("SELECT path, mime FROM assets WHERE id=$1", [id]); if (!r.rowCount || !existsSync(r.rows[0].path)) return reply.code(404).send({ error: "not found" }); reply.header("Cache-Control", "public, max-age=31536000, immutable"); reply.type(r.rows[0].mime); return reply.send(createReadStream(r.rows[0].path)); }); }