344 lines
14 KiB
JavaScript
344 lines
14 KiB
JavaScript
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`);
|