183 lines
6.0 KiB
TypeScript
183 lines
6.0 KiB
TypeScript
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<string, number[][]> = {
|
|
"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<string, string> = {
|
|
".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));
|
|
}
|
|
);
|
|
}
|