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>
This commit is contained in:
1 parent
35d98da977
commit
06e9a0fe8d
4 files changed
+57
-8
No files matched your search
+18
-1
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+27
-1
@@ -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) {
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user