Add email notifications, SMTP settings and customer archive lifecycle

- Optional email field on users and customers
- SMTP config in admin settings with test-mail button and an opt-in
  "notify admins on upload" toggle
- Debounced upload notifier sends one summary email per customer session
  to the customer, assigned staff and (optionally) admins
- Two-step customer lifecycle: "Deaktivieren" archives the link and
  keeps data, "Dateien löschen" purges files and the DB entry after a
  name-typed confirmation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-16 12:00:06 +02:00
parent 4567e93aa2
commit 3f86fca578
6 changed files with 389 additions and 44 deletions

View File

@ -12,6 +12,7 @@
"express": "^4.21.0", "express": "^4.21.0",
"express-basic-auth": "^1.2.1", "express-basic-auth": "^1.2.1",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"nanoid": "^3.3.7" "nanoid": "^3.3.7",
"nodemailer": "^6.9.15"
} }
} }

View File

@ -157,6 +157,9 @@
.pill.lock { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 50%, transparent); .pill.lock { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 50%, transparent);
background: color-mix(in srgb, var(--warn) 10%, transparent); } background: color-mix(in srgb, var(--warn) 10%, transparent); }
.pill.count { background: var(--bg-raise); color: var(--text-muted); } .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); } .small { font-size: .8rem; color: var(--text-muted); }
.muted { color: var(--text-muted); } .muted { color: var(--text-muted); }
@ -267,6 +270,7 @@
<h3 class="card-title">Neuen Kunden anlegen</h3> <h3 class="card-title">Neuen Kunden anlegen</h3>
<form id="createCustomerForm" class="form-row"> <form id="createCustomerForm" class="form-row">
<div><label>Name</label><input name="name" required placeholder="Firma ABC" /></div> <div><label>Name</label><input name="name" required placeholder="Firma ABC" /></div>
<div><label>E-Mail (optional)</label><input name="email" type="email" placeholder="kunde@firma.de" /></div>
<div><label>Passwort (optional)</label><input name="password" placeholder="Leer = offen" /></div> <div><label>Passwort (optional)</label><input name="password" placeholder="Leer = offen" /></div>
<div><label>Ablaufdatum (optional)</label><input name="expires_at" placeholder="YYYY-MM-DD HH:MM" /></div> <div><label>Ablaufdatum (optional)</label><input name="expires_at" placeholder="YYYY-MM-DD HH:MM" /></div>
<div><label>&nbsp;</label><button class="btn primary" type="submit">Anlegen</button></div> <div><label>&nbsp;</label><button class="btn primary" type="submit">Anlegen</button></div>
@ -278,7 +282,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Kunde</th><th>Slug</th><th>Upload-Link</th><th>Schutz</th> <th>Kunde</th><th>E-Mail</th><th>Slug</th><th>Upload-Link</th><th>Schutz</th>
<th>Ablauf</th><th>Uploads</th><th></th> <th>Ablauf</th><th>Uploads</th><th></th>
</tr> </tr>
</thead> </thead>
@ -296,6 +300,7 @@
<form id="createUserForm" class="form-row"> <form id="createUserForm" class="form-row">
<div><label>Benutzername</label><input name="username" required pattern="[A-Za-z0-9._-]+" /></div> <div><label>Benutzername</label><input name="username" required pattern="[A-Za-z0-9._-]+" /></div>
<div><label>Passwort</label><input name="password" required minlength="6" type="text" /></div> <div><label>Passwort</label><input name="password" required minlength="6" type="text" /></div>
<div><label>E-Mail (optional)</label><input name="email" type="email" placeholder="name@firma.de" /></div>
<div><label>Rolle</label> <div><label>Rolle</label>
<select name="role"> <select name="role">
<option value="staff">Sachbearbeiter</option> <option value="staff">Sachbearbeiter</option>
@ -309,7 +314,7 @@
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
<tr><th>Benutzername</th><th>Rolle</th><th>Angelegt</th><th></th></tr> <tr><th>Benutzername</th><th>E-Mail</th><th>Rolle</th><th>Angelegt</th><th></th></tr>
</thead> </thead>
<tbody id="userRows"></tbody> <tbody id="userRows"></tbody>
</table> </table>
@ -355,6 +360,37 @@
</div> </div>
</div> </div>
<div class="card">
<h3 class="card-title">E-Mail-Benachrichtigungen (SMTP)</h3>
<p class="card-sub">Bestimmt den Versand der Upload-Benachrichtigungen. Kunden und zuständige Sachbearbeiter mit E-Mail-Adresse bekommen nach einem Upload eine Zusammenfassung.</p>
<form id="smtpForm">
<div class="form-row">
<div><label>SMTP-Host</label><input name="smtp_host" placeholder="smtp.mailserver.de" /></div>
<div><label>Port</label><input name="smtp_port" type="number" min="1" style="max-width:7rem" /></div>
<div><label style="display:flex; align-items:center; gap:.4rem; margin-top:1.5rem">
<input type="checkbox" name="smtp_secure" style="width:auto" />TLS (Port 465)
</label></div>
</div>
<div class="form-row">
<div><label>Benutzername</label><input name="smtp_user" /></div>
<div><label>Passwort</label><input name="smtp_pass" type="password" placeholder="unverändert lassen = nicht überschreiben" autocomplete="new-password" /></div>
<div><label>Absender (From)</label><input name="smtp_from" placeholder="noreply@firma.de" /></div>
</div>
<div class="field" style="margin-top:.75rem">
<label style="display:flex; align-items:center; gap:.4rem">
<input type="checkbox" name="smtp_notify_admin" style="width:auto" />
Admins bei jedem Upload benachrichtigen (alle Admins mit E-Mail-Adresse)
</label>
</div>
<div style="margin-top:1rem; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap">
<button class="btn primary" type="submit">Speichern</button>
<input type="email" id="testMailTo" placeholder="Test-Empfänger" style="max-width:240px" />
<button class="btn" type="button" id="sendTestMail">Test-Mail senden</button>
<span class="ok-msg" id="smtpMsg"></span>
</div>
</form>
</div>
<div class="card"> <div class="card">
<h3 class="card-title">Allgemein</h3> <h3 class="card-title">Allgemein</h3>
<form id="settingsForm"> <form id="settingsForm">
@ -513,27 +549,41 @@ async function loadCustomers() {
const adminOnly = me.role === 'admin'; const adminOnly = me.role === 'admin';
rows.innerHTML = ''; rows.innerHTML = '';
if (!data.length) { if (!data.length) {
rows.innerHTML = `<tr><td colspan="7" class="small" style="padding:1.5rem; text-align:center">Noch keine Kunden.</td></tr>`; rows.innerHTML = `<tr><td colspan="8" class="small" style="padding:1.5rem; text-align:center">Noch keine Kunden.</td></tr>`;
return; return;
} }
for (const c of data) { for (const c of data) {
const link = c.upload_url || ''; const link = c.upload_url || '';
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.innerHTML = ` if (c.archived) tr.classList.add('archived');
<td><strong>${esc(c.name)}</strong></td> const nameCell = `<strong>${esc(c.name)}</strong>` + (c.archived ? ' <span class="pill archived">archiviert</span>' : '');
<td><code>${esc(c.slug)}</code></td> const linkCell = c.archived
<td>${adminOnly ? '<span class="small">— archiviert —</span>'
: (adminOnly
? `<div style="display:flex; gap:.35rem; align-items:center"><code style="max-width:26ch; overflow:hidden; text-overflow:ellipsis">${esc(link)}</code><button class="btn sm copy-btn" data-copy="${esc(link)}">📋</button></div>` ? `<div style="display:flex; gap:.35rem; align-items:center"><code style="max-width:26ch; overflow:hidden; text-overflow:ellipsis">${esc(link)}</code><button class="btn sm copy-btn" data-copy="${esc(link)}">📋</button></div>`
: '<span class="small"></span>'}</td> : '<span class="small"></span>');
<td>${c.has_password ? '<span class="pill lock">🔒 PW</span>' : '<span class="small"></span>'}</td> const actions = !adminOnly
<td class="small">${c.expires_at ? new Date(c.expires_at).toLocaleString() : ''}</td> ? `<span class="pill ${c.my_access}">${c.my_access}</span>`
<td><span class="pill count">${c.upload_count} · ${fmtSize(c.total_size)}</span></td> : (c.archived
<td class="cell-actions">${adminOnly ? ` ? `
<button class="btn sm" data-access="${c.id}" data-name="${esc(c.name)}">Zugriff</button>
<button class="btn sm" data-unarchive="${c.id}">Reaktivieren</button>
<button class="btn sm danger" data-purge="${c.id}" data-name="${esc(c.name)}">Dateien löschen</button>`
: `
<button class="btn sm" data-access="${c.id}" data-name="${esc(c.name)}">Zugriff</button> <button class="btn sm" data-access="${c.id}" data-name="${esc(c.name)}">Zugriff</button>
<button class="btn sm" data-edit="${c.id}">Bearbeiten</button> <button class="btn sm" data-edit="${c.id}">Bearbeiten</button>
<button class="btn sm" data-regen="${c.id}">Neuer Link</button> <button class="btn sm" data-regen="${c.id}">Neuer Link</button>
<button class="btn sm danger" data-del="${c.id}">Löschen</button> <button class="btn sm" data-archive="${c.id}">Deaktivieren</button>
` : `<span class="pill ${c.my_access}">${c.my_access}</span>`}</td>`; <button class="btn sm danger" data-purge="${c.id}" data-name="${esc(c.name)}">Dateien löschen</button>`);
tr.innerHTML = `
<td>${nameCell}</td>
<td class="small">${c.email ? esc(c.email) : ''}</td>
<td><code>${esc(c.slug)}</code></td>
<td>${linkCell}</td>
<td>${c.has_password ? '<span class="pill lock">🔒 PW</span>' : '<span class="small"></span>'}</td>
<td class="small">${c.expires_at ? new Date(c.expires_at).toLocaleString() : ''}</td>
<td><span class="pill count">${c.upload_count} · ${fmtSize(c.total_size)}</span></td>
<td class="cell-actions">${actions}</td>`;
rows.appendChild(tr); rows.appendChild(tr);
} }
} }
@ -543,16 +593,28 @@ document.getElementById('customerRows').addEventListener('click', async (e) => {
navigator.clipboard.writeText(t.dataset.copy); navigator.clipboard.writeText(t.dataset.copy);
const orig = t.textContent; t.textContent = '✓'; setTimeout(() => t.textContent = orig, 1200); const orig = t.textContent; t.textContent = '✓'; setTimeout(() => t.textContent = orig, 1200);
} }
if (t.dataset.del && confirm('Kunde wirklich löschen? (Dateien bleiben auf Disk.)')) { if (t.dataset.archive && confirm('Kunden deaktivieren? Der Upload-Link wird ungültig, Einträge & Dateien bleiben bestehen.')) {
await api.send('DELETE', `/customers/${t.dataset.del}`); loadCustomers(); 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.')) { if (t.dataset.regen && confirm('Neuen Link erzeugen? Alter Link wird ungültig.')) {
await api.send('POST', `/customers/${t.dataset.regen}/regenerate-token`); loadCustomers(); await api.send('POST', `/customers/${t.dataset.regen}/regenerate-token`); loadCustomers();
} }
if (t.dataset.edit) { 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 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 exp = prompt('Neuer Ablauf (YYYY-MM-DD HH:MM, leer = unverändert, "-" = entfernen):', ''); if (exp === null) return;
const body = {}; 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 (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); if (exp === '-') body.expires_at = null; else if (exp) body.expires_at = parseDate(exp);
await api.send('PATCH', `/customers/${t.dataset.edit}`, body); loadCustomers(); await api.send('PATCH', `/customers/${t.dataset.edit}`, body); loadCustomers();
@ -565,6 +627,7 @@ document.getElementById('createCustomerForm').addEventListener('submit', async (
try { try {
await api.send('POST', '/customers', { await api.send('POST', '/customers', {
name: fd.get('name'), name: fd.get('name'),
email: fd.get('email') || undefined,
password: fd.get('password') || undefined, password: fd.get('password') || undefined,
expires_at: parseDate(fd.get('expires_at')) || undefined, expires_at: parseDate(fd.get('expires_at')) || undefined,
}); });
@ -581,9 +644,11 @@ async function loadUsers() {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.innerHTML = ` tr.innerHTML = `
<td><strong>${esc(u.username)}</strong>${u.id === me.id ? ' <span class="small">(du)</span>' : ''}</td> <td><strong>${esc(u.username)}</strong>${u.id === me.id ? ' <span class="small">(du)</span>' : ''}</td>
<td class="small">${u.email ? esc(u.email) : ''}</td>
<td><span class="pill ${u.role === 'admin' ? 'admin' : 'staff'}">${u.role}</span></td> <td><span class="pill ${u.role === 'admin' ? 'admin' : 'staff'}">${u.role}</span></td>
<td class="small">${new Date(u.created_at).toLocaleDateString()}</td> <td class="small">${new Date(u.created_at).toLocaleDateString()}</td>
<td class="cell-actions"> <td class="cell-actions">
<button class="btn sm" data-email="${u.id}">E-Mail</button>
<button class="btn sm" data-pw="${u.id}">Passwort</button> <button class="btn sm" data-pw="${u.id}">Passwort</button>
<button class="btn sm" data-role="${u.id}" data-current="${u.role}">Rolle</button> <button class="btn sm" data-role="${u.id}" data-current="${u.role}">Rolle</button>
<button class="btn sm danger" data-deluser="${u.id}" data-name="${esc(u.username)}">Löschen</button> <button class="btn sm danger" data-deluser="${u.id}" data-name="${esc(u.username)}">Löschen</button>
@ -594,6 +659,11 @@ async function loadUsers() {
document.getElementById('userRows').addEventListener('click', async (e) => { document.getElementById('userRows').addEventListener('click', async (e) => {
const t = e.target.closest('button'); if (!t) return; const t = e.target.closest('button'); if (!t) return;
try { 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) { if (t.dataset.pw) {
const pw = prompt('Neues Passwort (min. 6 Zeichen):', ''); if (!pw) return; const pw = prompt('Neues Passwort (min. 6 Zeichen):', ''); if (!pw) return;
await api.send('PATCH', `/users/${t.dataset.pw}`, { password: pw }); loadUsers(); 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); setSlider('logoHeight', s.logo_height_px || 0);
updateScaleFromWH(); updateScaleFromWH();
updateLogoPreview(s.logo_filename); 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) { function setSlider(id, val) {
document.getElementById(id).value = val; document.getElementById(id).value = val;
@ -669,6 +751,37 @@ document.getElementById('settingsForm').addEventListener('submit', async (e) =>
applyLogo(getSlider('logoWidth'), getSlider('logoHeight')); applyLogo(getSlider('logoWidth'), getSlider('logoHeight'));
} catch (ex) { alert('Fehler: ' + ex.message); } } 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 () => { document.getElementById('runJanitor').addEventListener('click', async () => {
const msg = document.getElementById('settingsMsg'); msg.textContent = '… läuft'; const msg = document.getElementById('settingsMsg'); msg.textContent = '… läuft';
try { const r = await api.send('POST', '/janitor/run'); msg.textContent = `✓ +${r.added} / -${r.removed}`; } try { const r = await api.send('POST', '/janitor/run'); msg.textContent = `✓ +${r.added} / -${r.removed}`; }

View File

@ -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; module.exports = db;

45
src/mailer.js Normal file
View File

@ -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 };

View File

@ -9,6 +9,13 @@ const auth = require('./auth');
const webdavConfig = require('./webdav-config'); const webdavConfig = require('./webdav-config');
const settings = require('./settings'); const settings = require('./settings');
const janitor = require('./janitor'); 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 PORT = parseInt(process.env.PORT || '3000', 10);
const UPLOAD_ROOT = process.env.UPLOAD_ROOT || '/data/uploads'; 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(); return customer.expires_at && customer.expires_at < Date.now();
} }
function isArchived(customer) { return !!customer.archived_at; }
function canAccessCustomer(user, customer, needWrite = false) { function canAccessCustomer(user, customer, needWrite = false) {
if (user.role === 'admin') return true; if (user.role === 'admin') return true;
const row = db.prepare( const row = db.prepare(
@ -144,14 +153,16 @@ const api = express.Router();
// --- Users (admin only) --- // --- Users (admin only) ---
api.get('/users', auth.requireAdmin, (req, res) => { 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); res.json(rows);
}); });
api.post('/users', auth.requireAdmin, async (req, res) => { 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 { try {
const id = await auth.createUser(username, password, role || 'staff'); 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(); webdavConfig.sync();
res.json({ id }); res.json({ id });
} catch (e) { } catch (e) {
@ -164,8 +175,9 @@ api.patch('/users/:id', auth.requireAdmin, async (req, res) => {
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
const u = db.prepare('SELECT * FROM users WHERE id = ?').get(id); const u = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
if (!u) return res.status(404).json({ error: 'not found' }); 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 (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 // Don't let admin demote themselves to the last non-admin
if (role && role !== 'admin' && u.id === req.user.id) { 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; 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 { try {
if (password) await auth.setUserPassword(id, password); if (password) await auth.setUserPassword(id, password);
if (role) db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id); 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(); webdavConfig.sync();
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
@ -202,31 +215,62 @@ api.get('/settings', auth.requireAdmin, (req, res) => {
logo_filename: settings.get('logo_filename', ''), logo_filename: settings.get('logo_filename', ''),
logo_width_px: parseInt(settings.get('logo_width_px', '0'), 10), logo_width_px: parseInt(settings.get('logo_width_px', '0'), 10),
logo_height_px: parseInt(settings.get('logo_height_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) => { api.put('/settings', auth.requireAdmin, (req, res) => {
const { public_base_url, janitor_interval_minutes, logo_width_px, logo_height_px } = req.body || {}; const b = req.body || {};
if (public_base_url !== undefined) { if (b.public_base_url !== undefined) {
const v = String(public_base_url || '').trim().replace(/\/+$/, ''); settings.set('public_base_url', String(b.public_base_url || '').trim().replace(/\/+$/, ''));
settings.set('public_base_url', v);
} }
if (janitor_interval_minutes !== undefined) { if (b.janitor_interval_minutes !== undefined) {
const n = Math.max(1, parseInt(janitor_interval_minutes, 10) || 30); const n = Math.max(1, parseInt(b.janitor_interval_minutes, 10) || 30);
settings.set('janitor_interval_minutes', String(n)); settings.set('janitor_interval_minutes', String(n));
janitor.restart(n * 60 * 1000); janitor.restart(n * 60 * 1000);
} }
if (logo_width_px !== undefined) { if (b.logo_width_px !== undefined) {
const n = Math.max(0, Math.min(800, parseInt(logo_width_px, 10) || 0)); settings.set('logo_width_px', String(Math.max(0, Math.min(800, parseInt(b.logo_width_px, 10) || 0))));
settings.set('logo_width_px', String(n));
} }
if (logo_height_px !== undefined) { if (b.logo_height_px !== undefined) {
const n = Math.max(0, Math.min(600, parseInt(logo_height_px, 10) || 0)); settings.set('logo_height_px', String(Math.max(0, Math.min(600, parseInt(b.logo_height_px, 10) || 0))));
settings.set('logo_height_px', String(n));
} }
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 }); 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: '<p>Wenn du diese Mail liest, funktioniert die SMTP-Konfiguration. ✅</p>',
});
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// --- Logo (admin manages, public serves) --- // --- Logo (admin manages, public serves) ---
const logoUpload = multer({ const logoUpload = multer({
storage: multer.memoryStorage(), storage: multer.memoryStorage(),
@ -295,20 +339,24 @@ api.get('/customers', auth.requireAuth, (req, res) => {
id: r.id, id: r.id,
name: r.name, name: r.name,
slug: r.slug, slug: r.slug,
email: r.email,
token: isAdmin ? r.token : undefined, token: isAdmin ? r.token : undefined,
has_password: !!r.password_hash, has_password: !!r.password_hash,
expires_at: r.expires_at, expires_at: r.expires_at,
archived_at: r.archived_at,
archived: !!r.archived_at,
created_at: r.created_at, created_at: r.created_at,
upload_count: r.upload_count, upload_count: r.upload_count,
total_size: r.total_size, total_size: r.total_size,
my_access: isAdmin ? 'admin' : r.my_access, 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) => { 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 (!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 base = slugify(name);
const slug = ensureUniqueSlug(base); const slug = ensureUniqueSlug(base);
const token = nanoid(24); 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 exp = expires_at ? parseInt(expires_at, 10) : null;
const created_at = Date.now(); const created_at = Date.now();
const info = db.prepare(` const info = db.prepare(`
INSERT INTO customers (name, slug, token, password_hash, expires_at, created_at) INSERT INTO customers (name, slug, token, password_hash, expires_at, created_at, email)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(String(name).trim(), slug, token, password_hash, exp, created_at); `).run(String(name).trim(), slug, token, password_hash, exp, created_at, email ? String(email).trim() : null);
customerDir(slug); customerDir(slug);
webdavConfig.sync(); webdavConfig.sync();
res.json({ res.json({
@ -333,14 +381,17 @@ api.patch('/customers/:id', auth.requireAdmin, async (req, res) => {
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
const c = db.prepare('SELECT * FROM customers WHERE id = ?').get(id); const c = db.prepare('SELECT * FROM customers WHERE id = ?').get(id);
if (!c) return res.status(404).json({ error: 'not found' }); 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; let pwHash = c.password_hash;
if (clear_password) pwHash = null; if (clear_password) pwHash = null;
else if (password) pwHash = await bcrypt.hash(password, 10); else if (password) pwHash = await bcrypt.hash(password, 10);
let exp = c.expires_at; let exp = c.expires_at;
if (expires_at === null) exp = null; if (expires_at === null) exp = null;
else if (expires_at) exp = parseInt(expires_at, 10); 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 }); 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}` }); 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) => { api.delete('/customers/:id', auth.requireAdmin, (req, res) => {
const c = db.prepare('SELECT * FROM customers WHERE id = ?').get(req.params.id); const c = db.prepare('SELECT * FROM customers WHERE id = ?').get(req.params.id);
if (!c) return res.status(404).json({ error: 'not found' }); 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); db.prepare('DELETE FROM customers WHERE id = ?').run(c.id);
webdavConfig.sync(); webdavConfig.sync();
// Files are kept on disk; admin can remove via WebDAV.
res.json({ ok: true }); res.json({ ok: true });
}); });
@ -400,14 +474,14 @@ app.use('/admin/api', api);
// ---------- Customer Upload Portal ---------- // ---------- Customer Upload Portal ----------
app.get('/u/:token', (req, res) => { app.get('/u/:token', (req, res) => {
const c = getCustomerByToken(req.params.token); 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.'); if (isExpired(c)) return res.status(410).send('Link ist abgelaufen.');
res.sendFile(path.join(__dirname, '..', 'public', 'upload.html')); res.sendFile(path.join(__dirname, '..', 'public', 'upload.html'));
}); });
app.post('/u/:token/auth', async (req, res) => { app.post('/u/:token/auth', async (req, res) => {
const c = getCustomerByToken(req.params.token); 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 }); if (!c.password_hash) return res.json({ ok: true });
const ok = await bcrypt.compare(req.body.password || '', c.password_hash); const ok = await bcrypt.compare(req.body.password || '', c.password_hash);
res.json({ ok }); res.json({ ok });
@ -415,7 +489,7 @@ app.post('/u/:token/auth', async (req, res) => {
app.get('/u/:token/info', (req, res) => { app.get('/u/:token/info', (req, res) => {
const c = getCustomerByToken(req.params.token); 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({ res.json({
name: c.name, name: c.name,
has_password: !!c.password_hash, has_password: !!c.password_hash,
@ -448,7 +522,7 @@ const upload = multer({
function uploadAuth(req, res, next) { function uploadAuth(req, res, next) {
const c = getCustomerByToken(req.params.token); 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) { if (c.password_hash) {
const provided = req.headers['x-upload-password'] || ''; const provided = req.headers['x-upload-password'] || '';
bcrypt.compare(provided, c.password_hash).then(ok => { 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) INSERT INTO uploads (customer_id, filename, relative_path, size, uploaded_at)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`).run(c.id, f.filename, rel, f.size, Date.now()); `).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 } }); res.json({ ok: true, file: { name: f.filename, path: rel, size: f.size } });
}); });

100
src/upload-notifier.js Normal file
View File

@ -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 =>
`<tr><td style="padding:2px 10px 2px 0">${escapeHtml(i.name)}</td>` +
`<td style="padding:2px 0;color:#666;text-align:right">${fmtSize(i.size)}</td></tr>`
).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 = `
<div style="font-family:system-ui,sans-serif;max-width:560px;color:#111">
<h2 style="margin:0 0 .5rem">${kind === 'customer' ? 'Upload-Bestätigung' : 'Neuer Upload'}</h2>
<p>${escapeHtml(intro)}</p>
<table style="border-collapse:collapse;font-size:.9rem">${listHtml}</table>
</div>`;
return { subject, text, html };
}
function escapeHtml(s) {
return String(s || '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 };