import type { FastifyInstance } from 'fastify'; import { authenticate } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; import { config } from '../config.js'; import { getDb, schema } from '../db/index.js'; import { eq } from 'drizzle-orm'; import fs from 'fs'; import path from 'path'; import { pipeline } from 'stream/promises'; import type { Attachment } from '@backspace/shared'; import { generateThumbnail, isResizableImage, probeImageDimensions, probeMediaMeta, generateVideoThumbnail } from '../utils/thumbnail.js'; const EXT_MIMETYPES: Record = { '.webp': 'image/webp', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.avif': 'image/avif', '.tiff': 'image/tiff', '.bmp': 'image/bmp', '.ico': 'image/x-icon', '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.mp3': 'audio/mpeg', '.ogg': 'audio/ogg', '.wav': 'audio/wav', '.flac': 'audio/flac', '.aac': 'audio/aac', '.opus': 'audio/opus', '.pdf': 'application/pdf', }; export async function uploadRoutes(app: FastifyInstance): Promise { // Ensure upload directory exists if (!fs.existsSync(config.uploadDir)) { fs.mkdirSync(config.uploadDir, { recursive: true }); } // POST /api/uploads - Upload a file app.post('/api/uploads', { preHandler: authenticate, config: { rateLimit: { max: 30, timeWindow: '1 minute', keyGenerator: (request: any) => (request as any).userId || request.ip, }, }, }, async (request, reply) => { // Read dynamic upload limit from instance settings const db = getDb(); const settings = db.select({ maxUploadSizeBytes: schema.instanceSettings.maxUploadSizeBytes }) .from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get(); const maxSize = settings?.maxUploadSizeBytes ?? config.maxUploadSize; const data = await request.file({ limits: { fileSize: maxSize } }); if (!data) { return reply.code(400).send({ error: 'No file provided', statusCode: 400 }); } const originalName = data.filename; const mimetype = data.mimetype; // Generate unique filename const id = generateSnowflake(); const ext = path.extname(originalName); const filename = `${id}${ext}`; const filepath = path.join(config.uploadDir, filename); // Save file to disk const writeStream = fs.createWriteStream(filepath); await pipeline(data.file, writeStream); // Check if file was truncated by multipart limit if ((data.file as any).truncated) { fs.unlinkSync(filepath); return reply.code(413).send({ error: 'File too large', statusCode: 413 }); } // Get file size const stats = fs.statSync(filepath); const size = stats.size; const now = Date.now(); // ─── Media processing ──────────────────────────────────────────────────── let thumbnailFilename: string | null = null; let width: number | null = null; let height: number | null = null; let duration: number | null = null; if (isResizableImage(mimetype)) { // Image: generate thumbnail (unchanged) + probe dimensions thumbnailFilename = await generateThumbnail(filepath, mimetype, config.uploadDir); const dims = await probeImageDimensions(filepath); if (dims) { width = dims.width; height = dims.height; } } else if (mimetype.startsWith('video/')) { // Video: generate thumbnail frame + probe duration const videoThumb = await generateVideoThumbnail(filepath, config.uploadDir); if (videoThumb) { thumbnailFilename = videoThumb.thumbnailFilename; width = videoThumb.width; height = videoThumb.height; } // Duration (and fallback dimensions if thumbnail failed) const meta = await probeMediaMeta(filepath, mimetype); if (meta) { duration = meta.duration ?? null; if (width === null && meta.width) width = meta.width; if (height === null && meta.height) height = meta.height; } } else if (mimetype.startsWith('audio/')) { // Audio: duration only const meta = await probeMediaMeta(filepath, mimetype); if (meta?.duration) duration = meta.duration; } // Save attachment record db.insert(schema.attachments).values({ id, uploaderId: request.userId, filename, originalName, mimetype, size, thumbnailFilename, width, height, duration, createdAt: now, }).run(); const attachment: Attachment = { id, messageId: '', filename, originalName, mimetype, size, thumbnailFilename: thumbnailFilename ?? undefined, width: width ?? undefined, height: height ?? undefined, duration: duration ?? undefined, createdAt: now, }; return reply.code(201).send(attachment); }); // GET /api/uploads/:filename - Serve uploaded file app.get<{ Params: { filename: string } }>('/api/uploads/:filename', async (request, reply) => { const { filename } = request.params; // Prevent directory traversal const safeName = path.basename(filename); const filepath = path.join(config.uploadDir, safeName); if (!fs.existsSync(filepath)) { return reply.code(404).send({ error: 'File not found', statusCode: 404 }); } // Get mimetype from DB, falling back to extension-based lookup for thumbnails/orphans const db = getDb(); const attachment = db.select().from(schema.attachments).where(eq(schema.attachments.filename, safeName)).get(); const originalName = attachment?.originalName ?? safeName; const mimetype = attachment?.mimetype ?? EXT_MIMETYPES[path.extname(safeName).toLowerCase()] ?? 'application/octet-stream'; // Set caching and security headers reply.header('Cache-Control', 'public, max-age=31536000, immutable'); reply.header('Content-Type', mimetype); reply.header('X-Content-Type-Options', 'nosniff'); reply.header('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'"); reply.header('X-Frame-Options', 'DENY'); // For non-media files and SVGs, force download instead of inline rendering const isSvg = mimetype === 'image/svg+xml'; if (isSvg || (!mimetype.startsWith('image/') && !mimetype.startsWith('video/') && !mimetype.startsWith('audio/'))) { reply.header('Content-Disposition', `attachment; filename="${encodeURIComponent(originalName)}"`); } // Support Range requests for audio/video seeking const stat = fs.statSync(filepath); const fileSize = stat.size; const rangeHeader = request.headers.range; if (rangeHeader) { const parts = rangeHeader.replace(/bytes=/, '').split('-'); const start = parseInt(parts[0] ?? '0', 10); const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; const chunkSize = end - start + 1; reply.header('Content-Range', `bytes ${start}-${end}/${fileSize}`); reply.header('Accept-Ranges', 'bytes'); reply.header('Content-Length', chunkSize); reply.code(206); const stream = fs.createReadStream(filepath, { start, end }); return reply.send(stream); } reply.header('Accept-Ranges', 'bytes'); reply.header('Content-Length', fileSize); const stream = fs.createReadStream(filepath); return reply.send(stream); }); }