security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states

This commit is contained in:
2026-03-21 17:37:01 +02:00
parent f07eddf29e
commit 5ba25fb956
142 changed files with 30397 additions and 2287 deletions

View File

@@ -0,0 +1,175 @@
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], config: { rateLimit: { max: 10, timeWindow: "1 hour" } } },
async (req, reply) => {
const user = await prisma.user.findUnique({ where: { id: req.user!.id } });
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));
}
);
}