const express = require('express'); const path = require('path'); const fs = require('fs'); require('./db'); const app = express(); // Hinter Nginx Proxy Manager (SSL-Terminierung) — sonst meldet req.protocol // immer "http", auch wenn der Nutzer über https zugreift (z.B. wichtig für // das MakerWorld-Bookmarklet: sonst mixed-content-blockiert der Browser den // fetch()-Call stillschweigend, weil die generierte Origin auf http:// zeigt) app.set('trust proxy', true); app.use(express.json({ limit: '10mb' })); // ── Aktivitäts-Tracking: nur bei echten Aktionen (POST/PUT/DELETE) ───────────── app.use((req, res, next) => { if (['POST','PUT','DELETE'].includes(req.method)) { const auth = req.headers.authorization; if (auth?.startsWith('Bearer ')) { try { const jwt = require('jsonwebtoken'); const p = jwt.verify(auth.slice(7), process.env.JWT_SECRET || 'dev-secret'); if (p?.id) { // Prüfen ob User vorher >15 Min inaktiv war → "gerade online gegangen" const user = db.prepare('SELECT last_active_at, role FROM users WHERE id=?').get(p.id); const wasInactive = !user?.last_active_at || (Date.now() - new Date(user.last_active_at).getTime()) > 15 * 60 * 1000; db.prepare("UPDATE users SET last_active_at=datetime('now','localtime') WHERE id=?").run(p.id); // Pushover an alle Admins (außer wenn User selbst Admin ist) if (wasInactive && user?.role !== 'admin') { const username = db.prepare('SELECT username FROM users WHERE id=?').get(p.id)?.username || 'Jemand'; const admins = db.prepare(` SELECT p.user_key, p.app_token FROM pushover_settings p JOIN users u ON u.id = p.user_id WHERE u.role = 'admin' AND p.user_key IS NOT NULL AND p.app_token IS NOT NULL `).all(); for (const admin of admins) { fetch('https://api.pushover.net/1/messages.json', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: admin.app_token, user: admin.user_key, title: '👤 DickenDock', message: `${username} ist gerade online gegangen.`, priority: -1, }), }).catch(() => {}); } } } } catch {} } } next(); }); // ── Security Headers ────────────────────────────────────────────────────────── app.use((_req, res, next) => { res.setHeader('Content-Security-Policy', [ "default-src 'self'", "script-src 'self'", // unsafe-inline nötig für React inline-styles + @import Google Fonts "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "font-src 'self' https://fonts.gstatic.com", // iCal-Feeds werden serverseitig geprosxt → nur 'self' nötig "connect-src 'self' https://web.archive.org wss://dickendock.sermer.org ws://localhost:4000", // data: für Avatare (base64), google.com + gstatic.com für Favicons im QuickLinks-Widget "img-src 'self' data: https://www.google.com https://*.gstatic.com https://image.tmdb.org", "worker-src 'self'", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-src https://archive.ph https://archive.is https://archive.today", ].join('; ')); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); next(); }); // ── API ─────────────────────────────────────────────────────────────────────── app.use('/api/auth', require('./routes/auth')); app.use('/api/admin', require('./routes/admin')); app.use('/api/calendar', require('./routes/calendar')); app.use('/api/dashboard', require('./routes/dashboard')); app.use('/api/system', require('./routes/system')); // Öffentliche Datei-Share Verwaltung (vor Auto-Loader damit /:id nicht matcht) app.use('/api/tools/dateien/public-shares', require('./tools/dateien/public-share')); // Tool-Routen automatisch laden const toolsDir = path.join(__dirname, 'tools'); fs.readdirSync(toolsDir, { withFileTypes: true }) .filter(d => d.isDirectory()) .forEach(d => { const routeFile = path.join(toolsDir, d.name, 'routes.js'); if (fs.existsSync(routeFile)) { app.use(`/api/tools/${d.name}`, require(routeFile)); console.log(` 🔧 Tool geladen: ${d.name}`); } }); app.use('/api/tools/snippets', require('./tools/snippets/routes')); app.use('/api/tools/qrcodes', require('./tools/qrcodes/routes')); app.use('/api/upload-shares', require('./tools/dateien/upload-share')); app.use('/api/public/file-share', require('./tools/dateien/public-share-public')); app.use('/api/search', require('./routes/search')); // Öffentliche Upload-Seite: React-App ausliefern – App erkennt /u/ Pfad selbst app.get('/u/:token', (_req, res) => { res.setHeader('Cache-Control', 'no-store, no-cache'); res.sendFile(require('path').join(PUBLIC, 'index.html')); }); // Öffentliche Datei-Share-Seite: React-App ausliefern – App erkennt /s/ Pfad selbst app.get('/s/:token', (_req, res) => { res.setHeader('Cache-Control', 'no-store, no-cache'); res.sendFile(require('path').join(PUBLIC, 'index.html')); }); const PUBLIC = path.join(__dirname, '../public'); app.get('/manifest.json', (_req, res) => { res.setHeader('Content-Type', 'application/manifest+json'); res.setHeader('Cache-Control', 'no-cache'); res.sendFile(path.join(PUBLIC, 'manifest.json')); }); app.get('/sw.js', (_req, res) => { res.setHeader('Content-Type', 'application/javascript'); res.setHeader('Service-Worker-Allowed', '/'); res.setHeader('Cache-Control', 'no-cache'); res.sendFile(path.join(PUBLIC, 'sw.js')); }); app.use(express.static(PUBLIC, { setHeaders: (res, filePath) => { // JS/CSS Assets haben Hash im Namen → lang cachen if (filePath.match(/\.(js|css)$/) && !filePath.includes('sw.js')) { res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); } } })); // index.html nie cachen app.get('*', (_req, res) => { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.sendFile(path.join(PUBLIC, 'index.html')); }); // Build-Zeit Endpoint – Frontend prüft ob es eine neue Version gibt app.get('/api/build-time', (_req, res) => { res.setHeader('Cache-Control', 'no-store'); try { const ver = require('fs').readFileSync(path.join(__dirname, '../version.txt'), 'utf8').trim(); res.json({ buildTime: ver }); } catch { res.json({ buildTime: 'unknown' }); } }); // ── HTTP + WebSocket Server ─────────────────────────────────────────────────── const http = require('http'); const { WebSocketServer } = require('ws'); const jwt = require('jsonwebtoken'); const server = http.createServer(app); const wss = new WebSocketServer({ server, path: '/ws/whiteboard' }); // Raum-Map: whiteboard_id → Set const rooms = new Map(); wss.on('connection', (ws, req) => { // Token aus Query-String auslesen: /ws/whiteboard?token=...&id=... const params = new URL(req.url, 'http://localhost').searchParams; const token = params.get('token'); const wbId = parseInt(params.get('id')); let userId = null; try { const p = jwt.verify(token, process.env.JWT_SECRET || 'dev-secret'); userId = p.id; } catch { ws.close(1008, 'Unauthorized'); return; } // Zugriff prüfen const wb = db.prepare('SELECT owner_id FROM whiteboards WHERE id=?').get(wbId); if (!wb) { ws.close(1008, 'Not found'); return; } const isOwner = wb.owner_id === userId; const perm = db.prepare('SELECT role FROM whiteboard_permissions WHERE whiteboard_id=? AND user_id=?').get(wbId, userId); if (!isOwner && !perm) { ws.close(1008, 'Forbidden'); return; } const role = isOwner ? 'owner' : perm.role; ws.userId = userId; ws.wbId = wbId; ws.role = role; ws.username = db.prepare('SELECT username FROM users WHERE id=?').get(userId)?.username || 'Unbekannt'; // Raum beitreten if (!rooms.has(wbId)) rooms.set(wbId, new Set()); rooms.get(wbId).add(ws); // Anderen im Raum mitteilen wer jointe broadcast(wbId, { type: 'user_join', userId, username: ws.username, role }, ws); // Aktive User-Liste an neuen Client schicken const activeUsers = [...rooms.get(wbId)] .filter(c => c !== ws && c.readyState === 1) .map(c => ({ userId: c.userId, username: c.username, role: c.role })); ws.send(JSON.stringify({ type: 'active_users', users: activeUsers })); ws.on('message', raw => { let msg; try { msg = JSON.parse(raw); } catch { return; } switch (msg.type) { case 'cursor': // Cursor-Position live broadcasten (nur wenn nicht view-only) broadcast(wbId, { type:'cursor', userId, username:ws.username, x:msg.x, y:msg.y }, ws); break; case 'elements': // Canvas-Änderungen von Edit-Berechtigten an alle broadcasten if (role === 'view') break; broadcast(wbId, { type:'elements', userId, elements: msg.elements }, ws); break; case 'ping': ws.send(JSON.stringify({ type: 'pong' })); break; } }); ws.on('close', () => { const room = rooms.get(wbId); if (room) { room.delete(ws); if (room.size === 0) rooms.delete(wbId); else broadcast(wbId, { type: 'user_leave', userId, username: ws.username }); } }); ws.on('error', () => ws.terminate()); }); function broadcast(wbId, msg, exclude = null) { const room = rooms.get(wbId); if (!room) return; const data = JSON.stringify(msg); for (const client of room) { if (client !== exclude && client.readyState === 1) { client.send(data); } } } server.listen(4000, () => console.log('🚀 Dicken Dock läuft auf Port 4000')); // ── Push-Zeitplaner Hintergrund-Job ────────────────────────────────────────── const db = require('./db'); const koepiRoutes = require('./tools/koepi/routes'); const mediaRoutes = require('./tools/media/routes'); const mqttClient = require('./mqtt'); mqttClient.connectMqtt(); // HA-Button "KöPi Check jetzt" ist ein reiner Anzeige-Refresh — sendet nie // Pushover. Benachrichtigungen bleiben exklusiv dem 06:00-Cron vorbehalten. mqttClient.setKoepiCheckHandler(() => koepiRoutes.refreshOffersOnly()); // HA-Buttons "Media Quittieren" / "Media Alle quittieren" — danach sofort den // Media-Anfragen-Sensor + die dynamischen Buttons in HA aktualisieren mqttClient.setMediaAckHandler((id) => { mediaRoutes.ackFavorite(id); mqttClient.publishMediaAnfragen(); }); mqttClient.setMediaAckAllHandler(() => { mediaRoutes.ackAllFavorites(); mqttClient.publishMediaAnfragen(); }); async function sendPushoverMsg(userKey, appToken, message, opts = {}) { try { const params = { token: appToken, user: userKey, title: 'DickenDock Erinnerung', message }; if (opts.retry && opts.expire) { params.priority = 2; params.retry = opts.retry; params.expire = opts.expire; } await fetch('https://api.pushover.net/1/messages.json', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(params), }); } catch(e) { console.error('Pushover Fehler:', e.message); } } // Beim Start auf die nächste volle Minute synchronisieren const msToNextMinute = (60 - new Date().getSeconds()) * 1000 - new Date().getMilliseconds(); setTimeout(() => { runScheduler(); setInterval(runScheduler, 60 * 1000); }, msToNextMinute); async function runScheduler() { mqttClient.publishPresence(); mqttClient.publishMediaAnfragen(); mqttClient.publishDruckStatistik(); mqttClient.publishPushErinnerungen(); mqttClient.publishKanban(); try { const now = db.prepare("SELECT datetime('now','localtime') as t").get().t; const due = db.prepare(` SELECT s.*, p.user_key, p.app_token, p.retry, p.expire FROM push_schedules s JOIN pushover_settings p ON p.user_id = s.user_id WHERE s.sent = 0 AND s.scheduled_at <= ? `).all(now); for (const row of due) { await sendPushoverMsg(row.user_key, row.app_token, row.message, { retry: row.retry, expire: row.expire }); db.prepare('UPDATE push_schedules SET sent=1 WHERE id=?').run(row.id); console.log(`✓ Push gesendet an user ${row.user_id}: "${row.message}" (fällig: ${row.scheduled_at})`); } } catch(e) { console.error('Push-Job Fehler:', e.message); } // KöPi Tages-Check: täglich um 06:00 (localtime), Duplikatschutz über admin_settings-Datum try { const now = db.prepare("SELECT datetime('now','localtime') as t").get().t; const date = now.slice(0, 10); const time = now.slice(11, 16); if (time === '06:00') { const lastRunDate = db.prepare("SELECT value FROM admin_settings WHERE key='koepi_cron_last_date'").get()?.value; if (lastRunDate !== date) { db.prepare("INSERT OR REPLACE INTO admin_settings (key, value) VALUES ('koepi_cron_last_date', ?)").run(date); await koepiRoutes.runDailyCheck(); } } } catch(e) { console.error('KöPi-Cron Fehler:', e.message); } } console.log(`⏰ Push-Scheduler aktiv – nächste Prüfung in ${Math.round(msToNextMinute/1000)}s`);