307 lines
9.9 KiB
JavaScript
307 lines
9.9 KiB
JavaScript
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/<ticketId>/` 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';
|
|
}
|