import fs from 'fs'; import path from 'path'; import { pipeline } from 'stream/promises'; import { randomUUID } from 'crypto'; import { fileURLToPath } from 'url'; import multer from 'multer'; import db from '../../db.js'; import { requireCrmEdit } from '../../middleware/auth.js'; import { badRequest, UUID } from '../../lib/http.js'; import { mapEvent } from '../../lib/mappers.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** Beschreibungstexte beim Zusammenführen (ohne doppelte Blöcke). */ function mergeDescriptions(existing, incoming) { const a = String(existing || '').trim(); const b = String(incoming || '').trim(); if (!b) return a; if (!a) return b; if (a.includes(b)) return a; return `${a}\n\n${b}`; } const uploadsRoot = path.resolve( process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', 'data', 'uploads'), ); const maxFileSize = Number(process.env.ATTACHMENT_MAX_BYTES) || 20 * 1024 * 1024; const maxFiles = Number(process.env.ATTACHMENT_MAX_FILES) || 20; function safeBasename(name) { let base = path.basename(name || 'file'); base = base.replace(/[/\\?%*:|"<>]/g, '_'); return base.slice(0, 200) || 'file'; } function handleMulterError(err, res) { if (err?.name === 'MulterError') { if (err.code === 'LIMIT_FILE_SIZE') { return res.status(400).json({ message: 'Datei zu groß.' }); } if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') { return res.status(400).json({ message: 'Zu viele Dateien.' }); } return res.status(400).json({ message: err.message || 'Upload fehlgeschlagen.' }); } return res.status(400).json({ message: err.message || 'Upload fehlgeschlagen.' }); } /** Multer 2.x: kein diskStorage — Dateien liegen temporär vor, Zugriff über `stream`. */ function uploadMiddleware(req, res, next) { const { ticketId } = req.params; if (!UUID.test(ticketId)) { return res.status(404).json({ message: 'Nicht gefunden' }); } const upload = multer({ limits: { fileSize: maxFileSize, files: maxFiles }, }).array('files', maxFiles); upload(req, res, (err) => { if (err) return handleMulterError(err, res); next(); }); } /** Streams nach `data/uploads/tickets//` schreiben (permanent). */ async function persistUploadedFiles(files, ticketId) { const destDir = path.join(uploadsRoot, 'tickets', ticketId); fs.mkdirSync(destDir, { recursive: true }); const saved = []; for (const f of files) { const filename = `${randomUUID()}_${safeBasename(f.originalName)}`; const absPath = path.join(destDir, filename); await pipeline(f.stream, fs.createWriteStream(absPath)); const stat = fs.statSync(absPath); saved.push({ path: absPath, originalname: f.originalName || 'Datei', mimetype: f.clientReportedMimeType, size: stat.size, }); } return saved; } function unlinkUploaded(files) { for (const f of files || []) { try { if (f?.path) fs.unlinkSync(f.path); } catch { /* ignore */ } } } /** Temporäre Multer-Dateien verwerfen, wenn kein persist erfolgt. */ function discardIncomingFiles(files) { for (const f of files || []) { try { if (f.stream && typeof f.stream.destroy === 'function') f.stream.destroy(); if (f.path && fs.existsSync(f.path)) fs.unlinkSync(f.path); } catch { /* ignore */ } } } export function registerAttachmentRoutes(api) { api.post( '/tickets/:ticketId/events/attachments', requireCrmEdit, uploadMiddleware, async (req, res) => { const { ticketId } = req.params; const incoming = req.files || []; if (incoming.length === 0) { return badRequest(res, 'Mindestens eine Datei erforderlich.'); } const t = db .prepare('SELECT 1 AS ok FROM tickets WHERE id = ?') .get(ticketId); if (!t) { discardIncomingFiles(incoming); return res.status(404).json({ message: 'Nicht gefunden' }); } const descRaw = req.body && req.body.description != null ? String(req.body.description).trim() : ''; const description = descRaw || ''; let saved; try { saved = await persistUploadedFiles(incoming, ticketId); } catch (e) { console.error(e); return res.status(500).json({ message: 'Dateien konnten nicht gespeichert werden.' }); } const existing = db .prepare( `SELECT id, description FROM events WHERE ticket_id = ? AND type = 'ATTACHMENT' AND date(created_at, 'localtime') = date('now', 'localtime') ORDER BY created_at ASC LIMIT 1`, ) .get(ticketId); let eid; let createdNewEvent = false; try { db.exec('BEGIN'); try { if (existing) { eid = existing.id; const mergedDesc = mergeDescriptions(existing.description, description); db.prepare('UPDATE events SET description = ? WHERE id = ?').run( mergedDesc, eid, ); } else { eid = randomUUID(); createdNewEvent = true; db.prepare( `INSERT INTO events (id, ticket_id, type, description, callback_number, teamviewer_id, article_number, remote_duration_seconds, teamviewer_notes) VALUES (?, ?, 'ATTACHMENT', ?, NULL, NULL, NULL, NULL, NULL)`, ).run(eid, ticketId, description); } const insAtt = db.prepare( `INSERT INTO ticket_attachments (id, event_id, original_name, stored_path, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?, ?)`, ); for (const f of saved) { const aid = randomUUID(); const rel = path .relative(uploadsRoot, f.path) .split(path.sep) .join('/'); if (rel.startsWith('..') || path.isAbsolute(rel)) { throw new Error('Ungültiger Speicherpfad'); } const mime = f.mimetype && String(f.mimetype).trim() ? String(f.mimetype).trim() : null; insAtt.run( aid, eid, f.originalname, rel, mime, f.size, ); } db.prepare( `UPDATE tickets SET updated_at = datetime('now'), sla_anchor_at = CASE WHEN status IN ('OPEN', 'WAITING') THEN datetime('now') ELSE sla_anchor_at END WHERE id = ?`, ).run(ticketId); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; } const row = db.prepare('SELECT * FROM events WHERE id = ?').get(eid); const attRows = db .prepare( 'SELECT * FROM ticket_attachments WHERE event_id = ? ORDER BY created_at ASC', ) .all(eid); res .status(createdNewEvent ? 201 : 200) .json(mapEvent(row, attRows)); } catch (e) { unlinkUploaded(saved); console.error(e); return res.status(500).json({ message: 'Speichern fehlgeschlagen.' }); } }, ); api.get('/tickets/:ticketId/attachments/:attachmentId/file', (req, res) => { const { ticketId, attachmentId } = req.params; if (!UUID.test(ticketId) || !UUID.test(attachmentId)) { return res.status(404).json({ message: 'Nicht gefunden' }); } const row = db .prepare( `SELECT ta.id, ta.original_name, ta.stored_path, e.ticket_id AS ev_tid FROM ticket_attachments ta JOIN events e ON e.id = ta.event_id WHERE ta.id = ? AND e.ticket_id = ?`, ) .get(attachmentId, ticketId); if (!row) { return res.status(404).json({ message: 'Nicht gefunden' }); } if (String(row.stored_path).includes('..')) { return res.status(400).json({ message: 'Ungültiger Pfad' }); } const abs = path.resolve(path.join(uploadsRoot, row.stored_path)); const rootResolved = path.resolve(uploadsRoot); if (abs !== rootResolved && !abs.startsWith(`${rootResolved}${path.sep}`)) { return res.status(400).json({ message: 'Ungültiger Pfad' }); } if (!fs.existsSync(abs)) { return res.status(404).json({ message: 'Datei fehlt' }); } const inline = req.query.inline === '1' || req.query.inline === 'true'; let mime = row.mime_type && String(row.mime_type).trim() ? String(row.mime_type).trim() : null; if (!mime) { mime = guessMimeFromFilename(row.original_name); } if (inline) { res.setHeader('Content-Type', mime); res.setHeader( 'Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(row.original_name)}`, ); return res.sendFile(abs); } res.setHeader('Content-Type', mime); res.download(abs, row.original_name); }); } function guessMimeFromFilename(name) { const ext = path.extname(name || '').toLowerCase(); const map = { '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp', '.txt': 'text/plain; charset=utf-8', '.csv': 'text/csv; charset=utf-8', '.json': 'application/json', '.xml': 'application/xml', '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8', '.md': 'text/markdown; charset=utf-8', '.webm': 'video/webm', '.mp4': 'video/mp4', '.ogg': 'video/ogg', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', }; return map[ext] || 'application/octet-stream'; }