security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states
This commit is contained in:
170
packages/api/src/routes/attachments.ts
Normal file
170
packages/api/src/routes/attachments.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user