Ticket-Anhänge: Bilder/PDF hochladen und anzeigen (geprüft anhand der Dateikennung)
This commit is contained in:
parent
236c76b4e2
commit
012f0aa0db
12 changed files with 254 additions and 22 deletions
58
apps/api/src/modules/tickets/files.ts
Normal file
58
apps/api/src/modules/tickets/files.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, rename, unlink } from 'node:fs/promises';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
export const STORE_ROOT = process.env.TICKET_ATTACHMENT_DIR || '/var/lib/kundencenter/ticket-attachments';
|
||||
export const MAX_FILE_BYTES = 15 * 1024 * 1024;
|
||||
export const MAX_FILES_PER_MESSAGE = 5;
|
||||
|
||||
const EXT: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'application/pdf': 'pdf' };
|
||||
|
||||
/** Erkennt den Dateityp anhand der ersten Bytes (nicht anhand des vom Client behaupteten Typs). Nur Bilder und PDF sind erlaubt. */
|
||||
export function sniff(head: Buffer): string | null {
|
||||
if (head.length >= 8 && head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e && head[3] === 0x47) return 'image/png';
|
||||
if (head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return 'image/jpeg';
|
||||
if (head.length >= 6 && head.toString('ascii', 0, 6) === 'GIF87a') return 'image/gif';
|
||||
if (head.length >= 6 && head.toString('ascii', 0, 6) === 'GIF89a') return 'image/gif';
|
||||
if (head.length >= 12 && head.toString('ascii', 0, 4) === 'RIFF' && head.toString('ascii', 8, 12) === 'WEBP') return 'image/webp';
|
||||
if (head.length >= 5 && head.toString('ascii', 0, 5) === '%PDF-') return 'application/pdf';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function pathFor(ticketId: string, attachmentId: string, contentType: string): string {
|
||||
return join(STORE_ROOT, ticketId, `${attachmentId}.${EXT[contentType] ?? 'bin'}`);
|
||||
}
|
||||
|
||||
/** Schreibt einen Multipart-Dateistrom auf die Platte, prüft dabei Größe und Dateikennung anhand der ersten Bytes.
|
||||
* Wirft bei Überschreitung/unbekanntem Typ, ohne einen Rest löschen zu müssen (nichts wird vorher persistiert). */
|
||||
export async function storeUpload(ticketId: string, stream: NodeJS.ReadableStream): Promise<{ id: string; contentType: string; sizeBytes: number; finalPath: string }> {
|
||||
const id = randomUUID();
|
||||
const dir = join(STORE_ROOT, ticketId);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const tmpPath = join(dir, `.tmp-${id}`);
|
||||
let head = Buffer.alloc(0);
|
||||
let size = 0;
|
||||
const collector = async function* (src: NodeJS.ReadableStream) {
|
||||
for await (const chunkUnknown of src as AsyncIterable<Buffer>) {
|
||||
const chunk = chunkUnknown as Buffer;
|
||||
size += chunk.length;
|
||||
if (size > MAX_FILE_BYTES) throw new Error('TOO_LARGE');
|
||||
if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]);
|
||||
yield chunk;
|
||||
}
|
||||
};
|
||||
try {
|
||||
await pipeline(collector(stream), createWriteStream(tmpPath));
|
||||
} catch (e) {
|
||||
await unlink(tmpPath).catch(() => undefined);
|
||||
throw e;
|
||||
}
|
||||
const contentType = sniff(head);
|
||||
if (!contentType) { await unlink(tmpPath).catch(() => undefined); throw new Error('UNSUPPORTED_TYPE'); }
|
||||
const finalPath = pathFor(ticketId, id, contentType);
|
||||
await rename(tmpPath, finalPath);
|
||||
return { id, contentType, sizeBytes: size, finalPath };
|
||||
}
|
||||
|
||||
|
|
@ -1,14 +1,17 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import type { PoolConnection } from 'mysql2/promise';
|
||||
import multipart from '@fastify/multipart';
|
||||
import { one, query, run, tx } from '../../core/db.js';
|
||||
import { audit } from '../../core/audit.js';
|
||||
import { enqueue } from '../../core/jobs.js';
|
||||
import { clientIp, requireAuth, requirePermission, type AuthContext } from '../../core/auth.js';
|
||||
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
|
||||
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
|
||||
import { AppError, badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
|
||||
import { can, canInOrg } from '../../core/policy.js';
|
||||
import type { KcModule } from '../../core/module.js';
|
||||
import { MAX_FILES_PER_MESSAGE, MAX_FILE_BYTES, pathFor, storeUpload } from './files.js';
|
||||
|
||||
const OPEN = new Set(['open', 'pending_customer', 'pending_staff']);
|
||||
async function nextNumber(c: PoolConnection): Promise<string> {
|
||||
|
|
@ -21,7 +24,8 @@ const ticketView = (t: any) => ({
|
|||
createdAt: t.created_at, updatedAt: t.updated_at, lastMessageAt: t.last_message_at, resolvedAt: t.resolved_at, closedAt: t.closed_at,
|
||||
});
|
||||
const TICKET_SQL = `SELECT t.*, g.name AS org_name, g.customer_number, u.name AS assigned_name FROM tickets t JOIN organizations g ON g.id = t.org_id LEFT JOIN users u ON u.id = t.assigned_to`;
|
||||
const messageView = (m: any) => ({ id: m.id, authorId: m.author_id, authorName: m.author_name, authorKind: m.author_kind, body: m.body, internalNote: !!m.internal_note, createdAt: m.created_at });
|
||||
const messageView = (m: any, atts: any[] = []) => ({ id: m.id, authorId: m.author_id, authorName: m.author_name, authorKind: m.author_kind, body: m.body, internalNote: !!m.internal_note, createdAt: m.created_at,
|
||||
attachments: atts.filter((a) => a.message_id === m.id).map((a) => ({ id: a.id, filename: a.filename, contentType: a.content_type, sizeBytes: a.size_bytes })) });
|
||||
const notify = (event: string, extra: Record<string, unknown>, key: string, correlationId: string) => enqueue('discord.notify', { event, ...extra }, { idempotencyKey: key, correlationId });
|
||||
|
||||
/** Meldet neue Nachrichten/Status auf CUSTOMER-sichtbare Zeilen; interne Notizen werden für Kunden ausgefiltert. */
|
||||
|
|
@ -30,7 +34,8 @@ async function loadTicket(id: string, forCustomer: boolean) {
|
|||
if (!t) return null;
|
||||
const msgs = await query(
|
||||
`SELECT m.*, u.name AS author_name FROM ticket_messages m JOIN users u ON u.id = m.author_id WHERE m.ticket_id = ?${forCustomer ? ' AND m.internal_note = 0' : ''} ORDER BY m.created_at`, [id]);
|
||||
return { t, msgs };
|
||||
const atts = msgs.length ? await query(`SELECT * FROM ticket_attachments WHERE message_id IN (${msgs.map(() => '?').join(',')})`, msgs.map((m) => m.id)) : [];
|
||||
return { t, msgs, atts };
|
||||
}
|
||||
|
||||
export const ticketsModule: KcModule = {
|
||||
|
|
@ -47,17 +52,17 @@ export const ticketsModule: KcModule = {
|
|||
if (!canInOrg(a.principal, b.orgId, 'tickets.create', 'tickets.write')) { if (!canInOrg(a.principal, b.orgId, 'tickets.read', 'tickets.read')) throw notFound(); throw forbidden(); }
|
||||
if (!staff && b.priority !== 'normal' && b.priority !== 'high') throw forbidden('Diese Priorität kann nur vom Personal gesetzt werden', 'PRIORITY_FORBIDDEN');
|
||||
if (b.resourceId && !(await one('SELECT 1 AS x FROM resources WHERE id = ? AND org_id = ?', [b.resourceId, b.orgId]))) throw badRequest('Ressource gehört nicht zu diesem Kunden');
|
||||
const id = randomUUID();
|
||||
const id = randomUUID(); const messageId = randomUUID();
|
||||
const number = await tx(async (c) => {
|
||||
const n = await nextNumber(c);
|
||||
await run('INSERT INTO tickets (id, number, org_id, resource_id, subject, status, priority, created_by) VALUES (?,?,?,?,?,?,?,?)',
|
||||
[id, n, b.orgId, b.resourceId ?? null, b.subject, staff ? 'pending_customer' : 'pending_staff', b.priority, a.user.id], c);
|
||||
await run('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [randomUUID(), id, a.user.id, staff ? 'staff' : 'customer', b.body], c);
|
||||
await run('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [messageId, id, a.user.id, staff ? 'staff' : 'customer', b.body], c);
|
||||
return n;
|
||||
});
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId, action: 'ticket.create', resourceType: 'ticket', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { number, subject: b.subject } });
|
||||
if (!staff) await notify('ticket.created', { number, subject: b.subject.slice(0, 100) }, `ticket.created:${id}`, req.correlationId);
|
||||
return { id, number };
|
||||
return { id, number, messageId };
|
||||
});
|
||||
|
||||
app.get('/tickets', async (req) => {
|
||||
|
|
@ -82,7 +87,7 @@ export const ticketsModule: KcModule = {
|
|||
const staff = can(a.principal, 'tickets.read');
|
||||
const res = await loadTicket(id, !staff);
|
||||
if (!res || !canInOrg(a.principal, res.t.org_id, 'tickets.read', 'tickets.read')) throw notFound();
|
||||
return { ...ticketView(res.t), messages: res.msgs.map(messageView), canWrite: staff || canInOrg(a.principal, res.t.org_id, 'tickets.create', 'tickets.write') };
|
||||
return { ...ticketView(res.t), messages: res.msgs.map((m) => messageView(m, res.atts)), canWrite: staff || canInOrg(a.principal, res.t.org_id, 'tickets.create', 'tickets.write') };
|
||||
});
|
||||
|
||||
app.post('/tickets/:id/messages', async (req) => {
|
||||
|
|
@ -94,13 +99,54 @@ export const ticketsModule: KcModule = {
|
|||
if (b.internalNote && !staff) throw forbidden('Interne Notizen sind nur für Personal', 'INTERNAL_NOTE_FORBIDDEN');
|
||||
if (t.status === 'closed') throw conflict('Das Ticket ist geschlossen. Bitte ein neues Ticket eröffnen.', 'TICKET_CLOSED');
|
||||
const nextStatus = b.internalNote ? t.status : staff ? 'pending_customer' : 'pending_staff';
|
||||
const messageId = randomUUID();
|
||||
await tx(async (c) => {
|
||||
await run('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body, internal_note) VALUES (?,?,?,?,?,?)', [randomUUID(), id, a.user.id, staff ? 'staff' : 'customer', b.body, b.internalNote ? 1 : 0], c);
|
||||
await run('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body, internal_note) VALUES (?,?,?,?,?,?)', [messageId, id, a.user.id, staff ? 'staff' : 'customer', b.body, b.internalNote ? 1 : 0], c);
|
||||
await run('UPDATE tickets SET status = ?, last_message_at = UTC_TIMESTAMP(3), resolved_at = NULL, closed_at = NULL WHERE id = ?', [nextStatus, id], c);
|
||||
});
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: t.org_id, action: 'ticket.message', resourceType: 'ticket', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { internalNote: b.internalNote } });
|
||||
if (!b.internalNote && !staff) await notify('ticket.message', { number: t.number }, `ticket.message:${id}:${Date.now()}`, req.correlationId);
|
||||
return { ok: true };
|
||||
return { ok: true, messageId };
|
||||
});
|
||||
|
||||
// ---- Dateianhänge (Bilder, PDF) zu einer eigenen Nachricht --------------
|
||||
// "files" bewusst höher als MAX_FILES_PER_MESSAGE: die genaue Zählung (inkl. bereits vorhandener
|
||||
// Anhänge) übernimmt die Schleife unten, mit einer verständlichen Fehlermeldung statt eines rohen Busboy-Fehlers.
|
||||
app.register(multipart, { limits: { fileSize: MAX_FILE_BYTES, files: MAX_FILES_PER_MESSAGE + 10 } });
|
||||
app.post('/tickets/:id/messages/:messageId/attachments', async (req) => {
|
||||
const a = requireAuth(req); const { id, messageId } = z.object({ id: z.string().uuid(), messageId: z.string().uuid() }).parse(req.params);
|
||||
const staff = can(a.principal, 'tickets.write');
|
||||
const t = await one('SELECT * FROM tickets WHERE id = ?', [id]); if (!t) throw notFound();
|
||||
if (!staff && !canInOrg(a.principal, t.org_id, 'tickets.create', 'tickets.write')) throw notFound();
|
||||
const msg = await one('SELECT * FROM ticket_messages WHERE id = ? AND ticket_id = ?', [messageId, id]); if (!msg) throw notFound();
|
||||
if (msg.author_id !== a.user.id && !staff) throw forbidden('Anhänge können nur zur eigenen Nachricht hinzugefügt werden', 'NOT_MESSAGE_AUTHOR');
|
||||
if (t.status === 'closed') throw conflict('Das Ticket ist geschlossen.', 'TICKET_CLOSED');
|
||||
const already = Number((await one('SELECT COUNT(*) AS n FROM ticket_attachments WHERE message_id = ?', [messageId]))!.n);
|
||||
const saved: { id: string; filename: string; contentType: string; sizeBytes: number }[] = [];
|
||||
for await (const part of req.parts()) {
|
||||
if (part.type !== 'file') continue;
|
||||
if (already + saved.length >= MAX_FILES_PER_MESSAGE) { part.file.resume(); throw badRequest(`Höchstens ${MAX_FILES_PER_MESSAGE} Dateien je Nachricht`, 'TOO_MANY_FILES'); }
|
||||
let up;
|
||||
try { up = await storeUpload(id, part.file); }
|
||||
catch (e) { if ((e as Error).message === 'TOO_LARGE') throw new AppError(413, 'FILE_TOO_LARGE', `Datei ist größer als ${Math.round(MAX_FILE_BYTES / 1024 / 1024)} MB`); if ((e as Error).message === 'UNSUPPORTED_TYPE') throw new AppError(415, 'UNSUPPORTED_TYPE', 'Nur Bilder (PNG/JPEG/GIF/WEBP) und PDF sind als Anhang erlaubt'); throw e; }
|
||||
const filename = (part.filename || 'datei').slice(0, 200);
|
||||
await run('INSERT INTO ticket_attachments (id, ticket_id, message_id, filename, content_type, size_bytes, uploaded_by) VALUES (?,?,?,?,?,?,?)',
|
||||
[up.id, id, messageId, filename, up.contentType, up.sizeBytes, a.user.id]);
|
||||
saved.push({ id: up.id, filename, contentType: up.contentType, sizeBytes: up.sizeBytes });
|
||||
}
|
||||
if (saved.length === 0) throw badRequest('Keine Datei übermittelt', 'NO_FILE');
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: t.org_id, action: 'ticket.attachment.add', resourceType: 'ticket', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { messageId, files: saved.map((s) => s.filename) } });
|
||||
return { attachments: saved };
|
||||
});
|
||||
|
||||
app.get('/tickets/:id/attachments/:attachmentId', async (req, reply) => {
|
||||
const a = requireAuth(req); const { id, attachmentId } = z.object({ id: z.string().uuid(), attachmentId: z.string().uuid() }).parse(req.params);
|
||||
const staff = can(a.principal, 'tickets.read');
|
||||
const t = await one('SELECT * FROM tickets WHERE id = ?', [id]); if (!t || !canInOrg(a.principal, t.org_id, 'tickets.read', 'tickets.read')) throw notFound();
|
||||
const att = await one('SELECT ta.*, m.internal_note FROM ticket_attachments ta JOIN ticket_messages m ON m.id = ta.message_id WHERE ta.id = ? AND ta.ticket_id = ?', [attachmentId, id]);
|
||||
if (!att || (att.internal_note && !staff)) throw notFound();
|
||||
reply.header('content-type', att.content_type).header('content-disposition', `inline; filename="${encodeURIComponent(att.filename)}"`).header('cache-control', 'private, max-age=3600');
|
||||
return reply.send(createReadStream(pathFor(id, att.id, att.content_type)));
|
||||
});
|
||||
|
||||
app.patch('/tickets/:id', async (req) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue