Timer-App unter Versionsverwaltung stellen
Stand der laufenden Anwendung von 192.168.1.5:/opt/timer-app (systemd-Dienst timer-app, Port 3003). Zeiterfassung mit Kostenberechnung: Node/Express, SQLite, Anmeldung ausschliesslich ueber Authentik (OIDC, PKCE, RP-Logout) - der frueher vorhandene Passwort-Login ist entfernt. Nicht im Repo, bewusst: - backend/.env und die .env-Sicherung: enthalten Session-Secret und die Authentik-Zugangsdaten. Vorlage ist backend/.env.example. - data/: die Datenbank mit den echten Zeiterfassungen. - node_modules/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# ===========================================================================
|
||||
# Timer App - Konfiguration
|
||||
# Diese Datei nach ".env" kopieren und ausfuellen. Die .env gehoert NICHT
|
||||
# in die Versionsverwaltung.
|
||||
# ===========================================================================
|
||||
|
||||
# --- Netzwerk --------------------------------------------------------------
|
||||
|
||||
PORT=3003
|
||||
|
||||
# Auf welcher Adresse gelauscht wird.
|
||||
# 127.0.0.1 = nur lokal erreichbar -> RICHTIG, wenn ein Reverse Proxy
|
||||
# (nginx, Caddy, Traefik) davorsteht
|
||||
# 0.0.0.0 = im ganzen Netz erreichbar -> nur ohne Reverse Proxy, und dann
|
||||
# bitte COOKIE_SECURE=false beachten (siehe unten)
|
||||
HOST=127.0.0.1
|
||||
|
||||
# Anzahl der Proxy-Ebenen davor. Bei genau einem Reverse Proxy: 1.
|
||||
# Ohne Reverse Proxy: false
|
||||
TRUST_PROXY=1
|
||||
|
||||
# --- Sicherheit ------------------------------------------------------------
|
||||
|
||||
# ACHTUNG, haeufigste Fehlerquelle beim Deployment:
|
||||
# true -> Cookie wird NUR ueber HTTPS gesetzt. Laeuft die App ueber http://,
|
||||
# scheitert der Login STUMM (Passwort wird akzeptiert, aber die
|
||||
# Sitzung kommt nie zustande).
|
||||
# false -> auch ueber http:// nutzbar. Nur im internen Netz vertretbar.
|
||||
COOKIE_SECURE=true
|
||||
|
||||
# Zufaelliger Wert, unterschreibt die Sitzungs-Cookies.
|
||||
# Erzeugen mit: npm run gen-secret
|
||||
SESSION_SECRET=HIER_EINEN_ZUFALLSWERT_EINSETZEN
|
||||
|
||||
# Passwort fuer die Anmeldung. ZWEI Wege - bitte den ersten waehlen:
|
||||
#
|
||||
# 1) EMPFOHLEN: bcrypt-Hash. Erzeugen mit:
|
||||
# npm run hash-password -- "MeinPasswort"
|
||||
PASSWORD_HASH=
|
||||
|
||||
# 2) Notfalls Klartext. Wird beim Start gehasht, steht aber lesbar in dieser
|
||||
# Datei. Der Server warnt beim Start. Nur eins von beiden setzen.
|
||||
#PASSWORD=
|
||||
|
||||
# Anmeldeversuche pro Zeitfenster (Schutz gegen Durchprobieren)
|
||||
LOGIN_MAX_ATTEMPTS=20
|
||||
LOGIN_WINDOW_MIN=15
|
||||
|
||||
# --- Daten -----------------------------------------------------------------
|
||||
|
||||
# Pfad zur SQLite-Datei. Standard: ../data/timer.db relativ zum backend/.
|
||||
# Das Verzeichnis muss existieren und fuer den Dienst-Benutzer schreibbar sein.
|
||||
#DB_PATH=/var/lib/timer-app/timer.db
|
||||
|
||||
# Nur beim allerersten Start relevant: setzt den Anfangstarif.
|
||||
# Danach wird der Tarif in der Datenbank gepflegt (Menue "Einstellungen").
|
||||
RATE_PER_10MIN=10
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentik-Anbindung (OpenID Connect) — gleiches Muster wie das VVL-Tool.
|
||||
//
|
||||
// openid-client v6 ist ESM-only, diese App ist CommonJS. Deshalb wird das
|
||||
// Modul einmalig per dynamischem import() geladen und zwischengespeichert.
|
||||
// Ist OIDC nicht konfiguriert (Variablen fehlen), bleibt der Passwort-Login
|
||||
// aktiv und diese Funktionen melden "nicht bereit".
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ISSUER = process.env.OIDC_ISSUER || '';
|
||||
const CLIENT_ID = process.env.OIDC_CLIENT_ID || '';
|
||||
const CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || '';
|
||||
const REDIRECT_URI = process.env.OIDC_REDIRECT_URI || '';
|
||||
const ALLOW_INSECURE = String(process.env.OIDC_ALLOW_INSECURE || '') === 'true';
|
||||
const SCOPE = 'openid profile email';
|
||||
|
||||
let oidc = null; // das openid-client-Modul (via dynamic import)
|
||||
let oidcConfig = null; // Ergebnis der Discovery
|
||||
|
||||
function isConfigured() {
|
||||
return Boolean(ISSUER && CLIENT_ID && CLIENT_SECRET && REDIRECT_URI);
|
||||
}
|
||||
|
||||
function isReady() {
|
||||
return oidcConfig !== null;
|
||||
}
|
||||
|
||||
/** Discovery beim Serverstart. Wirft nicht — der Server startet auch, wenn
|
||||
* Authentik gerade nicht erreichbar ist (Login schlaegt dann fehl). */
|
||||
async function init() {
|
||||
if (!isConfigured()) {
|
||||
console.warn('[oidc] Authentik nicht konfiguriert (OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI) — nur Passwort-Login aktiv.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
oidc = await import('openid-client');
|
||||
oidcConfig = await oidc.discovery(
|
||||
new URL(ISSUER),
|
||||
CLIENT_ID,
|
||||
CLIENT_SECRET,
|
||||
undefined,
|
||||
ALLOW_INSECURE ? { execute: [oidc.allowInsecureRequests] } : undefined
|
||||
);
|
||||
console.log('[oidc] Discovery erfolgreich:', ISSUER);
|
||||
} catch (err) {
|
||||
console.error('[oidc] Discovery fehlgeschlagen:', err.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Baut die Weiterleitungs-URL zu Authentik (PKCE). */
|
||||
async function buildLoginUrl() {
|
||||
if (!oidcConfig) throw new Error('OIDC nicht initialisiert');
|
||||
const codeVerifier = oidc.randomPKCECodeVerifier();
|
||||
const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
|
||||
const state = oidc.randomState();
|
||||
const url = oidc.buildAuthorizationUrl(oidcConfig, {
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: SCOPE,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
});
|
||||
return { url: url.href, codeVerifier, state };
|
||||
}
|
||||
|
||||
/** Tauscht den Code gegen Tokens und liefert die Nutzer-Claims. */
|
||||
async function handleCallback(currentUrl, codeVerifier, state) {
|
||||
if (!oidcConfig) throw new Error('OIDC nicht initialisiert');
|
||||
const tokens = await oidc.authorizationCodeGrant(oidcConfig, currentUrl, {
|
||||
pkceCodeVerifier: codeVerifier,
|
||||
expectedState: state,
|
||||
});
|
||||
const claims = tokens.claims();
|
||||
if (!claims || !claims.sub) throw new Error('ID-Token ohne sub-Claim');
|
||||
return {
|
||||
user: {
|
||||
sub: claims.sub,
|
||||
username: claims.preferred_username || claims.sub,
|
||||
displayName: claims.name || claims.preferred_username || null,
|
||||
email: claims.email || null,
|
||||
},
|
||||
idToken: tokens.id_token || '',
|
||||
};
|
||||
}
|
||||
|
||||
/** RP-Initiated Logout: beendet auch die Authentik-Sitzung. Liefert null,
|
||||
* wenn kein end_session_endpoint vorhanden oder OIDC nicht bereit ist. */
|
||||
function buildLogoutUrl(idToken) {
|
||||
if (!oidcConfig) return null;
|
||||
try {
|
||||
const params = { post_logout_redirect_uri: new URL(REDIRECT_URI).origin + '/' };
|
||||
if (idToken) params.id_token_hint = idToken;
|
||||
return oidc.buildEndSessionUrl(oidcConfig, params).href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
isReady,
|
||||
isConfigured,
|
||||
buildLoginUrl,
|
||||
handleCallback,
|
||||
buildLogoutUrl,
|
||||
REDIRECT_URI,
|
||||
};
|
||||
Generated
+2352
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "timer-app-backend",
|
||||
"version": "2.0.0",
|
||||
"description": "Zeiterfassung mit Kostenberechnung - Backend",
|
||||
"main": "server.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"hash-password": "node tools/hash-password.js",
|
||||
"gen-secret": "node -e \"console.log(require('crypto').randomBytes(48).toString('hex'))\""
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"express-session": "^1.17.3",
|
||||
"openid-client": "^6.8.4",
|
||||
"sqlite3": "^5.1.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
/**
|
||||
* Timer App - Backend
|
||||
*
|
||||
* Zeiterfassung mit Kostenberechnung fuer Ladengeschaefte.
|
||||
*
|
||||
* Kernprinzip: Timer leben SERVERSEITIG in der Datenbank, nicht im Browser.
|
||||
* Dadurch ueberleben sie Reload, Browserwechsel, Tablet-Neustart und sind
|
||||
* auf mehreren Geraeten gleichzeitig sichtbar.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const bcrypt = require('bcrypt');
|
||||
const path = require('path');
|
||||
require('dotenv').config();
|
||||
const oidc = require('./oidc');
|
||||
|
||||
const app = express();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Konfiguration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PORT = parseInt(process.env.PORT) || 3003;
|
||||
const HOST = process.env.HOST || '127.0.0.1';
|
||||
const TRUST_PROXY = process.env.TRUST_PROXY || '1';
|
||||
|
||||
// COOKIE_SECURE=true verlangt HTTPS. Ohne HTTPS wird dann KEIN Cookie gesetzt
|
||||
// und der Login scheitert stumm -> siehe DEPLOY.md, Abschnitt "Stolpersteine".
|
||||
const COOKIE_SECURE = String(process.env.COOKIE_SECURE || 'true') === 'true';
|
||||
|
||||
const SESSION_SECRET = process.env.SESSION_SECRET;
|
||||
const PASSWORD_HASH = process.env.PASSWORD_HASH;
|
||||
const PASSWORD_PLAIN = process.env.PASSWORD;
|
||||
|
||||
const LOGIN_MAX_ATTEMPTS = parseInt(process.env.LOGIN_MAX_ATTEMPTS) || 20;
|
||||
const LOGIN_WINDOW_MIN = parseInt(process.env.LOGIN_WINDOW_MIN) || 15;
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../data/timer.db');
|
||||
|
||||
// --- Startpruefungen: lieber sofort und laut scheitern als still falsch laufen
|
||||
|
||||
if (!SESSION_SECRET) {
|
||||
console.error('FATAL: SESSION_SECRET ist nicht gesetzt. Siehe .env.example.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Login ist ueber Authentik (OIDC) ODER Passwort moeglich. Nur wenn KEINES
|
||||
// von beiden konfiguriert ist, gibt es keinen Anmeldeweg -> laut abbrechen.
|
||||
if (!oidc.isConfigured() && !PASSWORD_HASH && !PASSWORD_PLAIN) {
|
||||
console.error('FATAL: Kein Anmeldeweg konfiguriert — weder Authentik (OIDC_*) noch PASSWORD_HASH/PASSWORD.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Passwort-Login ist entfernt; passwordHash wird nur noch definiert, falls in der
|
||||
// .env (Altbestand) ein Wert steht — schadet nicht, wird aber nicht mehr genutzt.
|
||||
let passwordHash = PASSWORD_HASH;
|
||||
if (!passwordHash && PASSWORD_PLAIN) {
|
||||
passwordHash = bcrypt.hashSync(PASSWORD_PLAIN, 12);
|
||||
}
|
||||
|
||||
app.set('trust proxy', TRUST_PROXY === 'false' ? false : (isNaN(parseInt(TRUST_PROXY)) ? TRUST_PROXY : parseInt(TRUST_PROXY)));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Datenbank
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const db = new sqlite3.Database(DB_PATH, (err) => {
|
||||
if (err) {
|
||||
console.error('FATAL: Datenbank nicht oeffenbar:', DB_PATH, err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Datenbank verbunden:', DB_PATH);
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
const dbRun = (sql, params = []) => new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) { err ? reject(err) : resolve(this); });
|
||||
});
|
||||
const dbGet = (sql, params = []) => new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (err, row) => err ? reject(err) : resolve(row));
|
||||
});
|
||||
const dbAll = (sql, params = []) => new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => err ? reject(err) : resolve(rows));
|
||||
});
|
||||
|
||||
async function initDatabase() {
|
||||
try {
|
||||
await dbRun('PRAGMA journal_mode = WAL');
|
||||
|
||||
await dbRun(`CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
start_time INTEGER NOT NULL,
|
||||
end_time INTEGER,
|
||||
duration INTEGER,
|
||||
amount REAL,
|
||||
created_at INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
)`);
|
||||
|
||||
// Laufende Timer. Ein Datensatz pro Platz/Kunde.
|
||||
await dbRun(`CREATE TABLE IF NOT EXISTS active_timers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
customer_name TEXT NOT NULL DEFAULT '',
|
||||
started_at INTEGER,
|
||||
accumulated_ms INTEGER NOT NULL DEFAULT 0,
|
||||
is_running INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL
|
||||
)`);
|
||||
|
||||
await dbRun(`CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)`);
|
||||
|
||||
// Migration: Spalten fuer Kundenname und geltenden Tarif nachruesten.
|
||||
const cols = await dbAll(`PRAGMA table_info(sessions)`);
|
||||
const names = cols.map(c => c.name);
|
||||
if (!names.includes('customer_name')) {
|
||||
await dbRun(`ALTER TABLE sessions ADD COLUMN customer_name TEXT DEFAULT ''`);
|
||||
console.log('Migration: sessions.customer_name ergaenzt');
|
||||
}
|
||||
if (!names.includes('rate_amount')) {
|
||||
await dbRun(`ALTER TABLE sessions ADD COLUMN rate_amount REAL`);
|
||||
console.log('Migration: sessions.rate_amount ergaenzt');
|
||||
}
|
||||
if (!names.includes('rate_interval')) {
|
||||
await dbRun(`ALTER TABLE sessions ADD COLUMN rate_interval INTEGER`);
|
||||
console.log('Migration: sessions.rate_interval ergaenzt');
|
||||
}
|
||||
|
||||
await dbRun(`CREATE INDEX IF NOT EXISTS idx_sessions_created ON sessions(created_at DESC)`);
|
||||
|
||||
// Standardtarif einmalig setzen (aus .env uebernommen, falls vorhanden)
|
||||
const existing = await dbGet(`SELECT value FROM settings WHERE key = 'rate_amount'`);
|
||||
if (!existing) {
|
||||
const envAmount = parseInt(process.env.RATE_PER_10MIN) || 10;
|
||||
await dbRun(`INSERT INTO settings (key, value) VALUES ('rate_amount', ?)`, [String(envAmount)]);
|
||||
await dbRun(`INSERT INTO settings (key, value) VALUES ('rate_interval', '10')`);
|
||||
console.log(`Standardtarif gesetzt: ${envAmount} EUR pro 10 Minuten`);
|
||||
}
|
||||
|
||||
console.log('Datenbank bereit');
|
||||
} catch (err) {
|
||||
console.error('FATAL: Datenbank-Initialisierung fehlgeschlagen:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tarif & Berechnung (bewusst NUR serverseitig - der Client rechnet nur fuer
|
||||
// die Anzeige mit, der verbindliche Betrag entsteht hier)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getRate() {
|
||||
const rows = await dbAll(`SELECT key, value FROM settings WHERE key IN ('rate_amount','rate_interval')`);
|
||||
const map = Object.fromEntries(rows.map(r => [r.key, r.value]));
|
||||
return {
|
||||
rateAmount: parseFloat(map.rate_amount) || 10,
|
||||
rateInterval: parseInt(map.rate_interval) || 10
|
||||
};
|
||||
}
|
||||
|
||||
/** Jede angefangene Einheit wird voll berechnet. */
|
||||
function calcAmount(ms, rateAmount, rateInterval) {
|
||||
if (!ms || ms <= 0) return 0;
|
||||
const minutes = Math.ceil(ms / 60000);
|
||||
const units = Math.ceil(minutes / rateInterval);
|
||||
return Math.round(units * rateAmount * 100) / 100;
|
||||
}
|
||||
|
||||
/** Vergangene Zeit eines Timers - laufend oder pausiert. */
|
||||
function elapsedMs(timer) {
|
||||
const base = timer.accumulated_ms || 0;
|
||||
if (!timer.is_running || !timer.started_at) return base;
|
||||
return base + (Date.now() - timer.started_at);
|
||||
}
|
||||
|
||||
async function decorate(timer, rate) {
|
||||
const ms = elapsedMs(timer);
|
||||
return {
|
||||
id: timer.id,
|
||||
customerName: timer.customer_name || '',
|
||||
isRunning: !!timer.is_running,
|
||||
elapsedMs: ms,
|
||||
amount: calcAmount(ms, rate.rateAmount, rate.rateInterval),
|
||||
startedAt: timer.started_at,
|
||||
createdAt: timer.created_at,
|
||||
serverNow: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sitzungsspeicher
|
||||
//
|
||||
// Bewusst selbst gebaut statt per Zusatzpaket: der Standardspeicher von
|
||||
// express-session liegt im Arbeitsspeicher: nach jedem Neustart des Dienstes
|
||||
// muessten sich alle Geraete neu anmelden. Da die Timer den Neustart
|
||||
// ueberleben, waere das ein Bruch im Bedienablauf.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class SqliteSessionStore extends session.Store {
|
||||
constructor() {
|
||||
super();
|
||||
db.run(`CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
sid TEXT PRIMARY KEY,
|
||||
expires INTEGER NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
)`, () => {
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires)`);
|
||||
});
|
||||
|
||||
// Abgelaufene Sitzungen stuendlich entfernen
|
||||
this.cleanupTimer = setInterval(() => {
|
||||
db.run(`DELETE FROM user_sessions WHERE expires < ?`, [Date.now()]);
|
||||
}, 60 * 60 * 1000);
|
||||
this.cleanupTimer.unref();
|
||||
}
|
||||
|
||||
_expiry(sess) {
|
||||
const ms = sess && sess.cookie && sess.cookie.maxAge;
|
||||
return Date.now() + (typeof ms === 'number' ? ms : 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
get(sid, cb) {
|
||||
db.get(`SELECT data, expires FROM user_sessions WHERE sid = ?`, [sid], (err, row) => {
|
||||
if (err) return cb(err);
|
||||
if (!row) return cb(null, null);
|
||||
if (row.expires < Date.now()) {
|
||||
return db.run(`DELETE FROM user_sessions WHERE sid = ?`, [sid], () => cb(null, null));
|
||||
}
|
||||
try {
|
||||
cb(null, JSON.parse(row.data));
|
||||
} catch (e) {
|
||||
cb(null, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
set(sid, sess, cb = () => {}) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.stringify(sess);
|
||||
} catch (e) {
|
||||
return cb(e);
|
||||
}
|
||||
db.run(`INSERT INTO user_sessions (sid, expires, data) VALUES (?, ?, ?)
|
||||
ON CONFLICT(sid) DO UPDATE SET expires = excluded.expires, data = excluded.data`,
|
||||
[sid, this._expiry(sess), data], cb);
|
||||
}
|
||||
|
||||
touch(sid, sess, cb = () => {}) {
|
||||
db.run(`UPDATE user_sessions SET expires = ? WHERE sid = ?`, [this._expiry(sess), sid], cb);
|
||||
}
|
||||
|
||||
destroy(sid, cb = () => {}) {
|
||||
db.run(`DELETE FROM user_sessions WHERE sid = ?`, [sid], cb);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Middleware
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
app.use(session({
|
||||
store: new SqliteSessionStore(),
|
||||
secret: SESSION_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
rolling: true, // aktive Nutzung verlaengert die Anmeldung
|
||||
cookie: {
|
||||
maxAge: 24 * 60 * 60 * 1000,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: COOKIE_SECURE
|
||||
}
|
||||
}));
|
||||
|
||||
app.use(express.static(path.join(__dirname, '../frontend')));
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (req.session.authenticated) return next();
|
||||
res.status(401).json({ error: 'Nicht angemeldet' });
|
||||
}
|
||||
|
||||
// Fehler aus async-Handlern sauber weiterreichen
|
||||
const wrap = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Anmeldung ausschliesslich ueber Authentik (SSO). Der fruehere Passwort-Login
|
||||
// (POST /api/login) wurde bewusst entfernt.
|
||||
|
||||
app.get('/api/auth/status', (req, res) => {
|
||||
res.json({
|
||||
authenticated: !!req.session.authenticated,
|
||||
user: req.session.user || null, // bei Authentik-Login gesetzt, bei Passwort-Login null
|
||||
oidcConfigured: oidc.isReady() // steuert den "Anmelden mit Authentik"-Button
|
||||
});
|
||||
});
|
||||
|
||||
// --- Authentik (OpenID Connect) -------------------------------------------
|
||||
|
||||
// Fuers Frontend: ist der Authentik-Login verfuegbar?
|
||||
app.get('/auth/config', (req, res) => {
|
||||
res.json({ oidcConfigured: oidc.isReady() });
|
||||
});
|
||||
|
||||
// Start des Authentik-Logins -> Weiterleitung zu Authentik
|
||||
app.get('/auth/login', wrap(async (req, res) => {
|
||||
if (!oidc.isReady()) {
|
||||
return res.status(503).send('Authentik ist nicht konfiguriert.');
|
||||
}
|
||||
const { url, codeVerifier, state } = await oidc.buildLoginUrl();
|
||||
req.session.oidcVerifier = codeVerifier;
|
||||
req.session.oidcState = state;
|
||||
req.session.save(err => {
|
||||
if (err) return res.status(500).send('Session-Fehler');
|
||||
res.redirect(url);
|
||||
});
|
||||
}));
|
||||
|
||||
// Rueckkehr von Authentik (Code -> Tokens -> Sitzung)
|
||||
app.get('/auth/callback', wrap(async (req, res) => {
|
||||
const { oidcVerifier, oidcState } = req.session;
|
||||
if (!oidcVerifier || !oidcState) {
|
||||
return res.redirect('/?error=session');
|
||||
}
|
||||
try {
|
||||
const currentUrl = new URL(req.originalUrl, oidc.REDIRECT_URI);
|
||||
const { user, idToken } = await oidc.handleCallback(currentUrl, oidcVerifier, oidcState);
|
||||
req.session.regenerate(err => {
|
||||
if (err) return res.redirect('/?error=session');
|
||||
req.session.authenticated = true;
|
||||
req.session.userId = req.sessionID;
|
||||
req.session.user = { username: user.username, displayName: user.displayName, email: user.email };
|
||||
req.session.idToken = idToken; // fuer den Single-Logout
|
||||
req.session.save(e => res.redirect(e ? '/?error=session' : '/'));
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[auth] Callback-Fehler:', err.message || err);
|
||||
res.redirect('/?error=oidc');
|
||||
}
|
||||
}));
|
||||
|
||||
// Logout per GET (Browser navigiert hierher): lokal beenden und – falls per
|
||||
// Authentik angemeldet – auch die SSO-Sitzung mitbeenden (RP-Initiated Logout).
|
||||
app.get('/auth/logout', (req, res) => {
|
||||
if (req.get('sec-fetch-site') === 'cross-site') {
|
||||
return res.status(403).send('Abmeldung nur aus der Anwendung heraus.');
|
||||
}
|
||||
const idToken = req.session.idToken;
|
||||
const logoutUrl = idToken ? oidc.buildLogoutUrl(idToken) : null;
|
||||
req.session.destroy(() => {
|
||||
res.clearCookie('connect.sid');
|
||||
res.redirect(logoutUrl || '/');
|
||||
});
|
||||
});
|
||||
|
||||
// Passwort-Logout (Fallback, wird per fetch aufgerufen)
|
||||
app.post('/api/logout', (req, res) => {
|
||||
req.session.destroy(() => res.json({ success: true }));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Laufende Timer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.get('/api/timers', requireAuth, wrap(async (req, res) => {
|
||||
const rate = await getRate();
|
||||
const rows = await dbAll(`SELECT * FROM active_timers ORDER BY created_at ASC`);
|
||||
const timers = await Promise.all(rows.map(t => decorate(t, rate)));
|
||||
res.json({ timers, rate, serverNow: Date.now() });
|
||||
}));
|
||||
|
||||
app.post('/api/timers', requireAuth, wrap(async (req, res) => {
|
||||
const name = String((req.body && req.body.customerName) || '').trim().slice(0, 80);
|
||||
const now = Date.now();
|
||||
const result = await dbRun(
|
||||
`INSERT INTO active_timers (customer_name, started_at, accumulated_ms, is_running, created_at)
|
||||
VALUES (?, ?, 0, 1, ?)`,
|
||||
[name, now, now]
|
||||
);
|
||||
const rate = await getRate();
|
||||
const row = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [result.lastID]);
|
||||
res.json({ timer: await decorate(row, rate) });
|
||||
}));
|
||||
|
||||
app.post('/api/timers/:id/pause', requireAuth, wrap(async (req, res) => {
|
||||
const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]);
|
||||
if (!t) return res.status(404).json({ error: 'Timer nicht gefunden' });
|
||||
if (!t.is_running) return res.json({ timer: await decorate(t, await getRate()) });
|
||||
|
||||
await dbRun(
|
||||
`UPDATE active_timers SET accumulated_ms = ?, is_running = 0, started_at = NULL WHERE id = ?`,
|
||||
[elapsedMs(t), t.id]
|
||||
);
|
||||
const row = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [t.id]);
|
||||
res.json({ timer: await decorate(row, await getRate()) });
|
||||
}));
|
||||
|
||||
app.post('/api/timers/:id/resume', requireAuth, wrap(async (req, res) => {
|
||||
const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]);
|
||||
if (!t) return res.status(404).json({ error: 'Timer nicht gefunden' });
|
||||
if (t.is_running) return res.json({ timer: await decorate(t, await getRate()) });
|
||||
|
||||
await dbRun(`UPDATE active_timers SET started_at = ?, is_running = 1 WHERE id = ?`, [Date.now(), t.id]);
|
||||
const row = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [t.id]);
|
||||
res.json({ timer: await decorate(row, await getRate()) });
|
||||
}));
|
||||
|
||||
app.patch('/api/timers/:id', requireAuth, wrap(async (req, res) => {
|
||||
const name = String((req.body && req.body.customerName) || '').trim().slice(0, 80);
|
||||
const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]);
|
||||
if (!t) return res.status(404).json({ error: 'Timer nicht gefunden' });
|
||||
await dbRun(`UPDATE active_timers SET customer_name = ? WHERE id = ?`, [name, t.id]);
|
||||
const row = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [t.id]);
|
||||
res.json({ timer: await decorate(row, await getRate()) });
|
||||
}));
|
||||
|
||||
/** Beenden: schreibt die Session in die Historie und entfernt den Timer. */
|
||||
app.post('/api/timers/:id/stop', requireAuth, wrap(async (req, res) => {
|
||||
const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]);
|
||||
if (!t) return res.status(404).json({ error: 'Timer nicht gefunden' });
|
||||
|
||||
const rate = await getRate();
|
||||
const duration = elapsedMs(t);
|
||||
const amount = calcAmount(duration, rate.rateAmount, rate.rateInterval);
|
||||
const endTime = Date.now();
|
||||
|
||||
if (duration <= 0) {
|
||||
await dbRun(`DELETE FROM active_timers WHERE id = ?`, [t.id]);
|
||||
return res.json({ saved: false, reason: 'Keine Zeit erfasst - nichts gespeichert.' });
|
||||
}
|
||||
|
||||
const result = await dbRun(
|
||||
`INSERT INTO sessions (session_id, start_time, end_time, duration, amount, customer_name, rate_amount, rate_interval, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[req.session.userId || 'unknown', t.created_at, endTime, duration, amount,
|
||||
t.customer_name || '', rate.rateAmount, rate.rateInterval, Math.floor(endTime / 1000)]
|
||||
);
|
||||
await dbRun(`DELETE FROM active_timers WHERE id = ?`, [t.id]);
|
||||
|
||||
res.json({ saved: true, id: result.lastID, duration, amount });
|
||||
}));
|
||||
|
||||
/** Verwerfen: Timer weg, nichts in der Historie. */
|
||||
app.delete('/api/timers/:id', requireAuth, wrap(async (req, res) => {
|
||||
await dbRun(`DELETE FROM active_timers WHERE id = ?`, [req.params.id]);
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Einstellungen (serverseitig - gelten fuer ALLE Geraete)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.get('/api/settings', requireAuth, wrap(async (req, res) => {
|
||||
res.json(await getRate());
|
||||
}));
|
||||
|
||||
app.put('/api/settings', requireAuth, wrap(async (req, res) => {
|
||||
const amount = parseFloat(req.body && req.body.rateAmount);
|
||||
const interval = parseInt(req.body && req.body.rateInterval);
|
||||
|
||||
if (!isFinite(amount) || amount <= 0 || amount > 10000) {
|
||||
return res.status(400).json({ error: 'Betrag muss zwischen 0 und 10000 liegen.' });
|
||||
}
|
||||
if (!Number.isInteger(interval) || interval < 1 || interval > 600) {
|
||||
return res.status(400).json({ error: 'Intervall muss zwischen 1 und 600 Minuten liegen.' });
|
||||
}
|
||||
|
||||
await dbRun(`INSERT INTO settings (key,value) VALUES ('rate_amount',?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [String(amount)]);
|
||||
await dbRun(`INSERT INTO settings (key,value) VALUES ('rate_interval',?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [String(interval)]);
|
||||
|
||||
res.json(await getRate());
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Historie
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Baut die WHERE-Klausel fuer den Datumsfilter (Serverzeitzone). */
|
||||
function dateFilter(query) {
|
||||
const clauses = [];
|
||||
const params = [];
|
||||
if (query.from) {
|
||||
clauses.push(`date(created_at, 'unixepoch', 'localtime') >= date(?)`);
|
||||
params.push(String(query.from));
|
||||
}
|
||||
if (query.to) {
|
||||
clauses.push(`date(created_at, 'unixepoch', 'localtime') <= date(?)`);
|
||||
params.push(String(query.to));
|
||||
}
|
||||
if (query.q) {
|
||||
clauses.push(`customer_name LIKE ?`);
|
||||
params.push('%' + String(query.q) + '%');
|
||||
}
|
||||
return { where: clauses.length ? 'WHERE ' + clauses.join(' AND ') : '', params };
|
||||
}
|
||||
|
||||
app.get('/api/sessions', requireAuth, wrap(async (req, res) => {
|
||||
const limit = Math.min(parseInt(req.query.limit) || 50, 500);
|
||||
const offset = Math.max(parseInt(req.query.offset) || 0, 0);
|
||||
const { where, params } = dateFilter(req.query);
|
||||
|
||||
const rows = await dbAll(
|
||||
`SELECT * FROM sessions ${where} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`,
|
||||
[...params, limit, offset]
|
||||
);
|
||||
// Summen ueber den GESAMTEN Filter, nicht nur ueber die angezeigte Seite
|
||||
const totals = await dbGet(
|
||||
`SELECT COUNT(*) AS count, COALESCE(SUM(duration),0) AS duration, COALESCE(SUM(amount),0) AS amount
|
||||
FROM sessions ${where}`,
|
||||
params
|
||||
);
|
||||
|
||||
res.json({ sessions: rows, totals, limit, offset });
|
||||
}));
|
||||
|
||||
app.delete('/api/sessions/:id', requireAuth, wrap(async (req, res) => {
|
||||
const result = await dbRun(`DELETE FROM sessions WHERE id = ?`, [req.params.id]);
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'Eintrag nicht gefunden' });
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
/** CSV fuer Excel: Semikolon, UTF-8-BOM, deutsches Dezimalkomma. */
|
||||
app.get('/api/sessions/export.csv', requireAuth, wrap(async (req, res) => {
|
||||
const { where, params } = dateFilter(req.query);
|
||||
const rows = await dbAll(`SELECT * FROM sessions ${where} ORDER BY created_at DESC, id DESC`, params);
|
||||
|
||||
const esc = v => {
|
||||
const s = String(v == null ? '' : v);
|
||||
return /[";\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
||||
};
|
||||
const fmtTs = ms => {
|
||||
if (!ms) return '';
|
||||
const d = new Date(Number(ms));
|
||||
const p = n => String(n).padStart(2, '0');
|
||||
return `${p(d.getDate())}.${p(d.getMonth() + 1)}.${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
};
|
||||
const fmtDur = ms => {
|
||||
const s = Math.floor((ms || 0) / 1000);
|
||||
const p = n => String(n).padStart(2, '0');
|
||||
return `${p(Math.floor(s / 3600))}:${p(Math.floor((s % 3600) / 60))}:${p(s % 60)}`;
|
||||
};
|
||||
const fmtNum = n => String(Number(n || 0).toFixed(2)).replace('.', ',');
|
||||
|
||||
const header = ['ID', 'Kunde', 'Beginn', 'Ende', 'Dauer', 'Dauer (Minuten)', 'Betrag (EUR)', 'Tarif'];
|
||||
const lines = [header.join(';')];
|
||||
|
||||
for (const r of rows) {
|
||||
lines.push([
|
||||
r.id,
|
||||
esc(r.customer_name || ''),
|
||||
fmtTs(r.start_time),
|
||||
fmtTs(r.end_time),
|
||||
fmtDur(r.duration),
|
||||
fmtNum((r.duration || 0) / 60000),
|
||||
fmtNum(r.amount),
|
||||
r.rate_amount ? `${fmtNum(r.rate_amount)} EUR / ${r.rate_interval} Min` : ''
|
||||
].join(';'));
|
||||
}
|
||||
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="zeiterfassung-${stamp}.csv"`);
|
||||
res.send('' + lines.join('\r\n') + '\r\n');
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health & Fehlerbehandlung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.get('/health', wrap(async (req, res) => {
|
||||
const rate = await getRate();
|
||||
const active = await dbGet(`SELECT COUNT(*) AS c FROM active_timers`);
|
||||
res.json({
|
||||
status: 'ok',
|
||||
rateAmount: rate.rateAmount,
|
||||
rateInterval: rate.rateInterval,
|
||||
activeTimers: active.c,
|
||||
uptimeSeconds: Math.floor(process.uptime())
|
||||
});
|
||||
}));
|
||||
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Unerwarteter Fehler:', err);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Authentik-Discovery anstossen (nicht-blockierend; loggt das Ergebnis selbst)
|
||||
oidc.init();
|
||||
|
||||
const server = app.listen(PORT, HOST, () => {
|
||||
console.log(`Timer App laeuft auf http://${HOST}:${PORT}`);
|
||||
console.log(`Cookie secure: ${COOKIE_SECURE} (bei false ist HTTP erlaubt)`);
|
||||
});
|
||||
|
||||
function shutdown(signal) {
|
||||
console.log(`${signal} empfangen - fahre herunter.`);
|
||||
server.close(() => db.close(() => process.exit(0)));
|
||||
setTimeout(() => process.exit(0), 5000).unref();
|
||||
}
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Erzeugt einen bcrypt-Hash fuer die .env-Variable PASSWORD_HASH.
|
||||
*
|
||||
* Aufruf: npm run hash-password -- "MeinPasswort"
|
||||
* or: node tools/hash-password.js "MeinPasswort"
|
||||
*/
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
const password = process.argv[2];
|
||||
|
||||
if (!password) {
|
||||
console.error('Aufruf: npm run hash-password -- "DasPasswort"');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
console.error('FEHLER: Bitte mindestens 8 Zeichen verwenden.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(password, 12);
|
||||
|
||||
console.log('');
|
||||
console.log('Diese Zeile in die .env eintragen (und PASSWORD entfernen):');
|
||||
console.log('');
|
||||
console.log(`PASSWORD_HASH=${hash}`);
|
||||
console.log('');
|
||||
Reference in New Issue
Block a user