- 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>
429 lines
19 KiB
JavaScript
429 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.
|
|
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)})`
|
|
})
|
|
}
|