const express = require('express'); const fs = require('fs'); const bcrypt = require('bcryptjs'); const multer = require('multer'); const db = require('../db'); const { logPush } = require('../pushLog'); const { authenticate, requireAdmin } = require('../middleware/auth'); const router = express.Router(); const DB_PATH = process.env.DB_PATH || '/data/dickendock.db'; const upload = multer({ dest: '/tmp/' }); // GET /api/admin/backup router.get('/backup', authenticate, requireAdmin, (req, res) => { if (!fs.existsSync(DB_PATH)) return res.status(404).json({ error: 'DB nicht gefunden' }); const date = new Date().toISOString().split('T')[0]; res.download(DB_PATH, `dickendock-backup-${date}.db`); }); // POST /api/admin/restore router.post('/restore', authenticate, requireAdmin, upload.single('database'), (req, res) => { if (!req.file) return res.status(400).json({ error: 'Keine Datei erhalten' }); try { if (fs.existsSync(DB_PATH)) fs.copyFileSync(DB_PATH, DB_PATH + '.bak'); fs.copyFileSync(req.file.path, DB_PATH); fs.unlinkSync(req.file.path); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // GET /api/admin/users – alle Benutzer mit Statistiken router.get('/users', authenticate, requireAdmin, (req, res) => { const users = db.prepare(` SELECT u.id, u.username, u.role, u.created_at, u.last_active_at, u.hidden, u.hidden_tools, CASE WHEN p.user_id IS NOT NULL THEN 1 ELSE 0 END as has_pushover FROM users u LEFT JOIN pushover_settings p ON p.user_id = u.id ORDER BY u.role DESC, u.username `).all(); const stats = users.map(u => { const archiv = db.prepare('SELECT COUNT(*) c FROM calculations WHERE user_id=?').get(u.id).c; const bestellung = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=?').get(u.id).c; const snippets_c = db.prepare('SELECT COUNT(*) c FROM snippets WHERE user_id=?').get(u.id).c; const todos = db.prepare('SELECT COUNT(*) c FROM todos WHERE user_id=?').get(u.id).c; const links = db.prepare('SELECT COUNT(*) c FROM quick_links WHERE user_id=?').get(u.id).c; const files = db.prepare('SELECT COUNT(*) c FROM files WHERE user_id=?').get(u.id).c; const noteLen = (db.prepare('SELECT content FROM notes WHERE user_id=?').get(u.id)?.content || '').length; const icals = db.prepare('SELECT COUNT(*) c FROM calendar_feeds WHERE user_id=?').get(u.id).c; let hidden_tools = []; try { hidden_tools = JSON.parse(u.hidden_tools || '[]'); } catch {} return { ...u, hidden_tools, archiv, bestellung, snippets_c, todos, links, files, icals, noteLen }; }); res.json(stats); }); // POST /api/admin/users – neuen Benutzer anlegen router.post('/users', authenticate, requireAdmin, (req, res) => { const { username, password, role = 'user' } = req.body; if (!username?.trim() || username.trim().length < 3) return res.status(400).json({ error: 'Benutzername mind. 3 Zeichen' }); if (!password || password.length < 8) return res.status(400).json({ error: 'Passwort mind. 8 Zeichen' }); if (!['user','admin'].includes(role)) return res.status(400).json({ error: 'Ungültige Rolle' }); const exists = db.prepare('SELECT id FROM users WHERE username=?').get(username.trim()); if (exists) return res.status(400).json({ error: 'Benutzername bereits vergeben' }); const r = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?,?,?)') .run(username.trim(), bcrypt.hashSync(password, 12), role); res.json({ id: r.lastInsertRowid, username: username.trim(), role }); }); // DELETE /api/admin/users/:id – Benutzer löschen router.delete('/users/:id', authenticate, requireAdmin, (req, res) => { const id = parseInt(req.params.id); if (id === req.user.id) return res.status(400).json({ error: 'Du kannst dich nicht selbst löschen' }); const user = db.prepare('SELECT * FROM users WHERE id=?').get(id); if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); // Sicherstellen dass mindestens ein Admin bleibt if (user.role === 'admin') { const adminCount = db.prepare("SELECT COUNT(*) c FROM users WHERE role='admin'").get().c; if (adminCount <= 1) return res.status(400).json({ error: 'Mindestens ein Administrator muss verbleiben' }); } // Cascade: alle Daten des Users löschen db.prepare('DELETE FROM calculations WHERE user_id=?').run(id); db.prepare('DELETE FROM orders WHERE user_id=?').run(id); db.prepare('DELETE FROM todos WHERE user_id=?').run(id); db.prepare('DELETE FROM quick_links WHERE user_id=?').run(id); db.prepare('DELETE FROM notes WHERE user_id=?').run(id); db.prepare('DELETE FROM users WHERE id=?').run(id); res.json({ success: true }); }); // PUT /api/admin/users/:id/reset-password – Passwort zurücksetzen router.put('/users/:id/reset-password', authenticate, requireAdmin, (req, res) => { const { newPassword } = req.body; if (!newPassword || newPassword.length < 8) return res.status(400).json({ error: 'Mind. 8 Zeichen' }); const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id); if (!user) return res.status(404).json({ error: 'Nicht gefunden' }); db.prepare('UPDATE users SET password_hash=? WHERE id=?').run(bcrypt.hashSync(newPassword, 12), req.params.id); res.json({ success: true }); }); // ── Login-Sicherheit Admin-Endpoints ───────────────────────────────────────── const getSetting = k => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k)?.value; // GET gesperrte Konten mit IPs router.get('/lockouts', authenticate, requireAdmin, (req, res) => { const locked = db.prepare(` SELECT id, username, failed_attempts, locked_until, last_failed_at FROM users WHERE locked_until IS NOT NULL ORDER BY locked_until DESC `).all(); // IPs aus login_attempts hinzufügen const result = locked.map(u => { const ips = db.prepare(` SELECT DISTINCT ip FROM login_attempts WHERE username=? AND success=0 ORDER BY created_at DESC LIMIT 10 `).all(u.username).map(r => r.ip).filter(Boolean); return { ...u, ips }; }); res.json(result); }); // DELETE Sperre aufheben + Login-Versuche löschen router.delete('/lockouts/:userId', authenticate, requireAdmin, (req, res) => { const user = db.prepare('SELECT username FROM users WHERE id=?').get(req.params.userId); if (user) { db.prepare('DELETE FROM login_attempts WHERE username=?').run(user.username); } db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?') .run(req.params.userId); res.json({ ok: true }); }); // GET Login-Einstellungen router.get('/security-settings', authenticate, requireAdmin, (req, res) => { res.json({ login_max_attempts: getSetting('login_max_attempts') || '5', login_lockout_minutes: getSetting('login_lockout_minutes') || '30', }); }); // PUT Login-Einstellungen router.put('/security-settings', authenticate, requireAdmin, (req, res) => { const { login_max_attempts, login_lockout_minutes } = req.body; for (const [k, v] of [['login_max_attempts', login_max_attempts], ['login_lockout_minutes', login_lockout_minutes]]) { if (v !== undefined) db.prepare('INSERT OR REPLACE INTO admin_settings (key, value) VALUES (?, ?)').run(k, String(parseInt(v) || 0)); } res.json({ ok: true }); }); // GET Login-Verlauf (letzte 50) router.get('/login-log', authenticate, requireAdmin, (req, res) => { res.json(db.prepare(` SELECT * FROM login_attempts ORDER BY created_at DESC LIMIT 50 `).all()); }); // Toggle hidden router.put('/users/:id/hidden', authenticate, requireAdmin, (req, res) => { const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id); if (!user) return res.status(404).json({ error: 'Nicht gefunden' }); const newHidden = user.hidden ? 0 : 1; db.prepare('UPDATE users SET hidden=? WHERE id=?').run(newHidden, user.id); res.json({ hidden: newHidden }); }); // PUT /api/admin/users/:id/hidden-tools – Sidebar-Sichtbarkeit für einen User setzen // body: { hidden_tools: ['schocken', 'paywallkiller', ...] } (tool-ids aus toolRegistry.js) router.put('/users/:id/hidden-tools', authenticate, requireAdmin, (req, res) => { const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id); if (!user) return res.status(404).json({ error: 'Nicht gefunden' }); const { hidden_tools } = req.body; if (!Array.isArray(hidden_tools)) return res.status(400).json({ error: 'hidden_tools muss ein Array sein' }); const cleaned = [...new Set(hidden_tools.filter(t => typeof t === 'string'))]; db.prepare('UPDATE users SET hidden_tools=? WHERE id=?').run(JSON.stringify(cleaned), user.id); res.json({ hidden_tools: cleaned }); }); // Test-Pushover an beliebigen User senden router.post('/users/:id/test-push', authenticate, requireAdmin, async (req, res) => { const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id); if (!user) return res.status(404).json({ error: 'Nicht gefunden' }); const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(user.id); if (!poCfg?.app_token || !poCfg?.user_key) return res.status(400).json({ error: 'Kein Pushover eingerichtet' }); const { message = 'Wat is mit meinen Fische?' } = req.body; const title = '📣 DockStation'; try { const r = await fetch('https://api.pushover.net/1/messages.json', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: poCfg.app_token, user: poCfg.user_key, title, message, priority: 0 }), }); const d = await r.json(); if (d.status === 1) { logPush({ userId: user.id, title, message, priority: 0, source: 'admin-test' }); res.json({ ok: true }); } else { logPush({ userId: user.id, title, message, priority: 0, source: 'admin-test', success: false }); res.status(500).json({ error: d.errors?.join(', ') || 'Pushover-Fehler' }); } } catch(e) { logPush({ userId: user.id, title, message, priority: 0, source: 'admin-test', success: false }); res.status(500).json({ error: e.message }); } }); // ── WebDAV / Synology NAS Einstellungen ─────────────────────────────────── router.get('/webdav', authenticate, requireAdmin, (req, res) => { const get = k => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k)?.value || ''; res.json({ url: get('webdav_url'), user: get('webdav_user'), password: get('webdav_password'), basePath: get('webdav_base_path') || '/dickendock', enabled: get('webdav_enabled') === '1', }); }); router.put('/webdav', authenticate, requireAdmin, (req, res) => { const { url, user, password, basePath, enabled } = req.body; const set = (k, v) => db.prepare('INSERT OR REPLACE INTO admin_settings (key,value) VALUES (?,?)').run(k, v || ''); set('webdav_url', url); set('webdav_user', user); set('webdav_password', password); set('webdav_base_path', basePath || '/dickendock'); set('webdav_enabled', enabled ? '1' : '0'); res.json({ ok: true }); }); router.post('/webdav/test', authenticate, requireAdmin, async (req, res) => { // Temporär die gesendeten Werte zum Testen nutzen (noch nicht gespeichert) const { url, user, password, basePath } = req.body; try { const https = require('https'); const http = require('http'); const base = new URL(url); const testPath = (base.pathname.replace(/\/$/, '') + (basePath || '/dickendock')).replace(/\/+/g, '/'); const mod = base.protocol === 'https:' ? https : http; const auth = 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64'); await new Promise((resolve, reject) => { const req2 = mod.request({ hostname: base.hostname, port: base.port || (base.protocol === 'https:' ? 443 : 80), path: testPath, method: 'PROPFIND', headers: { 'Authorization': auth, 'Depth': '0', 'Content-Type': 'application/xml' }, rejectUnauthorized: false, timeout: 8000, }, r => { r.resume(); r.statusCode < 400 || r.statusCode === 404 ? resolve(r.statusCode) : reject(new Error(`HTTP ${r.statusCode}`)); }); req2.on('error', reject); req2.on('timeout', () => { req2.destroy(); reject(new Error('Timeout')); }); req2.write(''); req2.end(); }); res.json({ ok: true }); } catch(e) { res.status(502).json({ error: e.message }); } }); // ── WebDAV Sync: alle lokalen Dateien auf NAS kopieren ──────────────────── router.post('/webdav/sync', authenticate, requireAdmin, async (req, res) => { const webdav = require('../tools/dateien/webdav'); const nodePath = require('path'); const fs = require('fs'); const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads'; const cfg = webdav.getConfig(); if (!cfg.enabled) return res.status(400).json({ error: 'WebDAV nicht aktiviert' }); const results = { folders: 0, ok: 0, failed: 0, errors: [] }; try { // ── Ordnerpfad rekursiv auflösen ─────────────────────────────────────── function getFolderPath(folderId) { if (!folderId) return ''; const parts = []; let cur = folderId; const seen = new Set(); while (cur) { if (seen.has(cur)) break; seen.add(cur); const fo = db.prepare('SELECT name, parent_id FROM folders WHERE id=?').get(cur); if (!fo) break; parts.unshift(fo.name.replace(/[\/]/g, '_')); cur = fo.parent_id; } return parts.join('/'); } // ── Schritt 1: User-Basisordner + alle DB-Ordner anlegen ─────────────── const users = db.prepare('SELECT id, username FROM users ORDER BY id').all(); for (const user of users) { const userBase = webdav.userPath(cfg, user.username); try { await webdav.mkdirp(userBase); results.folders++; } catch {} try { await webdav.mkdirp(`${userBase}/_Upload-Freigaben`); } catch {} // Ordner iterativ sortieren (flach, nach Tiefe) const allFolders = db.prepare('SELECT id, name, parent_id FROM folders WHERE user_id=? ORDER BY id').all(user.id); const depthCache = {}; function depth(fid) { if (!fid) return 0; if (depthCache[fid] !== undefined) return depthCache[fid]; const fo = allFolders.find(f => f.id === fid); depthCache[fid] = fo ? 1 + depth(fo.parent_id) : 0; return depthCache[fid]; } const sorted = [...allFolders].sort((a, b) => depth(a.id) - depth(b.id)); for (const folder of sorted) { const fp = getFolderPath(folder.id); if (fp) try { await webdav.mkdirp(`${userBase}/${fp}`); results.folders++; } catch {} } } // ── Schritt 2: Dateien hochladen ─────────────────────────────────────── const files = db.prepare(` SELECT f.*, u.username FROM files f JOIN users u ON u.id = f.user_id ORDER BY f.user_id, f.folder_id, f.id `).all(); for (const file of files) { const localPath = nodePath.join(UPLOAD_DIR, file.filename); if (!fs.existsSync(localPath)) { results.failed++; results.errors.push(`${file.originalname}: lokale Datei fehlt`); continue; } try { const buf = fs.readFileSync(localPath); const fp = getFolderPath(file.folder_id); const base = webdav.userPath(cfg, file.username); const davPath = fp ? `${base}/${fp}/${file.originalname}` : `${base}/${file.originalname}`; await webdav.uploadFile(buf, davPath, file.mimetype || 'application/octet-stream'); results.ok++; } catch(e) { results.failed++; results.errors.push(`${file.originalname}: ${e.message}`); } } res.json({ total: files.length, ...results, message: `${results.folders} Ordner angelegt, ${results.ok} Dateien kopiert${results.failed ? ', ' + results.failed + ' fehlgeschlagen' : ' – alles erfolgreich ✓'}`, }); } catch(err) { console.error('[webdav sync error]', err); res.status(500).json({ error: err.message, ...results }); } }); // ── Waisen aufräumen ────────────────────────────────────────────────────── // Findet und löscht: // 1. Dateien auf Disk die nicht in der DB sind (Disk-Waisen) // 2. DB-Einträge die keine Datei auf Disk haben (DB-Waisen) router.post('/cleanup-orphans', authenticate, requireAdmin, (req, res) => { const { dry_run = false } = req.body; const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads'; const path = require('path'); const fs = require('fs'); const results = { disk_orphans: [], // auf Disk aber nicht in DB db_orphans: [], // in DB aber nicht auf Disk disk_deleted: 0, db_deleted: 0, }; try { // Alle Dateinamen aus DB const dbFiles = db.prepare('SELECT id, filename, originalname, size FROM files').all(); const dbFilenames = new Set(dbFiles.map(f => f.filename)); // Alle Dateien auf Disk const diskFiles = fs.readdirSync(UPLOAD_DIR).filter(f => !f.startsWith('.')); // 1. Disk-Waisen: auf Disk aber nicht in DB for (const diskFile of diskFiles) { if (!dbFilenames.has(diskFile)) { const fullPath = path.join(UPLOAD_DIR, diskFile); const stat = fs.statSync(fullPath); results.disk_orphans.push({ filename: diskFile, size: stat.size }); if (!dry_run) { try { fs.unlinkSync(fullPath); results.disk_deleted++; } catch(e) { console.error('[cleanup] disk unlink failed:', diskFile, e.message); } } } } // 2. DB-Waisen: in DB aber nicht auf Disk const diskFileset = new Set(diskFiles); for (const dbFile of dbFiles) { if (!diskFileset.has(dbFile.filename)) { results.db_orphans.push({ id: dbFile.id, filename: dbFile.filename, originalname: dbFile.originalname }); if (!dry_run) { db.prepare('DELETE FROM files WHERE id=?').run(dbFile.id); results.db_deleted++; } } } res.json({ dry_run, disk_orphans_count: results.disk_orphans.length, db_orphans_count: results.db_orphans.length, disk_deleted: results.disk_deleted, db_deleted: results.db_deleted, disk_orphans: results.disk_orphans.slice(0, 50), db_orphans: results.db_orphans.slice(0, 50), }); } catch(err) { res.status(500).json({ error: err.message }); } }); // ── Disk-Analyse ────────────────────────────────────────────────────────── router.get('/disk-analysis', authenticate, requireAdmin, (req, res) => { const fs = require('fs'); const path = require('path'); function dirSize(dirPath) { let total = 0; try { for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) { const full = path.join(dirPath, entry.name); if (entry.isDirectory()) total += dirSize(full); else try { total += fs.statSync(full).size; } catch {} } } catch {} return total; } function fmt(b) { if (b < 1024) return b + ' B'; if (b < 1024**2) return (b/1024).toFixed(1) + ' KB'; if (b < 1024**3) return (b/1024**2).toFixed(1) + ' MB'; return (b/1024**3).toFixed(2) + ' GB'; } // Alle relevanten Verzeichnisse scannen const locations = [ '/data', '/data/uploads', '/tmp', '/app', '/root', '/var/log', '/var/lib/docker', ]; const result = {}; for (const loc of locations) { if (fs.existsSync(loc)) { result[loc] = { raw: dirSize(loc), fmt: fmt(dirSize(loc)) }; } } // DB-Größe const dbPath = process.env.DB_PATH || '/data/db.sqlite'; if (fs.existsSync(dbPath)) { result['database'] = { raw: fs.statSync(dbPath).size, fmt: fmt(fs.statSync(dbPath).size) }; } // /tmp Details const tmpFiles = []; try { for (const f of fs.readdirSync('/tmp')) { try { const stat = fs.statSync('/tmp/' + f); if (stat.size > 1024*1024) tmpFiles.push({ name: f, size: fmt(stat.size) }); } catch {} } } catch {} // Node modules Größe const nodeModules = '/app/node_modules'; const nodeSize = fs.existsSync(nodeModules) ? dirSize(nodeModules) : 0; // Top 10 größte Dateien in /data const bigFiles = []; function scanBig(dir) { try { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) scanBig(full); else try { const s = fs.statSync(full).size; if (s > 5*1024*1024) bigFiles.push({ path: full, size: fmt(s), raw: s }); } catch {} } } catch {} } scanBig('/data'); bigFiles.sort((a,b) => b.raw - a.raw); res.json({ locations: result, tmpFiles, nodeModulesMb: fmt(nodeSize), bigFiles: bigFiles.slice(0,20) }); }); // GET /push-logs – Übersicht aller verschickten Pushover-Nachrichten (Admin) router.get('/push-logs', authenticate, requireAdmin, (req, res) => { const limit = Math.min(parseInt(req.query.limit) || 200, 1000); const logs = db.prepare(` SELECT pl.id, pl.user_id, u.username, pl.title, pl.message, pl.priority, pl.source, pl.success, pl.created_at FROM push_log pl LEFT JOIN users u ON u.id = pl.user_id ORDER BY pl.id DESC LIMIT ? `).all(limit); res.json(logs); }); // GET /logs – kombinierte Übersicht: Pushover-Nachrichten + Besuche // öffentlicher Links, für den Logs-Bereich mit Filter-Dropdown (Admin) router.get('/logs', authenticate, requireAdmin, (req, res) => { const limit = Math.min(parseInt(req.query.limit) || 200, 1000); const pushRows = db.prepare(` SELECT pl.id, pl.user_id, u.username, pl.title, pl.message, pl.priority, pl.source, pl.success, pl.created_at FROM push_log pl LEFT JOIN users u ON u.id = pl.user_id ORDER BY pl.id DESC LIMIT ? `).all(limit).map(r => ({ logType: 'pushover', id: `push-${r.id}`, created_at: r.created_at, username: r.username, title: r.title, message: r.message, priority: r.priority, source: r.source, success: !!r.success, })); const accessRows = db.prepare(` SELECT id, link_type, path, ip, location, device, created_at FROM public_access_log ORDER BY id DESC LIMIT ? `).all(limit).map(r => ({ logType: 'public_access', id: `access-${r.id}`, created_at: r.created_at, linkType: r.link_type, path: r.path, ip: r.ip, location: r.location, device: r.device, })); const combined = [...pushRows, ...accessRows] .sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')) .slice(0, limit); res.json(combined); }); // DELETE /logs/:id – einzelnen Log-Eintrag löschen (Admin). Die ID trägt ein // Präfix ("push-123" / "access-45"), damit klar ist, aus welcher Tabelle // gelöscht werden muss. router.delete('/logs/:id', authenticate, requireAdmin, (req, res) => { const raw = req.params.id; const [prefix, numStr] = raw.split('-'); const numId = parseInt(numStr, 10); if (!numId) return res.status(400).json({ error: 'Ungültige ID' }); if (prefix === 'push') { db.prepare('DELETE FROM push_log WHERE id=?').run(numId); } else if (prefix === 'access') { db.prepare('DELETE FROM public_access_log WHERE id=?').run(numId); } else { return res.status(400).json({ error: 'Ungültige ID' }); } res.json({ ok: true }); }); // GET /console-log/download – Live-Konsolen-Mitschnitt (letzte 24h) als // Datei herunterladen (Admin) router.get('/console-log/download', authenticate, requireAdmin, (req, res) => { const { getLogFilePath } = require('../consoleLog'); const filePath = getLogFilePath(); if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Noch keine Logs vorhanden' }); const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19); res.download(filePath, `dickendock-console-${stamp}.log`); }); module.exports = router;