352 lines
15 KiB
JavaScript
352 lines
15 KiB
JavaScript
const express = require('express');
|
||
const fs = require('fs');
|
||
const bcrypt = require('bcryptjs');
|
||
const multer = require('multer');
|
||
const db = require('../db');
|
||
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,
|
||
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;
|
||
return { ...u, 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 });
|
||
});
|
||
|
||
// ── 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 path = 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' });
|
||
|
||
// Ordnerpfad rekursiv aus DB auflösen
|
||
function getFolderPath(folderId) {
|
||
if (!folderId) return '';
|
||
const parts = [];
|
||
let current = folderId;
|
||
const visited = new Set();
|
||
while (current) {
|
||
if (visited.has(current)) break;
|
||
visited.add(current);
|
||
const folder = db.prepare('SELECT id, name, parent_id FROM folders WHERE id=?').get(current);
|
||
if (!folder) break;
|
||
parts.unshift(folder.name.replace(/[\/\\]/g, '_'));
|
||
current = folder.parent_id;
|
||
}
|
||
return parts.join('/');
|
||
}
|
||
|
||
const results = { folders: 0, ok: 0, failed: 0, errors: [] };
|
||
|
||
// ── Schritt 1: Alle User-Basisordner + Unterordner 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);
|
||
|
||
// User-Basisordner
|
||
try { await webdav.mkdirp(userBase); results.folders++; } catch(e) {
|
||
console.warn('[sync] mkdirp', userBase, e.message);
|
||
}
|
||
|
||
// Upload-Freigabe Ordner
|
||
try { await webdav.mkdirp(`${userBase}/_Upload-Freigaben`); results.folders++; } catch {}
|
||
|
||
// Alle DB-Ordner dieses Users (sortiert nach parent_id damit Parents zuerst)
|
||
const folders = db.prepare(`
|
||
WITH RECURSIVE tree AS (
|
||
SELECT id, name, parent_id, 0 as depth FROM folders WHERE user_id=? AND parent_id IS NULL
|
||
UNION ALL
|
||
SELECT f.id, f.name, f.parent_id, t.depth+1 FROM folders f JOIN tree t ON f.parent_id=t.id
|
||
) SELECT * FROM tree ORDER BY depth, id
|
||
`).all(user.id);
|
||
|
||
for (const folder of folders) {
|
||
const folderPath = getFolderPath(folder.id);
|
||
const davPath = `${userBase}/${folderPath}`;
|
||
try { await webdav.mkdirp(davPath); results.folders++; } catch(e) {
|
||
results.errors.push(`Ordner ${davPath}: ${e.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Schritt 2: Alle 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 = path.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 folderPath = getFolderPath(file.folder_id);
|
||
const userBase = webdav.userPath(cfg, file.username);
|
||
const davPath = folderPath
|
||
? `${userBase}/${folderPath}/${file.originalname}`
|
||
: `${userBase}/${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}`);
|
||
}
|
||
}
|
||
|
||
// ── Schritt 3: Upload-Freigabe Dateien ───────────────────────────────────
|
||
const uploadLogs = db.prepare(`
|
||
SELECT l.filename, l.originalname, l.mimetype, l.size,
|
||
s.user_id, u.username
|
||
FROM upload_share_logs l
|
||
JOIN upload_shares s ON s.id = l.share_id
|
||
JOIN users u ON u.id = s.user_id
|
||
WHERE l.filename IS NOT NULL
|
||
`).all();
|
||
|
||
for (const log of uploadLogs) {
|
||
const localPath = path.join(UPLOAD_DIR, log.filename);
|
||
if (!fs.existsSync(localPath)) continue;
|
||
try {
|
||
const buf = fs.readFileSync(localPath);
|
||
const userBase = webdav.userPath(cfg, log.username);
|
||
const davPath = `${userBase}/_Upload-Freigaben/${log.originalname || log.filename}`;
|
||
await webdav.uploadFile(buf, davPath, log.mimetype || 'application/octet-stream');
|
||
results.ok++;
|
||
} catch(e) {
|
||
results.failed++;
|
||
results.errors.push(`Upload-Freigabe ${log.originalname}: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
res.json({
|
||
total: files.length + uploadLogs.length,
|
||
...results,
|
||
message: `${results.folders} Ordner angelegt, ${results.ok} Dateien kopiert, ${results.failed} fehlgeschlagen`
|
||
});
|
||
});
|
||
|
||
module.exports = router;
|