diff --git a/public/chat-composer.js b/public/chat-composer.js index 5ea2d5a..eadf7e8 100644 --- a/public/chat-composer.js +++ b/public/chat-composer.js @@ -75,6 +75,9 @@ const extOf = (name) => ((/\.([A-Za-z0-9]{1,8})$/.exec(String(name || '')) || [])[1] || '').toLowerCase() const withQuery = (url, query) => `${url}${url.includes('?') ? '&' : '?'}${query}` const isTouch = () => Boolean(window.matchMedia && window.matchMedia('(pointer: coarse)').matches) + // Handy/Tablet mit eigener Kamera-App (iPadOS meldet sich als Mac mit Touch) + const isMobileOs = () => /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) function stamp() { const d = new Date() @@ -953,7 +956,7 @@ // Kamera: am Handy/Tablet die eingebaute Kamera-App, sonst die Kamera im Browser function takePhoto() { - if (isTouch() || !(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)) { + if (isMobileOs() || !(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)) { captureInput.click() return } @@ -989,11 +992,21 @@ zone.classList.remove('chat-drop-active') addFiles(event.dataTransfer.files) } + // Knapp daneben losgelassen wuerde der Browser die Datei selbst oeffnen und das Portal verlassen. + // Andere Ablagen (z. B. Datei-Felder) bleiben unberuehrt. + const onWindowDrag = (event) => { + if (event.defaultPrevented || !hasFiles(event) || zone.contains(event.target)) return + if (event.target && event.target.closest && event.target.closest('input[type=file]')) return + event.preventDefault() + if (event.type === 'dragover') event.dataTransfer.dropEffect = 'none' + } zone.classList.add('chat-dropzone') zone.addEventListener('dragenter', onDragEnter) zone.addEventListener('dragover', onDragOver) zone.addEventListener('dragleave', onDragLeave) zone.addEventListener('drop', onDrop) + window.addEventListener('dragover', onWindowDrag) + window.addEventListener('drop', onWindowDrag) // ------------------------------------------------------ Sprachnachricht -- function paintLevels(levels) { @@ -1087,6 +1100,8 @@ if (recording === rec) recording = null composer.classList.remove('is-recording') if (destroyed) return + // Endet die Aufnahme von selbst (Headset getrennt, Anruf, Zugriff entzogen), wurde keine Dauer gesetzt + if (!rec.durationMs) rec.durationMs = Date.now() - rec.startedAt if (rec.then === 'keep') { if (rec.durationMs < MIN_VOICE_MS || !rec.chunks.length) { showNote('Die Aufnahme war zu kurz.') @@ -1121,6 +1136,8 @@ zone.removeEventListener('dragover', onDragOver) zone.removeEventListener('dragleave', onDragLeave) zone.removeEventListener('drop', onDrop) + window.removeEventListener('dragover', onWindowDrag) + window.removeEventListener('drop', onWindowDrag) zone.classList.remove('chat-dropzone', 'chat-drop-active') root.replaceChildren() } diff --git a/server/config.js b/server/config.js index 94597cb..235a110 100644 --- a/server/config.js +++ b/server/config.js @@ -126,6 +126,8 @@ export const config = { uploadDir: process.env.CHAT_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'chat'), maxFileBytes: 25 * 1024 * 1024, maxFilesPerMessage: 10, + // Uploads werden im Speicher gepuffert: hoechstens so viele gleichzeitig, weitere warten + maxParallelUploads: 4, // Missbrauchsschutz: Uploads je Kunde und Stunde, Gesamtspeicher je Kunde, freier Platz auf dem Server maxUploadsPerHour: 200, maxBytesPerCustomer: 1024 * 1024 * 1024, diff --git a/server/routes/chat.js b/server/routes/chat.js index 8bff456..f9cd3c4 100644 --- a/server/routes/chat.js +++ b/server/routes/chat.js @@ -34,8 +34,34 @@ export function sanitizeMessage(doc) { } } +const parseRaw = express.raw({ type: 'application/octet-stream', limit: config.chat.maxFileBytes }) + +// Jeder Upload liegt bis zu 25 MB im Speicher: nur config.chat.maxParallelUploads gleichzeitig einlesen, +// weitere warten (ihr Body bleibt so lange ungelesen im Netzwerkpuffer) +let activeUploads = 0 +const waitingUploads = [] +function startWaitingUploads() { + while (activeUploads < config.chat.maxParallelUploads && waitingUploads.length) waitingUploads.shift()() +} +function uploadSlot(_req, res, next) { + let state = 'waiting' + // 'close' kommt nach der Antwort oder wenn der Browser abbricht - auch waehrend des Wartens + res.once('close', () => { + if (state === 'active') activeUploads -= 1 + state = 'closed' + startWaitingUploads() + }) + waitingUploads.push(() => { + if (state !== 'waiting') return // abgebrochen, bevor er dran war + state = 'active' + activeUploads += 1 + next() + }) + startWaitingUploads() +} + /** 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 }) +export const rawUpload = [uploadSlot, parseRaw] /** Fehler beim Einlesen eines Uploads verstaendlich beantworten (auch fuer den Admin-Router). */ export function uploadErrors(err, _req, res, next) { diff --git a/server/services/chatAttachments.js b/server/services/chatAttachments.js index fc8d1d1..ac41645 100644 --- a/server/services/chatAttachments.js +++ b/server/services/chatAttachments.js @@ -21,6 +21,7 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ 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', @@ -92,7 +93,8 @@ export function cleanFileName(name, ext) { .trim() const own = extOf(base) if (own) base = base.slice(0, -(own.length + 1)) - base = base.slice(0, 100).trim() || 'Anhang' + // 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}` } @@ -110,8 +112,8 @@ function intHeader(value, max) { } /** - * 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. + * 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' @@ -123,8 +125,7 @@ export function classifyUpload(buffer, name, hint) { 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.') - + // 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.`) @@ -333,7 +334,10 @@ export function sendAttachment(req, res, { meta, file }) { 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' }) + 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' }) }) }