diff --git a/package.json b/package.json index c0392e0..12fb659 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "express": "^4.21.0", "express-basic-auth": "^1.2.1", "multer": "^1.4.5-lts.1", - "nanoid": "^3.3.7" + "nanoid": "^3.3.7", + "nodemailer": "^6.9.15" } } diff --git a/public/admin/index.html b/public/admin/index.html index 29e9f52..f81606e 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -157,6 +157,9 @@ .pill.lock { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 50%, transparent); background: color-mix(in srgb, var(--warn) 10%, transparent); } .pill.count { background: var(--bg-raise); color: var(--text-muted); } + .pill.archived { color: var(--text-dim); border-color: var(--border-strong); + background: color-mix(in srgb, var(--text-dim) 10%, transparent); } + tr.archived td:not(.cell-actions) { opacity: .55; } .small { font-size: .8rem; color: var(--text-muted); } .muted { color: var(--text-muted); } @@ -267,6 +270,7 @@

Neuen Kunden anlegen

+
@@ -278,7 +282,7 @@ - + @@ -296,6 +300,7 @@
+
KundeSlugUpload-LinkSchutzKundeE-MailSlugUpload-LinkSchutz AblaufUploads
- +
BenutzernameRolleAngelegt
BenutzernameE-MailRolleAngelegt
@@ -355,6 +360,37 @@ +
+

E-Mail-Benachrichtigungen (SMTP)

+

Bestimmt den Versand der Upload-Benachrichtigungen. Kunden und zuständige Sachbearbeiter mit E-Mail-Adresse bekommen nach einem Upload eine Zusammenfassung.

+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + + + +
+ +
+

Allgemein

@@ -513,27 +549,41 @@ async function loadCustomers() { const adminOnly = me.role === 'admin'; rows.innerHTML = ''; if (!data.length) { - rows.innerHTML = `Noch keine Kunden.`; + rows.innerHTML = `Noch keine Kunden.`; return; } for (const c of data) { const link = c.upload_url || ''; const tr = document.createElement('tr'); - tr.innerHTML = ` - ${esc(c.name)} - ${esc(c.slug)} - ${adminOnly + if (c.archived) tr.classList.add('archived'); + const nameCell = `${esc(c.name)}` + (c.archived ? ' archiviert' : ''); + const linkCell = c.archived + ? '— archiviert —' + : (adminOnly ? `
${esc(link)}
` - : ''} + : ''); + const actions = !adminOnly + ? `${c.my_access}` + : (c.archived + ? ` + + + ` + : ` + + + + + `); + tr.innerHTML = ` + ${nameCell} + ${c.email ? esc(c.email) : '–'} + ${esc(c.slug)} + ${linkCell} ${c.has_password ? '🔒 PW' : ''} ${c.expires_at ? new Date(c.expires_at).toLocaleString() : '–'} ${c.upload_count} · ${fmtSize(c.total_size)} - ${adminOnly ? ` - - - - - ` : `${c.my_access}`}`; + ${actions}`; rows.appendChild(tr); } } @@ -543,16 +593,28 @@ document.getElementById('customerRows').addEventListener('click', async (e) => { navigator.clipboard.writeText(t.dataset.copy); const orig = t.textContent; t.textContent = '✓'; setTimeout(() => t.textContent = orig, 1200); } - if (t.dataset.del && confirm('Kunde wirklich löschen? (Dateien bleiben auf Disk.)')) { - await api.send('DELETE', `/customers/${t.dataset.del}`); loadCustomers(); + if (t.dataset.archive && confirm('Kunden deaktivieren? Der Upload-Link wird ungültig, Einträge & Dateien bleiben bestehen.')) { + await api.send('POST', `/customers/${t.dataset.archive}/archive`); loadCustomers(); + } + if (t.dataset.unarchive && confirm('Kunden wieder aktivieren? Der Upload-Link wird wieder gültig.')) { + await api.send('POST', `/customers/${t.dataset.unarchive}/unarchive`); loadCustomers(); + } + if (t.dataset.purge) { + const name = t.dataset.name || ''; + const confirmName = prompt(`⚠️ Unwiderruflich: Alle Dateien und der Eintrag von "${name}" werden gelöscht.\n\nZum Bestätigen "${name}" eingeben:`); + if (confirmName === name) { + await api.send('DELETE', `/customers/${t.dataset.purge}`); loadCustomers(); + } } if (t.dataset.regen && confirm('Neuen Link erzeugen? Alter Link wird ungültig.')) { await api.send('POST', `/customers/${t.dataset.regen}/regenerate-token`); loadCustomers(); } if (t.dataset.edit) { + const em = prompt('E-Mail (leer = unverändert, "-" = entfernen):', ''); if (em === null) return; const pw = prompt('Neues Passwort (leer = unverändert, "-" = entfernen):', ''); if (pw === null) return; const exp = prompt('Neuer Ablauf (YYYY-MM-DD HH:MM, leer = unverändert, "-" = entfernen):', ''); if (exp === null) return; const body = {}; + if (em === '-') body.email = ''; else if (em) body.email = em; if (pw === '-') body.clear_password = true; else if (pw) body.password = pw; if (exp === '-') body.expires_at = null; else if (exp) body.expires_at = parseDate(exp); await api.send('PATCH', `/customers/${t.dataset.edit}`, body); loadCustomers(); @@ -565,6 +627,7 @@ document.getElementById('createCustomerForm').addEventListener('submit', async ( try { await api.send('POST', '/customers', { name: fd.get('name'), + email: fd.get('email') || undefined, password: fd.get('password') || undefined, expires_at: parseDate(fd.get('expires_at')) || undefined, }); @@ -581,9 +644,11 @@ async function loadUsers() { const tr = document.createElement('tr'); tr.innerHTML = ` ${esc(u.username)}${u.id === me.id ? ' (du)' : ''} + ${u.email ? esc(u.email) : '–'} ${u.role} ${new Date(u.created_at).toLocaleDateString()} + @@ -594,6 +659,11 @@ async function loadUsers() { document.getElementById('userRows').addEventListener('click', async (e) => { const t = e.target.closest('button'); if (!t) return; try { + if (t.dataset.email) { + const em = prompt('E-Mail (leer = entfernen):', ''); + if (em === null) return; + await api.send('PATCH', `/users/${t.dataset.email}`, { email: em || '' }); loadUsers(); + } if (t.dataset.pw) { const pw = prompt('Neues Passwort (min. 6 Zeichen):', ''); if (!pw) return; await api.send('PATCH', `/users/${t.dataset.pw}`, { password: pw }); loadUsers(); @@ -625,6 +695,18 @@ async function loadSettings() { setSlider('logoHeight', s.logo_height_px || 0); updateScaleFromWH(); updateLogoPreview(s.logo_filename); + + const sf = document.getElementById('smtpForm'); + sf.smtp_host.value = s.smtp_host || ''; + sf.smtp_port.value = s.smtp_port || 587; + sf.smtp_secure.checked = !!s.smtp_secure; + sf.smtp_user.value = s.smtp_user || ''; + sf.smtp_from.value = s.smtp_from || ''; + sf.smtp_pass.value = ''; + sf.smtp_pass.placeholder = s.smtp_pass_set + ? 'gesetzt — leer = unverändert lassen' + : 'nicht gesetzt'; + sf.smtp_notify_admin.checked = !!s.smtp_notify_admin; } function setSlider(id, val) { document.getElementById(id).value = val; @@ -669,6 +751,37 @@ document.getElementById('settingsForm').addEventListener('submit', async (e) => applyLogo(getSlider('logoWidth'), getSlider('logoHeight')); } catch (ex) { alert('Fehler: ' + ex.message); } }); +document.getElementById('smtpForm').addEventListener('submit', async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const body = { + smtp_host: fd.get('smtp_host') || '', + smtp_port: parseInt(fd.get('smtp_port') || '587', 10), + smtp_secure: !!fd.get('smtp_secure'), + smtp_user: fd.get('smtp_user') || '', + smtp_from: fd.get('smtp_from') || '', + smtp_notify_admin: !!fd.get('smtp_notify_admin'), + }; + const pw = fd.get('smtp_pass'); + if (pw) body.smtp_pass = pw; + try { + await api.send('PUT', '/settings', body); + const msg = document.getElementById('smtpMsg'); + msg.textContent = '✓ Gespeichert'; + setTimeout(() => msg.textContent = '', 2000); + loadSettings(); + } catch (ex) { alert('Fehler: ' + ex.message); } +}); +document.getElementById('sendTestMail').addEventListener('click', async () => { + const to = document.getElementById('testMailTo').value.trim(); + const msg = document.getElementById('smtpMsg'); + msg.textContent = '… sende'; + try { + await api.send('POST', '/settings/test-mail', { to }); + msg.textContent = '✓ Test-Mail versendet'; + } catch (ex) { msg.textContent = 'Fehler: ' + ex.message; } +}); + document.getElementById('runJanitor').addEventListener('click', async () => { const msg = document.getElementById('settingsMsg'); msg.textContent = '… läuft'; try { const r = await api.send('POST', '/janitor/run'); msg.textContent = `✓ +${r.added} / -${r.removed}`; } diff --git a/src/db.js b/src/db.js index 8b9178b..2dfe7e8 100644 --- a/src/db.js +++ b/src/db.js @@ -62,4 +62,15 @@ db.exec(` ); `); +// Additive migrations for existing DBs +function addColumnIfMissing(table, column, ddl) { + const cols = db.prepare(`PRAGMA table_info(${table})`).all(); + if (!cols.some(c => c.name === column)) { + db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddl}`); + } +} +addColumnIfMissing('users', 'email', 'TEXT'); +addColumnIfMissing('customers', 'email', 'TEXT'); +addColumnIfMissing('customers', 'archived_at', 'INTEGER'); + module.exports = db; diff --git a/src/mailer.js b/src/mailer.js new file mode 100644 index 0000000..923e01d --- /dev/null +++ b/src/mailer.js @@ -0,0 +1,45 @@ +const nodemailer = require('nodemailer'); +const settings = require('./settings'); + +function getConfig() { + return { + host: settings.get('smtp_host', ''), + port: parseInt(settings.get('smtp_port', '587'), 10), + secure: settings.get('smtp_secure', 'false') === 'true', + user: settings.get('smtp_user', ''), + pass: settings.get('smtp_pass', ''), + from: settings.get('smtp_from', ''), + notify_admin: settings.get('smtp_notify_admin', 'false') === 'true', + }; +} + +function isConfigured() { + const c = getConfig(); + return !!(c.host && c.from); +} + +function buildTransport() { + const c = getConfig(); + const opts = { + host: c.host, + port: c.port, + secure: c.secure, + }; + if (c.user) opts.auth = { user: c.user, pass: c.pass }; + return nodemailer.createTransport(opts); +} + +async function send({ to, subject, text, html }) { + if (!isConfigured()) throw new Error('SMTP nicht konfiguriert'); + const c = getConfig(); + const t = buildTransport(); + return t.sendMail({ from: c.from, to, subject, text, html }); +} + +async function verify() { + if (!isConfigured()) throw new Error('SMTP nicht konfiguriert'); + const t = buildTransport(); + return t.verify(); +} + +module.exports = { send, verify, isConfigured, getConfig }; diff --git a/src/server.js b/src/server.js index 8d37a47..801bc15 100644 --- a/src/server.js +++ b/src/server.js @@ -9,6 +9,13 @@ const auth = require('./auth'); const webdavConfig = require('./webdav-config'); const settings = require('./settings'); const janitor = require('./janitor'); +const mailer = require('./mailer'); +const uploadNotifier = require('./upload-notifier'); + +function validEmail(v) { + if (!v) return true; // optional + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v).trim()); +} const PORT = parseInt(process.env.PORT || '3000', 10); const UPLOAD_ROOT = process.env.UPLOAD_ROOT || '/data/uploads'; @@ -72,6 +79,8 @@ function isExpired(customer) { return customer.expires_at && customer.expires_at < Date.now(); } +function isArchived(customer) { return !!customer.archived_at; } + function canAccessCustomer(user, customer, needWrite = false) { if (user.role === 'admin') return true; const row = db.prepare( @@ -144,14 +153,16 @@ const api = express.Router(); // --- Users (admin only) --- api.get('/users', auth.requireAdmin, (req, res) => { - const rows = db.prepare('SELECT id, username, role, created_at FROM users ORDER BY username').all(); + const rows = db.prepare('SELECT id, username, email, role, created_at FROM users ORDER BY username').all(); res.json(rows); }); api.post('/users', auth.requireAdmin, async (req, res) => { - const { username, password, role } = req.body || {}; + const { username, password, role, email } = req.body || {}; + if (!validEmail(email)) return res.status(400).json({ error: 'invalid email' }); try { const id = await auth.createUser(username, password, role || 'staff'); + if (email) db.prepare('UPDATE users SET email = ? WHERE id = ?').run(String(email).trim(), id); webdavConfig.sync(); res.json({ id }); } catch (e) { @@ -164,8 +175,9 @@ api.patch('/users/:id', auth.requireAdmin, async (req, res) => { const id = parseInt(req.params.id, 10); const u = db.prepare('SELECT * FROM users WHERE id = ?').get(id); if (!u) return res.status(404).json({ error: 'not found' }); - const { password, role } = req.body || {}; + const { password, role, email } = req.body || {}; if (role && role !== 'admin' && role !== 'staff') return res.status(400).json({ error: 'invalid role' }); + if (email !== undefined && !validEmail(email)) return res.status(400).json({ error: 'invalid email' }); // Don't let admin demote themselves to the last non-admin if (role && role !== 'admin' && u.id === req.user.id) { const otherAdmins = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND id != ?").get(id).n; @@ -174,6 +186,7 @@ api.patch('/users/:id', auth.requireAdmin, async (req, res) => { try { if (password) await auth.setUserPassword(id, password); if (role) db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id); + if (email !== undefined) db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email ? String(email).trim() : null, id); webdavConfig.sync(); res.json({ ok: true }); } catch (e) { @@ -202,31 +215,62 @@ api.get('/settings', auth.requireAdmin, (req, res) => { logo_filename: settings.get('logo_filename', ''), logo_width_px: parseInt(settings.get('logo_width_px', '0'), 10), logo_height_px: parseInt(settings.get('logo_height_px', '0'), 10), + smtp_host: settings.get('smtp_host', ''), + smtp_port: parseInt(settings.get('smtp_port', '587'), 10), + smtp_secure: settings.get('smtp_secure', 'false') === 'true', + smtp_user: settings.get('smtp_user', ''), + smtp_pass_set: !!settings.get('smtp_pass', ''), + smtp_from: settings.get('smtp_from', ''), + smtp_notify_admin: settings.get('smtp_notify_admin', 'false') === 'true', }); }); api.put('/settings', auth.requireAdmin, (req, res) => { - const { public_base_url, janitor_interval_minutes, logo_width_px, logo_height_px } = req.body || {}; - if (public_base_url !== undefined) { - const v = String(public_base_url || '').trim().replace(/\/+$/, ''); - settings.set('public_base_url', v); + const b = req.body || {}; + if (b.public_base_url !== undefined) { + settings.set('public_base_url', String(b.public_base_url || '').trim().replace(/\/+$/, '')); } - if (janitor_interval_minutes !== undefined) { - const n = Math.max(1, parseInt(janitor_interval_minutes, 10) || 30); + if (b.janitor_interval_minutes !== undefined) { + const n = Math.max(1, parseInt(b.janitor_interval_minutes, 10) || 30); settings.set('janitor_interval_minutes', String(n)); janitor.restart(n * 60 * 1000); } - if (logo_width_px !== undefined) { - const n = Math.max(0, Math.min(800, parseInt(logo_width_px, 10) || 0)); - settings.set('logo_width_px', String(n)); + if (b.logo_width_px !== undefined) { + settings.set('logo_width_px', String(Math.max(0, Math.min(800, parseInt(b.logo_width_px, 10) || 0)))); } - if (logo_height_px !== undefined) { - const n = Math.max(0, Math.min(600, parseInt(logo_height_px, 10) || 0)); - settings.set('logo_height_px', String(n)); + if (b.logo_height_px !== undefined) { + settings.set('logo_height_px', String(Math.max(0, Math.min(600, parseInt(b.logo_height_px, 10) || 0)))); } + if (b.smtp_host !== undefined) settings.set('smtp_host', String(b.smtp_host || '').trim()); + if (b.smtp_port !== undefined) settings.set('smtp_port', String(parseInt(b.smtp_port, 10) || 587)); + if (b.smtp_secure !== undefined) settings.set('smtp_secure', b.smtp_secure ? 'true' : 'false'); + if (b.smtp_user !== undefined) settings.set('smtp_user', String(b.smtp_user || '').trim()); + if (b.smtp_pass !== undefined && b.smtp_pass !== '') + settings.set('smtp_pass', String(b.smtp_pass)); + if (b.smtp_pass_clear) settings.set('smtp_pass', ''); + if (b.smtp_from !== undefined) settings.set('smtp_from', String(b.smtp_from || '').trim()); + if (b.smtp_notify_admin !== undefined) + settings.set('smtp_notify_admin', b.smtp_notify_admin ? 'true' : 'false'); res.json({ ok: true }); }); +api.post('/settings/test-mail', auth.requireAdmin, async (req, res) => { + const to = (req.body && req.body.to) || req.user.email || ''; + if (!to || !validEmail(to)) return res.status(400).json({ error: 'gültige Empfänger-Adresse erforderlich' }); + try { + await mailer.verify(); + await mailer.send({ + to, + subject: 'Test-Mail vom Upload-Portal', + text: 'Wenn du diese Mail liest, funktioniert die SMTP-Konfiguration.', + html: '

Wenn du diese Mail liest, funktioniert die SMTP-Konfiguration. ✅

', + }); + res.json({ ok: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + // --- Logo (admin manages, public serves) --- const logoUpload = multer({ storage: multer.memoryStorage(), @@ -295,20 +339,24 @@ api.get('/customers', auth.requireAuth, (req, res) => { id: r.id, name: r.name, slug: r.slug, + email: r.email, token: isAdmin ? r.token : undefined, has_password: !!r.password_hash, expires_at: r.expires_at, + archived_at: r.archived_at, + archived: !!r.archived_at, created_at: r.created_at, upload_count: r.upload_count, total_size: r.total_size, my_access: isAdmin ? 'admin' : r.my_access, - upload_url: isAdmin ? `${baseUrl}/u/${r.token}` : undefined, + upload_url: isAdmin && !r.archived_at ? `${baseUrl}/u/${r.token}` : undefined, }))); }); api.post('/customers', auth.requireAdmin, async (req, res) => { - const { name, password, expires_at } = req.body || {}; + const { name, password, expires_at, email } = req.body || {}; if (!name || !String(name).trim()) return res.status(400).json({ error: 'name required' }); + if (!validEmail(email)) return res.status(400).json({ error: 'invalid email' }); const base = slugify(name); const slug = ensureUniqueSlug(base); const token = nanoid(24); @@ -316,9 +364,9 @@ api.post('/customers', auth.requireAdmin, async (req, res) => { const exp = expires_at ? parseInt(expires_at, 10) : null; const created_at = Date.now(); const info = db.prepare(` - INSERT INTO customers (name, slug, token, password_hash, expires_at, created_at) - VALUES (?, ?, ?, ?, ?, ?) - `).run(String(name).trim(), slug, token, password_hash, exp, created_at); + INSERT INTO customers (name, slug, token, password_hash, expires_at, created_at, email) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(String(name).trim(), slug, token, password_hash, exp, created_at, email ? String(email).trim() : null); customerDir(slug); webdavConfig.sync(); res.json({ @@ -333,14 +381,17 @@ api.patch('/customers/:id', auth.requireAdmin, async (req, res) => { const id = parseInt(req.params.id, 10); const c = db.prepare('SELECT * FROM customers WHERE id = ?').get(id); if (!c) return res.status(404).json({ error: 'not found' }); - const { password, expires_at, clear_password } = req.body || {}; + const { password, expires_at, clear_password, email } = req.body || {}; + if (email !== undefined && !validEmail(email)) return res.status(400).json({ error: 'invalid email' }); let pwHash = c.password_hash; if (clear_password) pwHash = null; else if (password) pwHash = await bcrypt.hash(password, 10); let exp = c.expires_at; if (expires_at === null) exp = null; else if (expires_at) exp = parseInt(expires_at, 10); - db.prepare('UPDATE customers SET password_hash = ?, expires_at = ? WHERE id = ?').run(pwHash, exp, id); + let mail = c.email; + if (email !== undefined) mail = email ? String(email).trim() : null; + db.prepare('UPDATE customers SET password_hash = ?, expires_at = ?, email = ? WHERE id = ?').run(pwHash, exp, mail, id); res.json({ ok: true }); }); @@ -352,12 +403,35 @@ api.post('/customers/:id/regenerate-token', auth.requireAdmin, (req, res) => { res.json({ token, upload_url: `${settings.getPublicBaseUrl(req)}/u/${token}` }); }); +// Archive = disable link, keep entry + files. Reversible. +api.post('/customers/:id/archive', auth.requireAdmin, (req, res) => { + const id = parseInt(req.params.id, 10); + const c = db.prepare('SELECT * FROM customers WHERE id = ?').get(id); + if (!c) return res.status(404).json({ error: 'not found' }); + db.prepare('UPDATE customers SET archived_at = ? WHERE id = ?').run(Date.now(), id); + res.json({ ok: true }); +}); + +api.post('/customers/:id/unarchive', auth.requireAdmin, (req, res) => { + const id = parseInt(req.params.id, 10); + const c = db.prepare('SELECT * FROM customers WHERE id = ?').get(id); + if (!c) return res.status(404).json({ error: 'not found' }); + db.prepare('UPDATE customers SET archived_at = NULL WHERE id = ?').run(id); + res.json({ ok: true }); +}); + +// Purge = remove files + DB row. Irreversible. api.delete('/customers/:id', auth.requireAdmin, (req, res) => { const c = db.prepare('SELECT * FROM customers WHERE id = ?').get(req.params.id); if (!c) return res.status(404).json({ error: 'not found' }); + try { + const dir = path.join(UPLOAD_ROOT, c.slug); + if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); + } catch (e) { + console.error('[purge] failed to remove files for', c.slug, e.message); + } db.prepare('DELETE FROM customers WHERE id = ?').run(c.id); webdavConfig.sync(); - // Files are kept on disk; admin can remove via WebDAV. res.json({ ok: true }); }); @@ -400,14 +474,14 @@ app.use('/admin/api', api); // ---------- Customer Upload Portal ---------- app.get('/u/:token', (req, res) => { const c = getCustomerByToken(req.params.token); - if (!c) return res.status(404).send('Link nicht gefunden.'); + if (!c || isArchived(c)) return res.status(404).send('Link nicht gefunden.'); if (isExpired(c)) return res.status(410).send('Link ist abgelaufen.'); res.sendFile(path.join(__dirname, '..', 'public', 'upload.html')); }); app.post('/u/:token/auth', async (req, res) => { const c = getCustomerByToken(req.params.token); - if (!c || isExpired(c)) return res.status(404).json({ error: 'invalid' }); + if (!c || isExpired(c) || isArchived(c)) return res.status(404).json({ error: 'invalid' }); if (!c.password_hash) return res.json({ ok: true }); const ok = await bcrypt.compare(req.body.password || '', c.password_hash); res.json({ ok }); @@ -415,7 +489,7 @@ app.post('/u/:token/auth', async (req, res) => { app.get('/u/:token/info', (req, res) => { const c = getCustomerByToken(req.params.token); - if (!c || isExpired(c)) return res.status(404).json({ error: 'invalid' }); + if (!c || isExpired(c) || isArchived(c)) return res.status(404).json({ error: 'invalid' }); res.json({ name: c.name, has_password: !!c.password_hash, @@ -448,7 +522,7 @@ const upload = multer({ function uploadAuth(req, res, next) { const c = getCustomerByToken(req.params.token); - if (!c || isExpired(c)) return res.status(404).json({ error: 'invalid' }); + if (!c || isExpired(c) || isArchived(c)) return res.status(404).json({ error: 'invalid' }); if (c.password_hash) { const provided = req.headers['x-upload-password'] || ''; bcrypt.compare(provided, c.password_hash).then(ok => { @@ -472,6 +546,7 @@ app.post('/u/:token/upload', uploadAuth, upload.single('file'), (req, res) => { INSERT INTO uploads (customer_id, filename, relative_path, size, uploaded_at) VALUES (?, ?, ?, ?, ?) `).run(c.id, f.filename, rel, f.size, Date.now()); + uploadNotifier.queue(c.id, { name: f.filename, path: rel, size: f.size }); res.json({ ok: true, file: { name: f.filename, path: rel, size: f.size } }); }); diff --git a/src/upload-notifier.js b/src/upload-notifier.js new file mode 100644 index 0000000..3a59560 --- /dev/null +++ b/src/upload-notifier.js @@ -0,0 +1,100 @@ +const db = require('./db'); +const mailer = require('./mailer'); +const settings = require('./settings'); + +const DEBOUNCE_MS = 30_000; // collect uploads then send one summary email +const pending = new Map(); // customer_id → { timer, items: [{name, size}] } + +function fmtSize(n) { + if (!n) return '0 B'; + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; + if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'; + return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB'; +} + +function recipientsFor(customer) { + const out = []; + if (customer.email) out.push({ kind: 'customer', to: customer.email }); + + // Staff assigned to this customer (with email) + const staff = db.prepare(` + SELECT u.email FROM customer_access ca + JOIN users u ON u.id = ca.user_id + WHERE ca.customer_id = ? AND u.email IS NOT NULL AND u.email != '' + `).all(customer.id); + for (const s of staff) out.push({ kind: 'staff', to: s.email }); + + // All admins with email, if global setting enabled + if (settings.get('smtp_notify_admin', 'false') === 'true') { + const admins = db.prepare(` + SELECT email FROM users WHERE role = 'admin' AND email IS NOT NULL AND email != '' + `).all(); + for (const a of admins) out.push({ kind: 'admin', to: a.email }); + } + + // Dedupe by email + const seen = new Set(); + return out.filter(r => (seen.has(r.to) ? false : (seen.add(r.to), true))); +} + +function buildBodies(customer, items, kind) { + const total = items.reduce((a, b) => a + b.size, 0); + const list = items.map(i => `- ${i.name} (${fmtSize(i.size)})`).join('\n'); + const listHtml = items.map(i => + `${escapeHtml(i.name)}` + + `${fmtSize(i.size)}` + ).join(''); + const subject = kind === 'customer' + ? `Upload-Bestätigung – ${items.length} Datei(en)` + : `Neuer Upload von ${customer.name} – ${items.length} Datei(en)`; + const intro = kind === 'customer' + ? `Vielen Dank! Ihr Upload war erfolgreich. Es wurden ${items.length} Datei(en) (${fmtSize(total)}) übertragen.` + : `Für Kunde "${customer.name}" wurden soeben ${items.length} Datei(en) (${fmtSize(total)}) hochgeladen.`; + const text = `${intro}\n\n${list}\n`; + const html = ` +
+

${kind === 'customer' ? 'Upload-Bestätigung' : 'Neuer Upload'}

+

${escapeHtml(intro)}

+ ${listHtml}
+
`; + return { subject, text, html }; +} + +function escapeHtml(s) { + return String(s || '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); +} + +async function flush(customerId) { + const entry = pending.get(customerId); + if (!entry) return; + pending.delete(customerId); + if (!entry.items.length) return; + if (!mailer.isConfigured()) return; + + const customer = db.prepare('SELECT * FROM customers WHERE id = ?').get(customerId); + if (!customer) return; + + const recipients = recipientsFor(customer); + for (const r of recipients) { + try { + const { subject, text, html } = buildBodies(customer, entry.items, r.kind); + await mailer.send({ to: r.to, subject, text, html }); + } catch (e) { + console.error(`[notifier] send to ${r.to} failed:`, e.message); + } + } +} + +function queue(customerId, file) { + let entry = pending.get(customerId); + if (!entry) { + entry = { timer: null, items: [] }; + pending.set(customerId, entry); + } + entry.items.push({ name: file.path || file.name, size: file.size }); + if (entry.timer) clearTimeout(entry.timer); + entry.timer = setTimeout(() => flush(customerId), DEBOUNCE_MS); +} + +module.exports = { queue, flush };