/** * Timer App - Backend * * Zeiterfassung mit Kostenberechnung fuer Ladengeschaefte. * * Kernprinzip: Timer leben SERVERSEITIG in der Datenbank, nicht im Browser. * Dadurch ueberleben sie Reload, Browserwechsel, Tablet-Neustart und sind * auf mehreren Geraeten gleichzeitig sichtbar. */ const express = require('express'); const session = require('express-session'); const rateLimit = require('express-rate-limit'); const sqlite3 = require('sqlite3').verbose(); const bcrypt = require('bcrypt'); const path = require('path'); require('dotenv').config(); const oidc = require('./oidc'); const app = express(); // --------------------------------------------------------------------------- // Konfiguration // --------------------------------------------------------------------------- const PORT = parseInt(process.env.PORT) || 3003; const HOST = process.env.HOST || '127.0.0.1'; const TRUST_PROXY = process.env.TRUST_PROXY || '1'; // COOKIE_SECURE=true verlangt HTTPS. Ohne HTTPS wird dann KEIN Cookie gesetzt // und der Login scheitert stumm -> siehe DEPLOY.md, Abschnitt "Stolpersteine". const COOKIE_SECURE = String(process.env.COOKIE_SECURE || 'true') === 'true'; const SESSION_SECRET = process.env.SESSION_SECRET; const PASSWORD_HASH = process.env.PASSWORD_HASH; const PASSWORD_PLAIN = process.env.PASSWORD; const LOGIN_MAX_ATTEMPTS = parseInt(process.env.LOGIN_MAX_ATTEMPTS) || 20; const LOGIN_WINDOW_MIN = parseInt(process.env.LOGIN_WINDOW_MIN) || 15; const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../data/timer.db'); // --- Startpruefungen: lieber sofort und laut scheitern als still falsch laufen if (!SESSION_SECRET) { console.error('FATAL: SESSION_SECRET ist nicht gesetzt. Siehe .env.example.'); process.exit(1); } // Login ist ueber Authentik (OIDC) ODER Passwort moeglich. Nur wenn KEINES // von beiden konfiguriert ist, gibt es keinen Anmeldeweg -> laut abbrechen. if (!oidc.isConfigured() && !PASSWORD_HASH && !PASSWORD_PLAIN) { console.error('FATAL: Kein Anmeldeweg konfiguriert — weder Authentik (OIDC_*) noch PASSWORD_HASH/PASSWORD.'); process.exit(1); } // Passwort-Login ist entfernt; passwordHash wird nur noch definiert, falls in der // .env (Altbestand) ein Wert steht — schadet nicht, wird aber nicht mehr genutzt. let passwordHash = PASSWORD_HASH; if (!passwordHash && PASSWORD_PLAIN) { passwordHash = bcrypt.hashSync(PASSWORD_PLAIN, 12); } app.set('trust proxy', TRUST_PROXY === 'false' ? false : (isNaN(parseInt(TRUST_PROXY)) ? TRUST_PROXY : parseInt(TRUST_PROXY))); // --------------------------------------------------------------------------- // Datenbank // --------------------------------------------------------------------------- const db = new sqlite3.Database(DB_PATH, (err) => { if (err) { console.error('FATAL: Datenbank nicht oeffenbar:', DB_PATH, err.message); process.exit(1); } console.log('Datenbank verbunden:', DB_PATH); initDatabase(); }); const dbRun = (sql, params = []) => new Promise((resolve, reject) => { db.run(sql, params, function (err) { err ? reject(err) : resolve(this); }); }); const dbGet = (sql, params = []) => new Promise((resolve, reject) => { db.get(sql, params, (err, row) => err ? reject(err) : resolve(row)); }); const dbAll = (sql, params = []) => new Promise((resolve, reject) => { db.all(sql, params, (err, rows) => err ? reject(err) : resolve(rows)); }); async function initDatabase() { try { await dbRun('PRAGMA journal_mode = WAL'); await dbRun(`CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, start_time INTEGER NOT NULL, end_time INTEGER, duration INTEGER, amount REAL, created_at INTEGER DEFAULT (strftime('%s', 'now')) )`); // Laufende Timer. Ein Datensatz pro Platz/Kunde. await dbRun(`CREATE TABLE IF NOT EXISTS active_timers ( id INTEGER PRIMARY KEY AUTOINCREMENT, customer_name TEXT NOT NULL DEFAULT '', started_at INTEGER, accumulated_ms INTEGER NOT NULL DEFAULT 0, is_running INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL )`); await dbRun(`CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL )`); // Migration: Spalten fuer Kundenname und geltenden Tarif nachruesten. const cols = await dbAll(`PRAGMA table_info(sessions)`); const names = cols.map(c => c.name); if (!names.includes('customer_name')) { await dbRun(`ALTER TABLE sessions ADD COLUMN customer_name TEXT DEFAULT ''`); console.log('Migration: sessions.customer_name ergaenzt'); } if (!names.includes('rate_amount')) { await dbRun(`ALTER TABLE sessions ADD COLUMN rate_amount REAL`); console.log('Migration: sessions.rate_amount ergaenzt'); } if (!names.includes('rate_interval')) { await dbRun(`ALTER TABLE sessions ADD COLUMN rate_interval INTEGER`); console.log('Migration: sessions.rate_interval ergaenzt'); } await dbRun(`CREATE INDEX IF NOT EXISTS idx_sessions_created ON sessions(created_at DESC)`); // Standardtarif einmalig setzen (aus .env uebernommen, falls vorhanden) const existing = await dbGet(`SELECT value FROM settings WHERE key = 'rate_amount'`); if (!existing) { const envAmount = parseInt(process.env.RATE_PER_10MIN) || 10; await dbRun(`INSERT INTO settings (key, value) VALUES ('rate_amount', ?)`, [String(envAmount)]); await dbRun(`INSERT INTO settings (key, value) VALUES ('rate_interval', '10')`); console.log(`Standardtarif gesetzt: ${envAmount} EUR pro 10 Minuten`); } console.log('Datenbank bereit'); } catch (err) { console.error('FATAL: Datenbank-Initialisierung fehlgeschlagen:', err.message); process.exit(1); } } // --------------------------------------------------------------------------- // Tarif & Berechnung (bewusst NUR serverseitig - der Client rechnet nur fuer // die Anzeige mit, der verbindliche Betrag entsteht hier) // --------------------------------------------------------------------------- async function getRate() { const rows = await dbAll(`SELECT key, value FROM settings WHERE key IN ('rate_amount','rate_interval')`); const map = Object.fromEntries(rows.map(r => [r.key, r.value])); return { rateAmount: parseFloat(map.rate_amount) || 10, rateInterval: parseInt(map.rate_interval) || 10 }; } /** Jede angefangene Einheit wird voll berechnet. */ function calcAmount(ms, rateAmount, rateInterval) { if (!ms || ms <= 0) return 0; const minutes = Math.ceil(ms / 60000); const units = Math.ceil(minutes / rateInterval); return Math.round(units * rateAmount * 100) / 100; } /** Vergangene Zeit eines Timers - laufend oder pausiert. */ function elapsedMs(timer) { const base = timer.accumulated_ms || 0; if (!timer.is_running || !timer.started_at) return base; return base + (Date.now() - timer.started_at); } async function decorate(timer, rate) { const ms = elapsedMs(timer); return { id: timer.id, customerName: timer.customer_name || '', isRunning: !!timer.is_running, elapsedMs: ms, amount: calcAmount(ms, rate.rateAmount, rate.rateInterval), startedAt: timer.started_at, createdAt: timer.created_at, serverNow: Date.now() }; } // --------------------------------------------------------------------------- // Sitzungsspeicher // // Bewusst selbst gebaut statt per Zusatzpaket: der Standardspeicher von // express-session liegt im Arbeitsspeicher: nach jedem Neustart des Dienstes // muessten sich alle Geraete neu anmelden. Da die Timer den Neustart // ueberleben, waere das ein Bruch im Bedienablauf. // --------------------------------------------------------------------------- class SqliteSessionStore extends session.Store { constructor() { super(); db.run(`CREATE TABLE IF NOT EXISTS user_sessions ( sid TEXT PRIMARY KEY, expires INTEGER NOT NULL, data TEXT NOT NULL )`, () => { db.run(`CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires)`); }); // Abgelaufene Sitzungen stuendlich entfernen this.cleanupTimer = setInterval(() => { db.run(`DELETE FROM user_sessions WHERE expires < ?`, [Date.now()]); }, 60 * 60 * 1000); this.cleanupTimer.unref(); } _expiry(sess) { const ms = sess && sess.cookie && sess.cookie.maxAge; return Date.now() + (typeof ms === 'number' ? ms : 24 * 60 * 60 * 1000); } get(sid, cb) { db.get(`SELECT data, expires FROM user_sessions WHERE sid = ?`, [sid], (err, row) => { if (err) return cb(err); if (!row) return cb(null, null); if (row.expires < Date.now()) { return db.run(`DELETE FROM user_sessions WHERE sid = ?`, [sid], () => cb(null, null)); } try { cb(null, JSON.parse(row.data)); } catch (e) { cb(null, null); } }); } set(sid, sess, cb = () => {}) { let data; try { data = JSON.stringify(sess); } catch (e) { return cb(e); } db.run(`INSERT INTO user_sessions (sid, expires, data) VALUES (?, ?, ?) ON CONFLICT(sid) DO UPDATE SET expires = excluded.expires, data = excluded.data`, [sid, this._expiry(sess), data], cb); } touch(sid, sess, cb = () => {}) { db.run(`UPDATE user_sessions SET expires = ? WHERE sid = ?`, [this._expiry(sess), sid], cb); } destroy(sid, cb = () => {}) { db.run(`DELETE FROM user_sessions WHERE sid = ?`, [sid], cb); } } // --------------------------------------------------------------------------- // Middleware // --------------------------------------------------------------------------- app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(session({ store: new SqliteSessionStore(), secret: SESSION_SECRET, resave: false, saveUninitialized: false, rolling: true, // aktive Nutzung verlaengert die Anmeldung cookie: { maxAge: 24 * 60 * 60 * 1000, httpOnly: true, sameSite: 'lax', secure: COOKIE_SECURE } })); app.use(express.static(path.join(__dirname, '../frontend'))); function requireAuth(req, res, next) { if (req.session.authenticated) return next(); res.status(401).json({ error: 'Nicht angemeldet' }); } // Fehler aus async-Handlern sauber weiterreichen const wrap = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); // --------------------------------------------------------------------------- // Auth // --------------------------------------------------------------------------- // Anmeldung ausschliesslich ueber Authentik (SSO). Der fruehere Passwort-Login // (POST /api/login) wurde bewusst entfernt. app.get('/api/auth/status', (req, res) => { res.json({ authenticated: !!req.session.authenticated, user: req.session.user || null, // bei Authentik-Login gesetzt, bei Passwort-Login null oidcConfigured: oidc.isReady() // steuert den "Anmelden mit Authentik"-Button }); }); // --- Authentik (OpenID Connect) ------------------------------------------- // Fuers Frontend: ist der Authentik-Login verfuegbar? app.get('/auth/config', (req, res) => { res.json({ oidcConfigured: oidc.isReady() }); }); // Start des Authentik-Logins -> Weiterleitung zu Authentik app.get('/auth/login', wrap(async (req, res) => { if (!oidc.isReady()) { return res.status(503).send('Authentik ist nicht konfiguriert.'); } const { url, codeVerifier, state } = await oidc.buildLoginUrl(); req.session.oidcVerifier = codeVerifier; req.session.oidcState = state; req.session.save(err => { if (err) return res.status(500).send('Session-Fehler'); res.redirect(url); }); })); // Rueckkehr von Authentik (Code -> Tokens -> Sitzung) app.get('/auth/callback', wrap(async (req, res) => { const { oidcVerifier, oidcState } = req.session; if (!oidcVerifier || !oidcState) { return res.redirect('/?error=session'); } try { const currentUrl = new URL(req.originalUrl, oidc.REDIRECT_URI); const { user, idToken } = await oidc.handleCallback(currentUrl, oidcVerifier, oidcState); req.session.regenerate(err => { if (err) return res.redirect('/?error=session'); req.session.authenticated = true; req.session.userId = req.sessionID; req.session.user = { username: user.username, displayName: user.displayName, email: user.email }; req.session.idToken = idToken; // fuer den Single-Logout req.session.save(e => res.redirect(e ? '/?error=session' : '/')); }); } catch (err) { console.error('[auth] Callback-Fehler:', err.message || err); res.redirect('/?error=oidc'); } })); // Logout per GET (Browser navigiert hierher): lokal beenden und – falls per // Authentik angemeldet – auch die SSO-Sitzung mitbeenden (RP-Initiated Logout). app.get('/auth/logout', (req, res) => { if (req.get('sec-fetch-site') === 'cross-site') { return res.status(403).send('Abmeldung nur aus der Anwendung heraus.'); } const idToken = req.session.idToken; const logoutUrl = idToken ? oidc.buildLogoutUrl(idToken) : null; req.session.destroy(() => { res.clearCookie('connect.sid'); res.redirect(logoutUrl || '/'); }); }); // Passwort-Logout (Fallback, wird per fetch aufgerufen) app.post('/api/logout', (req, res) => { req.session.destroy(() => res.json({ success: true })); }); // --------------------------------------------------------------------------- // Laufende Timer // --------------------------------------------------------------------------- app.get('/api/timers', requireAuth, wrap(async (req, res) => { const rate = await getRate(); const rows = await dbAll(`SELECT * FROM active_timers ORDER BY created_at ASC`); const timers = await Promise.all(rows.map(t => decorate(t, rate))); res.json({ timers, rate, serverNow: Date.now() }); })); app.post('/api/timers', requireAuth, wrap(async (req, res) => { const name = String((req.body && req.body.customerName) || '').trim().slice(0, 80); const now = Date.now(); const result = await dbRun( `INSERT INTO active_timers (customer_name, started_at, accumulated_ms, is_running, created_at) VALUES (?, ?, 0, 1, ?)`, [name, now, now] ); const rate = await getRate(); const row = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [result.lastID]); res.json({ timer: await decorate(row, rate) }); })); app.post('/api/timers/:id/pause', requireAuth, wrap(async (req, res) => { const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]); if (!t) return res.status(404).json({ error: 'Timer nicht gefunden' }); if (!t.is_running) return res.json({ timer: await decorate(t, await getRate()) }); await dbRun( `UPDATE active_timers SET accumulated_ms = ?, is_running = 0, started_at = NULL WHERE id = ?`, [elapsedMs(t), t.id] ); const row = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [t.id]); res.json({ timer: await decorate(row, await getRate()) }); })); app.post('/api/timers/:id/resume', requireAuth, wrap(async (req, res) => { const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]); if (!t) return res.status(404).json({ error: 'Timer nicht gefunden' }); if (t.is_running) return res.json({ timer: await decorate(t, await getRate()) }); await dbRun(`UPDATE active_timers SET started_at = ?, is_running = 1 WHERE id = ?`, [Date.now(), t.id]); const row = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [t.id]); res.json({ timer: await decorate(row, await getRate()) }); })); app.patch('/api/timers/:id', requireAuth, wrap(async (req, res) => { const name = String((req.body && req.body.customerName) || '').trim().slice(0, 80); const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]); if (!t) return res.status(404).json({ error: 'Timer nicht gefunden' }); await dbRun(`UPDATE active_timers SET customer_name = ? WHERE id = ?`, [name, t.id]); const row = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [t.id]); res.json({ timer: await decorate(row, await getRate()) }); })); /** Beenden: schreibt die Session in die Historie und entfernt den Timer. */ app.post('/api/timers/:id/stop', requireAuth, wrap(async (req, res) => { const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]); if (!t) return res.status(404).json({ error: 'Timer nicht gefunden' }); const rate = await getRate(); const duration = elapsedMs(t); const amount = calcAmount(duration, rate.rateAmount, rate.rateInterval); const endTime = Date.now(); if (duration <= 0) { await dbRun(`DELETE FROM active_timers WHERE id = ?`, [t.id]); return res.json({ saved: false, reason: 'Keine Zeit erfasst - nichts gespeichert.' }); } const result = await dbRun( `INSERT INTO sessions (session_id, start_time, end_time, duration, amount, customer_name, rate_amount, rate_interval, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [req.session.userId || 'unknown', t.created_at, endTime, duration, amount, t.customer_name || '', rate.rateAmount, rate.rateInterval, Math.floor(endTime / 1000)] ); await dbRun(`DELETE FROM active_timers WHERE id = ?`, [t.id]); res.json({ saved: true, id: result.lastID, duration, amount }); })); /** Verwerfen: Timer weg, nichts in der Historie. */ app.delete('/api/timers/:id', requireAuth, wrap(async (req, res) => { await dbRun(`DELETE FROM active_timers WHERE id = ?`, [req.params.id]); res.json({ success: true }); })); // --------------------------------------------------------------------------- // Einstellungen (serverseitig - gelten fuer ALLE Geraete) // --------------------------------------------------------------------------- app.get('/api/settings', requireAuth, wrap(async (req, res) => { res.json(await getRate()); })); app.put('/api/settings', requireAuth, wrap(async (req, res) => { const amount = parseFloat(req.body && req.body.rateAmount); const interval = parseInt(req.body && req.body.rateInterval); if (!isFinite(amount) || amount <= 0 || amount > 10000) { return res.status(400).json({ error: 'Betrag muss zwischen 0 und 10000 liegen.' }); } if (!Number.isInteger(interval) || interval < 1 || interval > 600) { return res.status(400).json({ error: 'Intervall muss zwischen 1 und 600 Minuten liegen.' }); } await dbRun(`INSERT INTO settings (key,value) VALUES ('rate_amount',?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [String(amount)]); await dbRun(`INSERT INTO settings (key,value) VALUES ('rate_interval',?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [String(interval)]); res.json(await getRate()); })); // --------------------------------------------------------------------------- // Historie // --------------------------------------------------------------------------- /** Baut die WHERE-Klausel fuer den Datumsfilter (Serverzeitzone). */ function dateFilter(query) { const clauses = []; const params = []; if (query.from) { clauses.push(`date(created_at, 'unixepoch', 'localtime') >= date(?)`); params.push(String(query.from)); } if (query.to) { clauses.push(`date(created_at, 'unixepoch', 'localtime') <= date(?)`); params.push(String(query.to)); } if (query.q) { clauses.push(`customer_name LIKE ?`); params.push('%' + String(query.q) + '%'); } return { where: clauses.length ? 'WHERE ' + clauses.join(' AND ') : '', params }; } app.get('/api/sessions', requireAuth, wrap(async (req, res) => { const limit = Math.min(parseInt(req.query.limit) || 50, 500); const offset = Math.max(parseInt(req.query.offset) || 0, 0); const { where, params } = dateFilter(req.query); const rows = await dbAll( `SELECT * FROM sessions ${where} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`, [...params, limit, offset] ); // Summen ueber den GESAMTEN Filter, nicht nur ueber die angezeigte Seite const totals = await dbGet( `SELECT COUNT(*) AS count, COALESCE(SUM(duration),0) AS duration, COALESCE(SUM(amount),0) AS amount FROM sessions ${where}`, params ); res.json({ sessions: rows, totals, limit, offset }); })); app.delete('/api/sessions/:id', requireAuth, wrap(async (req, res) => { const result = await dbRun(`DELETE FROM sessions WHERE id = ?`, [req.params.id]); if (result.changes === 0) return res.status(404).json({ error: 'Eintrag nicht gefunden' }); res.json({ success: true }); })); /** CSV fuer Excel: Semikolon, UTF-8-BOM, deutsches Dezimalkomma. */ app.get('/api/sessions/export.csv', requireAuth, wrap(async (req, res) => { const { where, params } = dateFilter(req.query); const rows = await dbAll(`SELECT * FROM sessions ${where} ORDER BY created_at DESC, id DESC`, params); const esc = v => { const s = String(v == null ? '' : v); return /[";\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; const fmtTs = ms => { if (!ms) return ''; const d = new Date(Number(ms)); const p = n => String(n).padStart(2, '0'); return `${p(d.getDate())}.${p(d.getMonth() + 1)}.${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; }; const fmtDur = ms => { const s = Math.floor((ms || 0) / 1000); const p = n => String(n).padStart(2, '0'); return `${p(Math.floor(s / 3600))}:${p(Math.floor((s % 3600) / 60))}:${p(s % 60)}`; }; const fmtNum = n => String(Number(n || 0).toFixed(2)).replace('.', ','); const header = ['ID', 'Kunde', 'Beginn', 'Ende', 'Dauer', 'Dauer (Minuten)', 'Betrag (EUR)', 'Tarif']; const lines = [header.join(';')]; for (const r of rows) { lines.push([ r.id, esc(r.customer_name || ''), fmtTs(r.start_time), fmtTs(r.end_time), fmtDur(r.duration), fmtNum((r.duration || 0) / 60000), fmtNum(r.amount), r.rate_amount ? `${fmtNum(r.rate_amount)} EUR / ${r.rate_interval} Min` : '' ].join(';')); } const stamp = new Date().toISOString().slice(0, 10); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="zeiterfassung-${stamp}.csv"`); res.send('' + lines.join('\r\n') + '\r\n'); })); // --------------------------------------------------------------------------- // Health & Fehlerbehandlung // --------------------------------------------------------------------------- app.get('/health', wrap(async (req, res) => { const rate = await getRate(); const active = await dbGet(`SELECT COUNT(*) AS c FROM active_timers`); res.json({ status: 'ok', rateAmount: rate.rateAmount, rateInterval: rate.rateInterval, activeTimers: active.c, uptimeSeconds: Math.floor(process.uptime()) }); })); app.use((err, req, res, next) => { console.error('Unerwarteter Fehler:', err); res.status(500).json({ error: 'Interner Serverfehler' }); }); // --------------------------------------------------------------------------- // Authentik-Discovery anstossen (nicht-blockierend; loggt das Ergebnis selbst) oidc.init(); const server = app.listen(PORT, HOST, () => { console.log(`Timer App laeuft auf http://${HOST}:${PORT}`); console.log(`Cookie secure: ${COOKIE_SECURE} (bei false ist HTTP erlaubt)`); }); function shutdown(signal) { console.log(`${signal} empfangen - fahre herunter.`); server.close(() => db.close(() => process.exit(0))); setTimeout(() => process.exit(0), 5000).unref(); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));