import { createHash, randomUUID } from 'node:crypto' import { mkdir, readdir, readFile, rename, rm, stat, statfs, writeFile } from 'node:fs/promises' import path from 'node:path' import { config } from '../config.js' /** * Chat-Anhaenge: Bilder, Dateien, Kamerafotos und Sprachnachrichten im Kunde-Admin-Chat. * * Ablauf: Der Browser laedt jede Datei einzeln roh hoch -> sie liegt als offener Anhang auf * dem Server -> beim Absenden werden die offenen Anhaenge an die Nachricht gebunden * (portalMessages.attachments). Nie abgeschickte Anhaenge loescht purgePendingChatUploads() * nach config.chat.pendingTtlHours. * * Ablage: //. plus .json (Metadaten), nie unter public/. * Ausgeliefert wird nur ueber die Chat-Routen mit Login-Pruefung. Direkt im Browser angezeigt * werden nur Bilder, Audio und PDF, deren Inhalt geprueft ist; alles andere nur als Download. */ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ // Appwrite-IDs: max. 36 Zeichen, a-z A-Z 0-9 . - _, nicht mit Sonderzeichen beginnend const CUSTOMER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ // Erlaubte Endungen. Programme, Skripte, HTML und Office-Dateien mit Makros bleiben draussen. export const ALLOWED_EXTENSIONS = [ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif', 'avif', 'bmp', 'tif', 'tiff', 'svg', 'pdf', 'txt', 'csv', 'rtf', 'md', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'pages', 'numbers', 'key', 'ai', 'eps', 'psd', 'indd', 'sketch', 'fig', 'xd', 'zip', 'rar', '7z', 'mp3', 'm4a', 'aac', 'wav', 'ogg', 'oga', 'opus', 'webm', 'mp4', 'mov', 'm4v', 'ttf', 'otf', 'woff', 'woff2', 'vcf', 'ics', 'json', 'xml', ] const ALLOWED = new Set(ALLOWED_EXTENSIONS) const AUDIO_MIME_BY_EXT = { mp3: 'audio/mpeg', m4a: 'audio/mp4', aac: 'audio/aac', wav: 'audio/wav', ogg: 'audio/ogg', oga: 'audio/ogg', opus: 'audio/ogg', } const ascii = (b, from, to) => b.subarray(from, to).toString('latin1') const startsWith = (b, bytes) => b.length >= bytes.length && bytes.every((v, i) => b[i] === v) // Der Typ kommt aus den ersten Bytes, nicht aus Dateiname oder Browser-Angabe const IMAGE_TYPES = [ { mime: 'image/jpeg', ext: 'jpg', test: (b) => startsWith(b, [0xff, 0xd8, 0xff]) }, { mime: 'image/png', ext: 'png', test: (b) => startsWith(b, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) }, { mime: 'image/gif', ext: 'gif', test: (b) => /^GIF8[79]a$/.test(ascii(b, 0, 6)) }, { mime: 'image/webp', ext: 'webp', test: (b) => ascii(b, 0, 4) === 'RIFF' && ascii(b, 8, 12) === 'WEBP' }, ] // Sprachaufnahmen aus dem Browser (WebM/Opus, MP4/AAC, Ogg/Opus) und gaengige Audiodateien const AUDIO_TYPES = [ { mime: 'audio/webm', ext: 'webm', test: (b) => startsWith(b, [0x1a, 0x45, 0xdf, 0xa3]) }, { mime: 'audio/ogg', ext: 'ogg', test: (b) => ascii(b, 0, 4) === 'OggS' }, { mime: 'audio/mp4', ext: 'm4a', test: (b) => ascii(b, 4, 8) === 'ftyp' }, { mime: 'audio/mpeg', ext: 'mp3', test: (b) => ascii(b, 0, 3) === 'ID3' || (b[0] === 0xff && (b[1] & 0xe0) === 0xe0) }, { mime: 'audio/wav', ext: 'wav', test: (b) => ascii(b, 0, 4) === 'RIFF' && ascii(b, 8, 12) === 'WAVE' }, ] const isPdf = (b) => b.subarray(0, 1024).includes('%PDF-') /** Programme erkennen, auch wenn sie umbenannt wurden (Windows PE, Linux ELF, macOS Mach-O). */ function isExecutable(b) { if (b.length >= 0x40 && ascii(b, 0, 2) === 'MZ') { const pe = b.readUInt32LE(0x3c) if (pe + 4 <= b.length && ascii(b, pe, pe + 4) === 'PE\0\0') return true } if (startsWith(b, [0x7f, 0x45, 0x4c, 0x46])) return true return b.length >= 4 && [0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe].includes(b.readUInt32BE(0)) } /** Direkt im Browser anzeigbar (alles andere wird als Download ausgeliefert). */ const INLINE_TYPES = new Set([...IMAGE_TYPES.map((t) => t.mime), ...AUDIO_TYPES.map((t) => t.mime), 'audio/aac', 'application/pdf']) export const KINDS = ['image', 'voice', 'audio', 'file'] function httpError(status, message) { const err = new Error(message) err.status = status return err } const extOf = (name) => (/\.([A-Za-z0-9]{1,8})$/.exec(String(name || ''))?.[1] || '').toLowerCase() /** Dateiname fuer Anzeige und Download: ohne Pfad, Steuer- und Richtungszeichen, max. ~120 Zeichen. */ export function cleanFileName(name, ext) { let base = String(name || '') .normalize('NFC') .split(/[\\/]/) .pop() // Steuerzeichen, fuer Dateisysteme verbotene Zeichen und Bidi-Steuerzeichen (verschleierte Endungen) .replace(/[\u0000-\u001f\u007f"<>|:*?\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '') .replace(/\s+/g, ' ') .replace(/^[.\s]+/, '') .trim() const own = extOf(base) if (own) base = base.slice(0, -(own.length + 1)) base = base.slice(0, 100).trim() || 'Anhang' return `${base}.${own && (own === ext || ALLOWED.has(own)) ? own : ext}` } function decodeHeader(value) { try { return decodeURIComponent(String(value || '')) } catch { return '' } } function intHeader(value, max) { const n = Math.round(Number(value)) return Number.isFinite(n) && n > 0 && n <= max ? n : null } /** * Art des Anhangs bestimmen. `hint` kommt vom Browser ('voice' = Sprachaufnahme, 'camera' = Kamerafoto) * und entscheidet nur, wie etwas angezeigt wird - erlaubt wird nach Inhalt bzw. Endung. */ export function classifyUpload(buffer, name, hint) { const label = String(name || '').slice(0, 120) || 'Datei' const image = IMAGE_TYPES.find((t) => t.test(buffer)) if (image) return { kind: 'image', mime: image.mime, ext: image.ext } if (hint === 'voice') { const audio = AUDIO_TYPES.find((t) => t.test(buffer)) if (!audio) throw httpError(400, 'Die Sprachnachricht konnte nicht gelesen werden. Bitte noch einmal aufnehmen.') return { kind: 'voice', mime: audio.mime, ext: audio.ext } } if (hint === 'camera') throw httpError(400, 'Das Foto konnte nicht gelesen werden. Bitte noch einmal aufnehmen.') const ext = extOf(name) if (!ALLOWED.has(ext)) throw httpError(415, `„${label}“: Dieser Dateityp wird im Chat nicht unterstützt.`) if (isExecutable(buffer)) throw httpError(415, `„${label}“ ist ein Programm und kann nicht gesendet werden.`) if (AUDIO_MIME_BY_EXT[ext] && AUDIO_TYPES.some((t) => t.test(buffer))) { return { kind: 'audio', mime: AUDIO_MIME_BY_EXT[ext], ext } } if (ext === 'pdf' && isPdf(buffer)) return { kind: 'file', mime: 'application/pdf', ext } return { kind: 'file', mime: 'application/octet-stream', ext } } // --------------------------------------------------------------------------- // Ablage // --------------------------------------------------------------------------- function customerDir(customerId) { if (!CUSTOMER_ID_RE.test(String(customerId || ''))) throw httpError(400, 'Ungültiger Kunde') return path.resolve(config.chat.uploadDir, customerId) } function metaPath(customerId, id) { if (!UUID_RE.test(String(id || ''))) throw httpError(404, 'Anhang nicht gefunden') return path.join(customerDir(customerId), `${id}.json`) } function filePath(customerId, meta) { if (!UUID_RE.test(String(meta?.id || '')) || !/^[a-z0-9]{1,8}$/.test(String(meta?.ext || ''))) { throw httpError(400, 'Ungültiger Dateiverweis') } return path.join(customerDir(customerId), `${meta.id}.${meta.ext}`) } async function readMeta(customerId, id) { return JSON.parse(await readFile(metaPath(customerId, id), 'utf8')) } async function writeMeta(customerId, meta) { const target = metaPath(customerId, meta.id) const tmp = `${target}.${process.pid}.tmp` await writeFile(tmp, JSON.stringify(meta), { mode: 0o600 }) await rename(tmp, target) } async function removeUpload(customerId, meta) { await rm(filePath(customerId, meta), { force: true }) await rm(metaPath(customerId, meta.id), { force: true }) } /** Oeffentliche Angaben zu einem Anhang (API-Antworten und portalMessages.attachments). */ function publicMeta(meta) { const out = { id: meta.id, name: meta.name, type: meta.type, kind: meta.kind, size: meta.size } if (meta.width && meta.height) Object.assign(out, { width: meta.width, height: meta.height }) if (meta.durationMs) out.durationMs = meta.durationMs return out } // Missbrauchsschutz je Kunde (im Speicher, reicht fuer einen Prozess) const recentUploads = new Map() export function uploadRateLimited(key) { const now = Date.now() const list = (recentUploads.get(key) || []).filter((t) => now - t < 60 * 60 * 1000) list.push(now) recentUploads.set(key, list) return list.length > config.chat.maxUploadsPerHour } async function ensureSpace(dir, bytes) { const fsInfo = await statfs(config.chat.uploadDir).catch(() => null) if (fsInfo && fsInfo.bavail * fsInfo.bsize - bytes < config.chat.minFreeDiskBytes) { console.error('[chat] Kaum noch freier Speicher auf dem Server - Upload abgelehnt') throw httpError(507, 'Anhänge können gerade nicht gespeichert werden. Bitte später erneut versuchen.') } let used = 0 for (const entry of await readdir(dir).catch(() => [])) { if (entry.endsWith('.json')) continue used += (await stat(path.join(dir, entry)).catch(() => null))?.size || 0 } if (used + bytes > config.chat.maxBytesPerCustomer) { throw httpError(413, 'Der Speicher für Chat-Anhänge ist voll. Bitte kurz Bescheid geben, dann räumen wir auf.') } } /** * Hochgeladene Datei pruefen und als offenen Anhang speichern. * `headers`: X-File-Name (URI-kodiert), X-Attachment-Kind (voice|camera), X-Duration-Ms, X-Image-Width/-Height. */ export async function storeChatUpload({ customerId, uploadedBy, buffer, headers }) { const dir = customerDir(customerId) if (!Buffer.isBuffer(buffer) || !buffer.length) throw httpError(400, 'Die Datei ist leer.') const maxMb = config.chat.maxFileBytes / 1024 / 1024 if (buffer.length > config.chat.maxFileBytes) throw httpError(413, `Die Datei ist größer als ${maxMb} MB.`) const hint = ['voice', 'camera'].includes(headers['x-attachment-kind']) ? headers['x-attachment-kind'] : '' const rawName = decodeHeader(headers['x-file-name']) const kind = classifyUpload(buffer, rawName, hint) await mkdir(dir, { recursive: true, mode: 0o700 }) await ensureSpace(dir, buffer.length) const meta = { id: randomUUID(), name: cleanFileName(rawName, kind.ext), type: kind.mime, ext: kind.ext, kind: kind.kind, size: buffer.length, sha256: createHash('sha256').update(buffer).digest('hex'), uploadedBy, uploadedAt: new Date().toISOString(), messageId: null, } if (kind.kind === 'image') { const width = intHeader(headers['x-image-width'], 50000) const height = intHeader(headers['x-image-height'], 50000) if (width && height) Object.assign(meta, { width, height }) } if (kind.kind === 'voice' || kind.kind === 'audio') { const durationMs = intHeader(headers['x-duration-ms'], 6 * 60 * 60 * 1000) if (durationMs) meta.durationMs = durationMs } // Metadaten zuerst: bricht der Upload danach ab, raeumt purgePendingChatUploads() beides weg await writeMeta(customerId, meta) try { await writeFile(filePath(customerId, meta), buffer, { mode: 0o600 }) } catch (err) { await removeUpload(customerId, meta).catch(() => {}) throw err } return publicMeta(meta) } // Anhaenge, die gerade an eine Nachricht gebunden werden (verhindert doppeltes Absenden) const claiming = new Set() /** * Offene Anhaenge fuer eine Nachricht reservieren. Danach `commit(messageId)` nach dem Speichern * der Nachricht bzw. `release()` bei einem Fehler aufrufen. */ export async function claimAttachments({ customerId, ids, uploadedBy }) { const list = [...new Set((Array.isArray(ids) ? ids : []).map(String))] if (list.length > config.chat.maxFilesPerMessage) { throw httpError(400, `Höchstens ${config.chat.maxFilesPerMessage} Anhänge pro Nachricht.`) } const metas = [] const unclaim = () => metas.forEach((m) => claiming.delete(m.id)) try { for (const id of list) { const meta = UUID_RE.test(id) ? await readMeta(customerId, id).catch(() => null) : null if (!meta || meta.uploadedBy !== uploadedBy || meta.messageId || claiming.has(id)) { throw httpError(400, 'Ein Anhang ist nicht mehr verfügbar. Bitte noch einmal hinzufügen.') } claiming.add(id) metas.push(meta) } // Schon vor dem Speichern der Nachricht als gesendet markieren: stuerzt der Server dazwischen ab, // bleibt die Datei lieber liegen, als dass eine gespeicherte Nachricht ihren Anhang verliert. for (const meta of metas) await writeMeta(customerId, { ...meta, messageId: 'sending' }) } catch (err) { for (const meta of metas) await writeMeta(customerId, meta).catch(() => {}) unclaim() throw err } return { attachments: metas.map(publicMeta), async commit(messageId) { const sentAt = new Date().toISOString() for (const meta of metas) await writeMeta(customerId, { ...meta, messageId, sentAt }).catch(() => {}) unclaim() }, async release() { for (const meta of metas) await writeMeta(customerId, meta).catch(() => {}) unclaim() }, } } /** Datei zum Ausliefern suchen. Kunden sehen keine noch nicht gesendeten Anhaenge des Teams. */ export async function openAttachment({ customerId, id, viewer }) { const meta = await readMeta(customerId, id).catch((err) => { throw err.status ? err : httpError(404, 'Anhang nicht gefunden') }) if (viewer === 'customer' && meta.uploadedBy !== 'customer' && !meta.messageId) { throw httpError(404, 'Anhang nicht gefunden') } const file = filePath(customerId, meta) if (!(await stat(file).catch(() => null))) throw httpError(404, 'Die Datei ist nicht mehr vorhanden.') return { meta, file } } function contentDisposition(type, name) { const fallback = name.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_') const encoded = encodeURIComponent(name).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`) return `${type}; filename="${fallback}"; filename*=UTF-8''${encoded}` } /** Anhang ausliefern: geprueftes Bild/Audio/PDF inline, alles andere (und ?download=1) als Download. */ export function sendAttachment(req, res, { meta, file }) { const inline = !req.query.download && INLINE_TYPES.has(meta.type) res.setHeader('Content-Type', inline ? meta.type : 'application/octet-stream') res.setHeader('Content-Disposition', contentDisposition(inline ? 'inline' : 'attachment', meta.name)) res.setHeader('X-Content-Type-Options', 'nosniff') res.setHeader('Cache-Control', 'private, no-store') // Auch direkt im Browser geoeffnet: keine Skripte, keine fremden Inhalte (Chromes PDF-Ansicht vertraegt das nicht) if (meta.type !== 'application/pdf') { res.setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'; sandbox") } res.sendFile(file, { cacheControl: false, etag: false, lastModified: false }, (err) => { if (err && !res.headersSent) res.status(err.status || 404).json({ error: 'Datei nicht verfügbar' }) }) } /** Offenen (noch nicht gesendeten) Anhang wieder entfernen. */ export async function deletePendingAttachment({ customerId, id, uploadedBy }) { const meta = await readMeta(customerId, id).catch((err) => { throw err.status ? err : httpError(404, 'Anhang nicht gefunden') }) if (meta.uploadedBy !== uploadedBy) throw httpError(404, 'Anhang nicht gefunden') if (meta.messageId || claiming.has(meta.id)) throw httpError(409, 'Der Anhang wurde schon gesendet.') await removeUpload(customerId, meta) } /** Nie abgeschickte Anhaenge nach config.chat.pendingTtlHours loeschen. */ export async function purgePendingChatUploads(now = Date.now()) { const cutoff = now - config.chat.pendingTtlHours * 60 * 60 * 1000 let purged = 0 const dirs = await readdir(config.chat.uploadDir, { withFileTypes: true }).catch(() => []) for (const dir of dirs) { if (!dir.isDirectory() || !CUSTOMER_ID_RE.test(dir.name)) continue for (const entry of await readdir(path.join(config.chat.uploadDir, dir.name)).catch(() => [])) { const id = entry.endsWith('.json') ? entry.slice(0, -5) : '' if (!UUID_RE.test(id) || claiming.has(id)) continue const meta = await readMeta(dir.name, id).catch(() => null) if (!meta || meta.messageId || new Date(meta.uploadedAt).getTime() > cutoff) continue if (claiming.has(id)) continue // wird genau jetzt gesendet await removeUpload(dir.name, meta) purged += 1 } } return { purged } } // --------------------------------------------------------------------------- // Anzeige // --------------------------------------------------------------------------- /** Anhaenge einer Nachricht aus portalMessages.attachments (JSON) lesen, nur bekannte Felder. */ export function messageAttachments(doc) { let list = [] try { list = JSON.parse(doc?.attachments || '[]') } catch { return [] } if (!Array.isArray(list)) return [] return list .filter((a) => a && UUID_RE.test(String(a.id)) && KINDS.includes(a.kind)) .map((a) => publicMeta({ id: a.id, name: String(a.name || 'Anhang').slice(0, 200), type: String(a.type || 'application/octet-stream').slice(0, 100), kind: a.kind, size: Number(a.size) || 0, width: Number(a.width) || 0, height: Number(a.height) || 0, durationMs: Number(a.durationMs) || 0, })) } export function fmtDuration(ms) { const s = Math.max(0, Math.round((Number(ms) || 0) / 1000)) return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` } function fmtSize(bytes) { const n = Number(bytes) || 0 if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1).replace('.', ',')} MB` return `${Math.max(1, Math.round(n / 1024))} KB` } /** Kurzform fuer Vorschauen, z. B. "Sprachnachricht (0:12), 2 Bilder". */ export function attachmentSummary(attachments) { const list = Array.isArray(attachments) ? attachments : [] const voice = list.filter((a) => a.kind === 'voice') const images = list.filter((a) => a.kind === 'image') const files = list.filter((a) => a.kind !== 'voice' && a.kind !== 'image') const parts = [] if (voice.length) parts.push(voice.length === 1 ? `Sprachnachricht (${fmtDuration(voice[0].durationMs)})` : `${voice.length} Sprachnachrichten`) if (images.length) parts.push(images.length === 1 ? 'Bild' : `${images.length} Bilder`) if (files.length) parts.push(files.length === 1 ? `Datei „${files[0].name}“` : `${files.length} Dateien`) return parts.join(', ') } /** Eine Zeile je Anhang fuer die Benachrichtigungs-Mail (nur Art und Name, keine Dateien). */ export function attachmentLines(attachments) { return (Array.isArray(attachments) ? attachments : []).map((a) => { if (a.kind === 'voice') return `- Sprachnachricht (${fmtDuration(a.durationMs)})` const label = a.kind === 'image' ? 'Bild' : a.kind === 'audio' ? 'Audio' : 'Datei' return `- ${label}: ${a.name} (${fmtSize(a.size)})` }) }