generated from Dicken/dickendock
Initial commit
This commit is contained in:
604
backend/src/routes/admin.js
Normal file
604
backend/src/routes/admin.js
Normal file
@@ -0,0 +1,604 @@
|
||||
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, link_name, 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,
|
||||
linkName: r.link_name,
|
||||
}));
|
||||
|
||||
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;
|
||||
179
backend/src/routes/auth.js
Normal file
179
backend/src/routes/auth.js
Normal file
@@ -0,0 +1,179 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('../db');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
const SECRET = process.env.JWT_SECRET || 'dev-secret';
|
||||
|
||||
const getSetting = k => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k)?.value;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function logAttempt(username, ip, success) {
|
||||
db.prepare("INSERT INTO login_attempts (username, ip, success, created_at) VALUES (?,?,?,datetime('now','localtime'))")
|
||||
.run(username, ip || 'unknown', success ? 1 : 0);
|
||||
}
|
||||
|
||||
function getClientIp(req) {
|
||||
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket?.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
function checkAndLock(user) {
|
||||
const maxAttempts = parseInt(getSetting('login_max_attempts') || '5');
|
||||
const lockoutMinutes = parseInt(getSetting('login_lockout_minutes') || '30');
|
||||
|
||||
// Gesperrt?
|
||||
if (user.locked_until) {
|
||||
const until = new Date(user.locked_until.replace(' ', 'T'));
|
||||
if (until > new Date()) return { locked: true, until };
|
||||
// Sperre abgelaufen → zurücksetzen
|
||||
db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?').run(user.id);
|
||||
}
|
||||
|
||||
// Fehlversuch zählen
|
||||
const newAttempts = (user.failed_attempts || 0) + 1;
|
||||
if (newAttempts >= maxAttempts) {
|
||||
db.prepare(`UPDATE users SET failed_attempts=?, locked_until=datetime('now','localtime','+${lockoutMinutes} minutes'), last_failed_at=datetime('now','localtime') WHERE id=?`)
|
||||
.run(newAttempts, user.id);
|
||||
const until = new Date(Date.now() + lockoutMinutes * 60 * 1000);
|
||||
return { locked: true, until, newLock: true };
|
||||
}
|
||||
db.prepare("UPDATE users SET failed_attempts=?, last_failed_at=datetime('now','localtime') WHERE id=?")
|
||||
.run(newAttempts, user.id);
|
||||
return { locked: false, remaining: maxAttempts - newAttempts };
|
||||
}
|
||||
|
||||
// ── POST /api/auth/login ──────────────────────────────────────────────────────
|
||||
router.post('/login', (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
const ip = getClientIp(req);
|
||||
|
||||
if (!username || !password)
|
||||
return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' });
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||
|
||||
// Unbekannter User → trotzdem loggen (kein Timing-Angriff)
|
||||
if (!user) {
|
||||
logAttempt(username, ip, false);
|
||||
return res.status(401).json({ error: 'Benutzername oder Passwort falsch' });
|
||||
}
|
||||
|
||||
// Gesperrt?
|
||||
if (user.locked_until) {
|
||||
const until = new Date(user.locked_until.replace(' ', 'T'));
|
||||
if (until > new Date()) {
|
||||
logAttempt(username, ip, false);
|
||||
const min = Math.ceil((until - Date.now()) / 60000);
|
||||
return res.status(403).json({
|
||||
error: `Konto gesperrt. Noch ${min} Minute${min !== 1 ? 'n' : ''} warten.`,
|
||||
locked: true,
|
||||
lockedUntil: until.toISOString(),
|
||||
});
|
||||
}
|
||||
// Abgelaufen → zurücksetzen + Attempts löschen
|
||||
db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?').run(user.id);
|
||||
db.prepare('DELETE FROM login_attempts WHERE username=?').run(username);
|
||||
}
|
||||
|
||||
// Passwort prüfen
|
||||
const valid = bcrypt.compareSync(password, user.password_hash);
|
||||
logAttempt(username, ip, valid);
|
||||
|
||||
if (!valid) {
|
||||
const result = checkAndLock(user);
|
||||
if (result.locked) {
|
||||
const min = parseInt(getSetting('login_lockout_minutes') || '30');
|
||||
return res.status(403).json({
|
||||
error: result.newLock
|
||||
? `Zu viele Fehlversuche. Konto für ${min} Minuten gesperrt.`
|
||||
: `Konto gesperrt. Bitte später versuchen.`,
|
||||
locked: true,
|
||||
});
|
||||
}
|
||||
return res.status(401).json({
|
||||
error: `Benutzername oder Passwort falsch. Noch ${result.remaining} Versuch${result.remaining !== 1 ? 'e' : ''}.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Erfolgreich → Zähler zurücksetzen + Attempts löschen
|
||||
db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?').run(user.id);
|
||||
db.prepare('DELETE FROM login_attempts WHERE username=?').run(username);
|
||||
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, username: user.username, role: user.role },
|
||||
SECRET, { expiresIn: '7d' }
|
||||
);
|
||||
res.json({ token, user: { id: user.id, username: user.username, role: user.role } });
|
||||
});
|
||||
|
||||
// ── POST /api/auth/change-password ───────────────────────────────────────────
|
||||
router.post('/change-password', authenticate, (req, res) => {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!bcrypt.compareSync(currentPassword, user.password_hash))
|
||||
return res.status(400).json({ error: 'Aktuelles Passwort falsch' });
|
||||
if (!newPassword || newPassword.length < 8)
|
||||
return res.status(400).json({ error: 'Mindestens 8 Zeichen erforderlich' });
|
||||
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?')
|
||||
.run(bcrypt.hashSync(newPassword, 12), req.user.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── POST /api/auth/change-username ───────────────────────────────────────────
|
||||
router.post('/change-username', authenticate, (req, res) => {
|
||||
const { newUsername, password } = req.body;
|
||||
if (!newUsername || newUsername.trim().length < 3)
|
||||
return res.status(400).json({ error: 'Mind. 3 Zeichen' });
|
||||
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.user.id);
|
||||
if (!bcrypt.compareSync(password, user.password_hash))
|
||||
return res.status(400).json({ error: 'Passwort falsch' });
|
||||
const exists = db.prepare('SELECT id FROM users WHERE username=? AND id!=?').get(newUsername.trim(), req.user.id);
|
||||
if (exists) return res.status(400).json({ error: 'Benutzername bereits vergeben' });
|
||||
db.prepare('UPDATE users SET username=? WHERE id=?').run(newUsername.trim(), req.user.id);
|
||||
const token = jwt.sign({ id: user.id, username: newUsername.trim(), role: user.role }, SECRET, { expiresIn: '7d' });
|
||||
res.json({ token, user: { id: user.id, username: newUsername.trim(), role: user.role } });
|
||||
});
|
||||
|
||||
// ── DELETE /api/auth/account ──────────────────────────────────────────────────
|
||||
router.delete('/account', authenticate, (req, res) => {
|
||||
const { password } = req.body;
|
||||
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.user.id);
|
||||
if (!password || !bcrypt.compareSync(password, user.password_hash))
|
||||
return res.status(400).json({ error: 'Passwort falsch' });
|
||||
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: 'Letzter Admin kann nicht gelöscht werden' });
|
||||
}
|
||||
db.prepare('DELETE FROM users WHERE id=?').run(req.user.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── GET/PUT /api/auth/preferences ─────────────────────────────────────────────
|
||||
router.get('/preferences', authenticate, (req, res) => {
|
||||
const user = db.prepare('SELECT preferences FROM users WHERE id=?').get(req.user.id);
|
||||
try { res.json(JSON.parse(user?.preferences || '{}')); }
|
||||
catch { res.json({}); }
|
||||
});
|
||||
|
||||
router.put('/preferences', authenticate, (req, res) => {
|
||||
const user = db.prepare('SELECT preferences FROM users WHERE id=?').get(req.user.id);
|
||||
let current = {};
|
||||
try { current = JSON.parse(user?.preferences || '{}'); } catch {}
|
||||
const merged = { ...current, ...req.body };
|
||||
db.prepare('UPDATE users SET preferences=? WHERE id=?').run(JSON.stringify(merged), req.user.id);
|
||||
res.json(merged);
|
||||
});
|
||||
|
||||
// ── GET /api/auth/hidden-tools ────────────────────────────────────────────────
|
||||
// Liefert die vom Admin für diesen Nutzer ausgeblendeten Sidebar-Bereiche.
|
||||
// Bewusst NICHT über /preferences (das ist selbst-editierbar) — Sichtbarkeit
|
||||
// darf nur der Admin ändern.
|
||||
router.get('/hidden-tools', authenticate, (req, res) => {
|
||||
const user = db.prepare('SELECT hidden_tools FROM users WHERE id=?').get(req.user.id);
|
||||
try { res.json(JSON.parse(user?.hidden_tools || '[]')); }
|
||||
catch { res.json([]); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
69
backend/src/routes/calendar.js
Normal file
69
backend/src/routes/calendar.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const db = require('../db');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
// Fetch mit Redirect-Unterstützung
|
||||
function fetchWithRedirects(url, hops = 0) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (hops > 5) return reject(new Error('Zu viele Redirects'));
|
||||
let parsed;
|
||||
try { parsed = new URL(url); } catch { return reject(new Error('Ungültige URL')); }
|
||||
const lib = parsed.protocol === 'https:' ? https : http;
|
||||
const req = lib.get(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 DickenDock/1.0', 'Accept': 'text/calendar, text/plain, */*' }
|
||||
}, res => {
|
||||
if ([301,302,303,307,308].includes(res.statusCode) && res.headers.location) {
|
||||
const next = res.headers.location.startsWith('http')
|
||||
? res.headers.location
|
||||
: new URL(res.headers.location, url).href;
|
||||
res.destroy();
|
||||
return fetchWithRedirects(next, hops + 1).then(resolve).catch(reject);
|
||||
}
|
||||
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
|
||||
let data = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', d => { data += d; if (data.length > 3*1024*1024) { res.destroy(); reject(new Error('Feed zu groß')); } });
|
||||
res.on('end', () => resolve(data));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); });
|
||||
});
|
||||
}
|
||||
|
||||
// GET /fetch?url=...
|
||||
router.get('/fetch', authenticate, async (req, res) => {
|
||||
const { url } = req.query;
|
||||
if (!url) return res.status(400).json({ error: 'URL fehlt' });
|
||||
try { new URL(url); } catch { return res.status(400).json({ error: 'Ungültige URL' }); }
|
||||
try {
|
||||
const data = await fetchWithRedirects(url);
|
||||
if (!data.includes('BEGIN:VCALENDAR'))
|
||||
return res.status(502).json({ error: 'Kein gültiger iCal-Feed' });
|
||||
res.type('text/calendar; charset=utf-8').send(data);
|
||||
} catch(e) { res.status(502).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// GET /feeds
|
||||
router.get('/feeds', authenticate, (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM calendar_feeds WHERE user_id=? ORDER BY id').all(req.user.id));
|
||||
});
|
||||
|
||||
// POST /feeds
|
||||
router.post('/feeds', authenticate, (req, res) => {
|
||||
const { name, url, color='#4ecdc4' } = req.body;
|
||||
if (!name?.trim() || !url?.trim()) return res.status(400).json({ error: 'Name und URL erforderlich' });
|
||||
const r = db.prepare('INSERT INTO calendar_feeds (user_id,name,url,color) VALUES (?,?,?,?)').run(req.user.id, name.trim(), url.trim(), color);
|
||||
res.json(db.prepare('SELECT * FROM calendar_feeds WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
// DELETE /feeds/:id
|
||||
router.delete('/feeds/:id', authenticate, (req, res) => {
|
||||
const r = db.prepare('DELETE FROM calendar_feeds WHERE id=? AND user_id=?').run(req.params.id, req.user.id);
|
||||
if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
330
backend/src/routes/dashboard.js
Normal file
330
backend/src/routes/dashboard.js
Normal file
@@ -0,0 +1,330 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const { authenticate, requireAdmin } = require('../middleware/auth');
|
||||
const router = express.Router();
|
||||
const uid = req => req.user.id;
|
||||
|
||||
// ── Quick Links ───────────────────────────────────────────────────────────────
|
||||
router.get('/links', authenticate, (req, res) => {
|
||||
const id = uid(req);
|
||||
// Rückwärtskomp: bestehende quick_links + neue link_list-Einträge mit in_quickaccess=1 + Ordner mit in_quickaccess=1
|
||||
const legacy = db.prepare("SELECT *, 'quicklink' as item_type FROM quick_links WHERE user_id=? ORDER BY sort_order,id").all(id);
|
||||
const listLinks = db.prepare("SELECT *, 'link_list' as item_type FROM link_list WHERE user_id=? AND in_quickaccess=1 ORDER BY sort_order,id").all(id);
|
||||
const folders = db.prepare("SELECT *, 'folder' as item_type FROM link_list_folders WHERE user_id=? AND in_quickaccess=1 ORDER BY sort_order,id").all(id);
|
||||
res.json([...legacy, ...listLinks, ...folders]);
|
||||
});
|
||||
router.post('/links', authenticate, (req, res) => {
|
||||
const { title, url, icon = '🔗' } = req.body;
|
||||
if (!title || !url) return res.status(400).json({ error: 'Titel und URL erforderlich' });
|
||||
const max = db.prepare('SELECT MAX(sort_order) m FROM quick_links WHERE user_id=?').get(uid(req));
|
||||
const r = db.prepare('INSERT INTO quick_links (user_id,title,url,icon,sort_order) VALUES (?,?,?,?,?)')
|
||||
.run(uid(req), title, url, icon, (max?.m ?? -1) + 1);
|
||||
res.json(db.prepare('SELECT * FROM quick_links WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
router.put('/links/:id', authenticate, (req, res) => {
|
||||
const ex = db.prepare('SELECT * FROM quick_links WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!ex) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { title=ex.title, url=ex.url, icon=ex.icon } = req.body;
|
||||
db.prepare('UPDATE quick_links SET title=?,url=?,icon=? WHERE id=? AND user_id=?')
|
||||
.run(title, url, icon, req.params.id, uid(req));
|
||||
res.json(db.prepare('SELECT * FROM quick_links WHERE id=?').get(req.params.id));
|
||||
});
|
||||
router.delete('/links/:id', authenticate, (req, res) => {
|
||||
const r = db.prepare('DELETE FROM quick_links WHERE id=? AND user_id=?').run(req.params.id, uid(req));
|
||||
if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.put('/links-order', authenticate, (req, res) => {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids erforderlich' });
|
||||
const update = db.prepare('UPDATE quick_links SET sort_order=? WHERE id=? AND user_id=?');
|
||||
db.transaction(() => { ids.forEach((id, i) => update.run(i, id, uid(req))); })();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Todos ─────────────────────────────────────────────────────────────────────
|
||||
router.get('/todos', authenticate, (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM todos WHERE user_id=? ORDER BY done,sort_order,id').all(uid(req)));
|
||||
});
|
||||
router.post('/todos', authenticate, (req, res) => {
|
||||
const { text } = req.body;
|
||||
if (!text?.trim()) return res.status(400).json({ error: 'Text erforderlich' });
|
||||
const max = db.prepare('SELECT MAX(sort_order) m FROM todos WHERE user_id=?').get(uid(req));
|
||||
const r = db.prepare('INSERT INTO todos (user_id,text,sort_order) VALUES (?,?,?)')
|
||||
.run(uid(req), text.trim(), (max?.m ?? -1) + 1);
|
||||
res.json(db.prepare('SELECT * FROM todos WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
router.put('/todos/:id', authenticate, (req, res) => {
|
||||
const ex = db.prepare('SELECT * FROM todos WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!ex) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const text = req.body.text ?? ex.text;
|
||||
const done = req.body.done !== undefined ? (req.body.done ? 1 : 0) : ex.done;
|
||||
db.prepare('UPDATE todos SET text=?,done=? WHERE id=? AND user_id=?').run(text, done, req.params.id, uid(req));
|
||||
res.json(db.prepare('SELECT * FROM todos WHERE id=?').get(req.params.id));
|
||||
});
|
||||
router.delete('/todos/:id', authenticate, (req, res) => {
|
||||
const r = db.prepare('DELETE FROM todos WHERE id=? AND user_id=?').run(req.params.id, uid(req));
|
||||
if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Notizen ───────────────────────────────────────────────────────────────────
|
||||
router.get('/note', authenticate, (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM notes WHERE user_id=?').get(uid(req)) || { content: '', updated_at: null });
|
||||
});
|
||||
router.put('/note', authenticate, (req, res) => {
|
||||
db.prepare(`INSERT INTO notes (user_id,content,updated_at) VALUES (?,?,CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET content=excluded.content,updated_at=CURRENT_TIMESTAMP`)
|
||||
.run(uid(req), req.body.content ?? '');
|
||||
res.json({ success: true, updated_at: new Date().toISOString() });
|
||||
});
|
||||
|
||||
|
||||
// ── Statistiken ───────────────────────────────────────────────────────────────
|
||||
router.get('/stats', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
|
||||
const orders = db.prepare(`
|
||||
SELECT status, bezahlt,
|
||||
SUM(COALESCE(custom_price_sum_view,0)) AS revenue
|
||||
FROM (
|
||||
SELECT o.status, o.bezahlt,
|
||||
SUM(COALESCE(oi.custom_price,0)*COALESCE(oi.stueckzahl,0)) AS custom_price_sum_view
|
||||
FROM orders o
|
||||
LEFT JOIN order_items oi ON oi.order_id=o.id
|
||||
WHERE o.user_id=?
|
||||
GROUP BY o.id
|
||||
)
|
||||
GROUP BY status, bezahlt
|
||||
`).all(uid);
|
||||
|
||||
const totalOrders = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=?').get(uid).c;
|
||||
const bezahltOrders = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=? AND bezahlt=1 AND (abgeholt=0 OR abgeholt IS NULL)').get(uid).c;
|
||||
// Revenue: Gesamt-Festpreis hat Priorität, sonst Summe der Positions-Festpreise
|
||||
const paidOrders = db.prepare('SELECT * FROM orders WHERE user_id=? AND bezahlt=1').all(uid);
|
||||
let totalRevenue = 0;
|
||||
for (const o of paidOrders) {
|
||||
if (o.custom_price != null) {
|
||||
totalRevenue += parseFloat(o.custom_price);
|
||||
} else {
|
||||
const sum = db.prepare('SELECT COALESCE(SUM(custom_price*stueckzahl),0) s FROM order_items WHERE order_id=? AND custom_price IS NOT NULL').get(o.id).s;
|
||||
totalRevenue += sum;
|
||||
}
|
||||
}
|
||||
|
||||
const openOrders = db.prepare("SELECT * FROM orders WHERE user_id=? AND bezahlt=0 AND status!='warteliste'").all(uid);
|
||||
let offeneRevenue = 0;
|
||||
for (const o of openOrders) {
|
||||
if (o.custom_price != null) {
|
||||
offeneRevenue += parseFloat(o.custom_price);
|
||||
} else {
|
||||
const sum = db.prepare('SELECT COALESCE(SUM(custom_price*stueckzahl),0) s FROM order_items WHERE order_id=? AND custom_price IS NOT NULL').get(o.id).s;
|
||||
offeneRevenue += sum;
|
||||
}
|
||||
}
|
||||
const thisMonth = new Date();
|
||||
thisMonth.setDate(1); thisMonth.setHours(0,0,0,0);
|
||||
const ordersThisMonth = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=? AND created_at>=?').get(uid, thisMonth.toISOString()).c;
|
||||
const totalCalcs = db.prepare('SELECT COUNT(*) c FROM calculations WHERE user_id=?').get(uid).c;
|
||||
|
||||
const byStatus = { warteliste:0, in_arbeit:0, fertig:0 };
|
||||
for (const r of db.prepare('SELECT status, COUNT(*) c FROM orders WHERE user_id=? AND bezahlt=0 GROUP BY status').all(uid)) {
|
||||
byStatus[r.status] = r.c;
|
||||
}
|
||||
|
||||
// Grundkosten berechnen (preis_freundschaft = Selbstkosten pro Stück aus Kalkulation)
|
||||
function getBaseCost(orderId) {
|
||||
const items = db.prepare(`
|
||||
SELECT oi.stueckzahl, oi.custom_price,
|
||||
COALESCE(c.preis_freundschaft, oi.preis_freundschaft) as basis
|
||||
FROM order_items oi
|
||||
LEFT JOIN calculations c ON c.id = oi.calculation_id
|
||||
WHERE oi.order_id = ?
|
||||
`).all(orderId);
|
||||
return items.reduce((sum, i) => sum + (i.basis * i.stueckzahl), 0);
|
||||
}
|
||||
|
||||
function getRevenue(order) {
|
||||
if (order.custom_price != null) return parseFloat(order.custom_price);
|
||||
return db.prepare('SELECT COALESCE(SUM(custom_price*stueckzahl),0) s FROM order_items WHERE order_id=? AND custom_price IS NOT NULL').get(order.id).s;
|
||||
}
|
||||
|
||||
let totalProfit = 0;
|
||||
for (const o of paidOrders) {
|
||||
const rev = getRevenue(o);
|
||||
const cost = getBaseCost(o.id);
|
||||
totalProfit += rev - cost;
|
||||
}
|
||||
|
||||
let offeneProfit = 0;
|
||||
for (const o of openOrders) {
|
||||
const rev = getRevenue(o);
|
||||
const cost = getBaseCost(o.id);
|
||||
offeneProfit += rev - cost;
|
||||
}
|
||||
|
||||
res.json({ totalOrders, bezahltOrders, totalRevenue, offeneRevenue, ordersThisMonth, totalCalcs, byStatus, totalProfit, offeneProfit });
|
||||
});
|
||||
|
||||
// ── Ideen-Board ───────────────────────────────────────────────────────────────
|
||||
router.get('/board', authenticate, (req, res) => {
|
||||
const u = uid(req);
|
||||
const items = db.prepare(`
|
||||
SELECT b.*, us.username as author
|
||||
FROM board_items b JOIN users us ON us.id=b.user_id
|
||||
ORDER BY b.type ASC, b.created_at DESC
|
||||
`).all();
|
||||
|
||||
const lastRead = db.prepare('SELECT last_read FROM board_reads WHERE user_id=?').get(u)?.last_read;
|
||||
const unread = lastRead
|
||||
? db.prepare("SELECT COUNT(*) c FROM board_items WHERE created_at > ? AND user_id != ?").get(lastRead, u).c
|
||||
: db.prepare('SELECT COUNT(*) c FROM board_items WHERE user_id != ?').get(u).c;
|
||||
|
||||
res.json({ items, unread });
|
||||
});
|
||||
|
||||
router.get('/board/unread', authenticate, (req, res) => {
|
||||
const u = uid(req);
|
||||
const lastRead = db.prepare('SELECT last_read FROM board_reads WHERE user_id=?').get(u)?.last_read;
|
||||
const unread = lastRead
|
||||
? db.prepare("SELECT COUNT(*) c FROM board_items WHERE created_at > ? AND user_id != ?").get(lastRead, u).c
|
||||
: db.prepare('SELECT COUNT(*) c FROM board_items WHERE user_id != ?').get(u).c;
|
||||
const unreadPromoted = lastRead
|
||||
? db.prepare("SELECT COUNT(*) c FROM board_items WHERE promoted_at > ?").get(lastRead).c
|
||||
: db.prepare("SELECT COUNT(*) c FROM board_items WHERE promoted_at IS NOT NULL").get().c;
|
||||
res.json({ unread, unreadPromoted });
|
||||
});
|
||||
|
||||
router.post('/board/read', authenticate, (req, res) => {
|
||||
const u = uid(req);
|
||||
db.prepare(`INSERT OR REPLACE INTO board_reads (user_id, last_read) VALUES (?, datetime('now','localtime'))`).run(u);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/board', authenticate, (req, res) => {
|
||||
const u = uid(req);
|
||||
const { type, title, description = '' } = req.body;
|
||||
if (!['roadmap','wish'].includes(type)) return res.status(400).json({ error: 'Ungültiger Typ' });
|
||||
if (type === 'roadmap' && req.user.role !== 'admin') return res.status(403).json({ error: 'Nur Admins' });
|
||||
if (!title?.trim()) return res.status(400).json({ error: 'Titel erforderlich' });
|
||||
const r = db.prepare(`INSERT INTO board_items (type, user_id, title, description, created_at) VALUES (?,?,?,?,datetime('now','localtime'))`)
|
||||
.run(type, u, title.trim(), description.trim());
|
||||
res.json({ ...db.prepare('SELECT * FROM board_items WHERE id=?').get(r.lastInsertRowid),
|
||||
author: req.user.username });
|
||||
});
|
||||
|
||||
router.put('/board/:id', authenticate, (req, res) => {
|
||||
const item = db.prepare('SELECT * FROM board_items WHERE id=?').get(req.params.id);
|
||||
if (!item) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
const isOwn = item.user_id === uid(req);
|
||||
if (!isAdmin && (!isOwn || item.type === 'roadmap')) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
const { title, description } = req.body;
|
||||
db.prepare('UPDATE board_items SET title=?, description=? WHERE id=?')
|
||||
.run(title ?? item.title, description ?? item.description, item.id);
|
||||
res.json({ ...db.prepare('SELECT * FROM board_items WHERE id=?').get(item.id), author: req.user.username });
|
||||
});
|
||||
|
||||
router.delete('/board/:id', authenticate, (req, res) => {
|
||||
const item = db.prepare('SELECT * FROM board_items WHERE id=?').get(req.params.id);
|
||||
if (!item) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
const isOwn = item.user_id === uid(req);
|
||||
if (!isAdmin && !isOwn) return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
db.prepare('DELETE FROM board_items WHERE id=?').run(item.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Wunsch zur Roadmap befördern (Admin only)
|
||||
router.post('/board/:id/promote', authenticate, (req, res) => {
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
const item = db.prepare('SELECT * FROM board_items WHERE id=?').get(req.params.id);
|
||||
if (!item) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
if (item.type !== 'wish') return res.status(400).json({ error: 'Nur Wünsche können befördert werden' });
|
||||
db.prepare("UPDATE board_items SET type='roadmap', promoted_from_wish=1, promoted_at=datetime('now','localtime') WHERE id=?").run(item.id);
|
||||
const updated = db.prepare(`
|
||||
SELECT b.*, u.username as author FROM board_items b
|
||||
JOIN users u ON u.id=b.user_id WHERE b.id=?
|
||||
`).get(item.id);
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
// ── Changelog ─────────────────────────────────────────────────────────────────
|
||||
router.get('/changelog', authenticate, (req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM changelog ORDER BY created_at DESC').all());
|
||||
});
|
||||
|
||||
router.get('/changelog/unread', authenticate, (req, res) => {
|
||||
const u = req.user.id;
|
||||
const lastRead = db.prepare('SELECT last_read FROM changelog_reads WHERE user_id=?').get(u)?.last_read;
|
||||
const count = lastRead
|
||||
? db.prepare('SELECT COUNT(*) c FROM changelog WHERE created_at > ?').get(lastRead).c
|
||||
: db.prepare('SELECT COUNT(*) c FROM changelog').get().c;
|
||||
res.json({ unread: count });
|
||||
});
|
||||
|
||||
router.post('/changelog/read', authenticate, (req, res) => {
|
||||
db.prepare(`INSERT OR REPLACE INTO changelog_reads (user_id, last_read) VALUES (?, datetime('now','localtime'))`)
|
||||
.run(req.user.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/changelog', authenticate, requireAdmin, (req, res) => {
|
||||
const { version, title, body, build_time } = req.body;
|
||||
if (!version?.trim() || !title?.trim()) return res.status(400).json({ error: 'Version und Titel erforderlich' });
|
||||
const r = db.prepare(`INSERT INTO changelog (version, title, body, build_time, created_at) VALUES (?,?,?,?,datetime('now','localtime'))`)
|
||||
.run(version.trim(), title.trim(), body?.trim() || '', build_time || null);
|
||||
res.json(db.prepare('SELECT * FROM changelog WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.delete('/changelog/:id', authenticate, requireAdmin, (req, res) => {
|
||||
db.prepare('DELETE FROM changelog WHERE id=?').run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Push-Zeitplaner ───────────────────────────────────────────────────────────
|
||||
router.get('/push-schedules', authenticate, (req, res) => {
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM push_schedules
|
||||
WHERE user_id=? AND sent=0 AND scheduled_at > datetime('now','localtime')
|
||||
ORDER BY scheduled_at ASC
|
||||
`).all(uid(req));
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.post('/push-schedules', authenticate, (req, res) => {
|
||||
const { message, scheduled_at } = req.body;
|
||||
if (!message?.trim() || !scheduled_at) return res.status(400).json({ error: 'Nachricht und Zeitpunkt erforderlich' });
|
||||
// Normalisieren: T→Leerzeichen, Sekunden ergänzen
|
||||
const normalized = scheduled_at.replace('T',' ') + (scheduled_at.length <= 16 ? ':00' : '');
|
||||
const r = db.prepare(`
|
||||
INSERT INTO push_schedules (user_id, message, scheduled_at, created_at)
|
||||
VALUES (?, ?, ?, datetime('now','localtime'))
|
||||
`).run(uid(req), message.trim(), normalized);
|
||||
res.json(db.prepare('SELECT * FROM push_schedules WHERE id=?').get(r.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.put('/push-schedules/:id', authenticate, (req, res) => {
|
||||
const row = db.prepare('SELECT * FROM push_schedules WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||
if (!row) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { message, scheduled_at } = req.body;
|
||||
const normalized = scheduled_at ? scheduled_at.replace('T',' ') + (scheduled_at.length <= 16 ? ':00' : '') : row.scheduled_at;
|
||||
db.prepare('UPDATE push_schedules SET message=?, scheduled_at=?, sent=0 WHERE id=?')
|
||||
.run(message ?? row.message, normalized, row.id);
|
||||
res.json(db.prepare('SELECT * FROM push_schedules WHERE id=?').get(row.id));
|
||||
});
|
||||
|
||||
router.delete('/push-schedules/:id', authenticate, (req, res) => {
|
||||
db.prepare('DELETE FROM push_schedules WHERE id=? AND user_id=?').run(req.params.id, uid(req));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Unquittierte Favoriten für Admin-Dashboard-Badge
|
||||
router.get('/media-favorites/unacked', authenticate, (req, res) => {
|
||||
if (req.user.role !== 'admin') return res.json({ count: 0 });
|
||||
const count = db.prepare('SELECT COUNT(*) as n FROM movie_favorites WHERE acknowledged=0').get();
|
||||
res.json({ count: count.n });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
260
backend/src/routes/search.js
Normal file
260
backend/src/routes/search.js
Normal file
@@ -0,0 +1,260 @@
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const db = require('../db');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
const safe = fn => { try { return fn(); } catch { return []; } };
|
||||
const safeGet = fn => { try { return fn(); } catch { return null; } };
|
||||
|
||||
// ── iCal Fetch + Parse ────────────────────────────────────────────────────────
|
||||
function fetchIcal(url) {
|
||||
return new Promise(resolve => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const lib = parsed.protocol === 'https:' ? https : http;
|
||||
const req = lib.get(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 DickenDock/1.0', 'Accept': 'text/calendar,*/*' }
|
||||
}, res => {
|
||||
// Redirects verfolgen
|
||||
if ([301,302,303,307,308].includes(res.statusCode) && res.headers.location) {
|
||||
res.destroy();
|
||||
const next = res.headers.location.startsWith('http')
|
||||
? res.headers.location : new URL(res.headers.location, url).href;
|
||||
fetchIcal(next).then(resolve);
|
||||
return;
|
||||
}
|
||||
let data = '';
|
||||
res.on('data', d => { data += d; if (data.length > 1e6) { res.destroy(); resolve(null); } });
|
||||
res.on('end', () => resolve(data.includes('BEGIN:VCALENDAR') ? data : null));
|
||||
});
|
||||
req.on('error', () => resolve(null));
|
||||
req.setTimeout(10000, () => { req.destroy(); resolve(null); });
|
||||
} catch { resolve(null); }
|
||||
});
|
||||
}
|
||||
|
||||
function parseIcal(raw) {
|
||||
const events = [];
|
||||
const parts = raw.split('BEGIN:VEVENT');
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const b = parts[i];
|
||||
const get = key => {
|
||||
// Handle folded lines and key variants like DTSTART;TZID=...
|
||||
const m = b.match(new RegExp(key + '[^:]*:([^\\r\\n]+)'));
|
||||
return m ? m[1].replace(/\\n/g,' ').replace(/\\,/g,',').replace(/\\;/g,';').trim() : '';
|
||||
};
|
||||
const summary = get('SUMMARY');
|
||||
if (!summary) continue;
|
||||
events.push({ summary, description:get('DESCRIPTION'), location:get('LOCATION'), start:get('DTSTART'), end:get('DTEND') });
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
// ── Kalender-Sync Endpoint (POST /api/search/sync-calendar) ──────────────────
|
||||
// Frontend ruft das beim Dashboard-Laden auf — kein Sync beim Suchen selbst
|
||||
router.post('/sync-calendar', authenticate, async (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const force = req.body?.force === true;
|
||||
|
||||
const feeds = safeGet(() => db.prepare('SELECT * FROM calendar_feeds WHERE user_id=?').all(uid)) || [];
|
||||
if (!feeds.length) return res.json({ ok: true, synced: 0, message: 'Keine Kalender-Abos vorhanden' });
|
||||
|
||||
// Alter des Caches prüfen
|
||||
// Alte Cache-Prüfung: Wenn noch historische Events drin (start_dt < 2000), force-sync
|
||||
const hasOldData = safeGet(() =>
|
||||
db.prepare("SELECT 1 FROM calendar_event_cache WHERE user_id=? AND start_dt < '20200101' LIMIT 1").get(uid)
|
||||
);
|
||||
if (!force && !hasOldData) {
|
||||
const lastSync = safeGet(() =>
|
||||
db.prepare("SELECT MAX(synced_at) as s FROM calendar_event_cache WHERE user_id=?").get(uid)?.s
|
||||
);
|
||||
if (lastSync && (Date.now() - new Date(lastSync).getTime()) < 10 * 60 * 1000) {
|
||||
return res.json({ ok: true, synced: 0, message: 'Cache noch frisch' });
|
||||
}
|
||||
}
|
||||
|
||||
let totalSynced = 0;
|
||||
const errors = [];
|
||||
|
||||
for (const feed of feeds) {
|
||||
try {
|
||||
const raw = await fetchIcal(feed.url);
|
||||
if (!raw) { errors.push(`${feed.name}: kein gültiger iCal-Feed`); continue; }
|
||||
const events = parseIcal(raw);
|
||||
if (!events.length) { errors.push(`${feed.name}: 0 Events geparst`); continue; }
|
||||
|
||||
// Nur zukünftige Events speichern (ab gestern rückwärts 1 Tag für Tagesevents)
|
||||
// start_dt Format: YYYYMMDD oder YYYYMMDDTHHmmssZ → string-vergleichbar
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const minDt = yesterday.toISOString().replace(/-/g,'').slice(0,8); // '20260604'
|
||||
const futureEvents = events.filter(ev => !ev.start || ev.start >= minDt);
|
||||
|
||||
// prepare INSIDE try-catch – wirft wenn Tabelle nicht existiert
|
||||
const ins = db.prepare(`
|
||||
INSERT INTO calendar_event_cache
|
||||
(feed_id, user_id, summary, description, location, start_dt, end_dt, synced_at)
|
||||
VALUES (?,?,?,?,?,?,?,datetime('now','localtime'))
|
||||
`);
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM calendar_event_cache WHERE feed_id=?').run(feed.id);
|
||||
futureEvents.slice(0, 500).forEach(ev =>
|
||||
ins.run(feed.id, uid, ev.summary||'', ev.description||'', ev.location||'', ev.start||'', ev.end||'')
|
||||
);
|
||||
})();
|
||||
totalSynced += futureEvents.length;
|
||||
} catch(e) {
|
||||
console.error(`[CalendarSync] Feed "${feed.name}" (${feed.url}): ${e.message}`);
|
||||
errors.push(`${feed.name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[CalendarSync] uid=${uid} synced=${totalSynced} feeds=${feeds.length} errors=${errors.length}`);
|
||||
res.json({ ok: true, synced: totalSynced, feeds: feeds.length, errors });
|
||||
});
|
||||
|
||||
// ── Suche ─────────────────────────────────────────────────────────────────────
|
||||
router.get('/', authenticate, (req, res) => {
|
||||
const q = (req.query.q || '').trim();
|
||||
const uid = req.user.id;
|
||||
|
||||
if (q.length < 2) return res.json({
|
||||
links:[], folders:[], snippets:[], calculations:[], orders:[],
|
||||
todos:[], notes:[], board_items:[], changelog:[],
|
||||
files:[], file_folders:[], push_schedules:[],
|
||||
calendar_feeds:[], calendar_events:[], qr_codes:[]
|
||||
});
|
||||
|
||||
const like = `%${q.toLowerCase()}%`;
|
||||
|
||||
const links = safe(() => db.prepare(`
|
||||
SELECT id, title, url, icon, description FROM link_list
|
||||
WHERE user_id=? AND (LOWER(title) LIKE ? OR LOWER(url) LIKE ? OR LOWER(description) LIKE ?)
|
||||
ORDER BY title LIMIT 8
|
||||
`).all(uid, like, like, like));
|
||||
|
||||
const folders = safe(() => db.prepare(`
|
||||
SELECT id, name, icon FROM link_list_folders
|
||||
WHERE user_id=? AND LOWER(name) LIKE ? ORDER BY name LIMIT 5
|
||||
`).all(uid, like));
|
||||
|
||||
const snippets = safe(() => db.prepare(`
|
||||
SELECT id, title, description, language FROM snippets
|
||||
WHERE user_id=? AND (LOWER(title) LIKE ? OR LOWER(description) LIKE ?)
|
||||
ORDER BY title LIMIT 6
|
||||
`).all(uid, like, like));
|
||||
|
||||
const calculations = safe(() => db.prepare(`
|
||||
SELECT id, name FROM calculations
|
||||
WHERE user_id=? AND LOWER(name) LIKE ? ORDER BY name LIMIT 5
|
||||
`).all(uid, like));
|
||||
|
||||
const orders = safe(() => db.prepare(`
|
||||
SELECT id, name, status FROM orders
|
||||
WHERE user_id=? AND LOWER(name) LIKE ? ORDER BY name LIMIT 5
|
||||
`).all(uid, like));
|
||||
|
||||
const todos = safe(() => db.prepare(`
|
||||
SELECT id, text, done FROM todos
|
||||
WHERE user_id=? AND LOWER(text) LIKE ?
|
||||
ORDER BY done, created_at DESC LIMIT 6
|
||||
`).all(uid, like));
|
||||
|
||||
const noteRow = safeGet(() => db.prepare(
|
||||
'SELECT content FROM notes WHERE user_id=? AND LOWER(content) LIKE ?'
|
||||
).get(uid, like));
|
||||
const notes = noteRow ? [(() => {
|
||||
const idx = noteRow.content.toLowerCase().indexOf(q.toLowerCase());
|
||||
const s = Math.max(0, idx - 30);
|
||||
return { id:'note', preview: (s>0?'…':'') + noteRow.content.slice(s, idx+60) + '…' };
|
||||
})()] : [];
|
||||
|
||||
const board_items = safe(() => db.prepare(`
|
||||
SELECT id, title, description, type FROM board_items
|
||||
WHERE user_id=? AND (LOWER(title) LIKE ? OR LOWER(description) LIKE ?)
|
||||
ORDER BY created_at DESC LIMIT 5
|
||||
`).all(uid, like, like));
|
||||
|
||||
const changelog = safe(() => db.prepare(`
|
||||
SELECT id, version, title, body FROM changelog
|
||||
WHERE LOWER(title) LIKE ? OR LOWER(body) LIKE ?
|
||||
ORDER BY created_at DESC LIMIT 4
|
||||
`).all(like, like));
|
||||
|
||||
const files = safe(() => db.prepare(`
|
||||
SELECT id, originalname, mimetype, size FROM files
|
||||
WHERE user_id=? AND (LOWER(originalname) LIKE ? OR LOWER(filename) LIKE ?)
|
||||
ORDER BY originalname LIMIT 6
|
||||
`).all(uid, like, like));
|
||||
|
||||
const file_folders = safe(() => db.prepare(`
|
||||
SELECT id, name FROM folders
|
||||
WHERE user_id=? AND LOWER(name) LIKE ? ORDER BY name LIMIT 5
|
||||
`).all(uid, like));
|
||||
|
||||
const push_schedules = safe(() => db.prepare(`
|
||||
SELECT id, message, scheduled_at FROM push_schedules
|
||||
WHERE user_id=? AND LOWER(message) LIKE ? AND sent=0
|
||||
ORDER BY scheduled_at LIMIT 5
|
||||
`).all(uid, like));
|
||||
|
||||
const calendar_feeds = safe(() => db.prepare(`
|
||||
SELECT id, name, url FROM calendar_feeds
|
||||
WHERE user_id=? AND (LOWER(name) LIKE ? OR LOWER(url) LIKE ?)
|
||||
ORDER BY name LIMIT 4
|
||||
`).all(uid, like, like));
|
||||
|
||||
const calendar_events = safe(() => db.prepare(`
|
||||
SELECT c.id, c.summary, c.description, c.location, c.start_dt, f.name as feed_name
|
||||
FROM calendar_event_cache c
|
||||
JOIN calendar_feeds f ON f.id=c.feed_id
|
||||
WHERE c.user_id=? AND f.user_id=?
|
||||
AND (LOWER(c.summary) LIKE ? OR LOWER(c.description) LIKE ? OR LOWER(c.location) LIKE ?)
|
||||
AND c.start_dt >= strftime('%Y%m%d', 'now')
|
||||
ORDER BY c.start_dt LIMIT 8
|
||||
`).all(uid, uid, like, like, like));
|
||||
|
||||
const qr_codes = safe(() => db.prepare(`
|
||||
SELECT id, label, url FROM qr_codes
|
||||
WHERE user_id=? AND (LOWER(label) LIKE ? OR LOWER(url) LIKE ?)
|
||||
ORDER BY created_at DESC LIMIT 5
|
||||
`).all(uid, like, like));
|
||||
|
||||
// Gebietseroberung-Spiele
|
||||
const geo_games = safe(() => db.prepare(`
|
||||
SELECT g.id, u1.username as owner_name, u2.username as opp_name, g.status, g.current_turn
|
||||
FROM geo_games g
|
||||
JOIN users u1 ON u1.id=g.owner_id
|
||||
JOIN users u2 ON u2.id=g.opponent_id
|
||||
WHERE (g.owner_id=? OR g.opponent_id=?)
|
||||
AND (LOWER(u1.username) LIKE ? OR LOWER(u2.username) LIKE ?)
|
||||
ORDER BY g.updated_at DESC LIMIT 5
|
||||
`).all(uid, uid, like, like));
|
||||
|
||||
// Andere Benutzer – für Chat-Navigation (nur Benutzernamen, keine sensiblen Daten)
|
||||
const users = safe(() => db.prepare(`
|
||||
SELECT id, username FROM users
|
||||
WHERE id != ? AND LOWER(username) LIKE ?
|
||||
ORDER BY username LIMIT 5
|
||||
`).all(uid, like));
|
||||
|
||||
res.json({ links, folders, snippets, calculations, orders, todos, notes,
|
||||
board_items, changelog, files, file_folders, push_schedules,
|
||||
calendar_feeds, calendar_events, qr_codes, users, geo_games });
|
||||
});
|
||||
|
||||
// Debug-Endpoint: Kalender-Cache inspizieren
|
||||
router.get('/calendar-debug', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
const count = db.prepare('SELECT COUNT(*) as n FROM calendar_event_cache WHERE user_id=?').get(uid);
|
||||
const samples = db.prepare('SELECT summary, start_dt FROM calendar_event_cache WHERE user_id=? ORDER BY start_dt LIMIT 10').all(uid);
|
||||
const today = db.prepare("SELECT strftime('%Y%m%d','now') as d").get();
|
||||
const future = db.prepare("SELECT COUNT(*) as n FROM calendar_event_cache WHERE user_id=? AND start_dt >= strftime('%Y%m%d','now')").get(uid);
|
||||
const search = db.prepare("SELECT summary, start_dt FROM calendar_event_cache WHERE user_id=? AND LOWER(summary) LIKE '%arzt%'").all(uid);
|
||||
res.json({ count: count.n, today: today.d, future_count: future.n, samples, arzt_matches: search });
|
||||
});
|
||||
|
||||
|
||||
module.exports = router;
|
||||
40
backend/src/routes/system.js
Normal file
40
backend/src/routes/system.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
let VERSION = 'v1.0.0';
|
||||
try { VERSION = fs.readFileSync(path.join(__dirname, '../../version.txt'), 'utf8').trim(); } catch {}
|
||||
|
||||
const GITEA_URL = process.env.GITEA_URL;
|
||||
const GITEA_REPO = process.env.GITEA_REPO;
|
||||
|
||||
router.get('/version', authenticate, (_req, res) => res.json({ version: VERSION }));
|
||||
|
||||
router.get('/update-check', authenticate, async (_req, res) => {
|
||||
if (!GITEA_URL || !GITEA_REPO)
|
||||
return res.json({ hasUpdate: false, currentVersion: VERSION, configured: false });
|
||||
try {
|
||||
const r = await fetch(`${GITEA_URL}/api/v1/repos/${GITEA_REPO}/releases?limit=5`,
|
||||
{ signal: AbortSignal.timeout(5000) });
|
||||
const releases = await r.json();
|
||||
if (!Array.isArray(releases) || !releases.length)
|
||||
return res.json({ hasUpdate: false, currentVersion: VERSION, configured: true });
|
||||
res.json({
|
||||
hasUpdate: releases[0].tag_name !== VERSION,
|
||||
currentVersion: VERSION,
|
||||
latestVersion: releases[0].tag_name,
|
||||
configured: true,
|
||||
releases: releases.map(r => ({
|
||||
version: r.tag_name, name: r.name || r.tag_name,
|
||||
body: r.body || '(Keine Beschreibung)', publishedAt: r.published_at,
|
||||
})),
|
||||
});
|
||||
} catch (e) {
|
||||
res.json({ hasUpdate: false, currentVersion: VERSION, configured: true });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user