diff --git a/backend/src/routes/files.ts b/backend/src/routes/files.ts new file mode 100644 index 0000000..cab59fd --- /dev/null +++ b/backend/src/routes/files.ts @@ -0,0 +1,160 @@ +import { PassThrough } from 'node:stream'; +import type { Readable } from 'node:stream'; +import express from 'express'; +import busboy from 'busboy'; +import { fileTypeStream } from 'file-type'; +import { env } from '../config/env.js'; +import { rateLimit } from '../middleware/rateLimit.js'; +import { HttpError } from '../middleware/errorHandler.js'; +import { store } from '../services/store.js'; +import type { FileRecord } from '../types.js'; +import { randomId } from '../utils/crypto.js'; +import { fileToApi, sanitizeFilename } from '../utils/files.js'; +import * as gdrive from '../utils/gdrive.js'; +import { getSessionId } from '../middleware/session.js'; + +const router = express.Router(); + +// File types blocked by magic bytes regardless of claimed Content-Type +const BLOCKED_MIME = new Set([ + 'application/x-msdownload', // EXE / DLL + 'application/x-dosexec', // COM + 'application/x-executable', // ELF + 'application/x-msi', // MSI installer + 'application/vnd.microsoft.portable-executable', // PE format +]); + +function formatBytes(n: number) { + if (n >= 1_073_741_824) return `${(n / 1_073_741_824).toFixed(1)} GB`; + if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(0)} MB`; + return `${(n / 1024).toFixed(0)} KB`; +} + +// 20 uploads per IP per hour +router.post('/upload', rateLimit(20, 3_600_000), (req, res, next) => { + const ct = req.headers['content-type'] ?? ''; + if (!ct.includes('multipart/form-data')) { + return next(new HttpError(400, 'MULTIPART_REQUIRED', 'Upload must be multipart/form-data')); + } + + const bb = busboy({ + headers: req.headers as Record, + limits: { fileSize: env.maxFileSizeBytes, files: 1 }, + }); + + let expiresIn = 2_592_000; // 30 days default + let uploader = 'You'; + let fileHandled = false; + + bb.on('field', (name, value) => { + if (name === 'expiresIn') { + const n = parseInt(value, 10); + if (!isNaN(n)) expiresIn = Math.min(Math.max(n, 3600), 7_776_000); + } + if (name === 'uploader') uploader = value.trim().slice(0, 80) || 'You'; + }); + + bb.on('file', (_field, fileStream, info) => { + if (fileHandled) { fileStream.resume(); return; } + fileHandled = true; + + let fileTooLarge = false; + fileStream.on('limit', () => { fileTooLarge = true; }); + + const originalName = sanitizeFilename(info.filename || 'file'); + const linkId = randomId(5); + const now = new Date(); + + (async () => { + // Detect type from first ~4100 bytes; rest of stream flows through unmodified + const ftStream = await fileTypeStream(fileStream as unknown as Readable); + const detectedMime = ftStream.fileType?.mime ?? null; + + if (detectedMime && BLOCKED_MIME.has(detectedMime)) { + ftStream.destroy(); + throw new HttpError(415, 'FILE_TYPE_BLOCKED', `File type "${detectedMime}" is not allowed`); + } + + // Count bytes as they stream to Drive + let sizeBytes = 0; + const counter = new PassThrough(); + counter.on('data', (chunk: Buffer) => { sizeBytes += chunk.length; }); + ftStream.pipe(counter); + + // Stream directly from HTTP request body → Drive API (no rclone, no disk) + const gdriveId = await gdrive.uploadStream(counter, originalName, detectedMime ?? 'application/octet-stream'); + + if (fileTooLarge) { + await gdrive.deleteFile(gdriveId).catch(() => {}); + throw new HttpError(413, 'FILE_TOO_LARGE', `File exceeds the ${formatBytes(env.maxFileSizeBytes)} limit`); + } + + const id = crypto.randomUUID(); + const file: FileRecord = { + id, + linkId, + gdriveId, + sessionId: getSessionId(req), + originalName, + mimeType: detectedMime ?? 'application/octet-stream', + sizeBytes, + status: 'uploaded', + uploader, + createdAt: now.toISOString(), + expiresAt: new Date(now.getTime() + expiresIn * 1000).toISOString(), + views: 0, + downloads: 0, + }; + + await store.update((db) => { db.files.unshift(file); }); + res.status(201).json({ data: fileToApi(file) }); + })().catch(next); + }); + + bb.on('finish', () => { + if (!fileHandled) next(new HttpError(400, 'FILE_REQUIRED', 'A file upload is required')); + }); + + bb.on('error', next); + req.pipe(bb); +}); + +router.get('/', async (req, res) => { + const page = Math.max(Number(req.query.page ?? 1), 1); + const limit = Math.min(Math.max(Number(req.query.limit ?? 100), 1), 100); + const sid = getSessionId(req); + const db = await store.read(); + const active = db.files.filter((f) => f.status === 'uploaded' && f.sessionId === sid); + const start = (page - 1) * limit; + res.json({ + data: active.slice(start, start + limit).map(fileToApi), + meta: { page, limit, total: active.length }, + }); +}); + +router.get('/:id', async (req, res) => { + const db = await store.read(); + const file = db.files.find((f) => f.id === req.params.id && f.status === 'uploaded'); + if (!file) throw new HttpError(404, 'FILE_NOT_FOUND', 'File not found'); + res.json({ data: fileToApi(file) }); +}); + +// Admin-only delete (enforced in admin router; this route is not exposed publicly) +router.delete('/:id', async (req, res) => { + const sid = getSessionId(req); + const deleted = await store.update(async (db) => { + const file = db.files.find( + (f) => f.id === req.params.id && f.status === 'uploaded' && f.sessionId === sid, + ); + if (!file) return null; + file.status = 'deleted'; + file.deletedAt = new Date().toISOString(); + await gdrive.deleteFile(file.gdriveId).catch(() => {}); + return file; + }); + + if (!deleted) throw new HttpError(404, 'FILE_NOT_FOUND', 'File not found or not yours'); + res.status(204).send(); +}); + +export { router as filesRouter };