import { Router, Request } from 'express'; import multer from 'multer'; import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { AuthRequest, authenticate } from '../middleware/auth'; import { HttpError } from '../lib/httpError'; import * as attachmentService from '../services/attachmentService'; import * as ticketService from '../services/ticketService'; import prisma from '../lib/prisma'; import { ATTACHMENT_MAX_BYTES } from '../../../shared/schemas/attachment'; const storage = multer.diskStorage({ destination: (_req, _file, cb) => { attachmentService .ensureUploadsDir() .then(() => cb(null, attachmentService.UPLOADS_DIR)) .catch((err: Error) => cb(err, '')); }, filename: (_req, file, cb) => { const ext = path.extname(file.originalname).slice(0, 20); const safeBase = crypto.randomBytes(16).toString('hex'); cb(null, `${safeBase}${ext}`); }, }); const upload = multer({ storage, limits: { fileSize: ATTACHMENT_MAX_BYTES }, }); async function cleanup(file?: Express.Multer.File) { if (!file) return; await import('node:fs/promises').then((fsp) => fsp.unlink(file.path).catch(() => undefined)); } const router = Router(); router.use(authenticate); router.post( '/tickets/:ticketId/attachments', upload.single('file'), async (req: AuthRequest, res) => { const file = req.file; if (!file) throw new HttpError(400, 'No file provided'); try { attachmentService.validateUpload(file); const ticket = await ticketService.findByIdOrDisplay(req.params.ticketId); if (!ticket) throw new HttpError(404, 'Ticket not found'); const attachment = await prisma.$transaction(async (tx) => { const created = await tx.attachment.create({ data: { filename: file.originalname, mimetype: file.mimetype, size: file.size, storagePath: path.relative(attachmentService.UPLOADS_DIR, file.path), uploadedById: req.user!.id, ticketId: ticket.id, }, include: { uploadedBy: { select: { id: true, username: true, displayName: true } }, }, }); await tx.auditLog.create({ data: { ticketId: ticket.id, userId: req.user!.id, action: 'ATTACHMENT_ADDED', detail: file.originalname, }, }); return created; }); res.status(201).json(attachment); } catch (err) { await cleanup(file); throw err; } }, ); router.post( '/comments/:commentId/attachments', upload.single('file'), async (req: AuthRequest, res) => { const file = req.file; if (!file) throw new HttpError(400, 'No file provided'); try { attachmentService.validateUpload(file); const comment = await prisma.comment.findUnique({ where: { id: req.params.commentId }, select: { id: true, ticketId: true }, }); if (!comment) throw new HttpError(404, 'Comment not found'); const attachment = await prisma.$transaction(async (tx) => { const created = await tx.attachment.create({ data: { filename: file.originalname, mimetype: file.mimetype, size: file.size, storagePath: path.relative(attachmentService.UPLOADS_DIR, file.path), uploadedById: req.user!.id, commentId: comment.id, }, include: { uploadedBy: { select: { id: true, username: true, displayName: true } }, }, }); await tx.auditLog.create({ data: { ticketId: comment.ticketId, userId: req.user!.id, action: 'ATTACHMENT_ADDED', detail: file.originalname, }, }); return created; }); res.status(201).json(attachment); } catch (err) { await cleanup(file); throw err; } }, ); router.get('/tickets/:ticketId/attachments', async (req, res) => { const ticket = await ticketService.findByIdOrDisplay(req.params.ticketId); if (!ticket) throw new HttpError(404, 'Ticket not found'); const attachments = await attachmentService.listForTicket(ticket.id); res.json(attachments); }); router.get('/attachments/:id', async (req: Request, res) => { const attachment = await attachmentService.getAttachment(req.params.id); const abs = attachmentService.absolutePathFor(attachment.storagePath); res.setHeader('Content-Type', attachment.mimetype); res.setHeader('Content-Length', String(attachment.size)); res.setHeader( 'Content-Disposition', `inline; filename="${encodeURIComponent(attachment.filename)}"`, ); const stream = fs.createReadStream(abs); stream.on('error', (err) => { if (!res.headersSent) res.status(404).json({ error: 'File not found on disk' }); else res.destroy(err); }); stream.pipe(res); }); router.delete('/attachments/:id', async (req: AuthRequest, res) => { const attachment = await attachmentService.deleteAttachment(req.params.id, { id: req.user!.id, role: req.user!.role, }); const ticketId = attachment.ticketId ?? (attachment.commentId ? ( await prisma.comment.findUnique({ where: { id: attachment.commentId }, select: { ticketId: true }, }) )?.ticketId : null); if (ticketId) { await prisma.auditLog.create({ data: { ticketId, userId: req.user!.id, action: 'ATTACHMENT_REMOVED', detail: attachment.filename, }, }); } res.status(204).send(); }); export default router;