- Users page: paginated, searchable, sortable with inline roles (no N+1) - Roles page: paginated, searchable, sortable + /roles/all for dropdowns - User is_active field with migration, PATCH toggle, auth check (disabled=401) - Frontend permission checks: auth store loads permissions, sidebar/buttons conditional - Profile pictures via file storage for users and members, avatar component - Identifier images use file storage API instead of base64 - Fix TypeScript errors across admin UI - 64 API tests passing (10 new)
124 lines
5.1 KiB
TypeScript
124 lines
5.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')
|
|
}
|
|
|
|
// Files are company-scoped in the service — companyId from JWT ensures access control
|
|
const fileRecords = await FileService.listByEntity(app.db, request.companyId, 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']
|
|
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, request.companyId, {
|
|
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)
|
|
// Path traversal protection: validate the path starts with the requesting company's ID
|
|
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: must start with company ID, no '..' allowed
|
|
if (filePath.includes('..') || !filePath.startsWith(request.companyId)) {
|
|
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', 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, request.companyId, 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 })
|
|
})
|
|
|
|
// 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, request.companyId, 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)
|
|
})
|
|
}
|