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

Binary file not shown.
+7
View File
@@ -82,6 +82,11 @@
background: rgba(255,255,255,0.06); color: inherit; font: inherit;
}
.chat-empty { display: flex; align-items: center; justify-content: center; flex: 1; opacity: 0.6; }
/* Anhaenge und Sprachnachrichten (chat-composer.css) im Tuerkis des Admin-Panels */
body {
--chat-accent: #2dd4bf; --chat-accent-hover: #5eead4; --chat-accent-soft: rgba(94,234,212,0.16);
--chat-accent-line: rgba(94,234,212,0.5); --chat-accent-text: #5eead4; --chat-on-accent: #042f2e;
}
/* Übernahmegutschriften */
.credit-filter { display: flex; gap: 8px; margin-bottom: 12px; }
@@ -126,6 +131,7 @@
.history-item { font-size: 13px; padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
.history-item:last-child { border-bottom: none; }
</style>
<link rel="stylesheet" href="/chat-composer.css">
</head>
<body class="portal-page dashboard-page">
<div class="dot-background" aria-hidden="true">
@@ -259,6 +265,7 @@
</section>
</main>
<script src="/chat-composer.js"></script>
<script src="/admin.js"></script>
<script>
initAdminPage();
+40 -37
View File
@@ -269,8 +269,12 @@ const adminChat = {
activeCustomerName: '',
lastCreatedAt: '',
threadTimer: null,
shownIds: new Set(),
composer: null,
}
const adminChatUrl = (customerId) => `/api/portal-admin/chats/${encodeURIComponent(customerId)}`
function updateAdminChatBadge(unread) {
const badge = document.getElementById('admin-chat-badge')
if (!badge) return
@@ -319,11 +323,16 @@ async function loadChatList() {
function appendAdminChatMessages(messages) {
const box = document.getElementById('admin-chat-messages')
if (!box || !messages.length) return
const base = adminChatUrl(adminChat.activeCustomerId)
for (const msg of messages) {
const div = document.createElement('div')
div.className = `chat-bubble ${msg.sender === 'admin' ? 'mine' : 'theirs'}`
div.innerHTML = `${escapeHtml(msg.text)}<span class="chat-meta">${msg.sender === 'admin' ? escapeHtml(msg.senderName || 'Admin') : escapeHtml(adminChat.activeCustomerName)} · ${fmtDateTime(msg.createdAt)}</span>`
box.appendChild(div)
if (adminChat.shownIds.has(msg.id)) continue
adminChat.shownIds.add(msg.id)
const mine = msg.sender === 'admin'
box.appendChild(ChatComposer.renderMessage(msg, {
mine,
meta: `${mine ? msg.senderName || 'Admin' : adminChat.activeCustomerName} · ${fmtDateTime(msg.createdAt)}`,
fileUrl: (att) => `${base}/attachments/${encodeURIComponent(att.id)}`,
}))
if (msg.createdAt > adminChat.lastCreatedAt) adminChat.lastCreatedAt = msg.createdAt
}
box.scrollTop = box.scrollHeight
@@ -332,8 +341,10 @@ function appendAdminChatMessages(messages) {
async function pollAdminThread() {
if (!adminChat.activeCustomerId || document.hidden) return
try {
const customerId = adminChat.activeCustomerId
const q = adminChat.lastCreatedAt ? `?since=${encodeURIComponent(adminChat.lastCreatedAt)}` : ''
const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/messages${q}`)
const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/messages${q}`)
if (customerId !== adminChat.activeCustomerId) return // inzwischen andere Unterhaltung geoeffnet
if (messages.length) {
appendAdminChatMessages(messages)
await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/read`, { method: 'POST' }).catch(() => {})
@@ -343,52 +354,44 @@ async function pollAdminThread() {
}
}
async function sendAdminChatMessage() {
const input = document.getElementById('admin-chat-input')
const btn = document.getElementById('admin-chat-send')
const text = input.value.trim()
if (!text || !adminChat.activeCustomerId) return
btn.disabled = true
try {
const { message } = await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/messages`, {
method: 'POST',
body: JSON.stringify({ text }),
})
input.value = ''
appendAdminChatMessages([message])
loadChatList()
} catch (err) {
alert('Nachricht konnte nicht gesendet werden: ' + err.message)
} finally {
btn.disabled = false
input.focus()
}
}
async function openChatThread(customerId, customerName) {
adminChat.activeCustomerId = customerId
adminChat.activeCustomerName = customerName || 'Kunde'
adminChat.lastCreatedAt = ''
adminChat.shownIds = new Set()
if (adminChat.composer) adminChat.composer.destroy()
const thread = document.getElementById('admin-chat-thread')
thread.innerHTML = `
<div style="margin-bottom:8px"><strong>${escapeHtml(adminChat.activeCustomerName)}</strong></div>
<div class="chat-messages" id="admin-chat-messages"></div>
<div class="chat-input-row">
<textarea id="admin-chat-input" maxlength="2000" placeholder="Antwort an ${escapeHtml(adminChat.activeCustomerName)}…"></textarea>
<button type="button" class="mini-btn primary" id="admin-chat-send" style="padding:12px 18px">Senden</button>
</div>`
<div id="admin-chat-composer"></div>`
ChatComposer.trackScroll(document.getElementById('admin-chat-messages'))
document.getElementById('admin-chat-send').addEventListener('click', sendAdminChatMessage)
document.getElementById('admin-chat-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendAdminChatMessage()
}
// Text, Bilder, Kamerafoto, Dateien und Sprachnachrichten (public/chat-composer.js)
adminChat.composer = ChatComposer.create({
root: document.getElementById('admin-chat-composer'),
dropZone: thread,
uploadUrl: `${adminChatUrl(customerId)}/attachments`,
placeholder: `Antwort an ${adminChat.activeCustomerName}…`,
placeholderShort: 'Antwort…',
send: async ({ text, attachmentIds }) => {
const { message } = await api(`${adminChatUrl(customerId)}/messages`, {
method: 'POST',
body: JSON.stringify({ text, attachmentIds }),
})
return message
},
onSent: (message) => {
if (adminChat.activeCustomerId !== customerId) return
appendAdminChatMessages([message])
loadChatList()
},
})
try {
const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/messages`)
if (customerId !== adminChat.activeCustomerId) return // inzwischen andere Unterhaltung geoeffnet
appendAdminChatMessages(messages)
await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/read`, { method: 'POST' }).catch(() => {})
updateAdminChatBadge(0)
+32 -41
View File
@@ -517,16 +517,25 @@ const chatState = {
badgeTimer: null,
initialized: false,
viewAs: false,
shownIds: new Set(),
composer: null,
}
const chatFileUrl = (att) => `/api/chat/attachments/${encodeURIComponent(att.id)}`
function appendChatMessages(messages) {
const box = document.getElementById('chat-messages')
if (!box || !messages.length) return
for (const msg of messages) {
const div = document.createElement('div')
div.className = `chat-bubble ${msg.sender === 'customer' ? 'mine' : 'theirs'}`
div.innerHTML = `${escapeHtml(msg.text)}<span class="chat-meta">${msg.sender === 'customer' ? 'Du' : escapeHtml(msg.senderName || 'Webklar-Team')} · ${fmtDateTime(msg.createdAt)}</span>`
box.appendChild(div)
// Poll und Antwort auf das Senden koennen dieselbe Nachricht liefern
if (chatState.shownIds.has(msg.id)) continue
chatState.shownIds.add(msg.id)
const mine = msg.sender === 'customer'
box.appendChild(ChatComposer.renderMessage(msg, {
mine,
meta: `${mine ? 'Du' : msg.senderName || 'Webklar-Team'} · ${fmtDateTime(msg.createdAt)}`,
fileUrl: chatFileUrl,
}))
if (msg.createdAt > chatState.lastCreatedAt) chatState.lastCreatedAt = msg.createdAt
}
box.scrollTop = box.scrollHeight
@@ -562,47 +571,29 @@ function isChatTabActive() {
return Boolean(document.getElementById('view-chat')?.classList.contains('active'))
}
async function sendChatMessage() {
const input = document.getElementById('chat-input')
const btn = document.getElementById('chat-send')
const text = input.value.trim()
if (!text) return
btn.disabled = true
try {
const { message } = await api('/api/chat/messages', {
method: 'POST',
body: JSON.stringify({ text }),
})
input.value = ''
appendChatMessages([message])
} catch (err) {
alert(err.status === 403 ? err.message : 'Nachricht konnte nicht gesendet werden: ' + err.message)
} finally {
btn.disabled = false
input.focus()
}
}
async function initChat() {
if (chatState.initialized) return
chatState.initialized = true
const input = document.getElementById('chat-input')
const btn = document.getElementById('chat-send')
if (chatState.viewAs) {
input.disabled = true
btn.disabled = true
input.placeholder = 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.'
} else {
btn.addEventListener('click', sendChatMessage)
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendChatMessage()
}
})
}
ChatComposer.trackScroll(document.getElementById('chat-messages'))
// Text, Bilder, Kamerafoto, Dateien und Sprachnachrichten (public/chat-composer.js)
chatState.composer = ChatComposer.create({
root: document.getElementById('chat-composer'),
dropZone: document.querySelector('#view-chat .chat-box'),
uploadUrl: '/api/chat/attachments',
placeholder: 'Nachricht an das WEBklar-Team…',
placeholderShort: 'Nachricht…',
disabled: chatState.viewAs,
disabledText: 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.',
send: async ({ text, attachmentIds }) => {
const { message } = await api('/api/chat/messages', {
method: 'POST',
body: JSON.stringify({ text, attachmentIds }),
})
return message
},
onSent: (message) => appendChatMessages([message]),
})
await pollChatMessages()
await api('/api/chat/read', { method: 'POST' }).catch(() => {})
+238
View File
@@ -0,0 +1,238 @@
/* Chat: Anhaenge, Kamera und Sprachnachrichten (Kundenbereich + Admin-Panel, siehe chat-composer.js).
Farben ueber --chat-*: Standard ist das Lila des Kundenbereichs, admin.html setzt Tuerkis. */
:root {
--chat-accent: #9d3dfe;
--chat-accent-hover: #8a25f0;
--chat-accent-soft: rgba(157, 61, 254, 0.16);
--chat-accent-line: rgba(157, 61, 254, 0.5);
--chat-accent-text: #c9a2ff;
--chat-on-accent: #fff;
--chat-line: rgba(255, 255, 255, 0.16);
--chat-muted: rgba(255, 255, 255, 0.6);
--chat-surface: #17171b;
}
.chat-composer [hidden], .chat-overlay [hidden], .chat-bubble [hidden] { display: none !important; }
.chat-composer button, .chat-bubble button, .chat-bubble a, .chat-overlay button { -webkit-tap-highlight-color: transparent; }
.chat-ico { display: inline-grid; place-items: center; line-height: 0; }
.chat-ico svg { width: 20px; height: 20px; }
/* ------------------------------------------------------------- Verlauf --- */
.chat-bubble { white-space: normal; }
.chat-bubble .chat-text { white-space: pre-wrap; }
.chat-bubble.has-atts { padding: 6px 6px 8px; }
.chat-bubble.has-atts .chat-text, .chat-bubble.has-atts .chat-meta { padding: 0 8px; }
.chat-atts { display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; }
.chat-atts + .chat-text { margin-top: 8px; }
.chat-att-images { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; width: 272px; max-width: 100%; }
.chat-att-images.single { display: block; width: auto; }
.chat-att-image {
position: relative; display: block; max-width: 100%; padding: 0; border: 0; border-radius: 12px; overflow: hidden;
background: rgba(255, 255, 255, 0.06); color: inherit; font: inherit; cursor: zoom-in;
}
.chat-att-image img { display: block; }
.chat-att-images.single img { max-width: 100%; max-height: 320px; height: auto; }
.chat-att-images:not(.single) .chat-att-image { aspect-ratio: 1; }
.chat-att-images:not(.single) img { width: 100%; height: 100%; object-fit: cover; }
.chat-att-image:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.chat-att-image.is-broken {
display: flex; align-items: center; gap: 8px; padding: 12px 14px; aspect-ratio: auto;
font-size: 13px; color: var(--chat-muted); cursor: default;
}
.chat-att-file {
display: flex; align-items: center; gap: 10px; width: 270px; max-width: 100%; box-sizing: border-box; padding: 8px 12px 8px 8px;
border-radius: 12px; background: rgba(0, 0, 0, 0.22); border: 1px solid rgba(255, 255, 255, 0.12);
color: inherit !important; text-decoration: none !important;
}
.chat-att-file:hover { background: rgba(0, 0, 0, 0.34); }
.chat-att-file > .chat-ico svg { width: 18px; height: 18px; opacity: 0.75; }
.chat-file-icon { position: relative; flex: none; width: 36px; height: 40px; display: grid; place-items: center; }
.chat-file-icon .chat-ico svg { width: 34px; height: 34px; stroke-width: 1.4; opacity: 0.9; }
.chat-file-ext {
position: absolute; left: 50%; bottom: 6px; transform: translateX(-50%); padding: 0 3px; border-radius: 3px;
font-size: 8.5px; line-height: 13px; font-weight: 800; letter-spacing: 0.02em; background: var(--chat-accent); color: var(--chat-on-accent);
white-space: nowrap; word-break: normal;
}
.chat-file-ext:empty { display: none; }
.chat-file-text { flex: 1; min-width: 0; display: grid; gap: 1px; }
.chat-file-name { font-size: 13.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-file-size { font-size: 12px; opacity: 0.7; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-audio { display: flex; align-items: center; gap: 10px; width: 260px; max-width: 100%; box-sizing: border-box; padding: 2px; }
.chat-audio-play {
flex: none; width: 38px; height: 38px; padding: 0; border: 0; border-radius: 50%; display: grid; place-items: center;
background: var(--chat-accent); color: var(--chat-on-accent); cursor: pointer;
}
.chat-bubble.mine .chat-audio-play { background: #fff; color: #17102a; }
.chat-audio-play .chat-ico svg { width: 16px; height: 16px; }
.chat-audio-main { flex: 1; min-width: 0; display: grid; gap: 7px; }
.chat-audio-label { font-size: 12px; font-weight: 600; opacity: 0.8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-audio-bar { position: relative; display: block; height: 6px; border-radius: 999px; background: rgba(255, 255, 255, 0.2); cursor: pointer; }
.chat-audio-bar::after { content: ''; position: absolute; inset: -8px 0; }
.chat-audio-bar:focus-visible { outline: 2px solid #fff; outline-offset: 3px; }
.chat-audio-fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; border-radius: inherit; background: currentColor; }
.chat-audio-time { flex: none; min-width: 32px; text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; opacity: 0.8; }
.chat-audio-dl {
flex: none; display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%;
color: inherit !important; opacity: 0.7;
}
.chat-audio-dl:hover { opacity: 1; background: rgba(255, 255, 255, 0.1); }
.chat-audio-dl .chat-ico svg { width: 16px; height: 16px; }
.chat-audio.is-error .chat-audio-time { opacity: 1; color: #fca5a5; font-size: 11px; }
/* ------------------------------------------------------- Eingabezeile --- */
.chat-composer { position: relative; margin-top: 12px; }
.chat-composer .chat-input-row { display: flex; align-items: flex-end; gap: 8px; margin-top: 0; }
.chat-composer textarea { flex: 1; min-width: 0; box-sizing: border-box; }
.chat-icon-btn {
flex: none; width: 46px; height: 46px; padding: 0; border-radius: 50%; display: grid; place-items: center;
border: 1px solid var(--chat-line); background: rgba(255, 255, 255, 0.05); color: inherit; cursor: pointer;
}
.chat-icon-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.11); }
.chat-icon-btn:disabled { opacity: 0.45; cursor: default; }
.chat-icon-btn[aria-expanded="true"] { background: var(--chat-accent-soft); border-color: var(--chat-accent-line); }
.chat-icon-btn:focus-visible, .chat-send:focus-visible, .chat-audio-play:focus-visible, .chat-tray-remove:focus-visible {
outline: 2px solid var(--chat-accent-text); outline-offset: 2px;
}
.chat-attach { position: relative; flex: none; }
.chat-menu {
position: absolute; left: 0; bottom: calc(100% + 8px); z-index: 30; min-width: 190px; padding: 6px; display: grid; gap: 2px;
border-radius: 14px; background: var(--chat-surface); border: 1px solid var(--chat-line); box-shadow: 0 14px 36px rgba(0, 0, 0, 0.55);
}
.chat-menu button {
display: flex; align-items: center; gap: 12px; width: 100%; padding: 10px 12px; border: 0; border-radius: 10px;
background: none; color: inherit; font: inherit; font-size: 14px; text-align: left; cursor: pointer;
}
.chat-menu button:hover, .chat-menu button:focus-visible { background: rgba(255, 255, 255, 0.08); outline: none; }
.chat-menu .chat-ico { color: var(--chat-accent-text); }
.chat-send {
flex: none; display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 46px; padding: 0 20px;
border: none; border-radius: 999px; background: var(--chat-accent); color: var(--chat-on-accent);
font: inherit; font-weight: 700; cursor: pointer;
}
.chat-send:hover:not(:disabled) { background: var(--chat-accent-hover); }
.chat-send:disabled { opacity: 0.5; cursor: default; }
.chat-send.is-busy:disabled { opacity: 0.8; cursor: progress; }
.chat-send .chat-ico { display: none; }
.chat-send .chat-ico svg { width: 19px; height: 19px; }
.chat-composer-note { margin: 8px 2px 0; font-size: 13px; line-height: 1.45; color: #fca5a5; }
.chat-composer-note.is-info { color: var(--chat-muted); }
/* Vorschau der Anhaenge vor dem Senden */
.chat-tray { display: flex; gap: 8px; padding: 2px 2px 10px; overflow-x: auto; scrollbar-width: thin; }
.chat-tray-item {
position: relative; flex: none; display: flex; align-items: center; gap: 10px; height: 64px; box-sizing: border-box;
border-radius: 12px; border: 1px solid var(--chat-line); background: rgba(255, 255, 255, 0.05); overflow: hidden;
}
.chat-tray-item.is-image { width: 64px; }
.chat-tray-item.is-image img { display: block; width: 100%; height: 100%; object-fit: cover; }
.chat-tray-item.is-image.is-queued img, .chat-tray-item.is-image.is-uploading img { opacity: 0.55; }
.chat-tray-item.is-file { width: 220px; padding: 0 36px 0 8px; }
.chat-tray-item.is-file.is-error { padding-right: 64px; }
.chat-tray-item.is-voice { width: 280px; padding: 0 36px 0 10px; }
.chat-tray-item.is-voice .chat-audio { width: 100%; padding: 0; }
.chat-tray-progress { position: absolute; left: 0; bottom: 0; height: 3px; width: 0; background: var(--chat-accent); transition: width 0.2s; }
.chat-tray-item.is-done .chat-tray-progress { opacity: 0; transition: opacity 0.6s 0.3s; }
.chat-tray-item.is-error { border-color: rgba(248, 113, 113, 0.75); background: rgba(248, 113, 113, 0.08); }
.chat-tray-item.is-error .chat-file-size { color: #fca5a5; opacity: 1; }
.chat-tray-remove, .chat-tray-retry {
position: absolute; top: 5px; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 50%; display: grid; place-items: center;
background: rgba(0, 0, 0, 0.72); color: #fff; cursor: pointer;
}
.chat-tray-remove { right: 5px; }
.chat-tray-retry { right: 34px; }
.chat-tray-item.is-image .chat-tray-retry { top: 50%; left: 50%; right: auto; width: 30px; height: 30px; transform: translate(-50%, -50%); }
.chat-tray-remove .chat-ico svg, .chat-tray-retry .chat-ico svg { width: 14px; height: 14px; }
/* Sprachaufnahme (ersetzt waehrenddessen Textfeld und Anhang-Knopf) */
.chat-rec {
display: none; flex: 1; min-width: 0; align-items: center; gap: 10px; height: 46px; box-sizing: border-box; padding: 0 5px;
border-radius: 999px; border: 1px solid rgba(248, 113, 113, 0.45); background: rgba(248, 113, 113, 0.08);
}
.chat-composer.is-recording .chat-rec { display: flex; }
.chat-composer.is-recording .chat-attach,
.chat-composer.is-recording textarea,
.chat-composer.is-recording .chat-mic { display: none; }
.chat-rec .chat-icon-btn { width: 36px; height: 36px; border: 0; background: transparent; }
.chat-rec .chat-icon-btn:hover { background: rgba(255, 255, 255, 0.1); }
.chat-rec-stop { color: #f87171; }
.chat-rec-dot { flex: none; width: 10px; height: 10px; border-radius: 50%; background: #f87171; animation: chat-rec-pulse 1.2s ease-in-out infinite; }
.chat-rec-label { flex: none; font-size: 13px; color: var(--chat-muted); }
.chat-rec-time { flex: none; min-width: 36px; font-weight: 700; font-variant-numeric: tabular-nums; }
.chat-rec-wave { flex: 1; min-width: 0; height: 24px; display: flex; align-items: center; justify-content: flex-end; gap: 2px; overflow: hidden; color: #fca5a5; }
.chat-rec-wave i { flex: none; width: 3px; height: 100%; border-radius: 2px; background: currentColor; transform: scaleY(0.1); transition: transform 0.09s linear; }
@keyframes chat-rec-pulse { 50% { opacity: 0.25; } }
/* Dateien in den Chat ziehen */
.chat-dropzone { position: relative; }
.chat-drop-active::after {
content: 'Dateien hier ablegen'; position: absolute; inset: 0; z-index: 40; display: grid; place-items: center;
border: 2px dashed var(--chat-accent-line); border-radius: inherit; background: rgba(10, 10, 12, 0.85);
color: #fff; font-weight: 700; pointer-events: none;
}
/* ---------------------------------------------- Bildansicht und Kamera --- */
html.chat-overlay-open, html.chat-overlay-open body { overflow: hidden; }
.chat-overlay {
position: fixed; inset: 0; z-index: 1000; display: flex; flex-direction: column;
background: rgba(4, 4, 6, 0.95); color: #fff; font-family: inherit;
-webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
}
.chat-ov-top { flex: none; display: flex; align-items: center; gap: 10px; padding: 12px 14px; padding-top: max(12px, env(safe-area-inset-top)); }
.chat-ov-title { flex: 1; min-width: 0; font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-ov-btn {
flex: none; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-width: 44px; height: 44px; padding: 0 12px;
box-sizing: border-box; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.25); background: rgba(255, 255, 255, 0.1);
color: #fff !important; font: inherit; font-size: 14px; font-weight: 600; text-decoration: none; cursor: pointer;
}
.chat-ov-btn:hover { background: rgba(255, 255, 255, 0.2); }
.chat-ov-btn:focus-visible, .chat-shutter:focus-visible { outline: 2px solid #fff; outline-offset: 3px; }
.chat-ov-btn.wide { padding: 0 18px; }
.chat-ov-btn.primary { background: var(--chat-accent); border-color: transparent; color: var(--chat-on-accent) !important; }
.chat-ov-btn.primary:hover { background: var(--chat-accent-hover); }
.chat-lightbox-stage { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; padding: 0 12px 16px; }
.chat-lightbox-stage img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); }
.chat-cam-stage { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; padding: 0 12px; }
.chat-cam-video, .chat-cam-still { max-width: 100%; max-height: 100%; border-radius: 14px; background: #000; }
.chat-cam-video.is-mirrored { transform: scaleX(-1); }
.chat-camera:not(.is-review) .chat-cam-still, .chat-camera.is-review .chat-cam-video { display: none; }
.chat-cam-msgbox { max-width: 420px; display: grid; gap: 16px; justify-items: center; text-align: center; }
.chat-cam-msg { margin: 0; line-height: 1.5; color: rgba(255, 255, 255, 0.88); }
.chat-cam-controls {
flex: none; display: flex; align-items: center; justify-content: center; gap: 24px;
padding: 18px 16px; padding-bottom: max(18px, env(safe-area-inset-bottom));
}
.chat-camera .chat-cam-review, .chat-camera.is-review .chat-cam-live { display: none; }
.chat-camera.is-review .chat-cam-review { display: flex; }
.chat-cam-side { flex: 0 1 110px; display: flex; justify-content: center; }
.chat-shutter {
flex: none; width: 74px; height: 74px; padding: 0; border-radius: 50%; border: 4px solid #fff;
background: rgba(255, 255, 255, 0.18); box-shadow: inset 0 0 0 4px rgba(0, 0, 0, 0.35); cursor: pointer;
}
.chat-shutter:hover:not(:disabled) { background: rgba(255, 255, 255, 0.35); }
.chat-shutter:active:not(:disabled) { background: #fff; }
.chat-shutter:disabled { opacity: 0.4; cursor: default; }
@media (max-width: 640px) {
.chat-composer .chat-input-row { gap: 6px; }
.chat-composer textarea { font-size: 16px; } /* iOS zoomt sonst beim Tippen hinein */
.chat-icon-btn { width: 42px; height: 42px; }
.chat-send { width: 42px; height: 42px; padding: 0; }
.chat-send .chat-ico { display: inline-grid; }
.chat-send .chat-send-label { display: none; }
.chat-rec { height: 42px; }
.chat-rec-label { display: none; }
.chat-tray-item.is-file { width: 190px; }
.chat-tray-item.is-voice { width: 250px; }
}
@media (prefers-reduced-motion: reduce) {
.chat-rec-dot { animation: none; }
.chat-rec-wave i, .chat-tray-progress { transition: none; }
}
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -6,6 +6,7 @@
<title>Kundenbereich – WEBklar</title>
<link rel="stylesheet" href="/login.css">
<link rel="stylesheet" href="/kundenbereich.css">
<link rel="stylesheet" href="/chat-composer.css">
</head>
<body class="portal-page dashboard-page kb-page">
<div class="dot-background" aria-hidden="true">
@@ -90,11 +91,9 @@
</header>
<div class="kb-panel chat-box">
<div class="chat-messages" id="chat-messages"></div>
<div class="chat-input-row">
<textarea id="chat-input" maxlength="2000" placeholder="Nachricht an das WEBklar-Team…"></textarea>
<button type="button" class="chat-send" id="chat-send">Senden</button>
</div>
<p class="chat-hint">Enter = Senden, Shift+Enter = Zeilenumbruch.</p>
<!-- Eingabezeile mit Bild, Kamera, Datei und Sprachnachricht (chat-composer.js) -->
<div id="chat-composer"></div>
<p class="chat-hint">Enter = Senden, Shift+Enter = neue Zeile. Bilder und Dateien bis 25 MB – auch per Einfügen oder Hineinziehen.</p>
</div>
</section>
@@ -120,6 +119,7 @@
</main>
</div>
<script src="/chat-composer.js"></script>
<script src="/app.js"></script>
<script>
initDashboardPage();
+2 -1
View File
@@ -44,7 +44,7 @@
<div class="brand">WEBKLAR</div>
<h1>Datenschutzerklärung</h1>
<p class="meta">Stand 25.09.2026</p>
<p class="meta">Stand 26.09.2026</p>
<p>Hier erfahren Sie, welche personenbezogenen Daten wir verarbeiten, wenn Sie unsere Websites besuchen, unser Kundenportal nutzen oder mit uns einen Vertrag schließen, und welche Rechte Sie haben.</p>
@@ -78,6 +78,7 @@
<h2>6. Kundenportal</h2>
<p>Für Ihr Kundenkonto verarbeiten wir Name, E-Mail-Adresse, Passwort (nur verschlüsselt gespeichert), Kundennummer sowie gegebenenfalls Anschrift und Telefonnummer. Im Portal kommen hinzu: Ihre Projekte (Dateien und Änderungsverlauf Ihrer Website), unsere Arbeitsnachweise, Nachrichten im Chat, Rechnungen und Ihr Stundenkontingent. Schreiben Sie uns im Chat, erhalten unsere Mitarbeiter eine E-Mail-Benachrichtigung mit Ihrer Nachricht. Um Sie zu unterstützen, können unsere Mitarbeiter Ihr Portal so ansehen, wie Sie es sehen. Eine Nachricht in Ihrem Namen senden können sie dabei nicht.</p>
<p id="chat-anhaenge">Im Chat können Sie uns auch Bilder, Fotos, Dateien und Sprachnachrichten schicken. Auf Kamera und Mikrofon greift das Portal nur zu, während Sie selbst ein Foto aufnehmen oder eine Sprachnachricht aufzeichnen, und nur, wenn Sie das in Ihrem Browser erlauben. Anhänge werden schon beim Hinzufügen auf unseren Server übertragen. Nicht gesendete Anhänge, auch solche, die Sie vor dem Senden wieder entfernen, löschen wir spätestens nach 24 Stunden. Gesendete Anhänge speichern wir wie Ihre Chat-Nachrichten. Die E-Mail-Benachrichtigung an unsere Mitarbeiter nennt nur Art und Namen der Anhänge, nicht die Dateien selbst.</p>
<p>Alle Portal-Daten liegen in unseren selbst betriebenen Systemen auf unserem Server bei Hetzner. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO. Wir speichern die Daten für die Dauer der Geschäftsbeziehung und danach bis zum Ablauf der Verjährungsfrist (in der Regel drei Jahre). Rechnungen bewahren wir wie in Abschnitt 9 beschrieben auf.</p>
<h2>7. Wartungs-Abo und Zahlung über Stripe</h2>
-2
View File
@@ -279,8 +279,6 @@
flex: 1; resize: none; min-height: 46px; max-height: 130px; padding: 12px 14px; border-radius: 14px;
border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit;
}
.chat-send { padding: 12px 20px; border: none; border-radius: 999px; background: var(--kb-purple); color: #fff; font: inherit; font-weight: 700; cursor: pointer; }
.chat-send:disabled { opacity: 0.5; cursor: default; }
.chat-hint { margin-top: 6px; font-size: 12px; color: var(--kb-faint); }
/* ------------------------------------------------ Wartungs-Abo (Inhalt) --- */
+4 -1
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node
/**
* Legt die Collection portalMessages (Kunde-Admin-Chat) in woms-database an.
* Idempotent: existierende Collection/Attribute/Indexe werden uebersprungen.
* Idempotent: existierende Collection/Attribute/Indexe werden uebersprungen
* (also auch zum Nachruesten neuer Attribute wie `attachments` geeignet).
* Usage: APPWRITE_API_KEY=... node scripts/setup-chat-collection.mjs
*/
const endpoint = (process.env.APPWRITE_ENDPOINT || 'https://ticket.webklar.com/v1').replace(/\/$/, '')
@@ -78,6 +79,8 @@ const attributes = [
{ kind: 'string', key: 'sender', size: 16, required: true },
{ kind: 'string', key: 'senderName', size: 128, required: false },
{ kind: 'string', key: 'text', size: 4000, required: true },
// JSON-Liste der Anhaenge (Bilder, Dateien, Sprachnachrichten); Dateien selbst liegen auf dem Server
{ kind: 'string', key: 'attachments', size: 20000, required: false },
{ kind: 'boolean', key: 'readByAdmin', required: false },
{ kind: 'boolean', key: 'readByCustomer', required: false },
{ kind: 'datetime', key: 'createdAt', required: true },
+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)})`
})
}