Files
timer-app/backend/server.js
T
adminandClaude Opus 5 a7e77793a8 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>
2026-09-04 17:47:07 +02:00

617 lines
24 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/**
* 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'));