import type { FastifyPluginAsync } from 'fastify' import multipart from '@fastify/multipart' import { FileService } from '../../services/file.service.js' import { ValidationError } from '../../lib/errors.js' export const fileRoutes: FastifyPluginAsync = async (app) => { await app.register(multipart, { limits: { fileSize: 25 * 1024 * 1024, files: 1, }, }) // List files for an entity app.get('/files', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => { const { entityType, entityId } = request.query as { entityType?: string; entityId?: string } if (!entityType || !entityId) { throw new ValidationError('entityType and entityId query params required') } const fileRecords = await FileService.listByEntity(app.db, entityType, entityId) const data = await Promise.all( fileRecords.map(async (f) => ({ ...f, url: await app.storage.getUrl(f.path) })), ) return reply.send({ data }) }) // Upload a file app.post('/files', { preHandler: [app.authenticate, app.requirePermission('files.upload')] }, async (request, reply) => { const data = await request.file() if (!data) { throw new ValidationError('No file provided') } const entityType = (data.fields.entityType as { value?: string })?.value const entityId = (data.fields.entityId as { value?: string })?.value const category = (data.fields.category as { value?: string })?.value if (!entityType || !entityId || !category) { throw new ValidationError('entityType, entityId, and category are required') } // Validate entityType is a known type const allowedEntityTypes = ['user', 'member', 'member_identifier', 'product', 'rental_agreement', 'repair_ticket', 'repair_note', 'company'] if (!allowedEntityTypes.includes(entityType)) { throw new ValidationError(`Invalid entityType: ${entityType}`) } // Validate entityId format (UUID) if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(entityId)) { throw new ValidationError('entityId must be a valid UUID') } // Validate category (alphanumeric + underscore only) if (!/^[a-z0-9_]+$/.test(category)) { throw new ValidationError('category must be lowercase alphanumeric with underscores') } const buffer = await data.toBuffer() const file = await FileService.upload(app.db, app.storage, { data: buffer, filename: data.filename, contentType: data.mimetype, entityType, entityId, category, uploadedBy: request.user.id, }) request.log.info({ fileId: file.id, entityType, entityId, category, sizeBytes: file.sizeBytes }, 'File uploaded') const url = await app.storage.getUrl(file.path) return reply.status(201).send({ ...file, url }) }) // Serve file content (for local provider) app.get('/files/serve/*', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => { const filePath = (request.params as { '*': string })['*'] if (!filePath) { throw new ValidationError('Path required') } // Path traversal protection: no '..' allowed if (filePath.includes('..')) { return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } }) } try { const data = await app.storage.get(filePath) const ext = filePath.split('.').pop()?.toLowerCase() const contentTypeMap: Record = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', svg: 'image/svg+xml', pdf: 'application/pdf', } return reply .header('Content-Type', contentTypeMap[ext ?? ''] ?? 'application/octet-stream') .header('Cache-Control', 'private, max-age=3600') .send(data) } catch { return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } }) } }) // Get file metadata app.get('/files/:id', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => { const { id } = request.params as { id: string } const file = await FileService.getById(app.db, id) if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } }) const url = await app.storage.getUrl(file.path) return reply.send({ ...file, url }) }) // Generate signed URL for a file (short-lived token in query string) app.get('/files/signed-url/:id', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => { const { id } = request.params as { id: string } const file = await FileService.getById(app.db, id) if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } }) // Sign a short-lived token with the file path const token = app.jwt.sign( { path: file.path, purpose: 'file-access' } as any, { expiresIn: '15m' }, ) const signedUrl = `/v1/files/s/${file.path}?token=${token}` return reply.send({ url: signedUrl }) }) // Serve file via signed URL (no auth header required — token in query string) app.get('/files/s/*', async (request, reply) => { const filePath = (request.params as { '*': string })['*'] const { token } = request.query as { token?: string } if (!filePath || !token) { return reply.status(400).send({ error: { message: 'Path and token required', statusCode: 400 } }) } // Verify the signed token try { const payload = app.jwt.verify(token) as { path: string; purpose: string } if (payload.purpose !== 'file-access' || payload.path !== filePath) { return reply.status(403).send({ error: { message: 'Invalid token', statusCode: 403 } }) } } catch { return reply.status(403).send({ error: { message: 'Token expired or invalid', statusCode: 403 } }) } // Path traversal protection if (filePath.includes('..')) { return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } }) } try { const data = await app.storage.get(filePath) const ext = filePath.split('.').pop()?.toLowerCase() const contentTypeMap: Record = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', svg: 'image/svg+xml', pdf: 'application/pdf', } return reply .header('Content-Type', contentTypeMap[ext ?? ''] ?? 'application/octet-stream') .header('Cache-Control', 'private, max-age=900') .send(data) } catch { return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } }) } }) // Delete a file app.delete('/files/:id', { preHandler: [app.authenticate, app.requirePermission('files.delete')] }, async (request, reply) => { const { id } = request.params as { id: string } const file = await FileService.delete(app.db, app.storage, id) if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } }) request.log.info({ fileId: id, path: file.path }, 'File deleted') return reply.send(file) }) }