604 lines
24 KiB
JavaScript
604 lines
24 KiB
JavaScript
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('<?xml version="1.0"?><propfind xmlns="DAV:"><prop><displayname/></prop></propfind>');
|
||
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;
|