Timer-App unter Versionsverwaltung stellen

Stand der laufenden Anwendung von 192.168.1.5:/opt/timer-app (systemd-Dienst
timer-app, Port 3003). Zeiterfassung mit Kostenberechnung: Node/Express,
SQLite, Anmeldung ausschliesslich ueber Authentik (OIDC, PKCE, RP-Logout) -
der frueher vorhandene Passwort-Login ist entfernt.

Nicht im Repo, bewusst:
- backend/.env und die .env-Sicherung: enthalten Session-Secret und die
  Authentik-Zugangsdaten. Vorlage ist backend/.env.example.
- data/: die Datenbank mit den echten Zeiterfassungen.
- node_modules/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-04 17:47:07 +02:00
co-authored by Claude Opus 5
commit a7e77793a8
16 changed files with 5192 additions and 0 deletions
+616
View File
@@ -0,0 +1,616 @@
/**
* 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'));