import { FastifyInstance } from "fastify"; 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"; import prisma from "../lib/prisma.js"; const UPLOAD_DIR = resolve(process.cwd(), "uploads", "avatars"); const MAX_SIZE = 2 * 1024 * 1024; const ALLOWED_MIME = new Set(["image/jpeg", "image/png", "image/webp"]); const ALLOWED_EXT = new Set([".jpg", ".jpeg", ".png", ".webp"]); const MAGIC_BYTES: Record = { "image/jpeg": [[0xFF, 0xD8, 0xFF]], "image/png": [[0x89, 0x50, 0x4E, 0x47]], "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)); } export default async function avatarRoutes(app: FastifyInstance) { if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true }); app.post( "/me/avatar", { preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 10, timeWindow: "1 hour" } } }, async (req, reply) => { // if admin, use their linked user instead of the anonymous cookie user let userId = req.user!.id; if (req.adminId) { const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId }, select: { linkedUserId: true } }); if (admin?.linkedUserId) userId = admin.linkedUserId; } const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) { reply.status(403).send({ error: "Not authenticated" }); return; } // allow passkey users and admin-linked users if (user.authMethod !== "PASSKEY") { const isTeamMember = await prisma.adminUser.findUnique({ where: { linkedUserId: user.id }, select: { id: true } }); if (!isTeamMember) { reply.status(403).send({ error: "Save your identity with a passkey to upload avatars" }); return; } } 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, 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 2MB)" }); 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; } // delete old avatar if exists (with traversal check) if (user.avatarPath) { const oldPath = resolve(UPLOAD_DIR, user.avatarPath); try { const realOld = await realpath(oldPath); const realUpload = await realpath(UPLOAD_DIR); if (realOld.startsWith(realUpload + sep)) { await unlink(realOld).catch(() => {}); } } catch {} } const storedName = `${randomBytes(16).toString("hex")}${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); await prisma.user.update({ where: { id: user.id }, data: { avatarPath: storedName }, }); reply.send({ avatarUrl: `/api/v1/avatars/${user.id}` }); } ); app.delete( "/me/avatar", { preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (req, reply) => { const user = await prisma.user.findUnique({ where: { id: req.user!.id } }); if (!user || !user.avatarPath) { reply.status(204).send(); return; } const filePath = resolve(UPLOAD_DIR, user.avatarPath); try { const realFile = await realpath(filePath); const realUpload = await realpath(UPLOAD_DIR); if (realFile.startsWith(realUpload + sep)) { await unlink(realFile).catch(() => {}); } } catch {} await prisma.user.update({ where: { id: user.id }, data: { avatarPath: null }, }); reply.status(204).send(); } ); app.get<{ Params: { userId: string } }>( "/avatars/:userId", { config: { rateLimit: { max: 200, timeWindow: "1 minute" } } }, async (req, reply) => { const user = await prisma.user.findUnique({ where: { id: req.params.userId }, select: { avatarPath: true }, }); if (!user?.avatarPath) { reply.status(404).send({ error: "No avatar" }); return; } const filePath = resolve(UPLOAD_DIR, user.avatarPath); 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 ext = extname(user.avatarPath).toLowerCase(); const mimeMap: Record = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", }; reply .header("Content-Type", mimeMap[ext] || "application/octet-stream") .header("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400") .header("X-Content-Type-Options", "nosniff") .send(createReadStream(filePath)); } ); }