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,170 @@
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<string, number[][]> = {
"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();
}
);
}