/** * 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 printer = require('./printer'); const { buildReceipt } = require('./receipt'); const { provision } = require('./provision'); 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`); } // Bon-Texte: Kopf bleibt leer (der Laden traegt ihn selbst ein), der // Fuss steht auf dem, was der Kunde damit tun soll. await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('receipt_header','')`); await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('receipt_footer',?)`, ['Bitte an der Kasse bezahlen.\nVielen Dank fuer Ihren Besuch!']); await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('print_auto_on_stop','1')`); // Druckerzugang: die .env liefert nur die ERSTEN Werte. Danach lebt die // Konfiguration in der Datenbank, damit die Oberflaeche einen neuen Pi // einrichten kann, ohne dass jemand auf den Server muss. await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_host',?)`, [(process.env.PRINTER_HOST || '').trim()]); await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_port',?)`, [String(parseInt(process.env.PRINTER_PORT) || 9100)]); await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_token',?)`, [(process.env.PRINTER_TOKEN || '').trim()]); await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_width',?)`, [String(parseInt(process.env.PRINTER_WIDTH) || 42)]); await refreshPrinterConfig(); 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 setSetting(key, value) { await dbRun(`INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [key, String(value)]); } 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 }; } /** * Druckerzugang aus der Datenbank in das printer-Modul uebernehmen. * Wird beim Start und nach jeder Aenderung aufgerufen - ein Neustart des * Dienstes ist fuer einen Druckerwechsel nicht noetig. */ async function refreshPrinterConfig() { const rows = await dbAll( `SELECT key, value FROM settings WHERE key LIKE 'printer_%'`); const map = Object.fromEntries(rows.map(r => [r.key, r.value])); const cfg = printer.setConfig({ host: map.printer_host || '', port: map.printer_port, token: map.printer_token || '', width: map.printer_width }); console.log(cfg.host ? `Bondrucker: ${cfg.host}:${cfg.port}, ${cfg.width} Zeichen je Zeile` : 'Bondrucker: nicht eingerichtet'); return cfg; } /** Bon-Einstellungen: Kopf-/Fusstext und ob beim Beenden automatisch gedruckt wird. */ async function getPrintSettings() { const rows = await dbAll( `SELECT key, value FROM settings WHERE key IN ('receipt_header','receipt_footer','print_auto_on_stop')`); const map = Object.fromEntries(rows.map(r => [r.key, r.value])); return { receiptHeader: map.receipt_header || '', receiptFooter: map.receipt_footer || '', printAutoOnStop: map.print_auto_on_stop !== '0' }; } /** 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() }; } // --------------------------------------------------------------------------- // Bondruck // --------------------------------------------------------------------------- /** * Baut den Bon und schickt ihn an den Drucker-Pi. * * Gibt immer ein Ergebnis zurueck und wirft nie: ein Druckfehler ist ein * Papierproblem, kein Datenproblem. Der Vorgang ist zu diesem Zeitpunkt schon * gespeichert, der Bon laesst sich aus der Historie nachdrucken. * * @returns {Promise<{printed: boolean, error: string|null}>} */ async function printReceipt(vorgang) { if (!printer.isConfigured()) { return { printed: false, error: 'Kein Bondrucker eingerichtet.' }; } try { const texte = await getPrintSettings(); const daten = buildReceipt({ ...vorgang, header: texte.receiptHeader, footer: texte.receiptFooter, width: printer.width() }); const res = await printer.print(daten); return { printed: !!res.ok, error: res.ok ? null : res.error }; } catch (err) { console.error('[drucker] Bon konnte nicht gebaut werden:', err); return { printed: false, error: 'Bon konnte nicht erzeugt werden.' }; } } // --------------------------------------------------------------------------- // 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'))); // Papierbremse: ein hektisch mehrfach getippter Knopf soll nicht die halbe // Rolle ausspucken. Grosszuegig bemessen, im Alltag unauffaellig. const printLimiter = rateLimit({ windowMs: 5 * 60 * 1000, max: 60, standardHeaders: true, legacyHeaders: false, message: { error: 'Zu viele Druckauftraege in kurzer Zeit. Bitte kurz warten.' } }); 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))); // Die beiden Druck-Angaben steuern die Bon-Knoepfe und den Hinweistext in // der Beenden-Rueckfrage. Sie kommen hier mit, damit die Timer-Ansicht // dafuer keinen zweiten Aufruf braucht. const druck = await getPrintSettings(); res.json({ timers, rate, printerConfigured: printer.isConfigured(), printAutoOnStop: druck.printAutoOnStop, 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, entfernt den Timer und * druckt - sofern eingerichtet - den Bon fuer die Kasse. * * Reihenfolge ist Absicht: erst speichern, dann drucken. Andersherum koennte * ein Druckfehler einen bereits erfassten Vorgang verlieren. */ 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]); // Drucken, wenn es in den Einstellungen so hinterlegt ist - oder wenn der // Aufruf es ausdruecklich verlangt bzw. ausdruecklich abwaehlt. const texte = await getPrintSettings(); const wunsch = req.body && typeof req.body.print === 'boolean' ? req.body.print : texte.printAutoOnStop; let druck = { printed: false, error: null }; if (wunsch && printer.isConfigured()) { druck = await printReceipt({ kind: 'final', belegNr: result.lastID, customerName: t.customer_name || '', startedAt: t.created_at, endedAt: endTime, durationMs: duration, amount, rateAmount: rate.rateAmount, rateInterval: rate.rateInterval }); } res.json({ saved: true, id: result.lastID, duration, amount, printed: druck.printed, printError: druck.error, printerConfigured: printer.isConfigured() }); })); /** * Zwischenbon fuer einen laufenden Timer. * * Der eigentliche Anwendungsfall: der Kunde will jetzt zahlen gehen. Er * bekommt den aktuellen Stand auf Papier, der Timer laeuft davon unberuehrt * weiter - beendet wird er erst, wenn der Platz wirklich frei ist. */ app.post('/api/timers/:id/receipt', requireAuth, printLimiter, 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 (!printer.isConfigured()) { return res.status(503).json({ error: 'Kein Bondrucker eingerichtet.' }); } const rate = await getRate(); const dauer = elapsedMs(t); const betrag = calcAmount(dauer, rate.rateAmount, rate.rateInterval); const druck = await printReceipt({ kind: 'interim', belegNr: `T-${t.id}`, customerName: t.customer_name || '', startedAt: t.created_at, endedAt: Date.now(), durationMs: dauer, amount: betrag, rateAmount: rate.rateAmount, rateInterval: rate.rateInterval, stillRunning: !!t.is_running }); if (!druck.printed) return res.status(502).json({ error: druck.error }); res.json({ printed: true, duration: dauer, amount: betrag }); })); /** 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(), ...await getPrintSettings(), printer: printer.state() }); })); 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)]); // Bon-Texte sind optional: aeltere Clients schicken sie nicht mit und // sollen die vorhandenen Werte dann auch nicht leeren. const b = req.body || {}; if (typeof b.receiptHeader === 'string') { await setSetting('receipt_header', b.receiptHeader.slice(0, 240).replace(/\r/g, '')); } if (typeof b.receiptFooter === 'string') { await setSetting('receipt_footer', b.receiptFooter.slice(0, 240).replace(/\r/g, '')); } if (typeof b.printAutoOnStop === 'boolean') { await setSetting('print_auto_on_stop', b.printAutoOnStop ? '1' : '0'); } // Druckerzugang von Hand pflegen - fuer einen Pi, der nicht ueber die // Einrichtung in dieser Oberflaeche aufgesetzt wurde. let druckerGeaendert = false; if (typeof b.printerHost === 'string') { await setSetting('printer_host', b.printerHost.trim().slice(0, 120)); druckerGeaendert = true; } if (b.printerPort !== undefined) { const p = parseInt(b.printerPort); if (!Number.isInteger(p) || p < 1 || p > 65535) { return res.status(400).json({ error: 'Drucker-Port muss zwischen 1 und 65535 liegen.' }); } await setSetting('printer_port', String(p)); druckerGeaendert = true; } if (b.printerWidth !== undefined) { const w = parseInt(b.printerWidth); if (!Number.isInteger(w) || w < 24 || w > 96) { return res.status(400).json({ error: 'Zeichen je Zeile muss zwischen 24 und 96 liegen.' }); } await setSetting('printer_width', String(w)); druckerGeaendert = true; } // Das Token kommt nur herein, nie heraus. Ein leerer Wert wird ignoriert, // damit ein Speichern ohne Eingabe das vorhandene Token nicht loescht. if (typeof b.printerToken === 'string' && b.printerToken.trim()) { await setSetting('printer_token', b.printerToken.trim().slice(0, 200)); druckerGeaendert = true; } if (druckerGeaendert) await refreshPrinterConfig(); res.json({ ...await getRate(), ...await getPrintSettings(), printer: printer.state() }); })); // --------------------------------------------------------------------------- // Drucker // --------------------------------------------------------------------------- /** Erreichbarkeit und Zustand der Bruecke auf dem Drucker-Pi. */ app.get('/api/printer/status', requireAuth, wrap(async (req, res) => { if (!printer.isConfigured()) { return res.json({ configured: false, ok: false, error: 'Kein Bondrucker eingerichtet.' }); } res.json({ configured: true, ...await printer.status(), config: printer.config() }); })); // Einrichtung ist teuer und beruehrt ein fremdes Geraet - hier deutlich // enger begrenzt als beim Drucken. const provisionLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false, message: { error: 'Zu viele Einrichtungsversuche. Bitte 15 Minuten warten.' } }); /** * Drucker-Pi per SSH einrichten und anschliessend uebernehmen. * * Die Zugangsdaten kommen aus dem Formular, werden einmal benutzt und danach * vergessen: gespeichert wird ausschliesslich das erzeugte Drucker-Token. */ app.post('/api/printer/provision', requireAuth, provisionLimiter, wrap(async (req, res) => { const b = req.body || {}; const host = String(b.host || '').trim(); const username = String(b.username || '').trim(); const password = String(b.password || ''); const port = b.port === undefined || b.port === '' ? 22 : parseInt(b.port); if (!host) return res.status(400).json({ error: 'Adresse des Pi fehlt.' }); if (!username) return res.status(400).json({ error: 'Benutzername fehlt.' }); if (!password) return res.status(400).json({ error: 'Passwort fehlt.' }); if (!Number.isInteger(port) || port < 1 || port > 65535) { return res.status(400).json({ error: 'SSH-Port muss zwischen 1 und 65535 liegen.' }); } // Kein Doppelpunkt, kein Leerzeichen: eine Adresse, kein Befehlsfragment. if (!/^[A-Za-z0-9.\-_]+$/.test(host)) { return res.status(400).json({ error: 'Adresse enthält unerlaubte Zeichen.' }); } console.log(`[einrichtung] Pi ${username}@${host}:${port} wird eingerichtet`); const ergebnis = await provision({ host, port, username, password }); if (!ergebnis.ok) { console.warn('[einrichtung] fehlgeschlagen:', ergebnis.error); return res.status(502).json({ error: ergebnis.error, steps: ergebnis.steps }); } // Uebernehmen: ab hier druckt die App auf diesen Pi. await setSetting('printer_host', ergebnis.host); await setSetting('printer_port', String(ergebnis.port)); await setSetting('printer_token', ergebnis.token); await refreshPrinterConfig(); // Gegenprobe von aussen. Erreichbarkeit allein genuegt nicht: die Bruecke // meldet zusaetzlich, ob sie das Token annimmt. Ein Dienst, der noch mit // einem alten Token laeuft, wuerde sonst als "bereit" durchgehen und beim // ersten Bon mit 403 abbrechen. const probe = await printer.status(); const tokenOk = !probe.printer || probe.printer.tokenAccepted !== false; const bereit = probe.ok && tokenOk; let detail; if (bereit) { detail = `${ergebnis.host}:${ergebnis.port} antwortet und nimmt das neue Token an` + (probe.printer && probe.printer.device ? ` · Gerät ${probe.printer.device}` : ''); } else if (probe.ok && !tokenOk) { detail = 'Der Dienst antwortet, lehnt das neue Token aber ab. Läuft dort noch eine ' + 'alte Fassung der Brücke? Einrichtung bitte wiederholen.'; } else { detail = `${probe.error} — der Dienst wurde eingerichtet, antwortet aber noch nicht. ` + 'Meist steht eine Firewall zwischen Server und Pi.'; } ergebnis.steps.push({ name: 'Gegenprobe', ok: bereit, detail }); console.log(`[einrichtung] fertig, Drucker ${bereit ? 'bereit' : 'NICHT bereit'}`); res.json({ ok: true, ready: bereit, steps: ergebnis.steps, printer: printer.state() }); })); /** Probedruck - beantwortet die Frage "liegt es am Drucker oder an der App?". */ app.post('/api/printer/test', requireAuth, printLimiter, wrap(async (req, res) => { if (!printer.isConfigured()) { return res.status(503).json({ error: 'Kein Bondrucker eingerichtet.' }); } const rate = await getRate(); const jetzt = Date.now(); const druck = await printReceipt({ kind: 'interim', belegNr: 'PROBE', customerName: 'Probedruck', startedAt: jetzt - 25 * 60000, endedAt: jetzt, durationMs: 25 * 60000, amount: calcAmount(25 * 60000, rate.rateAmount, rate.rateInterval), rateAmount: rate.rateAmount, rateInterval: rate.rateInterval, stillRunning: false }); if (!druck.printed) return res.status(502).json({ error: druck.error }); res.json({ printed: true }); })); // --------------------------------------------------------------------------- // 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 }); })); /** Bon nachdrucken - fuer den Fall "Papier war leer" oder "Kunde hat ihn verlegt". */ app.post('/api/sessions/:id/receipt', requireAuth, printLimiter, wrap(async (req, res) => { const s = await dbGet(`SELECT * FROM sessions WHERE id = ?`, [req.params.id]); if (!s) return res.status(404).json({ error: 'Eintrag nicht gefunden' }); if (!printer.isConfigured()) { return res.status(503).json({ error: 'Kein Bondrucker eingerichtet.' }); } // Altbestand aus der Zeit vor der Tarif-Spalte: dann den heutigen Tarif // ausweisen - der Betrag selbst stammt weiter aus dem Datensatz. const rate = await getRate(); const druck = await printReceipt({ kind: 'final', belegNr: s.id, customerName: s.customer_name || '', startedAt: s.start_time, endedAt: s.end_time, durationMs: s.duration, amount: s.amount, rateAmount: s.rate_amount != null ? s.rate_amount : rate.rateAmount, rateInterval: s.rate_interval != null ? s.rate_interval : rate.rateInterval }); if (!druck.printed) return res.status(502).json({ error: druck.error }); res.json({ printed: 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, printer: printer.state(), 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'));