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:
parent
4567e93aa2
commit
3f86fca578
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<h3 class="card-title">Neuen Kunden anlegen</h3>
|
||||
<form id="createCustomerForm" class="form-row">
|
||||
<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>Ablaufdatum (optional)</label><input name="expires_at" placeholder="YYYY-MM-DD HH:MM" /></div>
|
||||
<div><label> </label><button class="btn primary" type="submit">Anlegen</button></div>
|
||||
|
|
@ -278,7 +282,7 @@
|
|||
<table>
|
||||
<thead>
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -296,6 +300,7 @@
|
|||
<form id="createUserForm" class="form-row">
|
||||
<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>E-Mail (optional)</label><input name="email" type="email" placeholder="name@firma.de" /></div>
|
||||
<div><label>Rolle</label>
|
||||
<select name="role">
|
||||
<option value="staff">Sachbearbeiter</option>
|
||||
|
|
@ -309,7 +314,7 @@
|
|||
<div class="table-wrap">
|
||||
<table>
|
||||
<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>
|
||||
<tbody id="userRows"></tbody>
|
||||
</table>
|
||||
|
|
@ -355,6 +360,37 @@
|
|||
</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">
|
||||
<h3 class="card-title">Allgemein</h3>
|
||||
<form id="settingsForm">
|
||||
|
|
@ -513,27 +549,41 @@ async function loadCustomers() {
|
|||
const adminOnly = me.role === 'admin';
|
||||
rows.innerHTML = '';
|
||||
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;
|
||||
}
|
||||
for (const c of data) {
|
||||
const link = c.upload_url || '';
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td><strong>${esc(c.name)}</strong></td>
|
||||
<td><code>${esc(c.slug)}</code></td>
|
||||
<td>${adminOnly
|
||||
if (c.archived) tr.classList.add('archived');
|
||||
const nameCell = `<strong>${esc(c.name)}</strong>` + (c.archived ? ' <span class="pill archived">archiviert</span>' : '');
|
||||
const linkCell = c.archived
|
||||
? '<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>`
|
||||
: '<span class="small">–</span>'}</td>
|
||||
: '<span class="small">–</span>');
|
||||
const actions = !adminOnly
|
||||
? `<span class="pill ${c.my_access}">${c.my_access}</span>`
|
||||
: (c.archived
|
||||
? `
|
||||
<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-edit="${c.id}">Bearbeiten</button>
|
||||
<button class="btn sm" data-regen="${c.id}">Neuer Link</button>
|
||||
<button class="btn sm" data-archive="${c.id}">Deaktivieren</button>
|
||||
<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">${adminOnly ? `
|
||||
<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-regen="${c.id}">Neuer Link</button>
|
||||
<button class="btn sm danger" data-del="${c.id}">Löschen</button>
|
||||
` : `<span class="pill ${c.my_access}">${c.my_access}</span>`}</td>`;
|
||||
<td class="cell-actions">${actions}</td>`;
|
||||
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 = `
|
||||
<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 class="small">${new Date(u.created_at).toLocaleDateString()}</td>
|
||||
<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-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>
|
||||
|
|
@ -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}`; }
|
||||
|
|
|
|||
11
src/db.js
11
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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
129
src/server.js
129
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: '<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) ---
|
||||
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 } });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => ({'&':'&','<':'<','>':'>','"':'"'}[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 };
|
||||
Loading…
Reference in New Issue