Files
timer-app/backend/server.js
T
Kerim (portable/claudecode)andClaude Opus 5 5e23ccdcbc Drucker-Pi aus der Oberflaeche heraus einrichten
Die Einrichtung war Handarbeit: Dateien per scp auf den Pi, dort install.sh
mit sudo starten, Token abschreiben, in die .env eintragen, Dienst neu
starten. Das ist genau die Sorte Arbeit, die beim naechsten Geraetetausch
niemand mehr weiss. Jetzt macht das ein Knopf unter "Einstellungen":
Adresse, Benutzer und Passwort eintragen, und die App meldet sich per SSH an,
prueft Python/systemd/Druckergeraet, uebertraegt deploy/bondrucker/, startet
install.sh mit einem selbst erzeugten Token, raeumt auf und prueft von aussen
nach. Jeder Schritt steht mit Ergebnis im Protokoll.

Damit das ueberhaupt Sinn ergibt, liegt der Druckerzugang jetzt in der
Datenbank statt in der .env - sonst muesste hinterher doch wieder jemand auf
den Server. Die PRINTER_*-Werte in der .env sind nur noch Startwerte beim
allerersten Start, wie RATE_PER_10MIN auch.

Zwei Fehler, die beim Testen der Einrichtung auffielen:

install.sh startete den Dienst nicht neu, sondern nur "enable --now". Bei
einer erneuten Einrichtung lief die Bruecke deshalb mit dem ALTEN Token
weiter und lehnte jeden Bon mit HTTP 403 ab. Jetzt: restart.

Die Gegenprobe fragte nur /status ab - und /status prueft kein Token. Ein
Dienst mit altem Token galt damit faelschlich als bereit. Die Bruecke meldet
in /status jetzt zusaetzlich, ob das mitgeschickte Token passt (Version 1.1),
und die Einrichtung prueft das mit.

Zugangsdaten werden einmal benutzt und danach vergessen; das erzeugte Token
wird aus dem Protokoll entfernt, bevor es in den Browser oder ins Journal
geht. Neu dabei: ssh2, reines JavaScript.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 20:59:56 +02:00

978 lines
40 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 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'));