import { and, eq } from 'drizzle-orm' import { Hono } from 'hono' import { z } from 'zod' import { hasRole } from '../../shared/enums.ts' import { requireAuth } from '../auth/middleware.ts' import type { SessionUser } from '../auth/sessions.ts' import type { Db } from '../db/client.ts' import { attachments, incidents } from '../db/schema.ts' import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts' import { auditIncident, visibleTo } from '../lib/incidents.ts' const idParam = z.object({ id: z.uuid() }) async function findAttachment(db: Db, user: SessionUser, id: string) { const [row] = await db .select({ file: attachments }) .from(attachments) .innerJoin(incidents, eq(incidents.id, attachments.incidentId)) .where(and(eq(attachments.id, id), visibleTo(db, user))) if (!row) throw fail(404, 'Attachment not found') return row.file } export const attachmentRoutes = new Hono() .get('/:id', requireAuth, validate('param', idParam), async (c) => { const file = await findAttachment(c.get('db'), currentUser(c), c.req.valid('param').id) const data = await c.get('storage').read(file.storageKey) const disposition = file.contentType.startsWith('image/') ? 'inline' : 'attachment' return c.body(new Uint8Array(data), 200, { 'Content-Type': file.contentType, 'Content-Length': String(data.byteLength), 'Content-Disposition': `${disposition}; filename*=UTF-8''${encodeURIComponent(file.fileName)}`, // Uploaded content never runs scripts, even if a file slipped past type detection 'Content-Security-Policy': "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox", 'Cache-Control': 'private, max-age=3600', }) }) .delete('/:id', requireAuth, validate('param', idParam), async (c) => { const me = currentUser(c) const db = c.get('db') const file = await findAttachment(db, me, c.req.valid('param').id) if (file.uploadedById !== me.id && !hasRole(me.role, 'supervisor')) throw fail(403, 'Only the uploader or a supervisor can remove this file') await db.delete(attachments).where(eq(attachments.id, file.id)) await c.get('storage').remove(file.storageKey) await auditIncident(db, me, me.organizationId, file.incidentId, 'attachment.remove', { fileName: file.fileName }) return c.body(null, 204) })