/** * Modul: Server Entry Point * Zweck: Express-App initialisieren, Middleware einbinden, Routen registrieren * Abhängigkeiten: express, helmet, dotenv, server/db.js, server/auth.js, server/routes/* */ 'use strict'; require('dotenv').config(); const express = require('express'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const path = require('path'); const { createLogger } = require('./logger'); const log = createLogger('Server'); const logSync = createLogger('Sync'); const logOikos = createLogger('Oikos'); // -------------------------------------------------------- // Datenbank initialisieren (muss vor require('./auth') stehen, // da BetterSQLiteStore im Konstruktor db.get() aufruft) // -------------------------------------------------------- const db = require('./db'); db.init(); const { router: authRouter, sessionMiddleware, requireAuth } = require('./auth'); const { csrfMiddleware } = require('./middleware/csrf'); const googleCalendar = require('./services/google-calendar'); const appleCalendar = require('./services/apple-calendar'); const app = express(); const PORT = process.env.PORT || 3000; // -------------------------------------------------------- // Security-Middleware // -------------------------------------------------------- const isSecure = process.env.SESSION_SECURE !== 'false'; app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: [ "'self'", // Inline-Script: Theme-Detection (Flash-Prevention) "'sha256-vqqBNo1oitnzIntwkG83UaYqkUAnV/oZ/RkvcA41Y6A='", // Alpine.js CDN (optional, falls verwendet) 'https://cdn.jsdelivr.net', ], styleSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", 'data:'], connectSrc: ["'self'"], fontSrc: ["'self'"], objectSrc: ["'none'"], frameSrc: ["'none'"], // upgrade-insecure-requests nur mit HTTPS aktivieren upgradeInsecureRequests: isSecure ? [] : null, }, }, // HSTS nur mit HTTPS aktivieren hsts: isSecure ? { maxAge: 31536000, includeSubDomains: true, preload: true, } : false, })); // Trust Proxy: nur aktivieren wenn ein Reverse Proxy vorgeschaltet ist (TRUST_PROXY env var). // Default 'loopback' akzeptiert nur X-Forwarded-For von localhost - verhindert IP-Spoofing. app.set('trust proxy', process.env.TRUST_PROXY || 'loopback'); // -------------------------------------------------------- // Request-Parsing // -------------------------------------------------------- app.use(express.json({ limit: '1mb' })); app.use(express.urlencoded({ extended: true, limit: '1mb' })); // JSON-Parse-Fehler abfangen (gibt sonst HTML zurück) app.use((err, req, res, next) => { if (err.type === 'entity.parse.failed') { return res.status(400).json({ error: 'Ungültiges JSON im Request-Body.', code: 400 }); } if (err.type === 'entity.too.large') { return res.status(413).json({ error: 'Request-Body zu groß (max. 1 MB).', code: 413 }); } next(err); }); // -------------------------------------------------------- // Sessions // -------------------------------------------------------- app.use(sessionMiddleware); // -------------------------------------------------------- // API-Antworten: kein Browser-Caching (Sicherheit + Aktualität) // -------------------------------------------------------- app.use('/api/', (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }); // -------------------------------------------------------- // Statische Dateien (Frontend) - differenzierte Caching-Strategie // // HTML + JS + CSS: no-cache (Browser revalidiert via ETag/304, kein stale Content // nach Deployment). Bei unverändertem File → 304 Not Modified ohne Übertragung. // Bilder + Icons + Fonts: 30 Tage immutable (ändern sich praktisch nie). // manifest.json + sw.js: no-cache (PWA-Updates sollen sofort greifen). // -------------------------------------------------------- app.use(express.static(path.join(__dirname, '..', 'public'), { etag: true, lastModified: true, setHeaders(res, filePath) { const ext = path.extname(filePath).toLowerCase(); const isPwaIcon = /\/icons\/(icon-|apple-touch-icon|favicon)/.test(filePath); if (isPwaIcon) { // PWA-Icons müssen bei Deployments sofort aktualisiert werden res.setHeader('Cache-Control', 'no-cache, must-revalidate'); } else if (['.png', '.jpg', '.jpeg', '.ico', '.svg', '.webp', '.woff2', '.woff'].includes(ext)) { res.setHeader('Cache-Control', 'public, max-age=2592000, immutable'); // 30 Tage } else { // HTML, JS, CSS, JSON, manifest, sw - immer revalidieren res.setHeader('Cache-Control', 'no-cache, must-revalidate'); } // manifest.json: korrekter MIME-Type für PWA-Erkennung durch Chrome/Android if (filePath.endsWith('manifest.json')) { res.setHeader('Content-Type', 'application/manifest+json; charset=utf-8'); } }, })); // -------------------------------------------------------- // Globaler API-Rate-Limiter (Schritt 29) // Verhindert Brute-Force und DoS auf allen API-Endpunkten. // Login hat einen eigenen, strengeren Limiter (auth.js). // -------------------------------------------------------- const apiLimiter = rateLimit({ windowMs: 60_000, // 1 Minute max: 300, // 300 Requests/Minute pro IP (großzügig für Familien-App) standardHeaders: true, legacyHeaders: false, message: { error: 'Zu viele Anfragen. Bitte warte kurz.', code: 429 }, skip: (req) => req.path === '/health', // Health-Check ausgenommen }); app.use('/api/', apiLimiter); // -------------------------------------------------------- // API-Routen // -------------------------------------------------------- app.use('/api/v1/auth', authRouter); // Alle weiteren API-Routen erfordern Authentifizierung + CSRF-Schutz app.use('/api/v1', requireAuth); app.use('/api/v1', csrfMiddleware); app.use('/api/v1/dashboard', require('./routes/dashboard')); app.use('/api/v1/tasks', require('./routes/tasks')); app.use('/api/v1/shopping', require('./routes/shopping')); app.use('/api/v1/meals', require('./routes/meals')); app.use('/api/v1/calendar', require('./routes/calendar')); app.use('/api/v1/notes', require('./routes/notes')); app.use('/api/v1/contacts', require('./routes/contacts')); app.use('/api/v1/budget', require('./routes/budget')); app.use('/api/v1/weather', require('./routes/weather')); // -------------------------------------------------------- // Health-Check (für Docker) // -------------------------------------------------------- app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // -------------------------------------------------------- // Rate-Limiter für SPA-Fallback (verhindert Dateisystem-Hammering) // -------------------------------------------------------- const spaLimiter = rateLimit({ windowMs: 60_000, max: 200, standardHeaders: true, legacyHeaders: false, message: { error: 'Zu viele Anfragen. Bitte warte kurz.', code: 429 }, }); // -------------------------------------------------------- // SPA Fallback: Alle nicht-API-Routen → index.html // -------------------------------------------------------- app.get('*', spaLimiter, (req, res) => { if (req.path.startsWith('/api/')) { return res.status(404).json({ error: 'Nicht gefunden.', code: 404 }); } res.sendFile(path.join(__dirname, '..', 'public', 'index.html')); }); // -------------------------------------------------------- // Globaler Error-Handler // -------------------------------------------------------- app.use((err, req, res, _next) => { log.error('Unbehandelter Fehler:', err); res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); }); // -------------------------------------------------------- // Auto-Sync Scheduler (Google + Apple Calendar) // -------------------------------------------------------- const SYNC_INTERVAL_MS = (parseInt(process.env.SYNC_INTERVAL_MINUTES, 10) || 15) * 60_000; async function runSync() { const { connected: googleConnected } = googleCalendar.getStatus(); if (googleConnected) { googleCalendar.sync().catch((e) => logSync.error('Google Fehler:', e.message)); } const { configured: appleConfigured } = appleCalendar.getStatus(); if (appleConfigured) { appleCalendar.sync().catch((e) => logSync.error('Apple Fehler:', e.message)); } } // -------------------------------------------------------- // Server starten // -------------------------------------------------------- app.listen(PORT, () => { logOikos.info(`Server laeuft auf Port ${PORT}`); logOikos.info(`Umgebung: ${process.env.NODE_ENV || 'development'}`); // Erster Sync nach 10 Sekunden (warten bis DB vollständig initialisiert) setTimeout(() => { runSync(); setInterval(runSync, SYNC_INTERVAL_MS); logSync.info(`Auto-Sync alle ${SYNC_INTERVAL_MS / 60_000} Minuten aktiv.`); }, 10_000); }); module.exports = app;