Files
dickendock/backend/src/index.js

344 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ws>
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`);