Chat: Bilder, Kamerafoto, Dateien und Sprachnachrichten

- Kundenbereich und Admin-Chat: Bueroklammer-Menue (Bild, Kamera, Datei),
  Mikrofon fuer Sprachnachrichten, Vorschau vor dem Senden mit
  Upload-Fortschritt, Einfuegen (Strg+V) und Hineinziehen von Dateien
- Kamera: am Handy/Tablet die Kamera-App, am Rechner die Kamera im Browser
  mit Vorschau, Kamerawechsel und "Nochmal"
- Sprachnachricht: Aufnahme mit Zeit und Pegel, vorher anhoerbar oder
  direkt senden, hoechstens 5 Minuten
- Verlauf: Bilder (einzeln bzw. Raster, Grossansicht), Player fuer
  Sprachnachrichten, Datei-Kacheln mit Download
- Server: jede Datei einzeln roh hochgeladen (max. 25 MB, 10 je Nachricht,
  max. 3 gleichzeitig), Typ aus dem Inhalt, nur erlaubte Endungen, keine
  Programme; Ablage unter upload-data/chat/<Kunde>/ ausserhalb von public/,
  Auslieferung nur mit Login; Bilder, Audio und PDF inline, alles andere
  als Download (nosniff, CSP sandbox)
- Nicht gesendete Anhaenge werden nach 24 Stunden geloescht
- Benachrichtigungs-Mail nennt nur Art und Namen der Anhaenge
- Appwrite: neues Attribut portalMessages.attachments
  (scripts/setup-chat-collection.mjs, vor dem Deploy ausfuehren)
- Datenschutzerklaerung Abschnitt 6 ergaenzt (Stand 26.09.2026), PDF neu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-26 14:01:17 +00:00
1 parent 6e18a70a5d
commit 35d98da977
15 files changed
+2086 -107

No files matched your search

+14
View File
@@ -120,6 +120,20 @@ export const config = {
rejectedRetentionDays: 183,
},
// Chat-Anhaenge (Bilder, Dateien, Kamerafotos, Sprachnachrichten): liegen ausserhalb von public/
// (gitignored, ueberleben Deploys wie preview-data/)
chat: {
uploadDir: process.env.CHAT_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'chat'),
maxFileBytes: 25 * 1024 * 1024,
maxFilesPerMessage: 10,
// Missbrauchsschutz: Uploads je Kunde und Stunde, Gesamtspeicher je Kunde, freier Platz auf dem Server
maxUploadsPerHour: 200,
maxBytesPerCustomer: 1024 * 1024 * 1024,
minFreeDiskBytes: 2 * 1024 * 1024 * 1024,
// Hochgeladen, aber nie abgeschickt: nach dieser Zeit loeschen (Datenschutzerklaerung Abschnitt 6)
pendingTtlHours: 24,
},
smtp: {
host: process.env.SMTP_HOST || '',
port: Number(process.env.SMTP_PORT) || 587,
+9
View File
@@ -14,6 +14,7 @@ import chatRoutes from './routes/chat.js'
import aboRoutes from './routes/abo.js'
import takeoverCreditRoutes from './routes/takeoverCredits.js'
import { purgeExpiredUploads } from './services/takeoverCredits.js'
import { purgePendingChatUploads } from './services/chatAttachments.js'
import giteaWebhookRoutes from './routes/webhook/gitea.js'
import adminGiteaRoutes from './routes/admin/gitea.js'
import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js'
@@ -118,6 +119,14 @@ const server = app.listen(config.port, '0.0.0.0', () => {
.catch((err) => console.warn('[takeover] Aufräumen fehlgeschlagen:', err.message))
setTimeout(purge, 60 * 1000).unref()
setInterval(purge, 24 * 60 * 60 * 1000).unref()
// Chat-Anhaenge, die nie abgeschickt wurden, nach config.chat.pendingTtlHours loeschen (stuendlich)
const purgeChat = () =>
purgePendingChatUploads()
.then((r) => r.purged && console.log(`[chat] ${r.purged} nicht gesendete Anhänge gelöscht`))
.catch((err) => console.warn('[chat] Aufräumen fehlgeschlagen:', err.message))
setTimeout(purgeChat, 90 * 1000).unref()
setInterval(purgeChat, 60 * 60 * 1000).unref()
})
server.on('error', (err) => {
+110 -11
View File
@@ -1,4 +1,4 @@
import { Router } from 'express'
import express, { Router } from 'express'
import { config } from '../config.js'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import {
@@ -9,6 +9,16 @@ import {
Query,
ID,
} from '../services/appwriteAdmin.js'
import {
attachmentLines,
claimAttachments,
deletePendingAttachment,
messageAttachments,
openAttachment,
sendAttachment,
storeChatUpload,
uploadRateLimited,
} from '../services/chatAttachments.js'
import { sendChatNotification } from '../services/mailer.js'
const router = Router()
@@ -19,10 +29,44 @@ export function sanitizeMessage(doc) {
sender: doc.sender || '',
senderName: doc.senderName || '',
text: doc.text || '',
attachments: messageAttachments(doc),
createdAt: doc.createdAt || doc.$createdAt || '',
}
}
/** Chat-Anhang als rohe Bytes (Content-Type application/octet-stream), Metadaten in X-Headern. */
export const rawUpload = express.raw({ type: 'application/octet-stream', limit: config.chat.maxFileBytes })
/** Fehler beim Einlesen eines Uploads verstaendlich beantworten (auch fuer den Admin-Router). */
export function uploadErrors(err, _req, res, next) {
if (err?.type === 'entity.too.large') {
return res.status(413).json({ error: `Die Datei ist größer als ${config.chat.maxFileBytes / 1024 / 1024} MB.` })
}
if (err?.type === 'request.aborted') return res.status(400).end()
return next(err)
}
/** Text und Anhaenge einer neuen Nachricht pruefen und die Anhaenge reservieren. */
export async function prepareMessage(body, { customerId, uploadedBy }) {
const text = String(body?.text || '').trim()
const attachmentIds = Array.isArray(body?.attachmentIds) ? body.attachmentIds : []
if (text.length > 2000) {
const err = new Error('Die Nachricht darf höchstens 2000 Zeichen lang sein.')
err.status = 400
throw err
}
if (!text && !attachmentIds.length) {
const err = new Error('Bitte eine Nachricht schreiben oder einen Anhang hinzufügen.')
err.status = 400
throw err
}
const claim = await claimAttachments({ customerId, ids: attachmentIds, uploadedBy })
// Nur mit Anhang setzen: reine Textnachrichten brauchen das Attribut nicht
const fields = { text }
if (claim.attachments.length) fields.attachments = JSON.stringify(claim.attachments)
return { text, claim, fields }
}
async function countUnreadForCustomer(customerId) {
const docs = await listDocuments(config.collections.portalMessages, [
Query.equal('customerId', customerId),
@@ -53,41 +97,94 @@ router.get('/messages', requireSession, async (req, res) => {
}
})
/** Nachricht des Kunden an die Administratoren. */
/** Nachricht des Kunden an die Administratoren (Text und/oder vorher hochgeladene Anhaenge). */
router.post('/messages', requireSession, async (req, res) => {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.' })
}
const text = String(req.body?.text || '').trim()
if (!text || text.length > 2000) {
return res.status(400).json({ error: 'Nachricht muss zwischen 1 und 2000 Zeichen lang sein.' })
}
const customerId = getSessionCustomerId(req)
let prepared = null
try {
prepared = await prepareMessage(req.body, { customerId, uploadedBy: 'customer' })
const doc = await createDocument(
config.collections.portalMessages,
{
customerId,
sender: 'customer',
senderName: req.session.name || '',
text,
...prepared.fields,
readByAdmin: false,
readByCustomer: true,
createdAt: new Date().toISOString(),
},
ID.unique()
)
await prepared.claim.commit(doc.$id)
// Admins benachrichtigen (gedrosselt, Fehler nicht durchreichen)
// Admins benachrichtigen (gedrosselt, Fehler nicht durchreichen). Anhaenge nur mit Art und Name.
const lines = attachmentLines(prepared.claim.attachments)
const mailText = [prepared.text, lines.length ? `Anhänge:\n${lines.join('\n')}` : ''].filter(Boolean).join('\n\n')
getDocument(config.collections.customers, customerId)
.then((customer) => sendChatNotification(customer, text))
.then((customer) => sendChatNotification(customer, mailText))
.catch(() => {})
return res.status(201).json({ message: sanitizeMessage(doc) })
} catch (err) {
return res.status(500).json({ error: err.message || 'Nachricht konnte nicht gesendet werden' })
await prepared?.claim.release()
return res.status(err.status || 500).json({ error: err.message || 'Nachricht konnte nicht gesendet werden' })
}
})
/** Anhang hochladen (eine Datei je Anfrage); wird erst mit dem Absenden der Nachricht sichtbar. */
router.post(
'/attachments',
requireSession,
(req, res, next) => {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im Ansichtsmodus kann nicht als Kunde gesendet werden.' })
}
if (uploadRateLimited(`customer:${getSessionCustomerId(req)}`)) {
return res.status(429).json({ error: 'Zu viele Anhänge in kurzer Zeit. Bitte später erneut versuchen.' })
}
return next()
},
rawUpload,
async (req, res) => {
try {
const attachment = await storeChatUpload({
customerId: getSessionCustomerId(req),
uploadedBy: 'customer',
buffer: req.body,
headers: req.headers,
})
return res.status(201).json({ attachment })
} catch (err) {
if ((err.status || 500) >= 500) console.error('[chat] Upload fehlgeschlagen:', err.message)
return res.status(err.status || 500).json({ error: err.status ? err.message : 'Datei konnte nicht gespeichert werden' })
}
}
)
router.get('/attachments/:id', requireSession, async (req, res) => {
try {
const found = await openAttachment({ customerId: getSessionCustomerId(req), id: req.params.id, viewer: 'customer' })
return sendAttachment(req, res, found)
} catch (err) {
return res.status(err.status || 500).json({ error: err.status ? err.message : 'Datei nicht verfügbar' })
}
})
/** Noch nicht gesendeten Anhang wieder entfernen (Kunde hat ihn aus der Nachricht genommen). */
router.delete('/attachments/:id', requireSession, async (req, res) => {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im Ansichtsmodus nicht möglich.' })
}
try {
await deletePendingAttachment({ customerId: getSessionCustomerId(req), id: req.params.id, uploadedBy: 'customer' })
return res.status(204).end()
} catch (err) {
return res.status(err.status || 500).json({ error: err.status ? err.message : 'Anhang konnte nicht entfernt werden' })
}
})
@@ -120,4 +217,6 @@ router.get('/unread-count', requireSession, async (req, res) => {
}
})
router.use(uploadErrors)
export default router
+64 -9
View File
@@ -16,7 +16,16 @@ import {
import { listAppointments, saveAppointment } from '../services/appointments.js'
import { getCustomerActivity } from '../services/customerActivity.js'
import { listInvoicesForClient } from '../services/invoiceNinja.js'
import { sanitizeMessage } from './chat.js'
import { prepareMessage, rawUpload, sanitizeMessage, uploadErrors } from './chat.js'
import {
attachmentSummary,
deletePendingAttachment,
messageAttachments,
openAttachment,
sendAttachment,
storeChatUpload,
uploadRateLimited,
} from '../services/chatAttachments.js'
import { offersAdminRouter } from './offers.js'
import { takeoverAdminRouter } from './takeoverCredits.js'
@@ -306,7 +315,7 @@ router.get('/chats', async (_req, res) => {
const entry = byCustomer.get(msg.customerId) || {
customerId: msg.customerId,
customerName: nameById[msg.customerId] || msg.customerId,
lastText: msg.text || '',
lastText: msg.text || attachmentSummary(messageAttachments(msg)),
lastSender: msg.sender || '',
lastAt: msg.createdAt || '',
unreadFromCustomer: 0,
@@ -355,17 +364,14 @@ router.get('/chats/:customerId/messages', async (req, res) => {
})
router.post('/chats/:customerId/messages', async (req, res) => {
const text = String(req.body?.text || '').trim()
if (!text || text.length > 2000) {
return res.status(400).json({ error: 'Nachricht muss zwischen 1 und 2000 Zeichen lang sein.' })
}
let prepared = null
try {
const customer = await getDocument(config.collections.customers, req.params.customerId).catch(() => null)
if (!customer) {
return res.status(404).json({ error: 'Kunde nicht gefunden' })
}
prepared = await prepareMessage(req.body, { customerId: customer.$id, uploadedBy: 'admin' })
const session = req.session
const doc = await createDocument(
config.collections.portalMessages,
@@ -373,16 +379,65 @@ router.post('/chats/:customerId/messages', async (req, res) => {
customerId: customer.$id,
sender: 'admin',
senderName: session.adminName || session.name || 'Webklar-Team',
text,
...prepared.fields,
readByAdmin: true,
readByCustomer: false,
createdAt: new Date().toISOString(),
},
ID.unique()
)
await prepared.claim.commit(doc.$id)
return res.status(201).json({ message: sanitizeMessage(doc) })
} catch (err) {
return res.status(500).json({ error: err.message || 'Nachricht konnte nicht gesendet werden' })
await prepared?.claim.release()
return res.status(err.status || 500).json({ error: err.message || 'Nachricht konnte nicht gesendet werden' })
}
})
// Anhaenge des Teams an einen Kunden (Ablage wie beim Kunden, siehe services/chatAttachments.js)
router.post(
'/chats/:customerId/attachments',
async (req, res, next) => {
if (uploadRateLimited(`admin:${req.session.appwriteUserId}`)) {
return res.status(429).json({ error: 'Zu viele Anhänge in kurzer Zeit. Bitte später erneut versuchen.' })
}
const customer = await getDocument(config.collections.customers, req.params.customerId).catch(() => null)
if (!customer) return res.status(404).json({ error: 'Kunde nicht gefunden' })
return next()
},
rawUpload,
async (req, res) => {
try {
const attachment = await storeChatUpload({
customerId: req.params.customerId,
uploadedBy: 'admin',
buffer: req.body,
headers: req.headers,
})
return res.status(201).json({ attachment })
} catch (err) {
if ((err.status || 500) >= 500) console.error('[chat] Upload (Admin) fehlgeschlagen:', err.message)
return res.status(err.status || 500).json({ error: err.status ? err.message : 'Datei konnte nicht gespeichert werden' })
}
},
uploadErrors
)
router.get('/chats/:customerId/attachments/:id', async (req, res) => {
try {
const found = await openAttachment({ customerId: req.params.customerId, id: req.params.id, viewer: 'admin' })
return sendAttachment(req, res, found)
} catch (err) {
return res.status(err.status || 500).json({ error: err.status ? err.message : 'Datei nicht verfügbar' })
}
})
router.delete('/chats/:customerId/attachments/:id', async (req, res) => {
try {
await deletePendingAttachment({ customerId: req.params.customerId, id: req.params.id, uploadedBy: 'admin' })
return res.status(204).end()
} catch (err) {
return res.status(err.status || 500).json({ error: err.status ? err.message : 'Anhang konnte nicht entfernt werden' })
}
})
+428
View File
@@ -0,0 +1,428 @@
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.
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)})`
})
}