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).
177 lines
7.1 KiB
TypeScript
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)
|
|
})
|
|
}
|