import { FastifyInstance } from "fastify"; import { prisma } from "../lib/prisma.js"; import { existsSync, mkdirSync, createReadStream } from "node:fs"; import { unlink, writeFile, realpath } from "node:fs/promises"; import { resolve, extname, sep } from "node:path"; import { randomBytes } from "node:crypto"; const UPLOAD_DIR = resolve(process.cwd(), "uploads"); const MAX_SIZE = 5 * 1024 * 1024; const ALLOWED_MIME = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]); const ALLOWED_EXT = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]); const MAGIC_BYTES: Record = { "image/jpeg": [[0xFF, 0xD8, 0xFF]], "image/png": [[0x89, 0x50, 0x4E, 0x47]], "image/gif": [[0x47, 0x49, 0x46, 0x38, 0x37, 0x61], [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]], "image/webp": [[0x52, 0x49, 0x46, 0x46, -1, -1, -1, -1, 0x57, 0x45, 0x42, 0x50]], }; function validateMagicBytes(buf: Buffer, mime: string): boolean { const sigs = MAGIC_BYTES[mime]; if (!sigs) return false; return sigs.some((sig) => sig.every((byte, i) => byte === -1 || buf[i] === byte)); } function sanitizeFilename(name: string): string { return name.replace(/[\/\\:*?"<>|\x00-\x1F]/g, "_").slice(0, 200); } function uid() { return randomBytes(16).toString("hex"); } export default async function attachmentRoutes(app: FastifyInstance) { if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true }); app.post( "/attachments", { preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 hour" } } }, async (req, reply) => { const data = await req.file(); if (!data) { reply.status(400).send({ error: "No file uploaded" }); return; } const ext = extname(data.filename).toLowerCase(); if (!ALLOWED_EXT.has(ext) || !ALLOWED_MIME.has(data.mimetype)) { reply.status(400).send({ error: "Only jpg, png, gif, webp images are allowed" }); return; } const chunks: Buffer[] = []; let size = 0; for await (const chunk of data.file) { size += chunk.length; if (size > MAX_SIZE) { reply.status(400).send({ error: "File too large (max 5MB)" }); return; } chunks.push(chunk); } const buffer = Buffer.concat(chunks); if (!validateMagicBytes(buffer, data.mimetype)) { reply.status(400).send({ error: "File content does not match declared type" }); return; } const safeName = sanitizeFilename(data.filename); const storedName = `${uid()}${ext}`; const filePath = resolve(UPLOAD_DIR, storedName); if (!filePath.startsWith(UPLOAD_DIR)) { reply.status(400).send({ error: "Invalid file path" }); return; } await writeFile(filePath, buffer); let attachment; try { attachment = await prisma.attachment.create({ data: { filename: safeName, path: storedName, size, mimeType: data.mimetype, uploaderId: req.user!.id, }, }); } catch (err) { await unlink(filePath).catch(() => {}); throw err; } reply.status(201).send(attachment); } ); app.get<{ Params: { id: string } }>( "/attachments/:id", { config: { rateLimit: { max: 200, timeWindow: "1 minute" } } }, async (req, reply) => { const attachment = await prisma.attachment.findUnique({ where: { id: req.params.id } }); if (!attachment) { reply.status(404).send({ error: "Attachment not found" }); return; } const filePath = resolve(UPLOAD_DIR, attachment.path); let realFile: string; try { realFile = await realpath(filePath); } catch { reply.status(404).send({ error: "File missing" }); return; } const realUpload = await realpath(UPLOAD_DIR); if (!realFile.startsWith(realUpload + sep)) { reply.status(404).send({ error: "File missing" }); return; } const safeName = sanitizeFilename(attachment.filename).replace(/"/g, ""); reply .header("Content-Type", attachment.mimeType) .header("Content-Disposition", `inline; filename="${safeName}"`) .header("Cache-Control", "public, max-age=31536000, immutable") .header("X-Content-Type-Options", "nosniff") .send(createReadStream(filePath)); } ); app.delete<{ Params: { id: string } }>( "/attachments/:id", { preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => { const attachment = await prisma.attachment.findUnique({ where: { id: req.params.id } }); if (!attachment) { reply.status(404).send({ error: "Attachment not found" }); return; } if (attachment.uploaderId !== req.user!.id && !req.adminId) { reply.status(403).send({ error: "Not your attachment" }); return; } const filePath = resolve(UPLOAD_DIR, attachment.path); let realFile: string; try { realFile = await realpath(filePath); } catch { await prisma.attachment.delete({ where: { id: attachment.id } }); reply.status(204).send(); return; } const realUpload = await realpath(UPLOAD_DIR); if (!realFile.startsWith(realUpload + sep)) { reply.status(400).send({ error: "Invalid file path" }); return; } await unlink(realFile).catch(() => {}); await prisma.attachment.delete({ where: { id: attachment.id } }); reply.status(204).send(); } ); }