- Benutzername und "Abmelden" im eigenen Header entfallen, nur noch Rückfall, wenn topbar.js nicht lädt - Sitzungsanzeige als slot="benutzer" im Benutzer-Menü der Leiste - hi-abmelden: POST /api/logout beendet die Timer-Sitzung, laufende Timer fragen vorher nach (Abbrechen stoppt das ganze Abmelden) - html data-theme="dark": die Leiste ist dunkel wie die App, auch bei hellem System - README/DEPLOY: Abmelden, Launch-URL /auth/login, CSP-Hinweis Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1114 lines
45 KiB
JavaScript
1114 lines
45 KiB
JavaScript
/**
|
||
* 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)]);
|
||
// Leer heisst: die Bruecke auf dem Pi sucht sich selbst ein Geraet.
|
||
// Sobald in den Einstellungen einer gewaehlt wurde, gilt der.
|
||
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_device',?)`,
|
||
[(process.env.PRINTER_DEVICE || '').trim()]);
|
||
|
||
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,
|
||
device: map.printer_device || ''
|
||
});
|
||
console.log(cfg.host
|
||
? `Bondrucker: ${cfg.host}:${cfg.port}, ${cfg.width} Zeichen je Zeile, ` +
|
||
`Geraet ${cfg.device || 'automatisch'}`
|
||
: '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 || '/');
|
||
});
|
||
});
|
||
|
||
// Logout per fetch: beendet nur die lokale Sitzung. Aufgerufen von der
|
||
// Portal-Kopfleiste (Ereignis "hi-abmelden"), die danach selbst bei Portal
|
||
// und Authentik abmeldet.
|
||
app.post('/api/logout', (req, res) => {
|
||
req.session.destroy(() => {
|
||
res.clearCookie('connect.sid');
|
||
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;
|
||
}
|
||
if (typeof b.printerDevice === 'string') {
|
||
const geraet = b.printerDevice.trim();
|
||
// Derselbe Rahmen wie auf dem Pi. Der Wert wird dort noch einmal
|
||
// geprueft - eine Schranke, die nur an einem Ende steht, ist keine.
|
||
if (geraet && !/^\/dev\/(usb\/)?lp[0-9]{1,3}$/.test(geraet)) {
|
||
return res.status(400).json({ error: 'Das ist kein Druckergerät (erwartet: /dev/usb/lp0).' });
|
||
}
|
||
await setSetting('printer_device', geraet);
|
||
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() });
|
||
}));
|
||
|
||
/**
|
||
* Welche Drucker am Pi haengen.
|
||
*
|
||
* Die Liste kommt frisch vom Pi und wird nicht zwischengespeichert: wer sie
|
||
* aufruft, hat gerade ein Kabel umgesteckt und will genau das sehen.
|
||
*/
|
||
app.get('/api/printer/devices', requireAuth, wrap(async (req, res) => {
|
||
if (!printer.isConfigured()) {
|
||
return res.status(503).json({ error: 'Kein Bondrucker eingerichtet.' });
|
||
}
|
||
const liste = await printer.devices();
|
||
if (!liste.ok) return res.status(502).json({ error: liste.error });
|
||
res.json({ devices: liste.devices, fallback: liste.fallback, selected: printer.config().device });
|
||
}));
|
||
|
||
// 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'));
|