Files
lunarfront-app/packages/backend/src/routes/v1/files.ts
Ryan Moon f9bf1c9bff Add rectangular logo upload to settings, support SVG content type
Settings page now shows a rectangular upload area for the store logo
instead of circular avatar. Uses authenticated image fetching with
blob URL cleanup. Accepts SVG in addition to JPEG/PNG/WebP. SVG
added to file serve content type map. Simplified to single logo
image (used on PDFs, sidebar, and login).
2026-03-29 16:27:02 -05:00

177 lines
7.1 KiB
TypeScript

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<string, string> = {
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<string, string> = {
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)
})
}