Files
timer-app/backend/server.js
T
Kerim (portable/claudecode)andClaude Opus 5 e68daee457 Drucker-Pi holt sich die Einrichtung, statt sie geschickt zu bekommen
Bisher richtete die App den Pi per SSH ein: Adresse, Benutzer und Passwort ins
Webformular, Knopf druecken. Das verlangte ein SSH-Passwort in einem Browser,
ein weiteres Paket (ssh2) und eine Verbindung vom Server zum Pi.

Jetzt andersherum. Die App legt einen Link bereit und wartet. Auf dem Pi wird
das Skript geholt und gestartet, dort wird der Drucker ausgewaehlt, und am
Ende meldet sich der Pi von selbst an. Wer einen Drucker ansteckt, steht
ohnehin am Pi - dort gehoert auch die Frage hin, welcher es sein soll.

Die Auswahl zeigt Klartextnamen aus dem sysfs: "EPSON TM-T88IV" statt "lp0".
Bei mehreren Druckern ist das der Unterschied zwischen Wissen und Raten.

Das ausgelieferte Skript ist zusammengesetzt, nicht statisch: einrichten.sh ist
eine Vorlage, in die setup.js Adresse, Code, print-server.py und die
systemd-Unit einsetzt. Heraus kommt EINE Datei - auf dem Pi ist nichts
nachzuladen, und eine halb geglueckte Uebertragung kann es nicht geben.

Zwei Endpunkte muessen ohne Anmeldung auskommen, weil der Pi sich bei Authentik
nicht anmelden kann. Der Einrichtungscode ist dort der einzige Schluessel: 32
Zeichen, gilt einmal und 30 Minuten, nur im Arbeitsspeicher, zeitkonstant
verglichen, hinter einem eigenen Zaehler.

Die Adresse des Pi wird der Verbindung entnommen und nicht dem Rumpf der
Anmeldung - was der Pi ueber sich behauptet, koennte auch jemand anders
behaupten. Die eigene Adresse leitet die App aus dem Host-Kopf ab und prueft
sie streng, statt sie zu maskieren: der Wert landet in einem Shell-Skript.

Geprueft am echten Pi (Debian 13, TM-T88IV): Link erzeugen, am Pi abholen,
Drucker aus der Liste waehlen, Anmeldung, Uebernahme, Probedruck. Dazu die
Abweise: verbrauchter Code, falscher Code, Abholung ohne Code, und dass die
uebrige API weiterhin 401 liefert.

Entfaellt: provision.js, install.sh, die Abhaengigkeit ssh2, das SSH-Formular.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:37:26 +02:00

1078 lines
44 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 { barcodeMaxLength } = require('./escpos');
const setup = require('./setup');
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')`);
// Strichcode fuer die Kasse: der Kassierer soll den Artikel scannen
// koennen, statt ihn zu suchen. Der Name steht hier und nicht im Code,
// weil er sich mit dem Artikelstamm der Kasse aendern kann.
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('receipt_barcode_enabled','1')`);
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('receipt_barcode_article','techniker')`);
// 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, Strichcode und der Druck beim Beenden. */
async function getPrintSettings() {
const rows = await dbAll(
`SELECT key, value FROM settings WHERE key IN
('receipt_header','receipt_footer','print_auto_on_stop',
'receipt_barcode_enabled','receipt_barcode_article')`);
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',
receiptBarcodeEnabled: map.receipt_barcode_enabled !== '0',
receiptBarcodeArticle: map.receipt_barcode_article || ''
};
}
/** 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,
barcode: texte.receiptBarcodeEnabled ? texte.receiptBarcodeArticle : '',
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');
}
if (typeof b.receiptBarcodeEnabled === 'boolean') {
await setSetting('receipt_barcode_enabled', b.receiptBarcodeEnabled ? '1' : '0');
}
if (typeof b.receiptBarcodeArticle === 'string') {
const artikel = b.receiptBarcodeArticle.trim();
// Ein Strichcode kann nur den druckbaren ASCII-Bereich darstellen. Ein
// Umlaut wuerde stillschweigend verschwinden - dann scannt die Kasse
// spaeter einen Artikel, den es so nicht gibt. Lieber hier sagen, warum.
if (/[^\x20-\x7e]/.test(artikel)) {
return res.status(400).json({
error: 'Der Artikelname darf keine Umlaute oder Sonderzeichen enthalten - mehr kann ein Strichcode nicht abbilden.'
});
}
// Zu lang heisst: der Code waere breiter als das Papier und damit
// abgeschnitten. Ein abgeschnittener Code wird gar nicht erst gelesen.
const max = barcodeMaxLength(printer.width());
if (artikel.length > max) {
return res.status(400).json({
error: `Der Artikelname passt nicht auf die Papierbreite: höchstens ${max} Zeichen.`
});
}
await setSetting('receipt_barcode_article', artikel);
}
// 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 beruehrt ein fremdes Geraet und ist von aussen erreichbar -
// hier deutlich enger begrenzt als beim Drucken.
const setupLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Zu viele Einrichtungsversuche. Bitte 15 Minuten warten.' }
});
/**
* Adresse, unter der der Pi diese App erreicht.
*
* Abgeleitet aus der Anfrage, denn die App weiss von sich aus nicht, unter
* welchem Namen sie erreichbar ist - hinter einem nginx sieht sie nur
* 127.0.0.1. Wer die Einrichtung startet, hat die App gerade offen; unter
* genau dieser Adresse findet sie auch der Pi im selben Netz. PUBLIC_URL
* ueberstimmt das, falls die Adresse fuer den Pi eine andere sein muss.
*/
function appBasisUrl(req) {
const fest = (process.env.PUBLIC_URL || '').trim();
if (fest) return fest.replace(/\/+$/, '');
return `${req.protocol}://${req.get('host')}`;
}
/**
* Adresse des Pi - aus der Verbindung, nicht aus dem Rumpf.
*
* Was der Pi ueber sich selbst behauptet, koennte auch jemand anders
* behaupten. Nur wenn die Anmeldung ueber die Rueckschleife kam (Testaufbau
* oder Tunnel), hilft die Selbstauskunft ueberhaupt weiter.
*/
function quelleAdresse(req, hinweis) {
const ip = String(req.ip || '').replace(/^::ffff:/, '');
if (ip && ip !== '::1' && ip !== '127.0.0.1') return ip;
const h = String(hinweis || '').trim();
return /^[A-Za-z0-9.\-]{1,120}$/.test(h) ? h : ip;
}
/** Einen Einrichtungscode erzeugen und den Abhol-Link dazu liefern. */
app.post('/api/printer/setup-code', requireAuth, setupLimiter, wrap(async (req, res) => {
const basis = appBasisUrl(req);
const { code, expiresAt } = setup.codeAnlegen();
// Das Skript gleich hier bauen: ein unbrauchbarer Hostname soll jetzt
// auffallen und nicht erst, wenn jemand mit dem Laptop am Pi steht.
try {
setup.skriptBauen({ appUrl: basis, code });
} catch (err) {
console.error('[einrichtung] Skript nicht baubar:', err.message);
return res.status(500).json({ error: err.message });
}
const url = `${basis}/setup/bondrucker.sh?code=${code}`;
console.log(`[einrichtung] Code erzeugt, gueltig bis ${new Date(expiresAt).toISOString()}`);
res.json({
url,
expiresAt,
command: `curl -fsSL "${url}" -o bondrucker.sh && sudo bash bondrucker.sh`
});
}));
/** Wartet noch jemand auf den Pi, oder hat er sich gemeldet? */
app.get('/api/printer/setup-status', requireAuth, wrap(async (req, res) => {
res.json({ ...setup.zustand(), printer: printer.state() });
}));
/**
* Das Einrichtungsskript ausliefern.
*
* OHNE Anmeldung: der Pi holt es mit curl ab und kann sich bei Authentik nicht
* anmelden. Der Einrichtungscode ist hier der Schluessel - er gilt einmal und
* eine halbe Stunde lang.
*/
app.get('/setup/bondrucker.sh', setupLimiter, wrap(async (req, res) => {
const eintrag = setup.codePruefen(req.query.code);
if (!eintrag) {
return res.status(403).type('text/plain; charset=utf-8').send(
'# Einrichtungscode ungueltig, verbraucht oder abgelaufen.\n' +
'# In der Timer-App unter Einstellungen einen neuen erzeugen.\n');
}
let skript;
try {
skript = setup.skriptBauen({ appUrl: appBasisUrl(req), code: eintrag.code });
} catch (err) {
console.error('[einrichtung] Skript nicht baubar:', err.message);
return res.status(500).type('text/plain; charset=utf-8').send(`# ${err.message}\n`);
}
res.type('text/x-shellscript; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="bondrucker.sh"');
res.setHeader('Cache-Control', 'no-store');
res.send(skript);
}));
/**
* Der Pi meldet sich nach der Einrichtung selbst an.
*
* OHNE Anmeldung, aus demselben Grund wie oben. Der Code wird hier verbraucht:
* ein zweiter Pi kann sich mit demselben Code nicht dazwischenschieben.
*/
app.post('/api/setup/register', setupLimiter, wrap(async (req, res) => {
const b = req.body || {};
const eintrag = setup.codePruefen(b.code);
if (!eintrag) {
console.warn('[einrichtung] Anmeldung mit ungueltigem Code abgewiesen');
return res.status(403).json({ error: 'Einrichtungscode ungueltig, verbraucht oder abgelaufen.' });
}
const token = String(b.token || '').trim();
const port = b.port === undefined ? 9100 : parseInt(b.port);
const device = String(b.device || '').slice(0, 120);
if (!token) return res.status(400).json({ error: 'Drucker-Token fehlt.' });
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return res.status(400).json({ error: 'Port muss zwischen 1 und 65535 liegen.' });
}
const host = quelleAdresse(req, b.hostHint);
if (!host) return res.status(400).json({ error: 'Adresse des Pi nicht ermittelbar.' });
await setSetting('printer_host', host);
await setSetting('printer_port', String(port));
await setSetting('printer_token', token);
await refreshPrinterConfig();
setup.codeEinloesen(host, device);
// Gegenprobe von aussen. Der Pi hat sich eben selbst geprueft, aber nur von
// innen - ob die App wirklich durchkommt, zeigt erst dieser Griff.
const probe = await printer.status();
const tokenOk = !probe.printer || probe.printer.tokenAccepted !== false;
const bereit = probe.ok && tokenOk;
console.log(`[einrichtung] ${host}:${port} angemeldet, Drucker ${bereit ? 'bereit' : 'NICHT erreichbar'}`);
res.json({ ok: true, host, port, ready: bereit });
}));
/** 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'));