Files
Webklar-Kundenbereich/server/services/chatAttachments.js
T
ClaudeandClaude Opus 5.5 06e9a0fe8d Chat-Anhaenge: Korrekturen aus dem Code-Review
- Kamerafotos in anderen Formaten (z. B. HEIF von Android-Kameras) werden
  wie Dateien angenommen statt abgelehnt
- Dateinamen nach Zeichen kuerzen: ein halbiertes Emoji machte den Anhang
  unabrufbar (encodeURIComponent)
- Endet eine Aufnahme von selbst (Headset getrennt, Anruf), bleibt die
  Sprachnachricht erhalten statt "zu kurz"
- Kamera-App nur auf iOS/iPadOS/Android; Touch-Laptops bekommen die Kamera
  im Browser statt eines Dateidialogs
- Knapp neben dem Chat abgelegte Dateien oeffnet der Browser nicht mehr
  selbst (Portal bliebe sonst stehen); Datei-Felder bleiben Ablagen
- Fehler beim Ausliefern kommt als JSON statt mit dem Dateityp
- Server liest hoechstens 4 Uploads gleichzeitig ein (Speicher), weitere
  warten; abgebrochene Wartende geben ihren Platz frei

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 14:01:17 +00:00

433 lines
19 KiB
JavaScript

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: <uploadDir>/<customerId>/<id>.<ext> plus <id>.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.
// Dieselbe Liste steht in public/chat-composer.js (ALLOWED_EXT) fuer die Pruefung vor dem Upload.
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))
// Nach Zeichen kuerzen: ein halbiertes Emoji liesse encodeURIComponent beim Download scheitern
base = Array.from(base).slice(0, 100).join('').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) 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 }
}
// Kamerafotos in anderen Formaten (z. B. HEIF von Android-Kameras) gehen wie jede Datei nach Endung durch
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) return
res.removeHeader('Content-Disposition')
res.removeHeader('Content-Security-Policy')
res.status(err.status || 404).type('application/json').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)})`
})
}