server.js
Rama main · réplica de lectura del repositorio original.
const http = require('http');
const https = require('https');
const net = require('net');
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const zlib = require('zlib');
const { execFile, spawn } = require('child_process');
const db = require('./db.js');
const ship = require('./shipping.js');
const channels = require('./channels.js');
const harvestFeed = require('./harvest-feed.js');
const syringeStockModule = require('./syringe-stock.js');
const orderBoard = require('./order-board.js');
const duckdns = require('./duckdns.js');
const { createMcpServer } = require('./mcp-server.js');
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
// ── CONFIGURATION ────────────────────────────────────────────
function loadEnv() {
try {
const envPath = path.join(__dirname, '.env');
if (fs.existsSync(envPath)) {
fs.readFileSync(envPath, 'utf8')
.split('\n')
.forEach((line) => {
const match = line.match(/^\s*([^#=]+?)\s*=\s*(.*?)\s*$/);
if (match) {
let val = match[2];
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'")))
val = val.slice(1, -1);
if (!process.env[match[1]]) process.env[match[1]] = val;
}
});
}
} catch (e) {
/* .env is optional */
}
}
loadEnv();
// ── STRUCTURED LOGGING ───────────────────────────────────────
// Outputs JSON lines for structured log aggregation (PM2, journald, etc.)
// Falls back to human-readable format when LOG_FORMAT=text
const LOG_FORMAT = process.env.LOG_FORMAT || 'json';
// S-13: the record's own three fields are written first and meta is copied in
// afterwards *without* being allowed to replace them. `...meta` used to be
// spread last, so any caller handing this function attacker-shaped data could
// forge the whole line — and /api/csp-reports did exactly that, passing a
// parsed request body straight through. A report of
// {"time":"1999-01-01T00:00:00.000Z","level":"INFO","msg":"nothing to see here"}
// became precisely that entry in the JSON stream that DEPLOYMENT.md tells
// operators to feed to pm2/journald, which is enough to fabricate an audit
// trail or to bury a real event under lines a log search filters out.
const LOG_RESERVED = new Set(['time', 'level', 'msg']);
function log(level, msg, meta) {
const ts = new Date().toISOString();
if (LOG_FORMAT === 'json') {
const record = { time: ts, level: level.toUpperCase(), msg };
if (meta && typeof meta === 'object') {
for (const k of Object.keys(meta)) {
if (!LOG_RESERVED.has(k)) record[k] = meta[k];
}
}
const entry = JSON.stringify(record);
if (level === 'error') console.error(entry);
else console.log(entry);
} else {
const entry = `${ts} [${level.toUpperCase()}] ${msg}`;
if (level === 'error') console.error(entry, meta || '');
else console.log(entry, meta ? JSON.stringify(meta) : '');
}
}
// The fields a CSP report is worth keeping, in the shape the spec defines them.
// Everything else the client sent is dropped: `original-policy` repeats a header
// we already know, and an arbitrary key set from an unauthenticated endpoint is
// not something a log stream should have to carry. Strings are truncated so one
// report cannot dominate a line.
const CSP_REPORT_FIELDS = [
'document-uri',
'violated-directive',
'effective-directive',
'blocked-uri',
'source-file',
'line-number',
'column-number',
'disposition'
];
function cspReportFields(report) {
const out = {};
if (!report || typeof report !== 'object') return out;
for (const k of CSP_REPORT_FIELDS) {
const v = report[k];
if (v == null) continue;
if (typeof v === 'number') out[k] = Number.isFinite(v) ? v : null;
else out[k] = String(v).slice(0, 300);
}
return out;
}
const PORT_RAW = parseInt(process.env.PORT, 10) || 3000;
if (PORT_RAW < 1 || PORT_RAW > 65535) {
log('error', 'Invalid PORT, using default 3000', { value: PORT_RAW });
}
const PORT = PORT_RAW >= 1 && PORT_RAW <= 65535 ? PORT_RAW : 3000;
// Set by update_server.sh / START.bat when launched from inside a git worktree.
// Surfaced via /api/health so the UI can render a "this is not production"
// banner — prevents people from confidently entering real data into a feature
// branch instance running alongside prod.
const WORKTREE_MODE = process.env.WORKTREE_MODE === '1' || process.env.WORKTREE_MODE === 'true';
// The farm PC has to be reachable from the phones in the growing rooms, so the
// default stays 0.0.0.0. HOST exists for the instances that must NOT be: the
// measuring stand spawns this file against a fixture database whose only
// account is an admin, and on the LAN that is an open door for as long as the
// run takes. Set HOST=127.0.0.1 there.
const HOST = process.env.HOST || '0.0.0.0';
const DIR = __dirname;
const CERT_KEY = path.join(DIR, 'certs', 'server.key');
const CERT_CRT = path.join(DIR, 'certs', 'server.crt');
// Beside the code by default, which is what the farm PC and every worktree
// expect. MT_DB_FILE exists for instances that must NOT write there: the
// measuring stand runs this file against a seeded fixture, and a fixture
// landing on top of real records is the one accident worth designing out
// rather than guarding against.
const DB_FILE = process.env.MT_DB_FILE || path.join(DIR, 'meistertracker.db');
const CAL_DIR = path.join(DIR, 'calendars');
// Which state of the code is running: the commit and its date. package.json
// has said 1.0.0 all along, so whether an update actually arrived was only
// visible on the machine itself — and an update that cannot fetch carries on
// with the old code without a word. Read once at start-up, because the code
// only changes with a restart; asynchronously, so start-up does not wait for
// git. Without git (a ZIP copy) it stays null. Admin-only, like the Node
// version: /api/health.
function readCodeState(dir, done) {
execFile('git', ['log', '-1', '--format=%h%x09%cI'], { cwd: dir, timeout: 5000, windowsHide: true }, (err, out) => {
if (err) return done(null);
const [commit, date] = String(out).trim().split('\t');
done(/^[0-9a-f]{7,40}$/.test(commit || '') ? { commit, date: date || null } : null);
});
}
let codeState = null;
readCodeState(DIR, (state) => {
codeState = state;
});
// Windows printer name — must match exactly what shows in Devices and Printers
// Validated to prevent command injection via PowerShell/WMI
const PRINTER_NAME_RAW = process.env.PRINTER_NAME || 'ZDesigner GK420d';
if (!/^[\w\s.\-()]+$/u.test(PRINTER_NAME_RAW)) {
log('error', 'PRINTER_NAME contains unsafe characters, falling back to default', { value: PRINTER_NAME_RAW });
}
const PRINTER_NAME = /^[\w\s.\-()]+$/u.test(PRINTER_NAME_RAW) ? PRINTER_NAME_RAW : 'ZDesigner GK420d';
// PM2 process name — used by the GitHub-webhook auto-deploy chain
// (`pm2 restart <name>` after `git pull`). Forks running PM2 with a
// different process name should set PM2_PROCESS_NAME in their env so
// the auto-deploy lands on the right process. Validated to a strict
// charset before any shell interpolation.
const PM2_PROCESS_NAME_RAW = process.env.PM2_PROCESS_NAME || 'meisterpilze';
const PM2_PROCESS_NAME = /^[A-Za-z0-9_\-]{1,64}$/.test(PM2_PROCESS_NAME_RAW) ? PM2_PROCESS_NAME_RAW : 'meisterpilze';
if (PM2_PROCESS_NAME !== PM2_PROCESS_NAME_RAW) {
log('error', 'PM2_PROCESS_NAME contains unsafe characters, falling back to default', {
value: PM2_PROCESS_NAME_RAW
});
}
// ZPL label dimensions in dots. Default 400×240 = 50×30mm at 203dpi
// (Zebra GK420d / ZD420 standard small label). Forks with different
// label stock can override via env, but note the field positions in
// app.js and mcp-server.js itemsToZPL() are laid out for 400 dots
// wide — significantly different sizes will need layout tweaks too.
function _intEnv(name, def, min, max) {
const v = parseInt(process.env[name], 10);
if (Number.isFinite(v) && v >= min && v <= max) return v;
return def;
}
const LABEL_WIDTH_DOTS = _intEnv('LABEL_WIDTH_DOTS', 400, 100, 4000);
const LABEL_HEIGHT_DOTS = _intEnv('LABEL_HEIGHT_DOTS', 240, 100, 4000);
// Optional Windows print bridge (scripts/print-bridge.ps1). When configured,
// the server forwards print + status calls to this URL instead of running
// PowerShell locally. Required for label printing when the server runs on
// Linux while the Zebra is attached to a Windows PC on the LAN.
//
// The env vars are a fallback for the DB-stored config (Settings → Drucker)
// so existing .env-based setups keep working untouched. UI-saved values
// take precedence over env values when present.
const PRINT_BRIDGE_URL_ENV = (process.env.PRINT_BRIDGE_URL || '').replace(/\/+$/, '');
const PRINT_BRIDGE_TOKEN_ENV = process.env.PRINT_BRIDGE_TOKEN || '';
const MAX_BODY_SIZE = 5 * 1024 * 1024; // 5 MB max request body
const SESSION_TTL_SECONDS = db.SESSION_TTL_MS / 1000; // keep in sync with db.js
const TRUST_PROXY = process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1';
/**
* The address to key a limit on.
*
* ⚠️ **X-Forwarded-For's first entry is the caller's, not the proxy's.** nginx's
* `$proxy_add_x_forwarded_for` APPENDS the real peer to whatever the client
* sent, so entry [0] is attacker-supplied — the comment on setupFromLoopback
* below has said so for as long as that guard has existed, and used it as the
* reason not to trust this function there. Every throttle in this file is keyed
* on the value returned here, so reading [0] handed an attacker a fresh
* identity per request and let them aim any per-address penalty at somebody
* else.
*
* X-Real-IP instead: it is single-valued, and the nginx configuration in
* DEPLOYMENT.md sets it from `$remote_addr` at every location, so a client
* cannot prepend to it. Falling back to the socket keeps the direct-TLS
* deployment (Path A, the recommended one) working unchanged, where
* TRUST_PROXY is unset and no header is read at all.
*/
function getClientIP(req) {
if (TRUST_PROXY) {
const real = req.headers['x-real-ip'];
if (typeof real === 'string' && real.trim()) return real.trim();
// No X-Real-IP behind a proxy that was told to send one: the socket is
// the proxy itself, so everyone shares a key. Said plainly rather than
// silently, because a shared key turns every per-address limit into a
// shared one — see the throttles' own comments.
return (req.socket.remoteAddress || 'unknown') + '|proxy-ohne-real-ip';
}
return req.socket.remoteAddress || 'unknown';
}
/**
* May this caller run first-time setup without the setup token?
*
* The loopback shortcut exists for the operator standing at the machine. Behind
* a reverse proxy it stops meaning that: `proxy_pass http://localhost:3000`
* makes the TCP peer 127.0.0.1 for *every* request, so the shortcut would wave
* the entire network through to claim the first admin account on an
* uninitialised install. Measured against a real proxy before this was pinned:
* setup succeeded from a LAN address, and set TRUST_PROXY=true — exactly what
* DEPLOYMENT.md tells a Path B operator to do — and it still succeeded.
*
* getClientIP() is deliberately NOT used here. It trusts the first
* X-Forwarded-For entry, and nginx's $proxy_add_x_forwarded_for appends the
* real address after the client's, so the first entry is attacker-supplied:
* sending `X-Forwarded-For: 127.0.0.1` would restore the hole. When a proxy is
* in front, there is no way to recognise the operator from the socket, so the
* token — printed to the log on first start — becomes the only way in.
*/
function setupFromLoopback(remoteAddress, trustProxy) {
if (trustProxy) return false;
const ip = remoteAddress || '';
return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
}
let database = db.openDb(DB_FILE);
// S-18: createUser rejects a name that would share a CalDAV calendar with an
// existing account, but a database created before that guard can already hold
// one. Those calendars fail closed in checkCalendarAccess, which is silent from
// the operator's side — so say it once at startup, with the names to rename.
for (const clash of db.findCaldavSlugCollisions(database)) {
log('warn', 'Users share a CalDAV calendar name — their personal calendars are unreachable until renamed', clash);
}
let protocol = 'http'; // set to 'https' at startup if TLS certs are found
// I-17: serialize backup restores. Two admins kicking off concurrent
// /api/backup/restore requests would race on the close→rename→reopen path
// against the same DB file, leaving a corrupt or wrong-version DB. The
// flag is process-local (good enough — only one node process owns the DB).
let restoreInProgress = false;
if (!fs.existsSync(CAL_DIR)) fs.mkdirSync(CAL_DIR);
// ── First-run setup token (S-06) ───────────────────────────
// On a fresh deployment with zero users, the operator must either:
// 1) call /api/auth/setup from loopback (default for the START.bat flow), or
// 2) call /api/auth/setup with header `X-Setup-Token: <generated>`.
// The token is process-lifetime only (never persisted) and printed via the
// structured logger so PM2/journald operators can copy it. Once setup
// completes, the in-memory value is cleared so a stolen log line can't be
// reused.
let SETUP_TOKEN = null;
try {
if (db.countUsers(database) === 0) {
SETUP_TOKEN = crypto.randomBytes(32).toString('hex');
log('warn', 'SETUP TOKEN: ' + SETUP_TOKEN, {
hint: 'send X-Setup-Token header to POST /api/auth/setup, or call from localhost'
});
}
} catch (e) {
log('error', 'countUsers failed at startup', { error: e.message });
}
// ── Constant-time login dummy hash (defense against username enumeration) ──
// If a login request hits a non-existent username, we still run a full scrypt
// against this throwaway hash so the response time matches a real user with
// a wrong password. Without this, attackers could enumerate valid accounts
// by measuring 10 ms (no scrypt) vs 50-200 ms (real scrypt) responses.
// Salt+hash are randomized per process, never persisted.
// S-14: minted through db.hashPassword so it carries the current parameters —
// a dummy that stayed on the old cost would answer faster than a real account
// and re-open the enumeration channel this exists to close.
const { salt: DUMMY_PASSWORD_SALT, hash: DUMMY_PASSWORD_HASH } = db.hashPassword(
crypto.randomBytes(32).toString('hex')
);
// ── MCP (Model Context Protocol) server ────────────────────
// Each session gets its own McpServer + transport (SDK requires one server per transport).
const mcpSessions = new Map(); // sessionId → { transport, server, lastActive }
const MCP_SESSION_TTL = 30 * 60 * 1000; // 30 minutes
// Tear down all live MCP sessions. Each session's server captured the `database`
// handle at creation time; after a restore swaps `database`, those captured
// handles are closed/stale, so the sessions must be dropped and the clients
// forced to re-initialize against the new database.
function closeAllMcpSessions() {
for (const session of mcpSessions.values()) {
try {
session.server.close();
} catch (e) {
/* best-effort */
}
}
mcpSessions.clear();
}
setInterval(
() => {
const now = Date.now();
for (const [sid, s] of mcpSessions) {
if (now - s.lastActive > MCP_SESSION_TTL) {
s.server.close().catch(() => {});
mcpSessions.delete(sid);
}
}
},
5 * 60 * 1000
); // check every 5 minutes
// Clean up expired OAuth codes and revoked tokens every hour — and, since
// S-25, auto-registered clients that never completed a flow (see
// db.deleteExpiredOAuthData for why each condition is there).
setInterval(
() => {
try {
db.deleteExpiredOAuthData(database);
} catch (e) {
log('error', 'OAuth cleanup failed', { error: e.message });
}
},
60 * 60 * 1000
);
// S-01: returns { userId, role } on success, null on failure.
// - Legacy static MCP token → role: 'admin' (preserves historical
// behaviour; the static token has always granted full access).
// - OAuth access token → role looked up from the OAuth user.
// S-08: passes `touchLastUsed:true` to bump the static token's
// audit timestamp on each verification, and uses Buffer.from(..., 'hex')
// explicitly so the encoding is unambiguous.
function checkMcpAuth(req) {
const auth = req.headers.authorization || '';
if (!auth.startsWith('Bearer ')) return null;
const token = auth.slice(7);
const hash = crypto.createHash('sha256').update(token).digest('hex');
// Try legacy static API token
const stored = db.getMcpToken(database);
if (stored) {
const a = Buffer.from(hash, 'hex');
const b = Buffer.from(stored, 'hex');
if (a.length === b.length && crypto.timingSafeEqual(a, b)) {
// S-17: after the comparison, never before it. Stamping on the way in
// meant every failed bearer probe refreshed "last used", so an admin
// deciding whether a token is still needed saw the attacker's traffic as
// evidence that it was. It also made an unauthenticated request write to
// the database.
db.touchMcpTokenUsed(database);
return { userId: null, role: 'admin', username: null };
}
}
// Try OAuth access token — look up the user's current role.
const oauthToken = db.getOAuthAccessToken(database, hash);
if (oauthToken) {
let role = 'user';
let username = null;
try {
const user = database.prepare('SELECT role, username FROM users WHERE id = ?').get(oauthToken.userId);
if (user && user.role) role = user.role;
// The name as well as the role: the task filter asks whether a row is
// assigned to *this person*, and an assignee is stored as a name. Without
// it a worker's own private tasks would disappear from the briefing —
// fail-closed, but wrong in a way they would report as data loss.
if (user && user.username) username = user.username;
} catch (_) {
// best effort — fall through with role: 'user' (least privilege)
}
return { userId: oauthToken.userId, role, username };
}
return null;
}
// Simple sliding-window rate limiter (shared for MCP + OAuth endpoints)
const MCP_RATE_WINDOW = 60 * 1000;
const rateLimits = new Map(); // ip → { timestamps[] }
function checkRate(req, limit) {
// R-08: respect TRUST_PROXY so the rate-limit key is the real client IP
// when running behind a reverse proxy (otherwise everyone shares the
// proxy's loopback address and trips the limit collectively).
const ip = getClientIP(req);
const now = Date.now();
let bucket = rateLimits.get(ip);
if (!bucket) {
bucket = { timestamps: [] };
rateLimits.set(ip, bucket);
}
bucket.timestamps = bucket.timestamps.filter((ts) => now - ts < MCP_RATE_WINDOW);
if (bucket.timestamps.length >= limit) return false;
bucket.timestamps.push(now);
return true;
}
function checkMcpRate(req) {
return checkRate(req, 60);
} // 60 req/min for MCP
function checkOAuthRate(req) {
return checkRate(req, 20);
} // 20 req/min for OAuth endpoints
// Evict stale IPs every 10 minutes
setInterval(
() => {
const cutoff = Date.now() - MCP_RATE_WINDOW;
for (const [ip, bucket] of rateLimits) {
if (bucket.timestamps.every((ts) => ts < cutoff)) rateLimits.delete(ip);
}
},
10 * 60 * 1000
);
// ── SSE (Server-Sent Events) for real-time multi-client sync ──
// Uses a Set for O(1) add/delete instead of array splice.
const sseClients = new Set();
// Set further down, once the database and the logger exist. Every write that
// tells the screens "data changed" also asks for a syringe recount — see
// syringe-stock.js for why that beats hooking each write path.
let syringeStock = null;
// Same moment for "bestellt, aber nicht da" (checkSaleAlerts below): an order
// sync ends here, so a new order is checked a few seconds after it arrives.
let saleAlertTimer = null;
const SALE_ALERT_DEBOUNCE_MS = 5000;
function broadcastSSE(excludeRes) {
if (syringeStock) syringeStock.schedule();
scheduleSaleAlerts();
const msg = 'data: {"type":"data-changed"}\n\n';
for (const c of sseClients) {
if (c === excludeRes) continue;
try {
c.write(msg);
} catch {
sseClients.delete(c);
}
}
}
// A named event and nothing else. Every connected client gets it, admin or
// not, so it carries no details: a client that cares asks the matching route,
// which checks who is asking.
function broadcastSSEType(type) {
const msg = 'data: ' + JSON.stringify({ type }) + '\n\n';
for (const c of sseClients) {
try {
c.write(msg);
} catch {
sseClients.delete(c);
}
}
}
setInterval(() => {
const hb = 'data: {"type":"heartbeat"}\n\n';
for (const c of sseClients) {
try {
c.write(hb);
} catch {
sseClients.delete(c);
}
}
}, 15000);
const MIME = {
'.html': 'text/html; charset=utf-8',
'.json': 'application/json',
'.js': 'application/javascript',
'.css': 'text/css; charset=utf-8',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.svg': 'image/svg+xml'
};
// ── P-01: Pre-compressed static assets ─────────────────────────
// Pre-gzip + pre-brotli text/JS/CSS/HTML/JSON assets at startup so each
// request just streams the cached bytes. Skip images / favicon / PDFs:
// already-compressed payloads can't be gzipped meaningfully and brotli
// makes them slightly larger. Recompresses only when the source mtime
// is newer than the cached .gz/.br (avoid touching disk on every boot).
const COMPRESSIBLE_EXT = new Set(['.html', '.js', '.css', '.json', '.svg']);
function precompressOne(file) {
if (!fs.existsSync(file)) return;
const ext = path.extname(file).toLowerCase();
if (!COMPRESSIBLE_EXT.has(ext)) return;
const stat = fs.statSync(file);
const data = fs.readFileSync(file);
// gzip
const gzPath = file + '.gz';
const gzStale = !fs.existsSync(gzPath) || fs.statSync(gzPath).mtimeMs < stat.mtimeMs;
if (gzStale) {
try {
fs.writeFileSync(gzPath, zlib.gzipSync(data, { level: 9 }));
} catch (e) {
log('warn', 'gzip precompress failed', { file, error: e.message });
}
}
// brotli (Node 11+)
if (typeof zlib.brotliCompressSync === 'function') {
const brPath = file + '.br';
const brStale = !fs.existsSync(brPath) || fs.statSync(brPath).mtimeMs < stat.mtimeMs;
if (brStale) {
try {
fs.writeFileSync(
brPath,
zlib.brotliCompressSync(data, {
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: 11,
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: data.length
}
})
);
} catch (e) {
log('warn', 'brotli precompress failed', { file, error: e.message });
}
}
}
}
function precompressStaticAssets() {
const root = DIR;
const files = ['app.js', 'styles.css', 'index.html', 'login.html', 'login.js', 'manifest.json'];
for (const f of files) precompressOne(path.join(root, f));
// lib/* — recurse one level
const libDir = path.join(root, 'lib');
if (fs.existsSync(libDir)) {
for (const entry of fs.readdirSync(libDir)) {
if (entry.endsWith('.gz') || entry.endsWith('.br')) continue;
precompressOne(path.join(libDir, entry));
}
}
// lang/* — pre-compress the per-locale files (added by P-03)
const langDir = path.join(root, 'lang');
if (fs.existsSync(langDir)) {
for (const entry of fs.readdirSync(langDir)) {
if (entry.endsWith('.gz') || entry.endsWith('.br')) continue;
precompressOne(path.join(langDir, entry));
}
}
}
// ── App-Stand ───────────────────────────────────────────────────
// Die Dateien, die zusammen die App im Browser sind, und ein Fingerabdruck über
// sie. Ändert sich eine davon — START.bat zieht main und startet neu —, ändert
// sich der Stand, und jedes offene Telefon merkt, dass es alten Code ausführt:
// der Stand steht in /api/data, in der ersten Zeile des Ereignisstroms und als
// X-App-Build an jeder dieser Dateien. Vorher lief eine App, die im Hintergrund
// offen blieb, tagelang mit dem Code von vor dem Deploy weiter, und niemand
// konnte es sehen.
//
// Je Datei wird nur neu gerechnet, wenn sich Größe oder Änderungszeit bewegt
// haben; ein Aufruf kostet sonst ein paar stat()-Aufrufe.
function shellFiles() {
const out = ['index.html', 'app.js', 'styles.css', 'sw.js'];
try {
for (const f of fs.readdirSync(path.join(DIR, 'lang')).sort()) if (f.endsWith('.js')) out.push('lang/' + f);
} catch {
/* no lang dir — nothing to add */
}
return out;
}
const _fileTags = new Map();
function fileTag(absPath) {
const st = fs.statSync(absPath);
const hit = _fileTags.get(absPath);
if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit.tag;
const tag = crypto.createHash('sha256').update(fs.readFileSync(absPath)).digest('hex').slice(0, 16);
_fileTags.set(absPath, { mtimeMs: st.mtimeMs, size: st.size, tag });
return tag;
}
function appBuild() {
const h = crypto.createHash('sha256');
for (const f of shellFiles()) {
let tag = 'missing';
try {
tag = fileTag(path.join(DIR, f));
} catch {
/* a file that cannot be read counts as missing */
}
h.update(f + ':' + tag + '\n');
}
return h.digest('hex').slice(0, 12);
}
// Die URLs dieser Dateien. Sie werden mit ETag und "no-cache" ausgeliefert
// statt fünf Minuten lang ungefragt aus dem Browser-Cache: ein Neuladen gleich
// nach dem Deploy hätte sonst das alte app.js bekommen, und das Neuladen, das
// den neuen Stand holen soll, hätte den alten geholt.
function isShellUrl(url) {
return (
url === '/' ||
url === '/index.html' ||
url === '/app.js' ||
url === '/styles.css' ||
url === '/sw.js' ||
/^\/lang\/[a-zA-Z-]+\.js$/.test(url)
);
}
// If-None-Match kann eine Liste sein, und "*" passt auf alles.
function etagMatches(header, tag) {
if (!header || !tag) return false;
return String(header)
.split(',')
.map((s) => s.trim())
.some((s) => s === '*' || s === tag);
}
// Files already reported as having a stale compressed copy, so the warning is
// logged once per asset per boot instead of once per request.
const _staleCompressed = new Set();
// Pick the best encoding based on Accept-Encoding. Returns { encoding, path }
// or null if the client doesn't accept any pre-compressed variant, or the
// compressed variant is missing — or is older than the source it was made
// from.
//
// That last case is the one that bites. precompressStaticAssets() runs at
// startup and nowhere else, so editing app.js under a running server leaves
// last boot's app.js.br on disk. Serving it silently hands every browser the
// previous build: the edit is on disk, the file mtime is new, a hard reload
// changes nothing, and the only symptom is that the change appears not to have
// been made. Compare mtimes and drop to the uncompressed source instead —
// bigger on the wire, but it is the code the operator actually wrote. Restart
// to get compression back.
function pickEncoding(acceptEncoding, filePath) {
if (!acceptEncoding) return null;
const ae = String(acceptEncoding).toLowerCase();
let srcMtime;
try {
srcMtime = fs.statSync(filePath).mtimeMs;
} catch {
// No source to compare against — let the caller read filePath and 404.
return null;
}
const usable = (variant) => {
let s;
try {
s = fs.statSync(variant);
} catch {
return false; // never precompressed, or cleaned away
}
if (s.mtimeMs >= srcMtime) return true;
if (!_staleCompressed.has(variant)) {
_staleCompressed.add(variant);
log('warn', 'Stale pre-compressed asset, serving uncompressed — restart to recompress', {
file: path.basename(variant)
});
}
return false;
};
if (ae.includes('br') && usable(filePath + '.br')) {
return { encoding: 'br', path: filePath + '.br' };
}
if (ae.includes('gzip') && usable(filePath + '.gz')) {
return { encoding: 'gzip', path: filePath + '.gz' };
}
return null;
}
// The names and addresses the certificate on disk actually vouches for.
//
// Read per call rather than cached at boot: the ACME renewal rewrites the file
// under a running process, and a stale answer here would be worse than none —
// it would state confidently that an address is covered when it stopped being
// covered an hour ago.
function certCoveredHosts() {
try {
if (!fs.existsSync(CERT_CRT)) return [];
const x = new crypto.X509Certificate(fs.readFileSync(CERT_CRT));
// subjectAltName reads "DNS:a, IP Address:1.2.3.4"; the CN is the fallback
// for certificates old enough to predate SAN being mandatory.
const san = x.subjectAltName || '';
const names = [...san.matchAll(/(?:DNS|IP Address):([^,]+)/g)].map((m) => m[1].trim());
if (names.length) return names;
const cn = (x.subject || '').match(/CN=([^\n,]+)/);
return cn ? [cn[1].trim()] : [];
} catch {
// An unreadable or malformed certificate is not something this function
// should turn into a boot failure — it exists to print a hint.
return [];
}
}
// The issuer of every certificate Let's Encrypt signs carries this line.
// X509Certificate#issuer gives one attribute per line.
const LE_ISSUER = /^O=Let's Encrypt$/m;
// What the certificate on disk says about itself: when it expires, and whether
// Let's Encrypt issued it. null when there is none or it cannot be read. It
// never throws: checkCertRenewal() runs while the server loads.
//
// The issuer decides, not the PEM text. PEM is base64, so "Let's Encrypt" never
// appears in it, and a needle like "R3" turns up by chance: measured, about one
// self-signed certificate in six contains one of the old needles.
function certOnDisk() {
try {
if (!fs.existsSync(CERT_CRT)) return null;
const x = new crypto.X509Certificate(fs.readFileSync(CERT_CRT));
const validTo = new Date(x.validTo);
return { validTo: isNaN(validTo) ? null : validTo, letsEncrypt: LE_ISSUER.test(x.issuer || '') };
} catch {
return null;
}
}
/** Which of `hosts` the certificate does NOT cover. Empty when it covers them all. */
function certUncoveredHosts(hosts) {
const covered = certCoveredHosts();
if (!covered.length) return []; // nothing to compare against; stay quiet
const matches = (host) =>
covered.some((c) => {
if (c === host) return true;
// A wildcard covers exactly one label: *.example.com matches a.example.com
if (c.startsWith('*.')) return host.endsWith(c.slice(1)) && host.split('.').length === c.split('.').length;
return false;
});
return hosts.filter((h) => h && !matches(h));
}
function getLocalIP() {
for (const ifaces of Object.values(os.networkInterfaces()))
for (const i of ifaces) if (i.family === 'IPv4' && !i.internal) return i.address;
return 'localhost';
}
function readData() {
return db.readAll(database, { inventoryLogLimit: 500 });
}
function writeData(data, wer) {
db.writeAll(database, data, { wer });
}
// The request body parsed as JSON, whatever its shape: an array, a string,
// null. Only for routes that check the shape themselves: /mcp, where a JSON-RPC
// batch is an array and the SDK answers anything else malformed, and the order
// import, which also takes a bare array of orders. Every other route uses
// jsonBody.
function anyJsonBody(req, res, cb) {
const body = [];
let sz = 0;
let aborted = false;
req.on('data', (c) => {
sz += c.length;
if (sz > MAX_BODY_SIZE) {
aborted = true;
jsonErr(res, 413, 'Payload too large');
req.destroy();
return;
}
// Collect Buffers and decode once at the end. `body += c` decoded each chunk
// independently, so a multi-byte character split across a ~64 KiB stream
// boundary became two U+FFFD replacement chars — "Kräuterseitling" arriving
// in two pieces persisted as "Kr��uterseitling", still valid JSON,
// so it reached the database with no error. In a German-language app every
// umlaut in a body over one chunk was at risk.
body.push(c);
});
req.on('end', () => {
if (aborted) return;
let parsed;
try {
parsed = JSON.parse(Buffer.concat(body).toString('utf8'));
} catch (e) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end('{"error":"bad json"}');
return;
}
// Many routes validate `data` before their own try opens, so the route itself
// can throw here. Uncaught, that would reach the process-level
// uncaughtException handler, which only logs, and the request would never be
// answered: Node's requestTimeout stops counting once the body is in.
try {
cb(null, parsed);
} catch (err) {
if (!res.headersSent) {
safeErr(res, err);
return;
}
// Too late for a status code. Log it, as uncaughtException would have, and
// cut off a half-written response rather than leave it open.
log('error', 'Unexpected error after the response started', {
error: String((err && err.message) || ''),
stack: err && err.stack
});
if (!res.writableEnded) res.destroy();
}
});
}
// The request body as a JSON object, for every route not on anyJsonBody. Most
// read `data.<field>` without checking what `data` is, so null would throw, and
// a number, a string or an array would pass as a body with no fields: a 200
// that changed nothing.
function jsonBody(req, res, cb) {
anyJsonBody(req, res, (e, data) => {
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
jsonErr(res, 400, 'Request body must be a JSON object');
return;
}
cb(null, data);
});
}
function formBody(req, res, cb) {
const body = [];
let sz = 0;
let aborted = false;
req.on('data', (c) => {
sz += c.length;
if (sz > MAX_BODY_SIZE) {
aborted = true;
jsonErr(res, 413, 'Payload too large');
req.destroy();
return;
}
// See anyJsonBody: decode once, or a multi-byte char split across a chunk
// boundary is silently replaced with U+FFFD.
body.push(c);
});
req.on('end', () => {
if (aborted) return;
let parsed;
try {
const params = new URLSearchParams(Buffer.concat(body).toString('utf8'));
parsed = Object.fromEntries(params.entries());
} catch (e) {
jsonErr(res, 400, 'bad form data');
return;
}
cb(null, parsed);
});
}
function verifyPkce(codeVerifier, storedCodeChallenge) {
return crypto.createHash('sha256').update(codeVerifier).digest('base64url') === storedCodeChallenge;
}
// Track whether we've already warned about a missing PUBLIC_HOSTNAME so the
// log line doesn't repeat on every request.
let _hostHeaderWarned = false;
function getBaseUrl(req) {
const reqHost = req.headers.host || 'localhost:' + PORT;
const expected = (process.env.PUBLIC_HOSTNAME || '').trim();
if (expected) {
// PUBLIC_HOSTNAME may be host or host:port — normalise both sides for
// comparison. If the request's Host header doesn't match, return the
// configured hostname instead of reflecting attacker-controlled input
// into OAuth issuer metadata. We don't 502 because legitimate
// localhost-from-LAN access still has to work.
const reqHostName = reqHost.split(':')[0].toLowerCase();
const expHostName = expected.split(':')[0].toLowerCase();
if (reqHostName !== expHostName) {
return protocol + '://' + expected;
}
return protocol + '://' + reqHost;
}
if (!_hostHeaderWarned) {
_hostHeaderWarned = true;
log('warn', 'PUBLIC_HOSTNAME not configured — Host header reflected into OAuth metadata', {});
}
return protocol + '://' + reqHost;
}
function jsonOk(res, data) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data || { ok: true }));
}
function jsonErr(res, code, msg) {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: msg }));
}
/**
* A whole number inside a range, with a fallback for anything unusable.
*
* Clamping rather than rejecting: these are interval and window settings from a
* number field, where "0 days" or "every 0 minutes" is a slip, not an attack.
* Silently taking the nearest sane value beats an error message about a field
* the person did not mean to touch.
*/
function clampInt(raw, min, max, fallback) {
const n = parseInt(raw, 10);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
}
// Safe error response: log internals, send generic message to client for
// unexpected errors. R-23: classifier lives in db.js (db.isSafeError) since
// the allowlist is fundamentally a registry of every validator message db.js
// emits — keeping it next to the throw sites makes drift easier to catch.
function safeErr(res, err) {
const msg = String((err && err.message) || '');
if (db.isSafeError(msg)) {
jsonErr(res, 400, msg);
} else {
log('error', 'Unexpected error', { error: msg, stack: err && err.stack });
jsonErr(res, 500, 'Internal server error');
}
}
// ── REQUEST VALIDATION ──────────────────────────────────────
// Returns null if valid, or an error string if invalid.
function validateRequired(data, fields) {
if (!data || typeof data !== 'object') return 'Request body must be a JSON object';
for (const f of fields) {
if (data[f] === undefined || data[f] === null || data[f] === '') return 'Missing required field: ' + f;
}
return null;
}
function validateTypes(data, schema) {
for (const [field, type] of Object.entries(schema)) {
if (data[field] === undefined || data[field] === null) continue;
if (type === 'number' && typeof data[field] !== 'number') return field + ' must be a number';
if (type === 'string' && typeof data[field] !== 'string') return field + ' must be a string';
if (type === 'boolean' && typeof data[field] !== 'boolean') return field + ' must be a boolean';
if (type === 'array' && !Array.isArray(data[field])) return field + ' must be an array';
}
return null;
}
// Validate numeric ranges: {field: {min, max}} — skips null/undefined fields
function validateRanges(data, ranges) {
for (const [field, { min, max }] of Object.entries(ranges)) {
const v = data[field];
if (v === undefined || v === null) continue;
if (typeof v !== 'number' || !Number.isFinite(v)) return field + ' must be a finite number';
if (min !== undefined && v < min) return field + ' must be >= ' + min;
if (max !== undefined && v > max) return field + ' must be <= ' + max;
}
return null;
}
// Validate string max lengths: {field: maxLen}
function validateLengths(data, limits) {
for (const [field, maxLen] of Object.entries(limits)) {
const v = data[field];
if (v === undefined || v === null) continue;
if (typeof v === 'string' && v.length > maxLen) return field + ' exceeds max length of ' + maxLen;
}
return null;
}
// Die Anteile, mit denen ein Ansatz vom Rezept der Sorte abweichen darf.
//
// Die Grenzen sind weit gefasst: sie fangen den Tippfehler ab (700 statt 70,
// ein leeres Feld als 0 % Feuchte), sie schreiben nicht vor, was ein sinnvolles
// Rezept ist. Ob die Zahlen zusammen aufgehen — Kleie und Mais unter 100 %,
// Zielfeuchte über der Restfeuchte der Sackware — entscheidet computeMixBatch,
// wo die Rechnung steht, die sonst daran scheitern würde.
function validateMixOverride(mix) {
if (mix === undefined || mix === null) return null;
if (typeof mix !== 'object' || Array.isArray(mix)) return 'mix must be an object';
return validateRanges(mix, {
branPct: { min: 0, max: 95 },
cornPct: { min: 0, max: 95 },
gypsumPct: { min: 0, max: 20 },
calciumPct: { min: 0, max: 20 },
moisturePct: { min: 20, max: 90 }
});
}
// A substrate mix names its recipe one of two ways: a stored mix (recipeId) or,
// the older path, a Sorte whose recipe is used (recipeStrainId).
function mixSourceMissing(data) {
if (data.recipeId == null && data.recipeStrainId == null) return 'Missing required field: recipeId';
return null;
}
// Validate ISO date strings (YYYY-MM-DD or full ISO)
function validateDate(value, fieldName) {
if (!value) return null;
const d = new Date(value);
if (isNaN(d.getTime())) return fieldName + ' is not a valid date';
return null;
}
// A calendar day as a date input sends it: YYYY-MM-DD, and a day that exists.
// validateDate accepts whatever new Date() accepts, which includes 5, [2026] and
// 2026-02-31. Unlike the others this one does not let an empty value through;
// the caller decides whether a field may be left out.
function validateDay(value, fieldName) {
const m = typeof value === 'string' && value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (m) {
const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
if (d.getUTCFullYear() === +m[1] && d.getUTCMonth() === +m[2] - 1 && d.getUTCDate() === +m[3]) return null;
}
return fieldName + ' must be a date (YYYY-MM-DD)';
}
// Validate 24-hour time strings in HH:MM format
function validateTimeOfDay(value, fieldName) {
if (!value) return null;
if (typeof value !== 'string' || !/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) {
return fieldName + ' must be a HH:MM time';
}
return null;
}
// Validate enum membership
// Culture types and statuses, as the client renders them into badges. The five
// types mirror VALID_CULTURE_PARENT_TYPES in db.js — GS included, which the MCP
// tool's shorter enum leaves out.
const CULTURE_TYPES = ['MC', 'PD', 'LC', 'G2G', 'GS', 'SY', 'DU'];
const CULTURE_STATUSES = ['active', 'stored', 'used', 'sold', 'contam'];
// The batch kinds the app knows, mirroring the buckets getProductionPipeline
// seeds. 'block' is the column default, so an omitted batchType is fine.
const BATCH_TYPES = ['block', 'grain', 'liquid'];
function validateEnum(value, allowed, fieldName) {
if (value === undefined || value === null) return null;
if (!allowed.includes(value)) return fieldName + ' must be one of: ' + allowed.join(', ');
return null;
}
// Defense-in-depth content validation. Server-rendered fields elsewhere (and
// some innerHTML interpolations on the client) trust these strings, so we
// pin the character set + length up-front. Mirrors the batchId regex used
// by /api/batches.
const ID_CHARSET_RE = /^[A-Za-z0-9_\-@.:]+$/;
// S-16: the charset and length every account is created with — /api/auth/setup
// and POST /api/users both enforce it, and createUser is the only writer of the
// users table, so a name that fails this cannot belong to a real account. It is
// applied on the way *in* as well now: the login handler used to take the
// username straight from a body that may be up to 5 MB and make it a Map key in
// two brute-force tables, where the 60-second sweep leaves it for the full
// 15-minute lockout window.
// S-25: what an unauthenticated registration may carry. Generous against any
// real client, small against a five-megabyte body sent twenty times a minute.
const OAUTH_MAX_CLIENT_NAME = 128;
const OAUTH_MAX_REDIRECT_URIS = 5;
const OAUTH_MAX_URI_LENGTH = 512;
const USERNAME_RE = /^[A-Za-z0-9._-]{1,64}$/;
// S-22: there was a minimum (8) and no maximum anywhere, so MAX_BODY_SIZE was
// the only bound on what reached scrypt — five megabytes of it. Set where the
// password is *chosen* as well as where it is checked, because a cap that only
// one of the two knows about is a lockout waiting to happen. Far above any real
// passphrase; the point is the megabyte, not the sentence.
const PASSWORD_MAX_LENGTH = 1024;
function validateMushroomStrain(data) {
if (!data || typeof data !== 'object') return 'Request body must be a JSON object';
if (data.name !== undefined && data.name !== null) {
if (typeof data.name !== 'string') return 'name must be a string';
if (data.name.length > 200) return 'name exceeds max length of 200';
}
if (data.kuerzel !== undefined && data.kuerzel !== null) {
if (typeof data.kuerzel !== 'string') return 'kuerzel must be a string';
if (data.kuerzel.length > 16) return 'kuerzel exceeds max length of 16';
if (data.kuerzel.length > 0 && !/^[A-Za-z0-9_\-]+$/.test(data.kuerzel)) {
return 'kuerzel must be alphanumeric with - or _';
}
}
if (data.description !== undefined && data.description !== null) {
if (typeof data.description !== 'string') return 'description must be a string';
if (data.description.length > 2000) return 'description exceeds max length of 2000';
}
// Whether the farm is growing this Sorte at the moment (v78). Only a real
// boolean is accepted: db.js stores `imProgramm ? 1 : 0`, so a string "false"
// would be truthy and switch the flag the wrong way round without a word.
if (data.imProgramm !== undefined && data.imProgramm !== null && typeof data.imProgramm !== 'boolean') {
return 'imProgramm must be a boolean';
}
return null;
}
const SCAN_ACTIONS = ['ADD', 'MOVE', 'MOVE_BATCH', 'REMOVE', 'CONTAM'];
function validateScanEntries(entries) {
if (!Array.isArray(entries)) return 'entries must be an array';
if (entries.length > 1000) return 'too many entries (max 1000 per request)';
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
if (!e || typeof e !== 'object') return 'entry[' + i + '] must be an object';
if (!SCAN_ACTIONS.includes(e.action)) {
return 'entry[' + i + '].action must be one of: ' + SCAN_ACTIONS.join(', ');
}
if (!e.time || typeof e.time !== 'string' || isNaN(new Date(e.time).getTime())) {
return 'entry[' + i + '].time must be an ISO timestamp';
}
// Canonicalise, do not merely accept. `new Date()` takes far more than ISO,
// and V8's legacy parser ignores a trailing parenthesised group — so
// "Aug 19 2099 12:00:00 GMT+0000 (' + fetch(...) + ')" is a *valid* date and
// was stored verbatim. The client renders time into markup, which is the
// same reason the charset pin below exists for batch/bag/from/to; time was
// the field that list forgot. Writing the ISO form makes the charset a
// property of the stored row instead of a promise about the caller.
// Real clients already send `new Date().toISOString()`, so this changes
// nothing for them — and it repairs the log's string-compare date filters
// for anything that did not.
e.time = new Date(e.time).toISOString();
// Optional ID-like fields — pin charset + length to prevent stored XSS via
// raw rendering in the client.
for (const f of ['batch', 'bag', 'from', 'to', 'expected_current_zone']) {
const v = e[f];
if (v === undefined || v === null || v === '') continue;
if (typeof v !== 'string') return 'entry[' + i + '].' + f + ' must be a string';
if (v.length > 100) return 'entry[' + i + '].' + f + ' exceeds max length of 100';
if (!ID_CHARSET_RE.test(v)) {
return 'entry[' + i + '].' + f + ' must be alphanumeric with - _ @ . :';
}
}
// Free-text-ish fields — only length-capped.
for (const [f, max] of [
['species', 200],
['strain', 200],
['reason', 2000]
]) {
const v = e[f];
if (v === undefined || v === null) continue;
if (typeof v !== 'string') return 'entry[' + i + '].' + f + ' must be a string';
if (v.length > max) return 'entry[' + i + '].' + f + ' exceeds max length of ' + max;
}
if (e.client_uuid !== undefined && e.client_uuid !== null && e.client_uuid !== '') {
if (typeof e.client_uuid !== 'string') return 'entry[' + i + '].client_uuid must be a string';
if (e.client_uuid.length > 64) return 'entry[' + i + '].client_uuid exceeds max length of 64';
if (!/^[A-Za-z0-9_\-]+$/.test(e.client_uuid)) {
return 'entry[' + i + '].client_uuid must be alphanumeric with - or _';
}
}
}
return null;
}
// ── Sales-channel OAuth (eBay auth-code, Etsy PKCE) ──────────────────────────
// The login round-trip must hit the public callback registered with the provider
// (https://meistertracker.com/api/channels/<ch>/oauth/callback), so it only
// completes on prod or a tunnel that serves that host. PUBLIC_BASE_URL lets a
// tunnel override the host. State is held in memory for the brief authorize→
// callback window — a server restart mid-login just means clicking "Verbinden" again.
const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || 'https://meistertracker.com').replace(/\/+$/, '');
const _channelOAuth = new Map(); // state -> { channel, codeVerifier, created }
// Order ids with a label purchase currently in flight — blocks a concurrent second
// buy (double-click / retry / replay) from billing the owner twice for one order.
const _labelBuysInFlight = new Set();
function _channelCallbackUrl(channel) {
return PUBLIC_BASE_URL + '/api/channels/' + channel + '/oauth/callback';
}
function _oauthGc() {
const cutoff = Date.now() - 15 * 60 * 1000;
for (const [k, v] of _channelOAuth) if (v.created < cutoff) _channelOAuth.delete(k);
}
// Refresh a channel's OAuth access token if it's within 2 min of expiry, persist the
// new token, and return the fresh config + provider. No-op for API-key channels (Wix).
async function withFreshChannelToken(channel) {
let cfg = db.getChannelConfig(database, channel);
const prov = channels.getChannelProvider(channel);
// Treat a missing/unparseable expiry as "needs refresh" so a stale token is never
// used — a NaN must not silently skip the refresh (which would 401 the provider call).
const exp = Date.parse(cfg.tokenExpires || '');
const needsRefresh = isNaN(exp) || exp - Date.now() < 120000;
if (typeof prov.refreshAccessToken === 'function' && cfg.refreshToken && needsRefresh) {
const tok = await prov.refreshAccessToken(cfg);
db.updateChannelConfig(database, channel, tok);
cfg = db.getChannelConfig(database, channel);
}
return { cfg, prov };
}
// At most this many orders are handed to Billbee in one sync. Billbee's throttle
// spaces the calls about 0.55s apart, so 50 is roughly half a minute — enough to
// clear a backlog over a few polls, short enough that the first sync after
// connecting Billbee does not sit there for ten minutes.
const BILLBEE_PUSH_PER_RUN = 50;
/**
* Hand Wix orders to Billbee.
*
* Billbee reads every other shop through its own API and needs nothing from this
* server. Wix is the exception: Billbee's Wix connector parses the order mail Wix
* sends the merchant, and that mail carries no order data at all — so left alone,
* a Wix sale reaches Billbee only if somebody types it in by hand. This server
* does read the Wix API, so it forwards what it finds. See BILLBEE_SUPERSEDES in
* db.js for why Billbee cannot simply be pointed at Wix instead.
*
* Every Wix order the sync saw is a candidate, not only the newly imported ones,
* because the marks in `meta` are what decide. So an order whose push failed last
* time is retried on its own, and connecting Billbee later backfills the history
* that predates it. Best-effort throughout, like pushTracking: one order Billbee
* refuses must not stop the rest, and none of it may fail the sync.
*/
async function pushWixOrdersToBillbee(channel, orders) {
if (channel !== 'wix') return { pushed: 0, error: null };
const cfg = db.getChannelConfig(database, 'billbee');
if (!cfg.enabled || !cfg.apiKey || !cfg.clientId || !cfg.clientSecret) return { pushed: 0, error: null };
const waiting = orders.filter(
(o) => o.channel === 'wix' && o.channelOrderId != null && !db.billbeePushedAt(database, 'wix', o.channelOrderId)
);
if (!waiting.length) return { pushed: 0, error: null };
// Oldest first, so a backlog is filed in the order the sales actually happened.
waiting.sort((a, b) => String(a.orderDate || '').localeCompare(String(b.orderDate || '')));
const prov = channels.getChannelProvider('billbee');
let pushed = 0;
let error = null;
let inARow = 0;
for (const o of waiting.slice(0, BILLBEE_PUSH_PER_RUN)) {
try {
await prov.pushOrder(cfg, o);
// Marked only once Billbee has taken it. An order stays unmarked on any
// failure, and that is exactly what makes the next run pick it up again.
db.markBillbeePushed(database, 'wix', o.channelOrderId);
pushed++;
inARow = 0;
} catch (e) {
if (!error) error = e.message || 'Billbee-Push fehlgeschlagen';
log('warn', 'Billbee push failed', { order: String(o.channelOrderId), error: e.message });
// Three failures in a row is not three bad orders — it is a key that was
// never approved, or a Billbee that is down. Stop rather than spend the next
// half minute proving it 47 more times; nothing was marked, so the next sync
// resumes here.
if (++inARow >= 3) break;
}
}
if (pushed) log('info', 'Pushed Wix orders to Billbee', { pushed });
return { pushed, error };
}
/**
* Pull one channel's orders and commit them. Never throws: the outcome is the
* return value, because both callers — the button and the timer below — have to
* record it rather than crash on it.
*
* Fetch pages first (network I/O), then commit them in ONE transaction: one
* fsync instead of one per order, and no write-lock held across the network
* round-trips. upsertOrder dedupes by (channel, channelOrderId). A mid-run page
* error (e.g. a 429) is captured, not thrown, so the pages already fetched still
* get committed instead of the whole run being discarded.
*/
async function syncChannelOrders(channel, excludeRes) {
let imported = 0;
// Billbee stands in for the direct connections while it is on — see
// BILLBEE_SUPERSEDES. Refused here rather than in the two callers so the timer
// and the "Jetzt synchronisieren" button are held to the same rule: a button
// that could still import the doubles the timer is avoiding would be a hole in
// the shape of a person having a bad day.
const cfgs = db.listChannelConfigs(database);
if ((cfgs.find((c) => c.channel === channel) || {}).supersededBy === 'billbee') {
const error = 'Aus, solange Billbee an ist: die Bestellungen kommen von dort';
// lastSync and lastCursor are passed back unchanged: setChannelSyncState
// writes null for anything left undefined, and nothing was synced here, so
// stamping "zuletzt: jetzt" over the real last sync would be a lie.
const prev = db.getChannelConfig(database, channel);
db.setChannelSyncState(database, channel, {
lastSync: prev.lastSync,
lastCursor: prev.lastCursor,
lastError: error
});
return { imported: 0, error, supersededBy: 'billbee' };
}
try {
const { cfg, prov } = await withFreshChannelToken(channel);
let cursor = null;
let pages = 0;
const collected = [];
let fetchErr = null;
try {
do {
const { orders, nextCursor } = await prov.fetchOrders(cfg, { cursor });
for (const o of orders) collected.push(o);
cursor = nextCursor;
pages++;
} while (cursor && pages < 20);
} catch (ePage) {
fetchErr = ePage;
}
database.exec('BEGIN');
try {
for (const o of collected) {
// A savepoint per order keeps a malformed one from poisoning the batch:
// it rolls back just that order, and the rest still commit together.
try {
db.withSavepoint(database, 'mt_order', () => db.upsertOrder(database, o));
imported++;
} catch (e2) {
/* this order is skipped; the others are kept */
}
}
database.exec('COMMIT');
} catch (eTx) {
try {
database.exec('ROLLBACK');
} catch (e4) {
/* ignore */
}
throw eTx;
}
// Wix orders travel on to Billbee, which has no way to fetch them itself.
// After the commit and outside the transaction: it is network I/O, and a
// Billbee that is switched off or refusing must never cost an order that is
// already safely imported here.
const push = await pushWixOrdersToBillbee(channel, collected);
// Partial success: earlier pages are committed. Record and report the error,
// but keep the imported orders rather than throwing the whole run away
// because a later page failed.
const error = fetchErr ? fetchErr.message || 'sync failed' : null;
db.setChannelSyncState(database, channel, { lastSync: new Date().toISOString(), lastError: error });
broadcastSSE(excludeRes);
return { imported, error, pushedToBillbee: push.pushed, billbeeError: push.error };
} catch (err) {
const error = err.message || 'sync failed';
db.setChannelSyncState(database, channel, { lastSync: new Date().toISOString(), lastError: error });
return { imported, error };
}
}
/**
* Fetch every switched-on channel on a timer.
*
* Until now a sales channel was pulled only when somebody opened Settings and
* pressed "Jetzt synchronisieren" — so the production planning knew about an
* order when a human remembered to ask, which is not a plan. Being switched on is
* the statement that this channel's orders belong here; the timer is what makes
* that true without anybody's memory.
*
* Sequential, one channel after another: they share the outbound connection and
* Billbee's throttle is per account, so two at once buys nothing and risks a 429.
* Skipped in worktree mode like the harvest feed — a second copy of the server
* carries the same credentials and would double every import.
*/
const CHANNEL_POLL_MS = 15 * 60 * 1000;
let _channelPollTimer = null;
let _channelPollBusy = false;
async function pollSalesChannels() {
if (_channelPollBusy) return;
_channelPollBusy = true;
try {
for (const c of db.listChannelConfigs(database)) {
if (!c.enabled || !c.connected) continue;
// syncChannelOrders would refuse these anyway; skipping them here keeps the
// refusal out of the log every quarter of an hour for as long as Billbee is
// on. The settings page reads the same flag, so it stays visible where
// somebody would look for it.
if (c.supersededBy) continue;
const r = await syncChannelOrders(c.channel);
if (r.error) log('warn', 'Channel poll failed', { channel: c.channel, error: r.error });
else if (r.imported) log('info', 'Channel poll imported orders', { channel: c.channel, imported: r.imported });
}
} finally {
_channelPollBusy = false;
}
}
function startChannelPoll() {
if (WORKTREE_MODE) return false;
if (_channelPollTimer) clearInterval(_channelPollTimer);
_channelPollTimer = setInterval(() => {
pollSalesChannels().catch((e) => log('warn', 'Channel poll errored', { error: e.message }));
}, CHANNEL_POLL_MS);
if (_channelPollTimer.unref) _channelPollTimer.unref();
return true;
}
startChannelPoll();
// Liquid-culture syringes → Billbee → shops. Off until switched on in the
// settings, and a no-op in worktree mode like the poll above.
syringeStock = syringeStockModule.createSyringeStockSync({
getDatabase: () => database,
dbApi: db,
channels,
log,
skip: WORKTREE_MODE
});
syringeStock.start();
// ── Nachproduzieren: an order the lab cannot cover ──
// Every signed-in user gets it in the bell, once per new order of that article
// (db.saleAlerts remembers what it announced). Only that, not "under target":
// a target is planning and lives on the Labor page; an order that cannot ship
// is waiting for someone. Checked shortly after any data change, and every
// quarter of an hour for what changed without one (a bag that finished, an
// order shipped elsewhere). A worktree copy stays quiet, like the sync above.
const SALE_ALERT_EVERY_MS = 15 * 60 * 1000;
function checkSaleAlerts() {
if (WORKTREE_MODE) return;
try {
const neu = db.saleAlerts(database);
if (!neu.length) return;
const users = db.listUsers(database);
for (const a of neu) {
for (const u of users) {
db.createNotificationOnce(database, {
userId: u.id,
type: 'sale_uncovered',
title: a.title,
body: a.body,
linkType: 'sale',
linkId: a.strainId + '/' + a.slot
});
}
}
log('info', 'Sale alerts raised', { articles: neu.length });
} catch (e) {
log('warn', 'Sale alert check failed', { error: e.message });
}
}
function scheduleSaleAlerts() {
if (WORKTREE_MODE) return;
if (saleAlertTimer) clearTimeout(saleAlertTimer);
saleAlertTimer = setTimeout(() => {
saleAlertTimer = null;
checkSaleAlerts();
}, SALE_ALERT_DEBOUNCE_MS);
if (saleAlertTimer.unref) saleAlertTimer.unref();
}
if (!WORKTREE_MODE) {
const t = setInterval(checkSaleAlerts, SALE_ALERT_EVERY_MS);
if (t.unref) t.unref();
}
// ── Printed, but not saved ──
// A label whose culture or bag is not in the app sits on a jar nobody counts.
// Each printed ID is checked LABEL_CHECK_GRACE_MS after the print (an offline
// save may still be on its way) and every signed-in user gets one bell notice
// per missing ID. Tapping it opens the intake, dated from the label.
const LABEL_CHECK_GRACE_MS = 5 * 60 * 1000;
let labelCheckTimer = null;
function checkUnsavedLabels() {
if (WORKTREE_MODE) return;
try {
const fehlt = db.unsavedLabels(database, new Date(), LABEL_CHECK_GRACE_MS);
if (!fehlt.length) return;
const users = db.listUsers(database);
for (const f of fehlt) {
const um = new Date(f.printedAt).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Berlin'
});
for (const u of users) {
db.createNotificationOnce(database, {
userId: u.id,
type: 'label_unsaved',
title: 'Gedruckt, aber nicht gespeichert: ' + f.id,
body:
'Das Etikett kam um ' +
um +
' Uhr aus dem Drucker, aber ' +
f.id +
' steht nicht in der App und zählt nirgends. Antippen zum Aufnehmen, oder das Glas scannen.',
linkType: 'label_unsaved',
linkId: f.id
});
}
}
log('warn', 'Printed labels not in the app', { count: fehlt.length });
} catch (e) {
log('warn', 'Unsaved label check failed', { error: e.message });
}
}
function rememberPrintedLabels(zpl) {
if (WORKTREE_MODE) return;
try {
if (!db.recordPrintedLabels(database, zpl)) return;
} catch (e) {
log('warn', 'Could not remember printed labels', { error: e.message });
return;
}
if (labelCheckTimer) return;
labelCheckTimer = setTimeout(
() => {
labelCheckTimer = null;
checkUnsavedLabels();
},
LABEL_CHECK_GRACE_MS + 10 * 1000
);
if (labelCheckTimer.unref) labelCheckTimer.unref();
}
if (!WORKTREE_MODE) {
const t = setInterval(checkUnsavedLabels, LABEL_CHECK_GRACE_MS);
if (t.unref) t.unref();
}
function _oauthResultHtml(channel, ok, msg) {
const label = channel === 'etsy' ? 'Etsy' : 'eBay';
const icon = ok ? '✓' : '⚠';
const color = ok ? '#16a34a' : '#dc2626';
const safe = String(msg).replace(
/[<>&"']/g,
(c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[c]
);
const redirect = ok ? '<script>setTimeout(function(){location.href="/"},2500)</script>' : '';
return `<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${label}</title></head><body style="font-family:system-ui,sans-serif;background:#0b0f14;color:#e5e7eb;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><div style="text-align:center;max-width:420px;padding:24px"><div style="font-size:48px;color:${color}">${icon}</div><p style="font-size:16px;line-height:1.5">${safe}</p><p style="margin-top:18px"><a href="/" style="color:#60a5fa">Zurück zur App</a></p>${redirect}</div></body></html>`;
}
// Admin-only guard — returns true if blocked (response already sent)
/**
* Does this calendar write create, move or withdraw a pickup window?
*
* A calendar event with category 'pickup' is not an entry in a diary — it is
* what harvest-feed.js publishes to the shop as a bookable collection slot,
* with its day, its clock, its address and its number of places. POST and PATCH
* on /api/calendar-events carried no permission check at all, so any logged-in
* worker could invent a 05:00 collection at an address the farm does not use,
* or set the capacity of a window customers had already booked to zero. The
* neighbours had long since decided this was not everyone's to do: DELETE on
* the same row and POST /api/pickup-locations are both requireAdmin.
*
* Both directions have to be asked about, which is why the stored row is read
* and not only the body. A PATCH that moves a window usually carries no
* category at all, and a PATCH that carries `category: 'meeting'` takes a
* published window off the shop without ever mentioning pickup.
*
* Ordinary calendar entries — meetings, deliveries, maintenance — stay
* everyone's, as before. The guard is scoped to the one category that talks to
* customers.
*/
function touchesPickupWindow(id, data) {
if (data && data.category === 'pickup') return true;
if (!id) return false;
const stored = db.getCalendarEventById(database, id);
return !!(stored && stored.category === 'pickup');
}
function requireAdmin(req, res) {
if (!req.authUser || req.authUser.role !== 'admin') {
jsonErr(res, 403, 'admin required');
return true;
}
return false;
}
// Shipping guard — billable label purchase + customer-PII ship routes. Admins
// always qualify; other users need the per-user can_ship capability (granted by
// an admin). Prevents any logged-in non-admin from spending the owner's postage
// balance or harvesting customer addresses.
function requireShipping(req, res) {
if (!req.authUser || (req.authUser.role !== 'admin' && req.authUser.can_ship !== 1)) {
jsonErr(res, 403, 'shipping permission required');
return true;
}
return false;
}
// ── Contamination-photo storage ─────────────────────────────
// The client compresses photos to ~200 KB JPEGs (canvas re-encode at 1280 px /
// quality 0.8) plus a ~15 KB thumbnail and sends both as base64 data URLs in
// the report POST body. Server validates magic bytes, sha256s, and writes to
// data/photos/{YYYY}/{MM}/{report_id}/{uuid}{,_thumb}.jpg.
const PHOTO_MAX_BYTES = 5 * 1024 * 1024;
const PHOTO_THUMB_MAX_BYTES = 200 * 1024;
// R-15: total-size cap on data/photos/. Without it, a stuck offsite-sync or
// pathological client can fill the disk and wedge the whole server. Default
// 100 GB; override with PHOTO_DIR_MAX_GB. Logic lives in scripts/photo-cap.js
// so the regression test can exercise it directly.
//
// It was 10 GB while every photo was a 200 KB JPEG. Experiment photos keep
// their original now, a few MB each, and 10 GB would be a year of a busy lab.
// What protects the disk is the free-space floor below, which the folder cap
// never was: PHOTO_MIN_FREE_GB (default 2) has to stay free on the disk after
// every original.
const photoCap = require('./scripts/photo-cap.js');
const jpegLocation = require('./scripts/jpeg-location.js');
const PHOTO_DIR_MAX_BYTES = (() => {
const raw = parseFloat(process.env.PHOTO_DIR_MAX_GB);
const gb = Number.isFinite(raw) && raw > 0 ? raw : 100;
return Math.round(gb * 1024 * 1024 * 1024);
})();
const PHOTO_MIN_FREE_BYTES = (() => {
const raw = parseFloat(process.env.PHOTO_MIN_FREE_GB);
const gb = Number.isFinite(raw) && raw >= 0 ? raw : 2;
return Math.round(gb * 1024 * 1024 * 1024);
})();
// The photos the app shows, a contamination report's among them, stop at a
// lower floor. They are a few hundred KB, and a report is not refused because
// the disk is below what originals of 25 MB need; the database still keeps
// its room. Never more than PHOTO_MIN_FREE_GB, so 0 turns both off.
const PHOTO_SMALL_MIN_FREE_BYTES = Math.min(PHOTO_MIN_FREE_BYTES, 256 * 1024 * 1024);
// An original as the camera took it. A 48-megapixel phone writes 15-25 MB.
const ORIGINAL_MAX_BYTES = 40 * 1024 * 1024;
// How far an original's aspect ratio may be from its scaled photo's: the
// scaling rounds to whole pixels, nothing more.
const ORIGINAL_ASPECT_TOLERANCE = 0.02;
// How long an original's upload may send nothing before it is dropped.
const ORIGINAL_IDLE_MS = 60 * 1000;
// Typed confirmation for the irreversible clean-slate reset. Must be echoed by
// the client exactly, so the endpoint can't be fired by a stray/replayed POST.
const RESET_CONFIRM_PHRASE = 'ALLES LOESCHEN';
const PHOTO_DIR = path.join(DIR, 'data', 'photos');
const PHOTO_SIZE_REFRESH_MS = 5 * 60 * 1000; // recompute every 5 min
let _photoDirSizeBytes = 0;
let _photoDirSizeStaleAt = 0;
function _ensurePhotoDirSize(force) {
const now = Date.now();
if (force || now > _photoDirSizeStaleAt) {
try {
_photoDirSizeBytes = photoCap.computePhotoDirSize(PHOTO_DIR);
} catch (e) {
log('warn', 'Failed to scan photo dir for size', { error: e.message });
}
_photoDirSizeStaleAt = now + PHOTO_SIZE_REFRESH_MS;
if (_photoDirSizeBytes > PHOTO_DIR_MAX_BYTES * 0.9) {
log('warn', 'Photo directory near cap', {
usedMB: Math.round(_photoDirSizeBytes / 1e6),
capMB: Math.round(PHOTO_DIR_MAX_BYTES / 1e6)
});
}
}
return _photoDirSizeBytes;
}
// Free space on the disk a photo goes to, checked before every write. The
// folder cap's warning above comes at 90 GB now, and the first sign of a
// filling disk should not be a worker's 507: below twice the originals'
// reserve the log says so, and a photo refused for space is logged too, each
// at most once an hour.
const PHOTO_SPACE_LOG_MS = 60 * 60 * 1000;
const _photoSpaceLoggedAt = { low: 0, refused: 0 };
function _ensurePhotoSpace(dir, addBytes, minFreeBytes) {
const warn = (kind, msg, meta) => {
const now = Date.now();
if (now - _photoSpaceLoggedAt[kind] < PHOTO_SPACE_LOG_MS) return;
_photoSpaceLoggedAt[kind] = now;
log('warn', msg, meta);
};
let left;
try {
left = photoCap.enforceFreeSpace(dir, addBytes, minFreeBytes);
} catch (e) {
warn('refused', 'Photo refused, disk nearly full', {
photoMB: Math.round(addBytes / 1e6),
mustStayFreeMB: Math.round(minFreeBytes / 1e6)
});
throw e;
}
if (left !== null && left < 2 * PHOTO_MIN_FREE_BYTES) {
warn('low', 'Photo disk getting full', {
freeMB: Math.round(left / 1e6),
reserveMB: Math.round(PHOTO_MIN_FREE_BYTES / 1e6)
});
}
}
function _parseImageDataUrl(s) {
if (typeof s !== 'string') return null;
const m = s.match(/^data:image\/jpeg;base64,([A-Za-z0-9+/=]+)$/);
if (!m) return null;
try {
return Buffer.from(m[1], 'base64');
} catch {
return null;
}
}
function _isJpegMagic(buf) {
return buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff;
}
// R-15: PhotoCapError reused from scripts/photo-cap.js so the test and the
// server share the same error class.
function savePhotoToDisk(reportId, photo, authUser) {
if (!photo || typeof photo !== 'object') return null;
// reportId ist der Ordnername unter data/photos/{Jahr}/{Monat}/. Er kam immer
// aus einer Zeilennummer und kommt jetzt auch als 'exp-12' herein; beides
// baut der Server selbst. Die Pruefung steht hier trotzdem, weil ein Ordner-
// name aus einem Aufrufer der Weg aus dem Verzeichnis heraus waere.
if (!/^[A-Za-z0-9_-]+$/.test(String(reportId))) throw new Error('photo: invalid scope');
const main = _parseImageDataUrl(photo.data_url);
if (!main) throw new Error('photo: invalid data_url (must be data:image/jpeg;base64,...)');
if (!_isJpegMagic(main)) throw new Error('photo: payload is not a JPEG');
if (main.length > PHOTO_MAX_BYTES) throw new Error('photo: too large (max 5 MB)');
const thumb = _parseImageDataUrl(photo.thumb_data_url || photo.data_url);
if (!thumb) throw new Error('photo: invalid thumb_data_url');
if (!_isJpegMagic(thumb)) throw new Error('photo: thumb is not a JPEG');
if (thumb.length > PHOTO_THUMB_MAX_BYTES) throw new Error('photo: thumb too large (max 200 KB)');
// R-15: enforce the photo-directory size cap before writing. The cached
// total is refreshed every 5 minutes; we add the about-to-be-written
// bytes to compare against the cap, then update the cache after the
// successful write so the next call sees the new total.
// TODO(R-15): orphan-photo cleanup is out of scope for this PR — once it
// lands, the cap will breathe in normal operation rather than just stop
// accepting writes when full.
const currentSize = _ensurePhotoDirSize(false);
photoCap.enforceCap(currentSize, main.length + thumb.length, PHOTO_DIR_MAX_BYTES);
const uuid = crypto.randomUUID();
const sha = crypto.createHash('sha256').update(main).digest('hex');
const now = new Date();
const yyyy = String(now.getUTCFullYear());
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
const absDir = path.join(DIR, 'data', 'photos', yyyy, mm, String(reportId));
fs.mkdirSync(absDir, { recursive: true });
// The free space of the disk the photo goes to: data/photos, or a year of
// it, can be a folder linked in from another disk.
_ensurePhotoSpace(absDir, main.length + thumb.length, PHOTO_SMALL_MIN_FREE_BYTES);
const relPath = `photos/${yyyy}/${mm}/${reportId}/${uuid}.jpg`;
const thumbPath = `photos/${yyyy}/${mm}/${reportId}/${uuid}_thumb.jpg`;
fs.writeFileSync(path.join(DIR, 'data', relPath), main);
fs.writeFileSync(path.join(DIR, 'data', thumbPath), thumb);
// Update the cached total so consecutive writes in the same 5-min window
// are bounded correctly without re-scanning.
_photoDirSizeBytes += main.length + thumb.length;
return {
uuid,
rel_path: relPath,
thumb_path: thumbPath,
width: typeof photo.width === 'number' ? photo.width : null,
height: typeof photo.height === 'number' ? photo.height : null,
bytes: main.length,
sha256: sha,
uploaded_at: now.toISOString(),
uploaded_by: authUser ? authUser.user_id : null
};
}
// Undo savePhotoToDisk for files whose rows were rolled back: unlink them and
// take their bytes back out of the cached total. Without the second half a
// failed report kept counting against the cap until the next rescan, up to
// five minutes later, and near the cap a retry got a 507 for space that was
// already free again. Returns the files that are still there; one that is
// already gone counts as deleted.
function _discardPhotoFiles(files) {
const left = [];
for (const f of files) {
try {
const bytes = fs.statSync(f).size;
fs.unlinkSync(f);
_photoDirSizeBytes -= bytes;
} catch (e) {
if (e.code !== 'ENOENT') left.push(f);
}
}
return left;
}
// Wieviele Beutel ein einzelner Bericht nennen darf. Eine Reihe im Regal passt
// locker hinein; die Grenze ist da, damit ein kaputter Aufrufer nicht 10.000 Berichte
// in eine Transaktion schreibt, nicht als Arbeitsgrenze.
const CONTAM_MAX_TARGETS = 50;
// Ein Griff zur Kamera sind ein paar Bilder, keine Bibliothek. Die Grenze
// schuetzt die Platte und den Arbeitsspeicher: jedes Bild reist als Base64
// durch den Hauptspeicher, bevor es geschrieben wird.
const EXP_MAX_PHOTOS_PER_CALL = 8;
// Die Felder eines Versuchs, fuer Anlegen und Aendern gleich. Titel und Beginn
// sind Pflicht in der Zeile und duerfen deshalb nicht leer werden; ein Datum ist
// ein Tag wie aus dem Datumsfeld; archived ist ja oder nein, und aus dem Text
// 'false' wurde vorher ein archivierter Versuch. Welche Status es gibt, weiss
// db.js.
function versuchPruefen(data) {
const vr = validateRequired(data, []);
if (vr) return vr;
const vt = validateTypes(data, {
code: 'string',
title: 'string',
recipe: 'string',
outcome: 'string',
status: 'string',
archived: 'boolean',
workPackageId: 'number'
});
if (vt) return vt;
// Ob es das Paket gibt, weiss db.js; hier nur, dass es eine Kennung ist.
if (data.workPackageId != null && !Number.isInteger(data.workPackageId))
return 'workPackageId must be a whole number';
if ('title' in data && !(typeof data.title === 'string' && data.title.trim())) return 'title must not be empty';
if ('status' in data && typeof data.status !== 'string') return 'status must be a string';
if ('archived' in data && typeof data.archived !== 'boolean') return 'archived must be a boolean';
if ('started' in data) {
const vd = validateDay(data.started, 'started');
if (vd) return vd;
}
// Ein leeres Ende heisst: noch kein Ende.
if (data.ended !== undefined && data.ended !== null && data.ended !== '') {
const vd = validateDay(data.ended, 'ended');
if (vd) return vd;
}
return null;
}
// Die Felder eines Gefaesses und ihre Typen, fuer Anlegen und Aendern gleich.
// cultureId und batchId sind Kennungen als Text. Kam dort ein Objekt, band
// node:sqlite es als benannte Parameter, das UPDATE traf keine Zeile, und der
// Verlauf verzeichnete trotzdem eine Aenderung.
const ANSATZ_TYPEN = {
code: 'string',
what: 'string',
result: 'string',
cultureId: 'string',
batchId: 'string',
sort: 'number'
};
// Was ueber die Typen hinaus gilt: ein Code ist nie leer (null traf die
// Pflichtspalte und endete als 500, '' wurde gespeichert), und die
// Reihenfolge ist eine ganze Zahl.
function ansatzPruefen(data) {
if ('code' in data && !(typeof data.code === 'string' && data.code.trim())) return 'code must not be empty';
if (data.sort !== undefined && !Number.isInteger(data.sort)) return 'sort must be a whole number';
return null;
}
// Die Beutel, die ein Bericht abdeckt.
//
// Ein Formular, mehrere Beutel: wer eine kontaminierte Reihe abgeht, scannt sie
// am Stück und füllt Typ, Schwere, Foto und Notiz einmal aus. Jeder Eintrag
// bringt seine eigene report_uuid mit, sonst fielen beim Nachspielen aus der
// Offline-Warteschlange alle Beutel auf denselben Bericht zusammen.
//
// Ohne `bags` bleiben die flachen Felder das eine Ziel — so sehen die Rümpfe
// aus, die vor dieser Änderung in eine Warteschlange gelegt wurden, und die
// werden nach dem Update noch abgespielt.
function contamReportTargets(data) {
if (!Array.isArray(data.bags)) {
return [
{
bag_id: data.bag_id || null,
batch_id: data.batch_id || null,
report_uuid: data.report_uuid || null
}
];
}
return data.bags.map((b) => ({
bag_id: (b && b.bag_id) || null,
batch_id: (b && b.batch_id) || null,
report_uuid: (b && b.report_uuid) || null
}));
}
// ── AUTH HELPERS ─────────────────────────────────────────────
// Session cookie name varies by transport:
// - HTTPS → "__Host-session" (the __Host- prefix forces the browser to
// reject any cookie that lacks Secure / Path=/ or that includes a
// Domain attribute, blocking sibling-subdomain fixation attacks).
// - HTTP → "session" (the __Host- prefix REQUIRES Secure, so
// it cannot be used over plaintext; we keep the unprefixed name there).
// We always parse BOTH names on incoming requests so a session minted
// before the protocol changed (e.g. cert renewal cycle) still resolves.
function sessionCookieName() {
return protocol === 'https' ? '__Host-session' : 'session';
}
function getSessionToken(req) {
const cookies = req.headers.cookie || '';
// Prefer the prefixed name, fall back to plain — covers both transports
// and clients that sent a stale cookie from an earlier session.
const m1 = cookies.match(/(?:^|;\s*)__Host-session=([a-f0-9]+)/);
if (m1) return m1[1];
// Over HTTPS, accept ONLY the __Host- prefixed cookie. The prefix is what makes
// the browser refuse a same-name cookie that lacks Secure/Path=/ or carries a
// Domain — accepting the bare name here gave that guarantee away: anyone able to
// set a cookie over plaintext for this host (the port-80 listener, or any
// network position — cookies are not scheme-isolated) could plant
// `session=<their token>` and silently place a victim inside their account. It
// also kept honouring non-Secure sessions minted during an HTTP fallback (what
// happens on a cert-renewal failure) after TLS came back.
if (protocol === 'https') return null;
const m2 = cookies.match(/(?:^|;\s*)session=([a-f0-9]+)/);
return m2 ? m2[1] : null;
}
function checkAuth(req) {
const token = getSessionToken(req);
if (!token) return null;
return db.getSession(database, token) || null;
}
function sendUnauthorized(res, isApi) {
if (isApi) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'unauthorized' }));
} else {
res.writeHead(302, { Location: '/login.html' });
res.end();
}
}
function cookieFlags() {
return 'HttpOnly; SameSite=Strict; Path=/;' + (protocol === 'https' ? ' Secure;' : '');
}
function setSessionCookie(res, token) {
res.setHeader(
'Set-Cookie',
sessionCookieName() + '=' + token + '; ' + cookieFlags() + ' Max-Age=' + SESSION_TTL_SECONDS
);
}
function clearSessionCookie(res) {
// Clear both names — if we previously set a __Host-session cookie and
// are now over HTTP, only clearing "session" would leave the prefixed
// cookie behind (and vice versa). Most browsers ignore the second
// header line if it duplicates the first so emit them as a single
// multi-value array instead.
res.setHeader('Set-Cookie', [
'session=; ' + cookieFlags() + ' Max-Age=0',
'__Host-session=; ' + cookieFlags() + ' Max-Age=0'
]);
}
// Clean expired sessions + OAuth data on startup and hourly
db.deleteExpiredSessions(database);
db.deleteExpiredOAuthData(database);
setInterval(
() => {
db.deleteExpiredSessions(database);
db.deleteExpiredOAuthData(database);
},
60 * 60 * 1000
);
// R-10: periodic cleanup of expired sessions + read notifications. The
// session table also has lazy/hourly cleanup above, but the dedicated
// helpers return a row count so we can log how much we GC'd, and the
// notifications table had no GC at all before this. Runs every 6h plus
// once at startup so a server that's been stopped a long time doesn't
// accumulate forever.
function runPeriodicCleanup() {
try {
const sessions = db.cleanupExpiredSessions(database);
const notifications = db.cleanupOldNotifications(database);
// Recipient names on Billbee orders are for the packing list only.
const billbeeNames = db.clearOldBillbeeNames(database);
if (sessions > 0 || notifications > 0 || billbeeNames > 0) {
log('info', 'Periodic cleanup', { sessions, notifications, billbeeNames });
}
} catch (e) {
log('warn', 'Periodic cleanup failed', { error: e.message });
}
}
runPeriodicCleanup();
setInterval(runPeriodicCleanup, 6 * 60 * 60 * 1000);
// ── DAILY AUTO-BACKUP ────────────────────────────────────────
// Every day at 00:00 writes a dated backup to /backups/
const BACKUP_DIR = path.join(DIR, 'backups');
const BACKUP_STATUS_FILE = path.join(BACKUP_DIR, '.backup-status.json');
const OFFSITE_MARKER_FILE = path.join(BACKUP_DIR, '.offsite-sync.json');
// R-14: webhook auto-deploy reliability — record each attempt + outcome to
// a sentinel JSON file so an admin can see whether the last deploy actually
// finished without scraping logs: in_progress, then success, or failed with
// the step it stopped at (fetch, snapshot, prepare, update, install,
// preflight, restart). See scripts/deploy-runner.js and deploy-chain.js.
const DEPLOY_STATE_FILE = path.join(DIR, 'data', 'deploy-state.json');
const deployRunner = require('./scripts/deploy-runner.js');
// R-01: auto-backup rotation only touches files matching its own pattern, so
// the pre-update snapshots (`meistertracker_YYYYMMDD_HHMMSS.db`) are never
// aged out with the auto-backups. They have a rotation of their own, by count.
// Any other artefact in the directory stays put. See scripts/rotate-backups.js
// for both helpers.
const { rotateAutoBackups, rotatePreUpdateSnapshots, BACKUP_PREFIX } = require('./scripts/rotate-backups.js');
const backupCrypto = require('./scripts/backup-crypto.js');
const usbStick = require('./scripts/usb-stick.js');
const photoMirror = require('./scripts/photo-mirror.js');
const { createUsbBackup, describeError: describeUsbError } = require('./scripts/usb-backup.js');
const AUTO_BACKUP_RETENTION_DAYS = 30;
if (!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { mode: 0o700 });
// Clean up orphaned temp files from interrupted backup operations.
// R-13: also picks up `.backup-status.json.tmp.<hex>` leftovers if a process
// crashed between writeFileSync and renameSync, and the `.tmp-<hex>` an
// atomic write (scripts/atomic-file.js) leaves when it dies half-way, such as
// the off-site marker's.
const { LEFTOVER: ATOMIC_LEFTOVER } = require('./scripts/atomic-file.js');
try {
fs.readdirSync(BACKUP_DIR)
.filter((f) => f.startsWith('_') || /^\.backup-status\.json\.tmp\./.test(f) || ATOMIC_LEFTOVER.test(f))
.forEach((f) => {
try {
fs.unlinkSync(path.join(BACKUP_DIR, f));
} catch (e) {
log('warn', 'Failed to clean orphaned temp file', { file: f, error: e.message });
}
});
} catch (e) {
log('warn', 'Failed to scan backup dir for orphans', { error: e.message });
}
// Tauscht die laufende Datenbank gegen eine zurueckgespielte (tmpPath, schon
// geprueft). Danach ist `database` wieder offen: auf der neuen Datei, oder,
// wenn etwas schiefging, auf der alten. Geht keine von beiden auf, bleibt es
// zu, und die Antwort sagt das, statt die zurueckgespielte als den Stand davor
// auszugeben.
//
// Zuerst der Stand davor, mit VACUUM INTO. Das liest durch das WAL, die Kopie
// ist also ganz, auch wenn ein anderes Programm die Datei gerade offen hat;
// eine Dateikopie nach dem Schliessen war es dann nicht. Ohne diese Kopie wird
// nicht getauscht: sie ist das Netz, falls die neue Datei nicht aufgeht, und
// sie bleibt danach bei den Sicherungen liegen, als pre-restore-<Zeit>.db, nie
// rotiert. In ihr steht, was das Zurueckspielen verwirft, auch Eintraege des
// Laborbuch-Verlaufs und ihre Zeitstempel. Bis hierher wurde sie geloescht.
//
// Hat nach dem Schliessen noch ein anderes Programm die Datei offen (der
// DuckDNS-Ersatz liest sie alle fuenf Minuten, oder ein Datenbank-Werkzeug),
// steht ihr WAL noch da, mit Seiten der alten Datenbank. SQLite spielte es beim
// naechsten Oeffnen in die neue Datei, und die waere dann wieder die alte, ohne
// dass etwas auffiele. Dann wird nicht getauscht; eine Minute spaeter geht es.
//
// Gibt { ok: true, kept } oder { ok: false, status, error } zurueck.
function swapRestoredDatabase(tmpPath) {
const kept = path.join(BACKUP_DIR, 'pre-restore-' + new Date().toISOString().replace(/[:.]/g, '-') + '.db');
const weg = (p) => {
try {
fs.rmSync(p, { force: true });
} catch (e) {
log('warn', 'Restore: could not remove a file', { file: path.basename(p), error: e.message });
}
};
try {
db.backupDb(database, kept);
} catch (e) {
log('error', 'Restore refused: the current database could not be copied first', { error: e.message });
weg(kept);
return { ok: false, status: 500, error: 'Restore refused: the current database could not be secured first' };
}
try {
database.close();
} catch (e) {
log('warn', 'Failed to close database before restore', { error: e.message });
}
// Drop live MCP sessions — their captured DB handle is now closed and every
// reopen path below rebinds `database` to a new handle.
closeAllMcpSessions();
// DB_FILE wieder auf, so wie es jetzt ist; false, wenn das nicht geht.
const wiederAuf = () => {
try {
database = db.openDb(DB_FILE);
return true;
} catch (e) {
log('error', 'Failed to reopen the database after a restore went wrong', { error: e.message });
return false;
}
};
const nichtWiederAuf = { ok: false, status: 500, error: 'The database could not be reopened; restart the server' };
let walBytes = 0;
try {
walBytes = fs.statSync(DB_FILE + '-wal').size;
} catch {
walBytes = 0;
}
let shmWeg = true;
if (!walBytes) {
// Ohne WAL ist eine uebrige -shm nur ein Rest; sie geht mit der alten Datei.
try {
fs.rmSync(DB_FILE + '-shm', { force: true });
} catch {
shmWeg = false;
}
}
if (walBytes || !shmWeg) {
log('warn', 'Restore postponed: the database is still open in another process', { walBytes });
weg(kept);
if (!wiederAuf()) return nichtWiederAuf;
return { ok: false, status: 409, error: 'The database is open in another program; try again in a minute' };
}
try {
fs.renameSync(tmpPath, DB_FILE);
} catch (renameErr) {
// The swap failed (e.g. Windows EBUSY/EPERM). The original DB_FILE is
// untouched and its WAL was empty, so reopening it loses nothing.
log('error', 'Failed to swap in restored database, reopening original', { error: renameErr.message });
weg(kept);
if (!wiederAuf()) return nichtWiederAuf;
return { ok: false, status: 500, error: 'Restore failed, previous data has been preserved' };
}
// Die zurueckgespielte Datei ging schon einmal auf, als sie geprueft wurde;
// dass sie es jetzt nicht tut, ist selten. openDb laesst sie dann nicht offen.
try {
database = db.openDb(DB_FILE);
} catch (openErr) {
// Zurueck zum Stand davor, aus der Kopie: erst neben die Datei, dann in
// einem Schritt darueber, so steht DB_FILE nie halb da. Die Kopie bleibt
// bei den Sicherungen liegen.
log('error', 'Failed to open restored database, rolling back', { error: openErr.message });
for (const s of ['-wal', '-shm']) weg(DB_FILE + s);
let zurueck = false;
try {
fs.copyFileSync(kept, DB_FILE + '.rollback');
fs.renameSync(DB_FILE + '.rollback', DB_FILE);
zurueck = true;
} catch (e) {
log('error', 'Rollback copy also failed', { error: e.message });
weg(DB_FILE + '.rollback');
}
// Geht keine Datei auf, sagt die Antwort, welche jetzt DB_FILE ist: nach
// einem Neustart liefe der Server sonst unbemerkt auf der Sicherung, ohne
// dass der Verlauf das Zurueckspielen kennt.
if (!wiederAuf()) {
return {
ok: false,
status: 500,
error: zurueck
? 'Restore failed and the database could not be reopened. The database file holds the data from before again (also kept as ' +
path.basename(kept) +
'); restart the server.'
: 'Restore failed and the database could not be reopened. The database file is still the restored backup: copy ' +
path.basename(kept) +
' over it before restarting the server, or it starts on the backup.'
};
}
// Kam die Kopie nicht zurueck, ist DB_FILE noch die zurueckgespielte, und
// die ging nun doch auf: dann hat das Zurueckspielen geklappt.
if (!zurueck) return { ok: true, kept: path.basename(kept) };
return { ok: false, status: 500, error: 'Restore failed, previous data has been preserved' };
}
// Was das Lesen der Sicherung vor ihren Migrationen neben ihr liegen lassen
// kann (historyDiscarded oeffnet sie nur lesend).
for (const s of ['-wal', '-shm']) weg(tmpPath + s);
return { ok: true, kept: path.basename(kept) };
}
function readBackupStatus() {
try {
if (!fs.existsSync(BACKUP_STATUS_FILE)) return {};
const raw = fs.readFileSync(BACKUP_STATUS_FILE, 'utf8');
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (e) {
log('warn', 'Could not read backup status file', { error: e.message });
return {};
}
}
// R-14: deploy-state sentinel I/O. Best-effort — we don't want a
// transient filesystem hiccup to break the webhook.
function readDeployState() {
try {
if (!fs.existsSync(DEPLOY_STATE_FILE)) return null;
const raw = fs.readFileSync(DEPLOY_STATE_FILE, 'utf8');
return JSON.parse(raw);
} catch (e) {
return null;
}
}
function writeDeployState(patch) {
try {
const dataDir = path.dirname(DEPLOY_STATE_FILE);
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const tmp = DEPLOY_STATE_FILE + '.tmp.' + crypto.randomBytes(4).toString('hex');
fs.writeFileSync(tmp, JSON.stringify(patch, null, 2));
fs.renameSync(tmp, DEPLOY_STATE_FILE);
} catch (e) {
log('warn', 'Could not write deploy-state file', { error: e.message });
}
}
function writeBackupStatus(patch) {
try {
const current = readBackupStatus();
const next = Object.assign({}, current, patch);
// Atomic write: write to temp then rename. R-13: random tmp suffix so
// two concurrent writers don't truncate each other's tmp file before
// either rename completes (e.g. backup completes at the same instant
// an offsite-sync status update fires).
const tmp = BACKUP_STATUS_FILE + '.tmp.' + crypto.randomBytes(4).toString('hex');
fs.writeFileSync(tmp, JSON.stringify(next, null, 2));
fs.renameSync(tmp, BACKUP_STATUS_FILE);
} catch (e) {
log('warn', 'Could not write backup status file', { error: e.message });
}
}
// Verify a freshly-written backup file on disk has the SQLite magic header
// and a plausible size. Returns null on success, an error string on failure.
function verifyBackupFile(dest) {
let sizeBytes = 0;
try {
sizeBytes = fs.statSync(dest).size;
} catch (e) {
return 'stat failed: ' + e.message;
}
if (sizeBytes < 1024) return 'backup file suspiciously small: ' + sizeBytes + ' bytes';
try {
const fd = fs.openSync(dest, 'r');
const header = Buffer.alloc(16);
fs.readSync(fd, header, 0, 16, 0);
fs.closeSync(fd);
if (header.toString('utf8', 0, 15) !== 'SQLite format 3') {
return 'backup file missing SQLite magic header';
}
} catch (e) {
return 'verify read failed: ' + e.message;
}
return null;
}
// The photos a second time on this disk, at no cost in space: every photo gets
// a hard link under backups/photos (scripts/photo-mirror.js). Deleted from the
// app, a photo stays there for 90 days. Runs with the daily backup, so on
// every start and every midnight; incremental, so a run with nothing new only
// reads the folders.
const PHOTO_MIRROR_DIR = path.join(BACKUP_DIR, 'photos');
let photoMirrorRunning = false;
function runPhotoMirror() {
if (photoMirrorRunning) return;
photoMirrorRunning = true;
const started = Date.now();
photoMirror
.mirrorPhotos({ src: PHOTO_DIR, dst: PHOTO_MIRROR_DIR })
.then((r) => {
const entry = {
time: new Date().toISOString(),
linked: r.linked,
copied: r.copied,
present: r.present,
pending: r.pending,
pruned: r.pruned,
conflicts: r.conflicts,
repaired: r.repaired,
errors: r.errors.length,
durationMs: Date.now() - started
};
writeBackupStatus({ photos: entry });
log(
r.errors.length || r.conflicts ? 'warn' : 'info',
'Photo mirror',
Object.assign({}, entry, { firstError: r.errors[0] })
);
})
.catch((e) => {
writeBackupStatus({ photos: { time: new Date().toISOString(), failed: e.message } });
log('error', 'Photo mirror failed', { error: e.message });
})
.finally(() => {
photoMirrorRunning = false;
});
}
// The pre-update snapshot rotation (scripts/rotate-backups.js), logged when it
// did something. Called from runDailyBackup and right after the webhook's own
// snapshot.
function rotatePreUpdate() {
try {
const rot = rotatePreUpdateSnapshots(BACKUP_DIR);
if (rot.error || rot.ledger || rot.deleted.length || rot.skipped.length) {
log(rot.error ? 'warn' : 'info', 'Pre-update snapshot rotation', {
kept: rot.kept.length,
predatingRotation: rot.legacy.length,
deleted: rot.deleted,
skipped: rot.skipped,
ledger: rot.ledger,
error: rot.error
});
}
} catch (e) {
log('warn', 'Pre-update snapshot rotation failed', { error: e.message });
}
}
// git in this checkout, for the deploy's fetch: no shell, a time limit, and no
// credential prompt that would wait for an answer nobody is there to give.
function deployGit(args) {
return new Promise((resolve, reject) => {
execFile(
'git',
args,
{
cwd: DIR,
timeout: 120000,
windowsHide: true,
env: Object.assign({}, process.env, { GIT_TERMINAL_PROMPT: '0' })
},
(err, stdout, stderr) => {
if (err) {
const detail = String(stderr || '').trim();
if (detail) err.message = detail;
return reject(err);
}
resolve(String(stdout));
}
);
});
}
// The GitHub webhook's deploy (scripts/deploy-runner.js). Its snapshot goes
// through this process's own handle on the database, so it is taken by the
// code that is running and the deploy chain never opens the live file. One
// deploy runs at a time; a push that arrives meanwhile, or during a restore,
// waits, across the restart if need be.
const deploys = deployRunner.createDeployRunner({
platform: process.platform,
dir: DIR,
node: process.execPath,
pm2Name: PM2_PROCESS_NAME,
stateFile: DEPLOY_STATE_FILE,
pendingFile: path.join(DIR, 'data', 'deploy-pending.json'),
logFile: path.join(DIR, 'data', 'deploy.log'),
backupDir: BACKUP_DIR,
git: deployGit,
snapshot: (dest) => {
db.backupDb(database, dest);
const bad = verifyBackupFile(dest);
if (bad) throw new Error(bad);
},
rotate: rotatePreUpdate,
restoreBusy: () => restoreInProgress,
runningCommit: () => (codeState && codeState.commit) || null,
readState: readDeployState,
writeState: writeDeployState,
log,
spawn
});
// A push that queued behind the deploy that restarted this process. Late
// enough for the start-up to settle and for codeState to know the commit.
if (!WORKTREE_MODE) {
setTimeout(() => {
deploys.resumePending().catch((e) => log('error', 'Queued deploy failed', { error: e.message }));
}, 15_000);
}
function runDailyBackup() {
const startedAt = Date.now();
const startedIso = new Date(startedAt).toISOString();
// The pre-update snapshots go on every run, ahead of the early return below
// for a day that already has its auto-backup. This runs once at start-up,
// and a start-up is what follows an update: the moment one has just been
// written. The newest is always kept; see scripts/rotate-backups.js.
rotatePreUpdate();
runPhotoMirror();
try {
// Snapshot KPIs before backup — always overwrite today's snapshot
// so it captures the latest state (bags created after server start, etc.)
try {
const snapResult = db.snapshotDailyKPIs(database, { force: true });
log('info', 'KPI snapshot', snapResult);
} catch (e) {
log('warn', 'KPI snapshot failed (backup continues)', { error: e.message });
}
const d = new Date();
const stamp =
d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
const dest = path.join(BACKUP_DIR, BACKUP_PREFIX + stamp + '.db');
if (fs.existsSync(dest)) {
// Already have today's backup — record lastAttempt but don't overwrite.
writeBackupStatus({ lastAttempt: { time: startedIso, success: true, skipped: 'already-exists' } });
return;
}
// R-01: rotate BEFORE writing today's file so the new file is never a
// candidate for deletion in the same pass. The 60s mtime guard inside
// rotateAutoBackups is belt + suspenders against a future ordering
// regression.
try {
const rot = rotateAutoBackups(BACKUP_DIR, AUTO_BACKUP_RETENTION_DAYS);
log('info', 'Auto-backup rotation', {
kept: rot.kept.length,
deleted: rot.deleted,
skipped: rot.skipped
});
} catch (e) {
log('warn', 'Backup rotation failed', { error: e.message });
}
db.backupDb(database, dest)
.then(() => {
let sizeBytes = 0;
try {
sizeBytes = fs.statSync(dest).size;
} catch {}
const durationMs = Date.now() - startedAt;
log('info', 'Auto-backup saved', { path: dest, sizeBytes, durationMs });
// Sanity-check the written file before marking lastSuccess.
const verifyError = verifyBackupFile(dest);
if (verifyError) {
log('error', 'Auto-backup verify failed', { path: dest, error: verifyError });
writeBackupStatus({
lastAttempt: { time: startedIso, success: false },
lastFailure: { time: new Date().toISOString(), error: verifyError, path: dest }
});
return;
}
writeBackupStatus({
lastAttempt: { time: startedIso, success: true },
lastSuccess: {
time: new Date().toISOString(),
path: dest,
sizeBytes,
durationMs,
verified: true
}
});
})
.catch((e) => {
log('error', 'Auto-backup failed', { error: e.message });
writeBackupStatus({
lastAttempt: { time: startedIso, success: false },
lastFailure: { time: new Date().toISOString(), error: e.message, path: dest }
});
});
} catch (e) {
log('error', 'Auto-backup failed', { error: e.message });
writeBackupStatus({
lastAttempt: { time: startedIso, success: false },
lastFailure: { time: new Date().toISOString(), error: e.message }
});
}
}
function scheduleDailyBackup() {
// Run one immediately on startup if today's doesn't exist yet
runDailyBackup();
// Schedule next at midnight
const now = new Date();
const next = new Date(now);
next.setHours(24, 0, 0, 0); // next midnight
const msUntil = next - now;
log('info', 'Next auto-backup scheduled', { at: next.toISOString() });
setTimeout(() => {
runDailyBackup();
setInterval(runDailyBackup, 24 * 60 * 60 * 1000); // then every 24h
}, msUntil);
// Refresh KPI snapshot every 4 hours so data stays current throughout the day
setInterval(
() => {
try {
db.snapshotDailyKPIs(database, { force: true });
} catch (e) {
/* ignore */
}
},
4 * 60 * 60 * 1000
);
}
scheduleDailyBackup();
// ── USB STICK BACKUP ───────────────────────────────────────
// The copy that leaves the building. scripts/usb-backup.js looks for a
// registered stick every few seconds and, once a day, writes the backup onto
// it by itself; the start screen tells admins when it is due and when the
// stick can come out. The routes are under /api/usb-backup. USB_BACKUP=off
// switches the looking off, for a server nobody can plug anything into.
fs.mkdirSync(path.join(DIR, 'data'), { recursive: true });
// A consistent copy to encrypt onto the stick. The "_" prefix is what the
// start-up sweep above removes, should a run die before cleaning up. A
// VACUUM INTO that fails half-way leaves its partial file (and a -journal)
// behind; the run never gets the name, so this removes them, or a copy that
// keeps failing would add one every ten minutes until the next restart.
async function snapshotForStick() {
const tmp = path.join(BACKUP_DIR, '_usb_tmp_' + crypto.randomBytes(8).toString('hex') + '.db');
try {
await db.backupDb(database, tmp);
} catch (e) {
for (const p of [tmp, tmp + '-journal']) fs.rmSync(p, { force: true });
throw e;
}
return tmp;
}
const usbBackup = createUsbBackup({
stateFile: path.join(DIR, 'data', 'usb-backup.json'),
photoDir: PHOTO_DIR,
appDir: DIR,
markerFile: OFFSITE_MARKER_FILE,
snapshotDb: snapshotForStick,
log,
onChange: () => broadcastSSEType('usb-backup'),
enabled: process.env.USB_BACKUP !== 'off'
});
usbBackup.start();
// Der Zeitstempel einer Stelle draussen auf die Pruefkette des Laborbuchs,
// einmal am Tag, wenn seit dem letzten etwas dazukam (scripts/lab-timestamps.js,
// "Pruefkette" in db.js). Dorthin geht nur ein Hash. LAB_TSA_URL waehlt die
// Stelle, "off" schaltet es ab; eine Worktree-Instanz stempelt nicht.
const { createLabTimestamps, tsaFromEnv } = require('./scripts/lab-timestamps.js');
const labStamps = createLabTimestamps({
getDb: () => database,
dbApi: db,
url: tsaFromEnv(process.env, WORKTREE_MODE),
log
});
labStamps.start();
// Das Laborbuch als ZIP (GET /api/lab-book/archive); eins zur Zeit.
const labArchive = require('./scripts/lab-archive.js');
let labArchiveRunning = false;
// Was /api/health und /api/health/full ueber die Kette sagen: der Befund aus
// db.js ohne Token, dazu, wohin gestempelt wird und ob es zuletzt klappte.
//
// Die Pruefung rechnet die ganze Kette nach, einige Millisekunden je tausend
// Eintraege, und die Signatur jedes Zeitstempels, beim ersten Mal etwa eine
// halbe Millisekunde je Token, danach gemerkt. Der Server-Reiter und der
// Neustart fragen oft; eine Minute lang gilt deshalb der letzte Befund,
// solange die Datenbank dieselbe ist. Das Laborbuch prueft jedes Mal selbst.
let labChainCache = null;
function labChainHealth() {
const jetzt = Date.now();
if (!labChainCache || labChainCache.database !== database || jetzt - labChainCache.at > 60 * 1000) {
labChainCache = { database, at: jetzt, befund: db.labChainStatus(database) };
}
return { ...labChainCache.befund, stamping: labStamps.health() };
}
// What the stick routes answer when something is in the way. The code is what
// the interface translates; anything unexpected is logged and stays generic.
const USB_BACKUP_BAD_REQUEST = new Set(['NOT_A_DRIVE', 'BAD_KEY']);
const USB_BACKUP_CONFLICT = new Set([
'SAME_DISK',
'NEEDS_KEY',
'HAS_KEY',
'EXISTS',
'WRONG_KEY',
'FORMAT',
'NO_STICK',
'NOT_PRESENT',
'BUSY',
'FULL',
'READ_ONLY',
'GONE',
'NOT_EMPTY',
'SHRUNK',
'HELD',
'DAMAGED',
'STATE'
]);
function usbBackupError(res, e) {
const code =
USB_BACKUP_BAD_REQUEST.has(e && e.code) || USB_BACKUP_CONFLICT.has(e && e.code) ? e.code : describeUsbError(e).code;
if (USB_BACKUP_BAD_REQUEST.has(code) || USB_BACKUP_CONFLICT.has(code)) {
res.writeHead(USB_BACKUP_BAD_REQUEST.has(code) ? 400 : 409, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: e.message, code }));
return;
}
log('error', 'USB backup request failed', { error: e && e.message });
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'USB backup request failed', code: 'FAILED' }));
}
// The recovery key travels in a response exactly twice: when the first stick
// is set up, and when an admin asks to see it. Neither may be cached.
function jsonNoStore(res, data) {
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(data));
}
// ── DUCKDNS IP UPDATE ──────────────────────────────────────
// The updater lives in duckdns.js. What used to be here was a bare five-minute
// timer that believed whatever duckdns.org replied; the retry, the check
// against the authoritative nameservers, and the heartbeat that notices a
// suspend are all over there, each with the reasoning for why it exists.
function startDuckdnsUpdater() {
// A worktree instance shares the parent repo's settings DB only when it
// points at the same file — but even with separate DBs the same DuckDNS
// creds often get copied in. Skip the updater entirely so the worktree
// never fights prod over the external A record.
return duckdns.start({ database, dbApi: db, log, skip: WORKTREE_MODE });
}
// Never let the DNS updater be the reason this process does not come up.
//
// start() is called at module load, so anything it throws is a server that
// will not boot — and a lab that cannot be reached from the LAN either,
// over a feature whose entire job is remote access. Keeping a stale DNS
// record is bad; refusing to run the application because of it is worse.
// The scheduler's own ticks are already guarded; this covers the arming.
try {
startDuckdnsUpdater();
} catch (e) {
log('error', 'DuckDNS updater failed to start — continuing without it', { error: e.message });
}
// ── OUTBOUND HARVEST FEED ────────────────────────────────────
// Off unless HARVEST_WEBHOOK_URL is set. Pushes a signed summary of recorded
// harvests and upcoming batches to a URL you choose, so a shop, a listing page
// or a chat bot can answer "what do you have today?" without this machine being
// reachable from the internet.
//
// The reply to that push may carry pickups back — collection slots the receiver
// took bookings for. Still no inbound route: no listener, no port, no way to
// reach this machine unbidden. What changed is that data from the far end now
// reaches the database, which makes the reply a trust boundary. See
// harvest-feed.js for the payload, the validation, and the reasoning.
//
// Skipped in worktree mode for the same reason as DuckDNS: a second copy of the
// server usually inherits the same .env, and two of them posting different
// snapshots to one receiver is worse than one posting none.
function startHarvestFeed() {
return harvestFeed.start({ database, env: process.env, log, skip: WORKTREE_MODE, dbApi: db });
}
startHarvestFeed();
/**
* Tell the feed a release changed, so the shop hears it now.
*
* ⚠️ **The gap this closes runs the dangerous way round.** Saving a release only
* wrote a row; the shop heard about it whenever the timer next fired, up to a
* full interval later. Raising a release late is harmless — the shop offers too
* little for a while. *Lowering* one is not: somebody sells 1.5 kg at the stall,
* cuts the release from 2 kg to 0.5, and for the next quarter of an hour the
* shop is still taking bookings against the old figure. The lab then shows
* 0.5 kg released above a 1 kg pickup, and the person who typed the number had
* no way to know.
*
* Fire-and-forget. The save has already succeeded and the row is the source of
* truth; a receiver that is down must not turn a stored decision into an error
* message. The timer carries the same numbers a few minutes later regardless.
*
* The guarding lives in harvest-feed.js — see pushNow() for why calling
* deliver() from here was wrong.
*/
function pushHarvestFeedNow(why) {
harvestFeed
.pushNow({ database, env: process.env, log, dbApi: db, skip: WORKTREE_MODE })
.then((wie) => {
if (wie === 'busy') log('info', 'Harvest feed already sending — immediate push folded in', { why });
})
.catch((e) => log('warn', 'Immediate harvest feed push errored', { why, error: e.message }));
}
// ── LET'S ENCRYPT CERT MANAGEMENT (native ACME v2) ─────────
// Pure Node.js — no bash, curl, or acme.sh required.
// Uses built-in crypto + https for ACME v2 (RFC 8555) with DNS-01 challenge.
const ACME_DIR_URL = 'https://acme-v02.api.letsencrypt.org/directory';
const ACME_ACCOUNT_KEY_PATH = path.join(DIR, 'certs', 'acme-account-key.pem');
function base64url(data) {
return (Buffer.isBuffer(data) ? data : Buffer.from(data)).toString('base64url');
}
// ── DER / ASN.1 encoding helpers ──
function derLen(len) {
if (len < 0x80) return Buffer.from([len]);
if (len < 0x100) return Buffer.from([0x81, len]);
return Buffer.from([0x82, len >> 8, len & 0xff]);
}
function derWrap(tag, buf) {
return Buffer.concat([Buffer.from([tag]), derLen(buf.length), buf]);
}
function derSeq(...items) {
return derWrap(0x30, Buffer.concat(items));
}
function derSet(...items) {
return derWrap(0x31, Buffer.concat(items));
}
function derOid(bytes) {
return derWrap(0x06, Buffer.from(bytes));
}
function derUtf8(str) {
return derWrap(0x0c, Buffer.from(str, 'utf8'));
}
function derBitStr(buf) {
return derWrap(0x03, Buffer.concat([Buffer.from([0x00]), buf]));
}
function derOctStr(buf) {
return derWrap(0x04, buf);
}
function derInt(n) {
return derWrap(0x02, Buffer.from([n]));
}
// ── HTTPS JSON request helper ──
function _acmeHttps(method, url, body, extraHeaders, callback) {
if (typeof extraHeaders === 'function') {
callback = extraHeaders;
extraHeaders = {};
}
const u = new URL(url);
const opts = {
hostname: u.hostname,
port: u.port || 443,
path: u.pathname + u.search,
method,
headers: Object.assign({}, extraHeaders)
};
let bodyStr = null;
if (body) {
bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
if (!opts.headers['Content-Type']) opts.headers['Content-Type'] = 'application/jose+json';
opts.headers['Content-Length'] = Buffer.byteLength(bodyStr);
}
const req = https.request(opts, (res) => {
let raw = '';
res.on('data', (c) => {
raw += c;
});
res.on('end', () => {
let json = null;
if (raw)
try {
json = JSON.parse(raw);
} catch (_) {}
callback(null, res.statusCode, res.headers, json || raw);
});
});
req.on('error', (err) => {
if (err.code === 'ENOTFOUND')
return callback(new Error('Server hat keinen Internetzugang (DNS-Auflösung fehlgeschlagen)'));
if (err.code === 'ECONNREFUSED') return callback(new Error('Verbindung zum ACME-Server abgelehnt'));
if (err.code === 'ETIMEDOUT' || err.code === 'ESOCKETTIMEDOUT')
return callback(new Error('Zeitüberschreitung bei Verbindung zum ACME-Server'));
callback(err);
});
req.setTimeout(30000, () => req.destroy(new Error('ACME request timeout')));
if (bodyStr) req.write(bodyStr);
req.end();
}
// ── Sequential async helper ──
function waterfall(fns, done) {
let i = 0;
(function next(err) {
if (err || i >= fns.length) return done(err);
try {
fns[i++](next);
} catch (e) {
done(e);
}
})(null);
}
// ── Private-key file handling ──
// S-12: fs.writeFileSync's `mode` only applies when it *creates* the file, and
// a renewal overwrites a key that is already there — which would keep whatever
// mode the old one had, including the 0644 this used to produce. So the mode is
// re-asserted after every write. chmod can fail when the file belongs to
// another account (a key dropped in by root, say); that is worth a warning, not
// a crash, because the alternative is a server that will not start.
function writePrivateFile(filePath, contents) {
fs.writeFileSync(filePath, contents, { mode: 0o600 });
try {
fs.chmodSync(filePath, 0o600);
} catch (e) {
log('warn', 'Could not restrict permissions on private file', { path: filePath, error: e.message });
}
}
function ensurePrivateDir(dir) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
try {
fs.chmodSync(dir, 0o700);
} catch (e) {
log('warn', 'Could not restrict permissions on key directory', { path: dir, error: e.message });
}
}
// ── ECDSA P-256 account key (persists in certs/) ──
function loadOrCreateAccountKey() {
if (fs.existsSync(ACME_ACCOUNT_KEY_PATH)) {
return crypto.createPrivateKey(fs.readFileSync(ACME_ACCOUNT_KEY_PATH));
}
log('info', 'Generating ACME account key...');
const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
const dir = path.dirname(ACME_ACCOUNT_KEY_PATH);
ensurePrivateDir(dir);
// S-12: this key re-issues certificates for the domain. Without a mode it
// lands at 0666 & ~umask — 0644 on a normal host, readable by every local
// account. BACKUP_DIR (0700) and the restore temp file (0600) already had
// this right; the two certs/ writes were simply missed.
writePrivateFile(ACME_ACCOUNT_KEY_PATH, privateKey.export({ type: 'pkcs8', format: 'pem' }));
return privateKey;
}
function getAccountJwk(key) {
const j = crypto.createPublicKey(key).export({ format: 'jwk' });
return { crv: j.crv, kty: j.kty, x: j.x, y: j.y };
}
function getJwkThumbprint(jwk) {
const ordered = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
return base64url(crypto.createHash('sha256').update(ordered).digest());
}
// ── JWS signing ──
function signJws(key, protectedHeader, payload) {
const protB64 = base64url(JSON.stringify(protectedHeader));
const payB64 = payload === '' ? '' : base64url(JSON.stringify(payload));
const sig = crypto.sign('sha256', Buffer.from(protB64 + '.' + payB64), {
key,
dsaEncoding: 'ieee-p1363'
});
return JSON.stringify({ protected: protB64, payload: payB64, signature: base64url(sig) });
}
// ── PKCS#10 CSR construction ──
function buildCsr(domain) {
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const spki = publicKey.export({ type: 'spki', format: 'der' });
// Subject: CN=domain
const subject = derSeq(derSet(derSeq(derOid([0x55, 0x04, 0x03]), derUtf8(domain))));
// SAN extension in extensionRequest attribute
const dnsName = Buffer.concat([
Buffer.from([0x82]),
derLen(Buffer.byteLength(domain, 'ascii')),
Buffer.from(domain, 'ascii')
]);
const sanExt = derSeq(derOid([0x55, 0x1d, 0x11]), derOctStr(derSeq(dnsName)));
const extReq = derSeq(derOid([0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x09, 0x0e]), derSet(derSeq(sanExt)));
const attrs = derWrap(0xa0, extReq);
// CertificationRequestInfo
const reqInfo = derSeq(derInt(0), subject, spki, attrs);
const sig = crypto.sign('sha256', reqInfo, privateKey);
const sigAlg = derSeq(derOid([0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0b]), Buffer.from([0x05, 0x00]));
return { der: derSeq(reqInfo, sigAlg, derBitStr(sig)), privateKey };
}
// ── Main certificate request orchestrator ──
function requestLetsEncryptCert(callback) {
const cfg = db.getDuckdnsCfg(database);
if (!cfg.domain || !cfg.token) {
return callback(new Error('DuckDNS domain and token required'));
}
const fullDomain = cfg.domain + '.duckdns.org';
let accountKey, jwk, thumbprint;
let acmeDir, accountUrl, nonce;
let order, orderUrl, challenge, certPem, domainKey;
// Signed ACME POST with nonce tracking + badNonce retry
function acmePost(url, payload, useJwk, cb) {
let retries = 0;
(function attempt() {
const hdr = { alg: 'ES256', nonce, url };
if (useJwk) hdr.jwk = jwk;
else hdr.kid = accountUrl;
const body = signJws(accountKey, hdr, payload);
_acmeHttps('POST', url, body, (err, status, headers, data) => {
if (err) return cb(err);
nonce = headers['replay-nonce'] || nonce;
if (data && data.type === 'urn:ietf:params:acme:error:badNonce' && ++retries < 3) {
return attempt();
}
if (status >= 400) {
const detail = data && data.detail ? data.detail : JSON.stringify(data);
return cb(new Error('ACME error (' + status + '): ' + detail));
}
cb(null, status, headers, data);
});
})();
}
// Poll until target status
function poll(url, target, maxAttempts, cb) {
let attempts = 0;
(function check() {
acmePost(url, '', false, (err, _s, _h, data) => {
if (err) return cb(err);
if (data.status === target) return cb(null, data);
if (data.status === 'invalid') {
const msg = (data.challenges || [])
.map((c) => c.error && c.error.detail)
.filter(Boolean)
.join('; ');
return cb(new Error('Validation failed: ' + (msg || JSON.stringify(data))));
}
if (++attempts >= maxAttempts) return cb(new Error('ACME polling timed out'));
setTimeout(check, 3000);
});
})();
}
log('info', "Requesting Let's Encrypt certificate...", { domain: fullDomain });
waterfall(
[
// 1. Load/create account key
(next) => {
try {
accountKey = loadOrCreateAccountKey();
} catch (e) {
return next(e);
}
jwk = getAccountJwk(accountKey);
thumbprint = getJwkThumbprint(jwk);
next(null);
},
// 2. Fetch ACME directory
(next) => {
_acmeHttps('GET', ACME_DIR_URL, null, (err, _s, _h, data) => {
if (err) return next(err);
acmeDir = data;
next(null);
});
},
// 3. Get initial nonce
(next) => {
_acmeHttps('HEAD', acmeDir.newNonce, null, (err, _s, headers) => {
if (err) return next(err);
nonce = headers['replay-nonce'];
next(null);
});
},
// 4. Create or find account
(next) => {
acmePost(acmeDir.newAccount, { termsOfServiceAgreed: true }, true, (err, _s, headers) => {
if (err) return next(err);
accountUrl = headers.location;
log('info', 'ACME account ready', { url: accountUrl });
next(null);
});
},
// 5. Create order
(next) => {
acmePost(
acmeDir.newOrder,
{
identifiers: [{ type: 'dns', value: fullDomain }]
},
false,
(err, _s, headers, data) => {
if (err) return next(err);
order = data;
orderUrl = headers.location;
next(null);
}
);
},
// 6. Get authorization + dns-01 challenge
(next) => {
acmePost(order.authorizations[0], '', false, (err, _s, _h, data) => {
if (err) return next(err);
challenge = (data.challenges || []).find((c) => c.type === 'dns-01');
if (!challenge) return next(new Error('No dns-01 challenge offered'));
next(null);
});
},
// 7. Set TXT record via DuckDNS
(next) => {
const keyAuth = challenge.token + '.' + thumbprint;
const dns01 = base64url(crypto.createHash('sha256').update(keyAuth).digest());
log('info', 'Setting DuckDNS TXT record...', { domain: cfg.domain });
duckdns.setTxt({ domain: cfg.domain, token: cfg.token, value: dns01 }).then(() => next(null), next);
},
// 8. Wait for DNS propagation
(next) => {
log('info', 'Waiting for DNS propagation (15s)...');
setTimeout(next, 15000);
},
// 9. Respond to challenge
(next) => {
acmePost(challenge.url, {}, false, (err) => next(err));
},
// 10. Poll authorization until valid
(next) => {
poll(order.authorizations[0], 'valid', 40, (err) => {
if (err) return next(err);
log('info', 'DNS-01 challenge validated');
next(null);
});
},
// 11. Build CSR and finalize order
(next) => {
const csr = buildCsr(fullDomain);
domainKey = csr.privateKey;
acmePost(order.finalize, { csr: base64url(csr.der) }, false, (err) => next(err));
},
// 12. Poll order until cert ready
(next) => {
poll(orderUrl, 'valid', 20, (err, data) => {
if (err) return next(err);
order = data;
next(null);
});
},
// 13. Download certificate
(next) => {
acmePost(order.certificate, '', false, (err, _s, _h, data) => {
if (err) return next(err);
certPem = typeof data === 'string' ? data : '';
if (!certPem || !certPem.includes('BEGIN CERTIFICATE')) {
return next(new Error('Invalid certificate response'));
}
next(null);
});
},
// 14. Save certificate and key
(next) => {
try {
const certsDir = path.dirname(CERT_KEY);
ensurePrivateDir(certsDir);
// The certificate is public; the key is not (S-12).
fs.writeFileSync(CERT_CRT, certPem);
writePrivateFile(CERT_KEY, domainKey.export({ type: 'pkcs8', format: 'pem' }));
} catch (e) {
return next(e);
}
next(null);
}
],
(err) => {
// Empty the challenge record, then re-assert the address. The cleanup
// used to send `clear=true`, which DuckDNS documents as clearing the
// address records too — on a path that runs after a *failed* renewal as
// well, and retries every twelve hours. duckdns.clearTxt no longer sends
// it, and the update behind it makes the record correct either way.
duckdns
.clearTxt({ domain: cfg.domain, token: cfg.token })
.then(() => duckdns.updateNow({ database, dbApi: db, log, skip: WORKTREE_MODE }))
.catch(() => {});
if (err) {
log('error', "Let's Encrypt request failed", { error: err.message });
return callback(err);
}
const now = new Date();
// The expiry the certificate itself carries. The lifetime is the CA's
// choice and is getting shorter; a fixed 90 days would put the renewal
// after the expiry of a shorter one. 90 days only if it cannot be read.
let expiry = null;
try {
expiry = new Date(new crypto.X509Certificate(certPem).validTo);
} catch {
/* fall back below */
}
if (!expiry || isNaN(expiry)) expiry = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000);
db.updateDuckdnsStatus(database, {
leLastRenewal: now.toISOString(),
leExpiry: expiry.toISOString()
});
reloadTlsCerts();
log('info', "Let's Encrypt cert installed", { domain: fullDomain, expiry: expiry.toISOString() });
callback(null, { domain: fullDomain, expiry: expiry.toISOString() });
}
);
}
// The expiry the renewal goes by, and whether le_expiry should be set to it.
//
// A Let's Encrypt certificate on disk is the fact. le_expiry is a note about
// the last issuance, written by it and never read back, so the note alone went
// wrong after an ordinary restore: a database from before the first issuance
// has no note, and the renewal returned on every tick while the certificate
// ran out; one from an earlier issuance has an old date, and the renewal asked
// for a certificate the disk already had. Without a Let's Encrypt certificate
// on disk (none, or the self-signed fallback) the note is all there is.
function renewalExpiry(note, disk) {
const noted = note ? new Date(note) : null;
if (disk && disk.letsEncrypt && disk.validTo) {
const same = noted && noted.getTime() === disk.validTo.getTime();
return { expiry: disk.validTo, writeBack: !same };
}
return { expiry: noted && !isNaN(noted) ? noted : null, writeBack: false };
}
function checkCertRenewal() {
// Worktree instance must not race prod for the ACME challenge: both
// would try to bind port 80 for http-01 and hammer the LE rate limit
// on the same hostname. Cert files live in this worktree's certs/
// (per-directory) so the renewal would be lost anyway.
if (WORKTREE_MODE) return;
// This runs while the server loads, so nothing in it may stop a start.
try {
const cfg = db.getDuckdnsCfg(database);
if (!cfg.leEnabled || !cfg.domain || !cfg.token) return;
const { expiry, writeBack } = renewalExpiry(cfg.leExpiry, certOnDisk());
if (!expiry) return; // no certificate and no note: nothing to renew
if (writeBack) {
log('info', 'le_expiry did not match the certificate on disk; taken from the certificate', {
note: cfg.leExpiry || null,
expiry: expiry.toISOString()
});
db.updateDuckdnsStatus(database, { leExpiry: expiry.toISOString() });
}
const daysLeft = (expiry - Date.now()) / (24 * 60 * 60 * 1000);
if (daysLeft < 30) {
log('info', "Let's Encrypt cert expires in " + Math.round(daysLeft) + ' days, renewing...');
requestLetsEncryptCert((err, result) => {
if (err) log('error', 'Auto-renewal failed', { error: err.message });
else log('info', 'Auto-renewal succeeded', result);
});
}
} catch (e) {
log('error', 'Certificate renewal check failed', { error: e.message });
}
}
checkCertRenewal();
setInterval(checkCertRenewal, 12 * 60 * 60 * 1000);
// Short-lived cache for printer availability check (5 seconds)
let _printerStatusCache = null;
let _printerStatusCacheTime = 0;
// R-20: cached count of stuck print-spooler jobs (Paused/Error/Blocked/etc).
// queueStuck > 0 is a degraded signal even when the printer reports online.
let _printerQueueStuckCache = 0;
// Read the effective bridge configuration. DB values (Settings → Drucker)
// take precedence over env vars (PRINT_BRIDGE_URL/PRINT_BRIDGE_TOKEN), so a
// user can configure the bridge from the UI without restarting the server.
// Returns null if neither is configured — callers should treat that as
// "no bridge available" and fall back to the local PowerShell path.
function getEffectiveBridgeConfig() {
try {
const cfg = db.getPrintBridgeCfg(database);
if (cfg.enabled && cfg.url) {
return { url: cfg.url.replace(/\/+$/, ''), token: cfg.token || '', source: 'db' };
}
} catch (e) {
log('warn', 'Failed to read print_bridge_config', { error: e.message });
}
if (PRINT_BRIDGE_URL_ENV) {
return { url: PRINT_BRIDGE_URL_ENV, token: PRINT_BRIDGE_TOKEN_ENV, source: 'env' };
}
return null;
}
// S-23: the bridge's certificate is self-signed by `print-bridge.ps1 -Install`,
// so there is no chain to validate — and the request used to go out with
// rejectUnauthorized:false and nothing else. Encryption without authentication
// stops a passive listener and does nothing about an active one: anybody on the
// LAN able to answer for the bridge's address could present their own
// certificate, collect the X-Bridge-Token from the header, and hand back
// whatever printer status they liked.
//
// A CA is the wrong tool for a machine on a workshop LAN, so the certificate is
// pinned instead. The first connection to an address records its SHA-256
// fingerprint; every connection after that compares. The first one is still
// trusted blind — that is the known limit of trust-on-first-use — but it
// happens once, on the operator's own network, right after they ran the
// installer, rather than on every print for the life of the bridge.
//
// The check runs on 'secureConnect' and destroys the socket there. Verified: at
// that point Node has not yet written the request head, so a mismatch costs the
// attacker the token as well as the payload.
//
// Re-pinning is a deliberate act — saving the printer settings clears the pin.
// See db.updatePrintBridgeCfg.
function _pinnedBridgeFingerprint(origin) {
try {
const cfg = db.getPrintBridgeCfg(database);
return cfg.certUrl === origin ? cfg.certFp || '' : '';
} catch (e) {
return '';
}
}
// Make an HTTPS request to the print bridge with a 5s timeout.
// callback(err, { statusCode, body, raw }) — body is parsed JSON or null.
//
// HTTP URLs are still tolerated for backwards compatibility with .env-based
// setups predating the TLS switch — they log a warning every request so the
// operator notices.
function _bridgeRequest(method, urlPath, body, callback) {
const cfg = getEffectiveBridgeConfig();
if (!cfg) {
return callback(new Error('No print bridge configured'));
}
let target;
try {
target = new URL(urlPath, cfg.url + '/');
} catch (e) {
return callback(new Error('Invalid bridge URL: ' + e.message));
}
const isHttps = target.protocol === 'https:';
if (!isHttps) {
log('warn', 'Print bridge using cleartext HTTP — re-run print-bridge.ps1 -Install on Windows to upgrade to TLS', {
bridge: cfg.url
});
}
const lib = isHttps ? https : http;
const headers = {};
if (cfg.token) headers['X-Bridge-Token'] = cfg.token;
const bodyBuf = body == null ? null : Buffer.from(body, 'utf8');
if (bodyBuf) {
headers['Content-Type'] = 'text/plain; charset=utf-8';
headers['Content-Length'] = bodyBuf.length;
}
const reqOpts = { method, headers, timeout: 5000 };
if (isHttps) {
// Self-signed, so the chain cannot be validated. The fingerprint check
// below is what authenticates the peer (S-23).
reqOpts.rejectUnauthorized = false;
}
const req = lib.request(target, reqOpts, (res) => {
let data = '';
res.setEncoding('utf8');
res.on('data', (c) => (data += c));
res.on('end', () => {
let parsed = null;
try {
parsed = JSON.parse(data);
} catch {
/* not JSON — leave parsed null */
}
callback(null, { statusCode: res.statusCode, body: parsed, raw: data });
});
});
if (isHttps) {
req.on('socket', (socket) => {
socket.on('secureConnect', () => {
const cert = socket.getPeerCertificate();
const fingerprint = (cert && cert.fingerprint256) || '';
if (!fingerprint) {
socket.destroy(new Error('Bridge presented no certificate'));
return;
}
const pinned = _pinnedBridgeFingerprint(target.origin);
if (!pinned) {
try {
db.setPrintBridgeCertPin(database, target.origin, fingerprint);
log('info', 'Pinned print bridge certificate on first connection', {
bridge: target.origin,
fingerprint
});
} catch (e) {
log('warn', 'Could not store the print bridge certificate pin', { error: e.message });
}
return;
}
if (fingerprint !== pinned) {
log('error', 'Print bridge certificate does not match the pinned one — refusing to send', {
bridge: target.origin,
expected: pinned,
got: fingerprint
});
socket.destroy(
new Error(
'Bridge certificate changed. If you re-installed the print bridge, save the printer settings again to re-pin it.'
)
);
}
});
});
}
req.on('timeout', () => {
req.destroy(new Error('Bridge timeout'));
});
req.on('error', (err) => callback(err));
if (bodyBuf) req.write(bodyBuf);
req.end();
}
// Returns a rich status object describing whether label printing is currently
// possible. State values:
// 'online' — printer reachable and ready to receive ZPL
// 'printer_offline' — bridge reachable but the printer is not connected
// 'bridge_unreachable' — bridge configured but did not respond (Windows
// PC off, network issue)
// 'no_bridge' — server is non-Windows and no bridge is
// configured; clients should use the ZPL-download
// flow
// 'local_unavailable' — Windows-local PowerShell path could not find
// the printer
function getPrinterStatus(callback) {
const cfg = getEffectiveBridgeConfig();
if (cfg) {
return _bridgeRequest('GET', 'status', null, (err, resp) => {
if (err) {
return callback(null, {
state: 'bridge_unreachable',
name: PRINTER_NAME,
online: false,
queueStuck: 0,
bridge: cfg.url,
error: err.message
});
}
const printer = resp && resp.body && resp.body.printer;
// R-20: pass queueStuck through unchanged. Bridge versions that
// pre-date the audit will return undefined — coerce to 0 so consumers
// can rely on the field being a number.
const queueStuck = resp && resp.body && Number.isFinite(resp.body.queueStuck) ? resp.body.queueStuck : 0;
if (resp && resp.statusCode === 200 && resp.body && resp.body.ok && printer && printer.online) {
return callback(null, {
state: 'online',
name: printer.name || PRINTER_NAME,
online: true,
queueStuck
});
}
return callback(null, {
state: 'printer_offline',
name: (printer && printer.name) || PRINTER_NAME,
online: false,
queueStuck,
error: (resp && resp.body && resp.body.error) || null
});
});
}
if (process.platform !== 'win32') {
return callback(null, { state: 'no_bridge', name: PRINTER_NAME, online: false, queueStuck: 0 });
}
execFile(
'powershell',
[
'-NoProfile',
'-Command',
`Get-Printer -Name "${PRINTER_NAME.replace(/"/g, '')}" | Select-Object -Property Name,PrinterStatus | ConvertTo-Json`
],
// Timeout so a hung spooler/PowerShell can't leave /api/printer-status and
// any /api/print waiting on checkPrinterAvailable hanging forever (the
// bridge and the print path already cap at 5 s / 15 s). On timeout execFile
// kills the child and calls back with an error → local_unavailable below.
{ timeout: 10000, windowsHide: true },
(err, stdout) => {
if (err || !stdout.includes(PRINTER_NAME)) {
return callback(null, { state: 'local_unavailable', name: PRINTER_NAME, online: false, queueStuck: 0 });
}
callback(null, { state: 'online', name: PRINTER_NAME, online: true, queueStuck: 0 });
}
);
}
function checkPrinterAvailable(callback) {
const now = Date.now();
if (_printerStatusCache !== null && now - _printerStatusCacheTime < 5000) {
return callback(null, _printerStatusCache);
}
getPrinterStatus((err, status) => {
const available = !err && status && status.state === 'online';
_printerStatusCache = available;
_printerQueueStuckCache = status && Number.isFinite(status.queueStuck) ? status.queueStuck : 0;
_printerStatusCacheTime = Date.now();
callback(null, available);
});
}
// Send raw ZPL to the Zebra GK420d. Routes through the print bridge if one
// is configured (DB or env), otherwise prints locally via PowerShell
// (Windows-only — fails on Linux servers without a bridge).
// S-24: ZPL is a command language, not a document format, and /api/print took
// whatever the client sent and handed it straight to the printer. The label
// layout is generated in the browser, so "the client" is anybody with a login —
// and the language reaches well past drawing: ^JU and ~JS persist configuration,
// ~DY and ^DF write files onto the printer, the ^W* family reconfigures the
// wireless interface, ^KP sets a password. A Link-OS printer on the lab network
// is a small networked computer, and this endpoint was an unauthenticated shell
// on it for anybody the app had let in the front door.
//
// So: an allowlist of the commands the label layouts actually emit, plus a
// couple of unambiguously safe drawing ones. Anything else is refused. It has to
// be an allowlist rather than a blocklist — the command set is large, printer
// firmware adds to it, and ^CC/~CC can redefine the command character itself and
// walk straight through any blocklist written against the default one.
//
// Every ^ or ~ in the payload is treated as introducing a command, which is
// exactly how the printer reads it. Legitimate labels never carry a bare one:
// the layout code strips ^ and ~ out of every data field before it gets here
// (zplText in app.js and mcp-server.js), so a ^ inside ^FD data means the client
// is not the one we ship.
const ZPL_ALLOWED_COMMANDS = new Set([
// Format boundaries
'XA',
'XZ',
// Label geometry, orientation, encoding, copies
'PW',
'LL',
'LH',
'LT',
'LS',
'PO',
'FW',
'CI',
'PQ',
// Fields: origin, typeset origin, block, separator, data, comment, reverse
'FO',
'FT',
'FB',
'FS',
'FD',
'FX',
'FR',
// Drawing
'GB',
// Barcodes: defaults, Code 128, QR, Code 39
'BY',
'BC',
'BQ',
'B3'
]);
// ^A0..^A9 and ^AA..^AZ select a font that lives in firmware. ^A@ loads one from
// the printer's file system, which is the kind of thing this list exists to keep
// out, so the character class is deliberately narrow.
const ZPL_FONT_RE = /^A[0-9A-Z]$/;
// A label is on the order of a kilobyte. The body cap is 5 MB, which would be
// tens of thousands of them.
const ZPL_MAX_CHARS = 1024 * 1024;
const ZPL_MAX_LABELS = 2000;
/** null when the payload is safe to send, otherwise the reason it is not. */
function checkZpl(zpl) {
if (typeof zpl !== 'string' || !zpl.trim()) return 'empty ZPL';
if (zpl.length > ZPL_MAX_CHARS) return 'ZPL too large (' + zpl.length + ' chars)';
if (!zpl.includes('^XA') || !zpl.includes('^XZ')) return 'ZPL must contain ^XA and ^XZ';
const labels = (zpl.match(/\^XA/g) || []).length;
if (labels > ZPL_MAX_LABELS) return 'too many labels in one job (' + labels + ')';
const rejected = new Set();
for (let i = 0; i < zpl.length; i++) {
const lead = zpl[i];
if (lead !== '^' && lead !== '~') continue;
const cmd = zpl.slice(i + 1, i + 3).toUpperCase();
// Tilde commands are all device control — none of them draw anything.
if (lead === '^' && (ZPL_ALLOWED_COMMANDS.has(cmd) || ZPL_FONT_RE.test(cmd))) continue;
rejected.add(lead + cmd);
if (rejected.size >= 5) break;
}
if (rejected.size) return 'ZPL contains commands that are not allowed: ' + [...rejected].join(' ');
return null;
}
function printZPL(zplData, callback) {
// Checked here rather than in the route, so the /api/print handler, the bridge
// self-test and the MCP print tools are all covered by the one gate.
const bad = checkZpl(zplData);
if (bad) {
log('warn', 'Refused a print job', { reason: bad });
return callback('Refused: ' + bad);
}
// Every label that left the printer is remembered by its ID, so one whose
// culture or bag never got saved shows up in the bell the same day.
const done = (err) => {
if (!err) rememberPrintedLabels(zplData);
callback(err);
};
if (getEffectiveBridgeConfig()) return _printViaBridge(zplData, done);
return _printViaPowerShell(zplData, done);
}
function _printViaBridge(zplData, callback) {
const zplFixed = zplData.replace(/\r?\n/g, '\r\n');
_bridgeRequest('POST', 'print', zplFixed, (err, resp) => {
if (err) {
const cfg = getEffectiveBridgeConfig();
log('error', 'Print bridge unreachable', { error: err.message, bridge: cfg && cfg.url });
return callback('Print bridge unreachable: ' + err.message);
}
if (!resp || resp.statusCode !== 200 || !resp.body || !resp.body.ok) {
const reason = (resp && resp.body && resp.body.error) || 'HTTP ' + (resp && resp.statusCode);
log('error', 'Bridge print failed', { reason });
return callback('Bridge print failed: ' + reason);
}
log('info', 'Print via bridge OK', { bytes: resp.body.bytes });
callback(null);
});
}
function _printViaPowerShell(zplData, callback) {
// I-18: rapid-fire prints (label batch on a single click) can land in the
// same millisecond, so two callers would share `mp_label_<ms>.zpl` and
// overwrite each other. Random suffix keeps each call's tempfile unique.
const tmp = path.join(os.tmpdir(), 'mp_label_' + crypto.randomBytes(8).toString('hex') + '.zpl');
const zplFixed = zplData.replace(/\r?\n/g, '\r\n');
fs.writeFile(tmp, zplFixed, 'binary', (err) => {
if (err) return callback('Could not write temp file: ' + err.message);
const ps = `
$printerName = "${PRINTER_NAME}"
$filePath = "${tmp.replace(/\\/g, '\\\\')}"
Try {
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public class RawPrinter {
[DllImport("winspool.drv", EntryPoint="OpenPrinterA", SetLastError=true)]
public static extern bool OpenPrinter(string szPrinter, out IntPtr hPrinter, IntPtr pd);
[DllImport("winspool.drv", EntryPoint="ClosePrinter", SetLastError=true)]
public static extern bool ClosePrinter(IntPtr hPrinter);
[DllImport("winspool.drv", EntryPoint="StartDocPrinterA", SetLastError=true)]
public static extern int StartDocPrinter(IntPtr hPrinter, int level, [In, MarshalAs(UnmanagedType.LPStruct)] DOCINFO di);
[DllImport("winspool.drv", EntryPoint="EndDocPrinter", SetLastError=true)]
public static extern bool EndDocPrinter(IntPtr hPrinter);
[DllImport("winspool.drv", EntryPoint="StartPagePrinter", SetLastError=true)]
public static extern bool StartPagePrinter(IntPtr hPrinter);
[DllImport("winspool.drv", EntryPoint="EndPagePrinter", SetLastError=true)]
public static extern bool EndPagePrinter(IntPtr hPrinter);
[DllImport("winspool.drv", EntryPoint="WritePrinter", SetLastError=true)]
public static extern bool WritePrinter(IntPtr hPrinter, IntPtr pBytes, int dwCount, out int dwWritten);
}
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Ansi)]
public class DOCINFO {
[MarshalAs(UnmanagedType.LPStr)] public string pDocName;
[MarshalAs(UnmanagedType.LPStr)] public string pOutputFile;
[MarshalAs(UnmanagedType.LPStr)] public string pDataType;
}
"@
$bytes = [System.IO.File]::ReadAllBytes($filePath)
$hPrinter = [IntPtr]::Zero
$di = New-Object DOCINFO
$di.pDocName = "ZPL Label"
$di.pOutputFile = $null
$di.pDataType = "RAW"
if (![RawPrinter]::OpenPrinter($printerName, [ref]$hPrinter, [IntPtr]::Zero)) { throw "Cannot open printer: $printerName" }
if ([RawPrinter]::StartDocPrinter($hPrinter, 1, $di) -eq 0) { [RawPrinter]::ClosePrinter($hPrinter); throw "StartDocPrinter failed" }
[RawPrinter]::StartPagePrinter($hPrinter) | Out-Null
$ptrBytes = [System.Runtime.InteropServices.Marshal]::AllocHGlobal($bytes.Length)
[System.Runtime.InteropServices.Marshal]::Copy($bytes, 0, $ptrBytes, $bytes.Length)
$written = 0
[RawPrinter]::WritePrinter($hPrinter, $ptrBytes, $bytes.Length, [ref]$written) | Out-Null
[System.Runtime.InteropServices.Marshal]::FreeHGlobal($ptrBytes)
[RawPrinter]::EndPagePrinter($hPrinter) | Out-Null
[RawPrinter]::EndDocPrinter($hPrinter) | Out-Null
[RawPrinter]::ClosePrinter($hPrinter) | Out-Null
Write-Output "OK:$written bytes sent"
} Catch {
Write-Error $_.Exception.Message
exit 1
}
`.trim();
// R-11: Phase 2 I-18 fixed Date.now() in the .zpl tempfile but missed
// this .ps1 sibling. Two concurrent print calls would step on the same
// tempfile and one would lose its script.
const psTmp = path.join(os.tmpdir(), 'mp_print_' + crypto.randomBytes(8).toString('hex') + '.ps1');
fs.writeFile(psTmp, ps, 'utf8', (err2) => {
if (err2) {
fs.unlink(tmp, () => {});
return callback('Could not write PS script: ' + err2.message);
}
const cleanup = () => {
fs.unlink(tmp, (e) => {
if (e) log('warn', 'Failed to delete temp ZPL file', { error: e.message });
});
fs.unlink(psTmp, (e) => {
if (e) log('warn', 'Failed to delete temp PS1 file', { error: e.message });
});
};
const child = execFile(
'powershell',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', psTmp],
(e, stdout, stderr) => {
cleanup();
if (e) {
log('error', 'PowerShell print error', { error: stderr || e.message });
callback('Print failed: ' + (stderr || e.message).trim());
} else {
log('info', 'Print OK', { output: stdout.trim() });
callback(null);
}
}
);
const timeout = setTimeout(() => {
child.kill();
cleanup();
log('warn', 'PowerShell print process timed out and was killed');
callback('Print failed: PowerShell process timed out');
}, 15000);
child.on('close', () => clearTimeout(timeout));
});
});
}
// ══════════════════════════════════════════════════════════════
// ── BUILT-IN CalDAV SERVER ───────────────────────────────────
// ══════════════════════════════════════════════════════════════
// Serves VTODO calendars at /caldav/ — compatible with any
// CalDAV client (Apple Calendar, Thunderbird, DAVx5, etc.)
// Data stored in /calendars/<cal-name>/<uid>.ics
// No npm dependencies — pure Node.js
// RFC 5545 §3.1: fold content lines longer than 75 octets
function foldIcsLines(icsText) {
return icsText
.split('\r\n')
.map((line) => {
if (Buffer.byteLength(line, 'utf8') <= 75) return line;
const parts = [];
let remaining = line;
let first = true;
while (Buffer.byteLength(remaining, 'utf8') > 75) {
const limit = first ? 75 : 74; // subsequent lines have leading space
let cut = limit;
while (cut > 0 && Buffer.byteLength(remaining.slice(0, cut), 'utf8') > limit) cut--;
parts.push(remaining.slice(0, cut));
remaining = remaining.slice(cut);
first = false;
}
if (remaining) parts.push(remaining);
return parts.join('\r\n ');
})
.join('\r\n');
}
// Unfold RFC 5545 §3.1 folded lines (CRLF + space/tab → join)
function unfoldIcs(text) {
return text.replace(/\r?\n[ \t]/g, '');
}
function generateUID() {
return 'mp-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
}
// Read only the first N bytes of a file (for orphan cleanup — avoids reading entire ICS)
function readFileHead(filePath, bytes) {
const fd = fs.openSync(filePath, 'r');
try {
const buf = Buffer.alloc(bytes);
const bytesRead = fs.readSync(fd, buf, 0, bytes, 0);
return buf.toString('utf8', 0, bytesRead);
} finally {
fs.closeSync(fd);
}
}
// Escape iCalendar TEXT values per RFC 5545 §3.3.11
function escapeIcsText(s) {
return String(s).replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
}
// I-15: build a single-line RRULE property from our internal recurrence enum.
// Returns null when recurrence is unset or unsupported (so the caller can omit
// the property entirely). UNTIL is encoded in UTC RFC-5545 form
// (YYYYMMDDTHHMMSSZ) when `until` is provided as an ISO date / datetime.
function buildRRuleLine(recurrence, until) {
const freqMap = { daily: 'DAILY', weekly: 'WEEKLY', monthly: 'MONTHLY', yearly: 'YEARLY' };
const freq = freqMap[String(recurrence || '').toLowerCase()];
if (!freq) return null;
let line = 'RRULE:FREQ=' + freq;
if (until) {
// Accept "YYYY-MM-DD" or full ISO; emit UTC "YYYYMMDDTHHMMSSZ".
const d = new Date(until);
if (!isNaN(d.getTime())) {
const u = d.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
line += ';UNTIL=' + u;
}
}
return line;
}
// I-15: format a single EXDATE value to match the event's DTSTART encoding.
// `dateStr` is whatever the JSON-array column holds — typically YYYY-MM-DD or
// a full ISO datetime. `dtstartIsAllDay` controls whether we emit a date-only
// value (VALUE=DATE) or a TZID-qualified local datetime.
function formatExceptionDate(dateStr, dtstartIsAllDay, dtstartTime) {
if (!dateStr) return null;
const trimmed = String(dateStr).trim();
if (!trimmed) return null;
if (dtstartIsAllDay) {
// Date-only: strip any time portion, emit YYYYMMDD.
const dateOnly = trimmed.slice(0, 10).replace(/-/g, '');
if (!/^\d{8}$/.test(dateOnly)) return null;
return dateOnly;
}
// Datetime: combine the exception date with the original event time so the
// EXDATE matches the DTSTART instance exactly. dtstartTime is "HH:MM".
const dateOnly = trimmed.slice(0, 10).replace(/-/g, '');
if (!/^\d{8}$/.test(dateOnly)) return null;
const hhmm = (dtstartTime || '00:00').replace(':', '') + '00';
return dateOnly + 'T' + hhmm;
}
// I-15: render the full EXDATE line(s) for an event. CalDAV clients accept a
// single EXDATE with comma-separated values. Returns null when there are no
// exceptions so the caller can skip pushing the property.
function buildExdateLine(exceptionDates, dtstartIsAllDay, dtstartTime, tzid) {
if (!Array.isArray(exceptionDates) || !exceptionDates.length) return null;
const formatted = exceptionDates.map((d) => formatExceptionDate(d, dtstartIsAllDay, dtstartTime)).filter(Boolean);
if (!formatted.length) return null;
if (dtstartIsAllDay) return 'EXDATE;VALUE=DATE:' + formatted.join(',');
return 'EXDATE;TZID=' + (tzid || 'Europe/Berlin') + ':' + formatted.join(',');
}
// Sanitize URL path parts to prevent directory traversal attacks.
// Platform-independent: rejects every byte that could mean "another path
// component" (forward slash, backslash, NUL) regardless of whether the
// process is running on POSIX or Windows. Also rejects empty / dot / dot-dot.
function sanitizePart(s) {
if (typeof s !== 'string' || !s) return null;
// Reject explicit traversal characters and dotfile names
if (/[\/\\\0]/.test(s)) return null;
if (s === '.' || s === '..') return null;
return s;
}
function escapeXml(s) {
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
// Extract username from Basic auth header (without verifying password)
function extractBasicAuthUsername(req) {
const authHeader = req.headers['authorization'] || '';
if (!authHeader.startsWith('Basic ')) return null;
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
const idx = decoded.indexOf(':');
if (idx < 0) return null;
const username = decoded.slice(0, idx);
// S-16: this value is only ever used to build brute-force throttle keys, and
// a name that cannot belong to an account has no business becoming one. The
// header is bounded by maxHeaderSize so the exposure is far smaller than on
// the JSON login route, but it is the same shape.
return USERNAME_RE.test(username) ? username : null;
}
// Check CalDAV basic auth against user accounts.
// Handles charset mismatches: iOS/Safari may send Basic Auth as ISO-8859-1
// (latin1) when the WWW-Authenticate header lacks charset="UTF-8".
// Also tries case-insensitive username lookup as fallback.
// S-14: CalDAV re-verifies Basic auth on every single request, so raising the
// account KDF to N=131072 would put a few hundred milliseconds of CPU behind
// each PROPFIND — a phone syncing eight calendars would keep a core busy on its
// own. Only the *outcome of the password check* is cached: the account row is
// re-read from the database on every hit, so a role change, a revoked
// capability or a deleted account still takes effect immediately. Failures are
// never cached — brute force is already bounded by the per-user and per-IP
// lockout above, and caching a miss would only raise an attacker's throughput.
//
// The key is peppered with a per-process random value, so the cache is
// meaningless to anyone who reads it out of a core dump on another machine.
const CALDAV_AUTH_CACHE_TTL = 5 * 60 * 1000;
const CALDAV_AUTH_CACHE_MAX = 200;
const CALDAV_AUTH_PEPPER = crypto.randomBytes(32);
const caldavAuthCache = new Map(); // key → { username, expires }
function caldavAuthCacheKey(user, pass) {
return crypto
.createHash('sha256')
.update(CALDAV_AUTH_PEPPER)
.update(user + '\u0000' + pass)
.digest('hex');
}
// Called whenever a password changes or an account goes away. The TTL alone
// would get there within five minutes; this makes it immediate.
function clearCaldavAuthCache() {
caldavAuthCache.clear();
}
function checkCaldavAuth(req) {
const authHeader = req.headers['authorization'] || '';
if (!authHeader.startsWith('Basic ')) return false;
const raw = Buffer.from(authHeader.slice(6), 'base64');
// Try UTF-8 first (standard), then latin1 (legacy Basic Auth default per RFC 2617)
for (const encoding of ['utf8', 'latin1']) {
const decoded = raw.toString(encoding);
const idx = decoded.indexOf(':');
if (idx < 0) continue;
const user = decoded.slice(0, idx);
const pass = decoded.slice(idx + 1);
if (!USERNAME_RE.test(user)) continue; // S-16: cannot be an account
// S-25: an app-specific password first. It is a SHA-256 lookup rather than
// a scrypt verify, so it costs nothing — which is also why it needs no
// cache entry. The username still has to match its owner: an app password
// opens that person's calendars, not whoever presents it.
const appPw = db.findCaldavAppPassword(database, pass);
if (appPw) {
const owner = db.getUserByUsername(database, user) || db.getUserByUsernameCaseInsensitive(database, user);
if (owner && owner.id === appPw.userId) {
try {
db.touchCaldavAppPassword(database, appPw.id);
} catch (e) {
/* the audit stamp is best effort; it must not cost somebody their calendar */
}
return owner;
}
}
const cacheKey = caldavAuthCacheKey(user, pass);
const cached = caldavAuthCache.get(cacheKey);
if (cached) {
if (cached.expires > Date.now()) {
const fresh = db.getUserByUsername(database, cached.username);
if (fresh) return fresh;
}
caldavAuthCache.delete(cacheKey);
}
// Try exact username match, then case-insensitive fallback
const account = db.getUserByUsername(database, user) || db.getUserByUsernameCaseInsensitive(database, user);
if (!account) continue;
if (db.verifyPassword(account.hash, account.salt, pass)) {
// Insertion order is eviction order — drop the oldest entry at the cap so
// a client cycling credentials cannot grow this without bound.
if (caldavAuthCache.size >= CALDAV_AUTH_CACHE_MAX) {
caldavAuthCache.delete(caldavAuthCache.keys().next().value);
}
caldavAuthCache.set(cacheKey, { username: account.username, expires: Date.now() + CALDAV_AUTH_CACHE_TTL });
return account;
}
}
return false;
}
// ── CalDAV category calendars with colors matching web calendar ──
const CALDAV_CATEGORY_CALS = {
// The key is the on-disk directory and URL segment and cannot be renamed
// without breaking every existing subscription; the display name is what the
// calendar client actually shows, so it carries no operator name.
meisterpilze: { displayName: 'Aufgaben (geteilt)', color: '#16a34a' },
faelligkeiten: { displayName: 'Fälligkeiten', color: '#ef4444' },
aufgaben: { displayName: 'Aufgaben', color: '#3b82f6' },
'eigene-termine': { displayName: 'Eigene Termine', color: '#22c55e' },
meetings: { displayName: 'Meetings', color: '#8b5cf6' },
lieferungen: { displayName: 'Lieferungen', color: '#14b8a6' },
wartung: { displayName: 'Wartung', color: '#64748b' },
abholung: { displayName: 'Abholfenster', color: '#f59e0b' }
};
const CALDAV_EVENT_CATEGORY_MAP = {
custom: 'eigene-termine',
meeting: 'meetings',
delivery: 'lieferungen',
maintenance: 'wartung',
pickup: 'abholung'
};
const USER_CALENDAR_COLORS = ['#f97316', '#ec4899', '#eab308', '#6366f1', '#06b6d4', '#84cc16', '#d946ef'];
// Compute dominant batch location from scan log (server-side equivalent of web getBatchLoc)
function getBatchLocServer(batch, scanLog) {
const locs = {};
(batch.bags || []).forEach((bag) => {
// A new Charge posts its bags as { id, bagKg }; one read back from the
// database has plain IDs. Both are a bag. Taking only the string form
// threw on every new Charge, and its due date never reached the calendar.
const id = String((bag && typeof bag === 'object' ? bag.id : bag) || '').toUpperCase();
if (!id) return;
const last = [...scanLog].reverse().find((e) => (e.bag || '').toUpperCase() === id);
if (last && last.action !== 'REMOVE' && last.to) locs[last.to] = (locs[last.to] || 0) + 1;
});
const entries = Object.entries(locs);
if (!entries.length) return '';
entries.sort((a, b) => b[1] - a[1]);
return entries[0][0];
}
// Per-calendar access control: personal calendars are only accessible by that user (or admins)
// Shared 'meisterpilze' calendar is accessible by all authenticated users
function checkCalendarAccess(req, calName) {
if (CALDAV_CATEGORY_CALS[calName]) return true;
if (req.caldavUser.role === 'admin') return true;
if (req.caldavUserSlug !== calName) return false;
// S-18: createUser rejects a colliding name now, but a database created
// before that guard can already hold one — and a personal calendar shared
// between two people is worse than a personal calendar that stops answering.
// Deny, and say who has to be renamed.
const owners = db.listUsers(database).filter((u) => db.caldavSlug(u.username) === calName);
if (owners.length > 1) {
log('warn', 'CalDAV calendar name is ambiguous — access denied until one account is renamed', {
calendar: calName,
users: owners.map((u) => u.username)
});
return false;
}
return true;
}
// Get display name and color for a calendar
function getCalDisplayInfo(calName) {
if (CALDAV_CATEGORY_CALS[calName]) {
return { displayName: CALDAV_CATEGORY_CALS[calName].displayName, color: CALDAV_CATEGORY_CALS[calName].color };
}
const users = db.listUsers(database);
const userIdx = users.findIndex((u) => db.caldavSlug(u.username) === calName);
if (userIdx >= 0) {
return { displayName: users[userIdx].username, color: USER_CALENDAR_COLORS[userIdx % USER_CALENDAR_COLORS.length] };
}
return { displayName: calName.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()), color: null };
}
// Ensure a calendar directory exists
function ensureCalDir(calName) {
const dir = path.join(CAL_DIR, calName);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
return dir;
}
// List all calendar directories
function listCalendars() {
if (!fs.existsSync(CAL_DIR)) return [];
return fs.readdirSync(CAL_DIR).filter((f) => {
return fs.statSync(path.join(CAL_DIR, f)).isDirectory();
});
}
// List all .ics files in a calendar
function listIcsFiles(calName) {
const dir = path.join(CAL_DIR, calName);
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir).filter((f) => f.endsWith('.ics'));
}
// CTag cache — avoids filesystem scan on every PROPFIND poll
const ctagCache = new Map();
// ETag cache — avoids stat() on every PROPFIND/REPORT item
const etagCache = new Map();
function invalidateCalendarCache(calName) {
ctagCache.delete(calName);
// Clear all etags for this calendar
const prefix = calName + '/';
for (const key of etagCache.keys()) {
if (key.startsWith(prefix)) etagCache.delete(key);
}
}
function invalidateCtag(calName) {
invalidateCalendarCache(calName);
}
function getEtag(calName, fileName) {
const key = calName + '/' + fileName;
if (etagCache.has(key)) return etagCache.get(key);
try {
const stat = fs.statSync(path.join(CAL_DIR, calName, fileName));
const etag = '"' + stat.mtimeMs.toString(36) + '"';
etagCache.set(key, etag);
return etag;
} catch {
return null;
}
}
// ── RFC 6578 sync-token tracking ──
const syncTokens = new Map(); // calName → current monotonic counter
const changeLog = new Map(); // calName → Map<fileName, {action:'changed'|'deleted', token}>
const CHANGE_LOG_MAX = 1000; // max entries per calendar before trimming
function bumpSyncToken(calName) {
const next = (syncTokens.get(calName) || 0) + 1;
syncTokens.set(calName, next);
return next;
}
function recordChange(calName, fileName, action) {
const token = bumpSyncToken(calName);
if (!changeLog.has(calName)) changeLog.set(calName, new Map());
const log = changeLog.get(calName);
log.set(fileName, { action, token });
// Trim oldest entries if over limit
if (log.size > CHANGE_LOG_MAX) {
const entries = [...log.entries()].sort((a, b) => a[1].token - b[1].token);
while (log.size > CHANGE_LOG_MAX) log.delete(entries.shift()[0]);
}
}
function getSyncToken(calName) {
return syncTokens.get(calName) || 0;
}
// Write an .ics file and invalidate caches / record change
// S-23: the calendar file name, checked where it becomes a path.
//
// Every writeIcsFile caller builds the name as `uid + '.ics'`, and on the
// push-one / push-event routes that uid is `task.caldavUid` straight out of a
// request body — any logged-in user, no role required. path.join() will follow
// a "../" chain out of the calendar directory without complaint, so a worker
// could drop an .ics carrying their own SUMMARY and DESCRIPTION anywhere the
// server process can write, including the Windows profile it runs under.
//
// The charset is the one the *read* side has always demanded of a uid
// (/^[A-Za-z0-9\-_.@]+$/ at the sync-back paths); it simply never held on the
// way in. It is sufficient on its own: with the separators and NUL excluded, a
// name cannot address a second path component at all, on POSIX or on Windows —
// so there is no need to also hunt for "..", and no legitimate uid gets refused
// for containing one.
//
// The check lives here rather than at each caller because there are fourteen of
// them, and calName is already safe (db.caldavSlug reduces it to [a-z0-9-]).
function icsFileName(fileName) {
const stem = typeof fileName === 'string' && fileName.endsWith('.ics') ? fileName.slice(0, -4) : null;
if (!db.isValidCaldavUid(stem)) {
// The 'caldav:' prefix is on db.isSafeError's list, so the caller answers
// 400 with this text rather than a 500 that reads like our own fault.
throw new Error('caldav: rejected calendar file name');
}
return fileName;
}
function writeIcsFile(calName, fileName, content) {
icsFileName(fileName);
const dir = ensureCalDir(calName);
fs.writeFileSync(path.join(dir, fileName), content, 'utf8');
invalidateCtag(calName);
recordChange(calName, fileName, 'changed');
}
// Delete an .ics file and invalidate caches / record change
function deleteIcsFile(calName, fileName) {
icsFileName(fileName);
const filePath = path.join(CAL_DIR, calName, fileName);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
invalidateCtag(calName);
recordChange(calName, fileName, 'deleted');
}
}
// Compute a stable ctag for a calendar based on file contents (cached)
function computeCtag(calName) {
if (ctagCache.has(calName)) return ctagCache.get(calName);
const dir = path.join(CAL_DIR, calName);
if (!fs.existsSync(dir)) return '0';
const files = fs
.readdirSync(dir)
.filter((f) => f.endsWith('.ics'))
.sort();
const hash = crypto.createHash('md5');
for (const f of files) {
const stat = fs.statSync(path.join(dir, f));
hash.update(f + ':' + stat.mtimeMs + ':' + stat.size + '\n');
}
const ctag = hash.digest('hex').slice(0, 16);
ctagCache.set(calName, ctag);
return ctag;
}
// CalDAV event color map — matches CATEGORY_COLORS in app.js
const CALDAV_CATEGORY_COLORS = {
custom: '#16a34a',
meeting: '#8b5cf6',
delivery: '#14b8a6',
maintenance: '#64748b',
pickup: '#f59e0b'
};
// RFC 5545 PRIORITY → app priority mapping (1=highest, 9=lowest, 0=undefined)
const CALDAV_PRIO_MAP = {
1: 'high',
2: 'high',
3: 'high',
4: 'high',
5: 'med',
6: 'low',
7: 'low',
8: 'low',
9: 'low',
0: 'med'
};
// Known calendar event categories.
//
// `pickup` is the odd one out and deliberately so: the other four describe work
// this lab does to itself, while this one states when someone from outside may
// come and collect. That statement has to be positive. Deriving it negatively —
// block out meetings and maintenance, call the rest open — reads an empty
// calendar as "always, including Sunday at 03:00".
const KNOWN_CATEGORIES = { custom: 1, meeting: 1, delivery: 1, maintenance: 1, pickup: 1 };
// Supported CalDAV report set XML (used in PROPFIND responses)
const SUPPORTED_REPORT_SET =
'<d:supported-report-set><d:supported-report><d:report><c:calendar-multiget/></d:report></d:supported-report><d:supported-report><d:report><c:calendar-query/></d:report></d:supported-report><d:supported-report><d:report><d:sync-collection/></d:report></d:supported-report></d:supported-report-set>';
// Extract the VEVENT block from ICS content (avoids parsing VTIMEZONE properties)
function extractVeventBlock(ics) {
const m = ics.match(/BEGIN:VEVENT([\s\S]*?)END:VEVENT/);
return m ? m[1] : '';
}
// Convert a task object to VTODO .ics content
function taskToVTODO(task) {
const uid = task.caldavUid || generateUID();
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
const created = task.created ? new Date(task.created).toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '') : now;
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Meisterpilze Lab Tracker//EN',
'BEGIN:VTODO',
'UID:' + uid,
'DTSTAMP:' + now,
'CREATED:' + created,
'LAST-MODIFIED:' + now,
// I-15: SEQUENCE counter (RFC 5545 §3.8.7.4) — CalDAV clients use this
// to detect that an event/todo has been updated. Bumped on every
// meaningful edit by db.updateTaskById.
'SEQUENCE:' + (Number(task.sequence) || 0),
'SUMMARY:' + escapeIcsText(task.text || '')
];
if (task.dueDate) {
const d = new Date(task.dueDate).toISOString().replace(/[-:]/g, '').split('T')[0];
if (task.dueTime && /^([01]\d|2[0-3]):[0-5]\d$/.test(task.dueTime)) {
// Floating local-time DUE so CalDAV clients render it at the wall-clock
lines.push('DUE:' + d + 'T' + task.dueTime.replace(':', '') + '00');
} else {
lines.push('DUE;VALUE=DATE:' + d);
}
}
// I-15: emit RRULE (RFC 5545 §3.8.5.3) when the task is recurring. UNTIL is
// expressed in UTC RFC-5545 form (YYYYMMDDTHHMMSSZ) for time-bounded series.
const rruleLine = buildRRuleLine(task.recurrence, task.recurrenceUntil);
if (rruleLine) lines.push(rruleLine);
const prioMap = { high: 1, med: 5, low: 9 };
lines.push('PRIORITY:' + (prioMap[task.priority] || 0));
lines.push('STATUS:' + (task.done ? 'COMPLETED' : 'NEEDS-ACTION'));
if (task.done) lines.push('PERCENT-COMPLETE:100');
lines.push('X-MEISTERPILZE-TYPE:task');
if (task.assignee) lines.push('X-MEISTERPILZE-ASSIGNEE:' + task.assignee);
if (task.description) lines.push('DESCRIPTION:' + escapeIcsText(task.description));
lines.push('COLOR:#3b82f6');
lines.push('END:VTODO', 'END:VCALENDAR');
return { uid, ics: foldIcsLines(lines.join('\r\n')) };
}
// Write a task as .ics file to the appropriate calendar
function writeTaskToCalendar(task, calName) {
calName = calName || 'meisterpilze';
if (!task.caldavUid) task.caldavUid = generateUID();
const { uid, ics } = taskToVTODO(task);
writeIcsFile(calName, uid + '.ics', ics);
task.caldavSynced = new Date().toISOString();
return uid;
}
// YYYYMMDD of `date` in Europe/Berlin (the .ics TZID), independent of the
// server's own timezone. Deriving an all-day date via toISOString() (UTC)
// shifts it a day earlier for a due date stored as Berlin local midnight.
function _berlinDateCompact(date) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
.format(date)
.replace(/-/g, '');
}
// The calendar day after a YYYYMMDD date, counted in UTC, where every day has
// 864e5 ms. Adding 864e5 to the due time and reading the Berlin date instead
// went wrong around a change: a due in the first hour of the autumn change day
// ended on that same day, and one in the last hour of the day before the spring
// change spanned two days. See test/sommerzeit.test.js.
function _nextDayCompact(ymd) {
const d = new Date(Date.UTC(+ymd.slice(0, 4), +ymd.slice(4, 6) - 1, +ymd.slice(6, 8) + 1));
return d.toISOString().slice(0, 10).replace(/-/g, '');
}
// The last day of an all-day event as YYYY-MM-DD, from the year, month and day
// of its DTEND;VALUE=DATE. RFC 5545 makes that DTEND exclusive, so this is the
// day before, counted in UTC like _nextDayCompact. The CalDAV PUT used to parse
// the DTEND with new Date('YYYY-MM-DD'), which is UTC midnight, step it back
// with the local setDate() and read it out with toISOString(). Around the
// autumn change that came out a day early: in Europe/Berlin an event ending on
// the change Sunday arrived ending on the Saturday. See test/sommerzeit.test.js.
function _allDayLastDate(y, m, d) {
return new Date(Date.UTC(+y, +m - 1, +d - 1)).toISOString().slice(0, 10);
}
// Convert a batch to VEVENT .ics content (all-day event for due date)
function batchToVEVENT(batch, scanLog) {
const uid = 'batch-' + batch.batchId + '@meisterpilze';
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
const dueDate = _berlinDateCompact(new Date(batch.due));
// DTEND is next day for all-day events per RFC 5545
const endDate = _nextDayCompact(dueDate);
const loc = scanLog ? getBatchLocServer(batch, scanLog) : '';
const summary = escapeIcsText(batch.batchId + (loc ? ' — ' + loc : ''));
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Meisterpilze Lab Tracker//EN',
'BEGIN:VEVENT',
'UID:' + uid,
'DTSTAMP:' + now,
// I-15: SEQUENCE on batch-due VEVENTs. Batches don't have an in-DB
// sequence column (the iCal artifact is one-shot per due-date), but
// emitting 0 for completeness keeps client validators quiet.
'SEQUENCE:0',
'DTSTART;VALUE=DATE:' + dueDate,
'DTEND;VALUE=DATE:' + endDate,
'SUMMARY:' + summary,
'CATEGORIES:Fälligkeiten'
];
if (loc) lines.push('LOCATION:' + escapeIcsText(loc));
// Prefer the joined mushroom_strains values; fall back to legacy free-text
// fields for historical batches without a strain_id.
const descName = batch.strainName || batch.species || '';
const descKuerzel = batch.strainKuerzel || batch.strain || '';
const descText = descName + (descKuerzel ? ' (' + descKuerzel + ')' : '');
lines.push(
'DESCRIPTION:' + escapeIcsText(descText),
'TRANSP:TRANSPARENT',
'X-MEISTERPILZE-TYPE:batch-due',
'X-MEISTERPILZE-BATCH:' + batch.batchId,
'COLOR:#ef4444',
'END:VEVENT',
'END:VCALENDAR'
);
return { uid, ics: foldIcsLines(lines.join('\r\n')) };
}
// Convert a task with due date to VEVENT .ics content
function taskDueToVEVENT(task) {
const uid = (task.caldavUid || generateUID()) + '-event';
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
const dueDate = new Date(task.dueDate).toISOString().replace(/[-:]/g, '').split('T')[0];
const timeRe = /^([01]\d|2[0-3]):[0-5]\d$/;
const hasTime = task.dueTime && timeRe.test(task.dueTime);
let dtStartLine, dtEndLine;
if (hasTime) {
const startRaw = dueDate + 'T' + task.dueTime.replace(':', '') + '00';
let endRaw;
if (task.dueEndTime && timeRe.test(task.dueEndTime) && task.dueEndTime > task.dueTime) {
endRaw = dueDate + 'T' + task.dueEndTime.replace(':', '') + '00';
} else {
// Default to 1h slot if no end given
const [sh, sm] = task.dueTime.split(':').map(Number);
const eh = String(Math.min(23, sh + 1)).padStart(2, '0');
endRaw = dueDate + 'T' + eh + String(sm).padStart(2, '0') + '00';
}
dtStartLine = 'DTSTART:' + startRaw;
dtEndLine = 'DTEND:' + endRaw;
} else {
const endDate = new Date(new Date(task.dueDate).getTime() + 86400000)
.toISOString()
.replace(/[-:]/g, '')
.split('T')[0];
dtStartLine = 'DTSTART;VALUE=DATE:' + dueDate;
dtEndLine = 'DTEND;VALUE=DATE:' + endDate;
}
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Meisterpilze Lab Tracker//EN',
'BEGIN:VEVENT',
'UID:' + uid,
'DTSTAMP:' + now,
// I-15: SEQUENCE — bumped by db.updateTaskById on meaningful edits.
'SEQUENCE:' + (Number(task.sequence) || 0),
dtStartLine,
dtEndLine,
'SUMMARY:' + escapeIcsText(task.text || ''),
'CATEGORIES:Aufgaben',
'STATUS:' + (task.done ? 'CANCELLED' : 'CONFIRMED'),
'TRANSP:TRANSPARENT',
'X-MEISTERPILZE-TYPE:task-due',
'COLOR:#3b82f6'
];
// I-15: emit RRULE for recurring tasks rendered as VEVENT.
const rruleLine = buildRRuleLine(task.recurrence, task.recurrenceUntil);
if (rruleLine) lines.push(rruleLine);
lines.push('END:VEVENT', 'END:VCALENDAR');
return { uid, ics: foldIcsLines(lines.join('\r\n')) };
}
// Convert a custom calendar event to VEVENT .ics content
/**
* "Name, address" for a location id, or '' when there is none.
*
* Address included: this string goes into a VEVENT, which stays inside the lab's
* own calendar clients. The harvest feed sends the name alone — see
* buildPickupWindows() over in harvest-feed.js.
*/
function pickupLocationLabel(id) {
if (!id) return '';
try {
const l = db.getPickupLocation(database, id);
if (!l) return '';
return l.address ? l.name + ', ' + l.address : l.name;
} catch {
// A missing table on a database mid-migration. An event without its place
// is worth writing; a calendar sync that stops halfway is not.
return '';
}
}
function customEventToVEVENT(event) {
const uid = event.caldavUid || 'cev-' + event.id + '@meisterpilze';
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
let dtstart, dtend;
if (event.allDay || !event.startTime) {
const d = new Date(event.startDate).toISOString().replace(/[-:]/g, '').split('T')[0];
dtstart = 'DTSTART;VALUE=DATE:' + d;
const endD = event.endDate
? new Date(new Date(event.endDate).getTime() + 86400000).toISOString().replace(/[-:]/g, '').split('T')[0]
: new Date(new Date(event.startDate).getTime() + 86400000).toISOString().replace(/[-:]/g, '').split('T')[0];
dtend = 'DTEND;VALUE=DATE:' + endD;
} else {
const d = event.startDate.replace(/-/g, '');
// Use endDate for the DTEND day so a timed event spanning multiple days
// isn't collapsed to its start day (which also produced DTEND < DTSTART when
// endTime < startTime). Falls back to startDate for same-day events.
const dEnd = (event.endDate || event.startDate).replace(/-/g, '');
const st = (event.startTime || '09:00').replace(':', '') + '00';
const et = (event.endTime || '10:00').replace(':', '') + '00';
dtstart = 'DTSTART;TZID=Europe/Berlin:' + d + 'T' + st;
dtend = 'DTEND;TZID=Europe/Berlin:' + dEnd + 'T' + et;
}
const needsTZ = dtstart.includes('TZID=');
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Meisterpilze Lab Tracker//EN'];
if (needsTZ) {
lines.push(
'BEGIN:VTIMEZONE',
'TZID:Europe/Berlin',
'BEGIN:STANDARD',
'DTSTART:19701025T030000',
'RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10',
'TZOFFSETFROM:+0200',
'TZOFFSETTO:+0100',
'TZNAME:CET',
'END:STANDARD',
'BEGIN:DAYLIGHT',
'DTSTART:19700329T020000',
'RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3',
'TZOFFSETFROM:+0100',
'TZOFFSETTO:+0200',
'TZNAME:CEST',
'END:DAYLIGHT',
'END:VTIMEZONE'
);
}
lines.push(
'BEGIN:VEVENT',
'UID:' + uid,
'DTSTAMP:' + now,
// I-15: SEQUENCE counter (RFC 5545 §3.8.7.4) — bumped by db.updateCalendarEvent.
'SEQUENCE:' + (Number(event.sequence) || 0),
dtstart,
dtend,
'SUMMARY:' + escapeIcsText(event.title || ''),
'CATEGORIES:' + (event.category || 'Benutzerdefiniert'),
'TRANSP:TRANSPARENT',
'X-MEISTERPILZE-TYPE:custom-event'
);
// I-15: emit RRULE when the event is recurring.
const rruleLine = buildRRuleLine(event.recurrence, event.recurrenceUntil);
if (rruleLine) lines.push(rruleLine);
// I-15: emit EXDATE for per-occurrence cancellations. Format must match
// DTSTART (date-only vs datetime). exceptionDates may arrive as a JSON
// string (from older code paths) or as a parsed array.
let exDates = event.exceptionDates;
if (typeof exDates === 'string') {
try {
exDates = JSON.parse(exDates);
} catch {
exDates = exDates
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
}
const exdateLine = buildExdateLine(
exDates,
event.allDay || !event.startTime,
event.startTime || '00:00',
'Europe/Berlin'
);
if (exdateLine) lines.push(exdateLine);
// #480: a standard iCalendar property, so every subscribed client shows the
// place without anything further. Name and address on one line, which is what
// clients expect and what a map link would be built from later.
const place = pickupLocationLabel(event.locationId != null ? event.locationId : event.location_id);
if (place) lines.push('LOCATION:' + escapeIcsText(place));
if (event.description) lines.push('DESCRIPTION:' + escapeIcsText(event.description));
if (event.assignees && event.assignees.length) {
for (const a of event.assignees) lines.push('ATTENDEE;CN=' + (a.username || a) + ':mailto:noreply@localhost');
}
lines.push('COLOR:' + (event.color || CALDAV_CATEGORY_COLORS[event.category] || '#16a34a'));
lines.push('END:VEVENT', 'END:VCALENDAR');
return { uid, ics: foldIcsLines(lines.join('\r\n')) };
}
// Delete a task's .ics file
function deleteTaskFromCalendar(uid, calName) {
calName = calName || 'meisterpilze';
deleteIcsFile(calName, uid + '.ics');
}
// ── Auto CalDAV sync helpers ───────────────────────────────
// Push a batch due-date VEVENT to CalDAV (server-side, called after mutations)
function autoPushBatchCaldav(batch) {
try {
const cfg = db.readCaldavConfig(database);
if (!cfg.enabled) return;
if (!batch || !batch.due) return;
const data = readData();
const { uid, ics } = batchToVEVENT(batch, data.scanLog || []);
writeIcsFile('faelligkeiten', uid + '.ics', ics);
// Clean from old meisterpilze location
deleteIcsFile('meisterpilze', uid + '.ics');
} catch (e) {
log('error', 'autoPushBatchCaldav failed', { error: e.message });
}
}
// Remove a batch's CalDAV .ics file
function autoDeleteBatchCaldav(batchId) {
try {
const cfg = db.readCaldavConfig(database);
if (!cfg.enabled) return;
const uid = 'batch-' + batchId + '@meisterpilze';
for (const cal of ['faelligkeiten', 'meisterpilze']) deleteIcsFile(cal, uid + '.ics');
} catch (e) {
log('error', 'autoDeleteBatchCaldav failed', { error: e.message });
}
}
// Push a task's VTODO + due-date VEVENT to CalDAV
function autoPushTaskCaldav(task) {
try {
const cfg = db.readCaldavConfig(database);
if (!cfg.enabled) return;
const isPrivate = task.private === 1 || task.private === true;
// Shared calendar: all non-private tasks
if (!isPrivate) {
writeTaskToCalendar(task, 'meisterpilze');
}
// Per-person calendar: if assigned
if (task.assignee) {
const slug = db.caldavSlug(task.assignee);
writeTaskToCalendar(task, slug);
}
// Due-date VEVENT → aufgaben calendar: respect privacy
if (task.dueDate && !isPrivate) {
const { uid, ics } = taskDueToVEVENT(task);
writeIcsFile('aufgaben', uid + '.ics', ics);
deleteIcsFile('meisterpilze', uid + '.ics');
}
} catch (e) {
log('error', 'autoPushTaskCaldav failed', { error: e.message });
}
}
// Remove a task's CalDAV files (VTODO + VEVENT) from all calendars
// Pass task object (with caldavUid/assignee) BEFORE deleting from DB
function autoDeleteTaskCaldav(task) {
try {
const cfg = db.readCaldavConfig(database);
if (!cfg.enabled) return;
if (!task || !task.caldavUid) return;
// Remove from shared calendar
deleteTaskFromCalendar(task.caldavUid, 'meisterpilze');
// Remove from personal calendar if assigned
if (task.assignee) {
const slug = db.caldavSlug(task.assignee);
deleteTaskFromCalendar(task.caldavUid, slug);
}
// Also remove VEVENT for due date (check aufgaben + legacy meisterpilze)
for (const cal of ['aufgaben', 'meisterpilze']) deleteIcsFile(cal, task.caldavUid + '-event.ics');
} catch (e) {
log('error', 'autoDeleteTaskCaldav failed', { error: e.message });
}
}
// Push a custom calendar event to CalDAV
function autoSyncCalendarEvent(ev) {
try {
const cfg = db.readCaldavConfig(database);
if (!cfg.enabled) return;
if (!ev.startDate && !ev.start_date) return;
const normalized = {
id: ev.id,
title: ev.title,
startDate: ev.startDate || ev.start_date,
endDate: ev.endDate || ev.end_date,
allDay: ev.allDay != null ? ev.allDay : ev.all_day,
startTime: ev.startTime || ev.start_time,
endTime: ev.endTime || ev.end_time,
category: ev.category,
description: ev.description,
caldavUid: ev.caldavUid || ev.caldav_uid,
// Both spellings arrive here: readAll() hands out camelCase, while the
// PATCH route re-reads the raw row and passes that.
locationId: ev.locationId != null ? ev.locationId : ev.location_id
};
const aMap = db.getAllCalendarEventAssignees(database);
normalized.assignees = aMap.get(ev.id) || [];
const calSlug = CALDAV_EVENT_CATEGORY_MAP[normalized.category] || 'eigene-termine';
const { uid, ics } = customEventToVEVENT(normalized);
writeIcsFile(calSlug, uid + '.ics', ics);
// Clean from other category calendars in case category changed
for (const other of ['eigene-termine', 'meetings', 'lieferungen', 'wartung', 'abholung', 'meisterpilze']) {
if (other !== calSlug) deleteIcsFile(other, uid + '.ics');
}
} catch (e) {
log('error', 'autoSyncCalendarEvent failed', { error: e.message });
}
}
// Resolve a list of usernames to user IDs. Unknown names are dropped silently.
// Note: the users table column is `id`, not `user_id` (sessions/authUser use
// `user_id` because they JOIN through sessions).
function resolveUsernamesToIds(names) {
if (!Array.isArray(names) || !names.length) return [];
const ids = [];
for (const name of names) {
if (!name || typeof name !== 'string') continue;
const u = db.getUserByUsernameCaseInsensitive(database, name.trim());
if (u && typeof u.id === 'number' && !ids.includes(u.id)) ids.push(u.id);
}
return ids;
}
// Single source of truth: given a calendar-event request body, return the
// user IDs that should be written to calendar_event_assignees. Prefer explicit
// `assignees` (already-resolved IDs); fall back to deriving them from
// `teamAssignees` names. Returns null if neither field is present so callers
// can distinguish "not provided" from "empty array".
function deriveEffectiveAssignees(data) {
if (Array.isArray(data.assignees)) return data.assignees;
if (Array.isArray(data.teamAssignees)) return resolveUsernamesToIds(data.teamAssignees);
return null;
}
// Best-effort notifications when a task gains new assignees. Notifies the
// actor too — self-assignments still produce an inbox entry.
function notifyTaskAssignees(task, userIds, actor) {
if (!Array.isArray(userIds) || !userIds.length) return;
const actorName = (actor && actor.username) || 'Jemand';
const title = task.text || 'Aufgabe';
const dateStr = task.dueDate || task.due_date || '';
const body = actorName + (dateStr ? ' · ' + dateStr : '');
for (const uid of userIds) {
if (typeof uid !== 'number') continue;
try {
db.createNotification(database, {
userId: uid,
type: 'task_assignment',
title,
body,
linkType: 'task',
linkId: String(task.id)
});
} catch (e) {
log('warn', 'notifyTaskAssignees failed', { userId: uid, taskId: task.id, error: e.message });
}
}
}
// Parse a task.assignee CSV ("Alice,Bob") into an array of trimmed names.
function parseTaskAssigneeCsv(s) {
if (!s || typeof s !== 'string') return [];
return s
.split(',')
.map((x) => x.trim())
.filter(Boolean);
}
// Create notification rows for each assignee who was added to a calendar event.
// Best-effort: failures are logged but do not block the primary request.
function notifyCalendarAssignees(ev, assigneeIds, actor) {
if (!Array.isArray(assigneeIds) || !assigneeIds.length) return;
const actorName = (actor && actor.username) || 'Jemand';
const title = ev.title || 'Termin';
// Body: creator + date range for quick context in the dropdown
const dateStr = ev.startDate || ev.start_date || '';
const body = actorName + (dateStr ? ' · ' + dateStr : '');
for (const uid of assigneeIds) {
if (typeof uid !== 'number') continue;
try {
db.createNotification(database, {
userId: uid,
type: 'calendar_assignment',
title,
body,
linkType: 'calendar_event',
linkId: ev.id
});
} catch (e) {
log('warn', 'notifyCalendarAssignees failed', { userId: uid, eventId: ev.id, error: e.message });
}
}
}
// Remove a custom calendar event's CalDAV file (search all category calendars)
function autoDeleteCalendarEventCaldav(eventId, caldavUid) {
try {
const cfg = db.readCaldavConfig(database);
if (!cfg.enabled) return;
const uid = caldavUid || 'cev-' + eventId + '@meisterpilze';
for (const cal of ['eigene-termine', 'meetings', 'lieferungen', 'wartung', 'meisterpilze']) {
deleteIcsFile(cal, uid + '.ics');
}
} catch (e) {
log('error', 'autoDeleteCalendarEventCaldav failed', { error: e.message });
}
}
// CalDAV sync mutex — serializes sync operations to prevent concurrent file writes.
// Uses a queue with setImmediate to avoid re-entrant calls from the finally block.
let caldavSyncRunning = false;
let caldavSyncQueued = null;
function autoSyncAllCaldav(data) {
if (caldavSyncRunning) {
caldavSyncQueued = data; // queue latest, discard stale
return;
}
caldavSyncRunning = true;
try {
_doAutoSyncAllCaldav(data);
} catch (e) {
log('error', 'CalDAV auto-sync failed', { error: e.message });
} finally {
const queued = caldavSyncQueued;
caldavSyncQueued = null;
caldavSyncRunning = false;
// Defer queued sync to next tick to prevent re-entrant race
if (queued) setImmediate(() => autoSyncAllCaldav(queued));
}
}
function _doAutoSyncAllCaldav(data) {
try {
const cfg = db.readCaldavConfig(database);
if (!cfg.enabled) return;
const scanLog = data.scanLog || [];
// Ensure all category calendars exist
for (const calSlug of Object.keys(CALDAV_CATEGORY_CALS)) ensureCalDir(calSlug);
// Track written UIDs per calendar for orphan cleanup
const writtenPerCal = {};
for (const calSlug of Object.keys(CALDAV_CATEGORY_CALS)) writtenPerCal[calSlug] = new Set();
// Batch due dates → faelligkeiten calendar
for (const b of data.batches || []) {
if (!b.due) continue;
try {
const { uid, ics } = batchToVEVENT(b, scanLog);
writeIcsFile('faelligkeiten', uid + '.ics', ics);
writtenPerCal['faelligkeiten'].add(uid + '.ics');
} catch (e) {
log('warn', 'CalDAV: failed to write batch event', { batchId: b.batchId, error: e.message });
}
}
// Track written UIDs per personal calendar for orphan cleanup
const personalWrittenUids = new Map(); // calSlug → Set<filename>
// Task VTODOs → meisterpilze + personal calendars
for (const t of data.manualTasks || []) {
try {
const isPriv = t.private === 1 || t.private === true;
if (!isPriv) {
writeTaskToCalendar(t, 'meisterpilze');
}
if (t.assignee) {
const slug = db.caldavSlug(t.assignee);
writeTaskToCalendar(t, slug);
if (t.caldavUid) {
if (!personalWrittenUids.has(slug)) personalWrittenUids.set(slug, new Set());
personalWrittenUids.get(slug).add(t.caldavUid + '.ics');
}
}
} catch (e) {
log('warn', 'CalDAV: failed to write task VTODO', { taskText: t.text?.slice(0, 50), error: e.message });
}
}
// Task due dates → aufgaben calendar (respect privacy)
for (const t of data.manualTasks || []) {
if (!t.dueDate) continue;
const isPrivate = t.private === 1 || t.private === true;
if (isPrivate) continue;
try {
const { uid, ics } = taskDueToVEVENT(t);
writeIcsFile('aufgaben', uid + '.ics', ics);
writtenPerCal['aufgaben'].add(uid + '.ics');
} catch (e) {
log('warn', 'CalDAV: failed to write task due event', { taskText: t.text?.slice(0, 50), error: e.message });
}
}
// Custom events → category-specific calendars
for (const ev of data.calendarEvents || []) {
const calSlug = CALDAV_EVENT_CATEGORY_MAP[ev.category] || 'eigene-termine';
try {
const { uid, ics } = customEventToVEVENT(ev);
writeIcsFile(calSlug, uid + '.ics', ics);
if (writtenPerCal[calSlug]) writtenPerCal[calSlug].add(uid + '.ics');
} catch (e) {
log('warn', 'CalDAV: failed to write calendar event', { eventId: ev.id, error: e.message });
}
}
// Clean orphaned meisterpilze-generated files in all category calendars
for (const calSlug of Object.keys(CALDAV_CATEGORY_CALS)) {
const dir = path.join(CAL_DIR, calSlug);
if (!fs.existsSync(dir)) continue;
const written = writtenPerCal[calSlug] || new Set();
try {
const existing = fs.readdirSync(dir).filter((f) => f.endsWith('.ics'));
for (const f of existing) {
const filePath = path.join(dir, f);
const head = readFileHead(filePath, 500);
if (head.includes('X-MEISTERPILZE-TYPE') && !written.has(f)) {
fs.unlinkSync(filePath);
invalidateCtag(calSlug);
recordChange(calSlug, f, 'deleted');
}
}
} catch (e) {
log('warn', 'CalDAV: failed to clean orphaned files', { calendar: calSlug, error: e.message });
}
}
// Clean old events from meisterpilze that were moved to category calendars
try {
const sharedDir = path.join(CAL_DIR, 'meisterpilze');
if (fs.existsSync(sharedDir)) {
for (const f of fs.readdirSync(sharedDir).filter((f) => f.endsWith('.ics'))) {
const head = readFileHead(path.join(sharedDir, f), 500);
if (
head.includes('X-MEISTERPILZE-TYPE:batch-due') ||
head.includes('X-MEISTERPILZE-TYPE:task-due') ||
head.includes('X-MEISTERPILZE-TYPE:custom-event')
) {
fs.unlinkSync(path.join(sharedDir, f));
invalidateCtag('meisterpilze');
recordChange('meisterpilze', f, 'deleted');
}
}
}
} catch (e) {
log('warn', 'CalDAV: failed to clean migrated events from the shared calendar', { error: e.message });
}
// Clean orphaned VTODOs in personal calendars
const categoryCals = new Set(Object.keys(CALDAV_CATEGORY_CALS));
categoryCals.add('meisterpilze');
try {
const allCals = listCalendars();
for (const cal of allCals) {
if (categoryCals.has(cal)) continue; // already handled above
const calDir = path.join(CAL_DIR, cal);
const written = personalWrittenUids.get(cal) || new Set();
const files = fs.readdirSync(calDir).filter((f) => f.endsWith('.ics'));
for (const f of files) {
if (written.has(f)) continue;
const head = readFileHead(path.join(calDir, f), 500);
if (head.includes('PRODID:-//Meisterpilze')) {
fs.unlinkSync(path.join(calDir, f));
invalidateCtag(cal);
recordChange(cal, f, 'deleted');
}
}
}
} catch (e) {
log('warn', 'CalDAV: failed to clean orphaned personal files', { error: e.message });
}
} catch (e) {
log('error', 'autoSyncAllCaldav failed', { error: e.message });
}
}
// Full sync: write all tasks to calendar directories
function syncAllTasksLocal(data) {
const tasks = data.manualTasks || [];
const results = { pushed: 0, errors: 0, calendarsCreated: 0 };
const scanLog = data.scanLog || [];
// Ensure all category calendars + user calendars exist
for (const calSlug of Object.keys(CALDAV_CATEGORY_CALS)) ensureCalDir(calSlug);
const users = db.listUsers(database);
for (const u of users) {
const slug = db.caldavSlug(u.username);
ensureCalDir(slug);
}
// Track written UIDs per calendar
const writtenPerCal = {};
for (const calSlug of Object.keys(CALDAV_CATEGORY_CALS)) writtenPerCal[calSlug] = new Set();
// Write each task as VTODO
for (const task of tasks) {
try {
const isPrivate = task.private === 1 || task.private === true;
if (!isPrivate) writeTaskToCalendar(task, 'meisterpilze');
if (task.assignee) {
const slug = db.caldavSlug(task.assignee);
writeTaskToCalendar(task, slug);
}
results.pushed++;
} catch (e) {
results.errors++;
}
}
// Batch due dates → faelligkeiten calendar
const batches = data.batches || [];
for (const b of batches) {
if (!b.due) continue;
try {
const { uid, ics } = batchToVEVENT(b, scanLog);
writeIcsFile('faelligkeiten', uid + '.ics', ics);
writtenPerCal['faelligkeiten'].add(uid + '.ics');
results.pushed++;
} catch (e) {
results.errors++;
}
}
// Task due dates → aufgaben calendar (respect privacy)
for (const task of tasks) {
if (!task.dueDate) continue;
const isPrivate = task.private === 1 || task.private === true;
if (isPrivate) continue;
try {
const { uid, ics } = taskDueToVEVENT(task);
writeIcsFile('aufgaben', uid + '.ics', ics);
writtenPerCal['aufgaben'].add(uid + '.ics');
results.pushed++;
} catch (e) {
results.errors++;
}
}
// Custom calendar events → category-specific calendars
const customEvents = data.calendarEvents || [];
for (const ev of customEvents) {
const calSlug = CALDAV_EVENT_CATEGORY_MAP[ev.category] || 'eigene-termine';
try {
const { uid, ics } = customEventToVEVENT(ev);
writeIcsFile(calSlug, uid + '.ics', ics);
if (writtenPerCal[calSlug]) writtenPerCal[calSlug].add(uid + '.ics');
results.pushed++;
} catch (e) {
results.errors++;
}
}
// Clean up orphaned .ics files in all category calendars
for (const calSlug of Object.keys(CALDAV_CATEGORY_CALS)) {
const dir = path.join(CAL_DIR, calSlug);
if (!fs.existsSync(dir)) continue;
const written = writtenPerCal[calSlug] || new Set();
try {
const existing = fs.readdirSync(dir).filter((f) => f.endsWith('.ics'));
for (const f of existing) {
const head = readFileHead(path.join(dir, f), 500);
if (head.includes('X-MEISTERPILZE-TYPE') && !written.has(f)) {
fs.unlinkSync(path.join(dir, f));
invalidateCtag(calSlug);
recordChange(calSlug, f, 'deleted');
}
}
} catch (e) {
/* ignore cleanup errors */
}
}
// Clean old events from meisterpilze that were moved to category calendars
try {
const sharedDir = path.join(CAL_DIR, 'meisterpilze');
if (fs.existsSync(sharedDir)) {
for (const f of fs.readdirSync(sharedDir).filter((f) => f.endsWith('.ics'))) {
const head = readFileHead(path.join(sharedDir, f), 500);
if (
head.includes('X-MEISTERPILZE-TYPE:batch-due') ||
head.includes('X-MEISTERPILZE-TYPE:task-due') ||
head.includes('X-MEISTERPILZE-TYPE:custom-event')
) {
fs.unlinkSync(path.join(sharedDir, f));
invalidateCtag('meisterpilze');
recordChange('meisterpilze', f, 'deleted');
}
}
}
} catch (e) {
/* ignore cleanup errors */
}
return results;
}
// ── CalDAV HTTP handler ─────────────────────────────────────
// Handles requests under /caldav/
function handleCaldav(req, res) {
// Reject requests if CalDAV is not enabled in config
const caldavCfg = db.readCaldavConfig(database);
if (!caldavCfg.enabled) {
res.writeHead(503, { 'Content-Type': 'text/plain' });
res.end('CalDAV is not enabled');
return;
}
// DAV headers for all CalDAV responses (no CORS — CalDAV clients don't need it)
res.setHeader('DAV', '1, 2, 3, calendar-access, extended-mkcol');
res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, DELETE, PROPFIND, REPORT, MKCALENDAR, OPTIONS, PROPPATCH');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Depth, Authorization, If-Match, If-None-Match');
// Reject Basic auth over plain HTTP (except localhost) to prevent credential sniffing
if (!req.socket.encrypted) {
const host = (req.headers.host || '').replace(/:.*$/, '');
if (host !== 'localhost' && host !== '127.0.0.1' && host !== '[::1]') {
res.writeHead(403);
res.end('CalDAV requires HTTPS');
return;
}
}
// Brute-force protection for CalDAV basic auth
const caldavIP = getClientIP(req);
const caldavUsername = extractBasicAuthUsername(req);
if (caldavUsername) {
const caldavUserKey = caldavUsername.toLowerCase();
const caldavThrottleKey = caldavUserKey + '@' + caldavIP;
if (!checkLoginAllowed(caldavThrottleKey)) {
res.writeHead(429, { 'Content-Type': 'text/plain' });
res.end('Too many login attempts. Try again later.');
return;
}
}
// S-22: the same budget the login route spends from, because this reaches the
// same KDF — checkCaldavAuth runs db.verifyPassword, and it runs the decode
// loop twice, so a request here costs *two* hashes. Guarding only
// /api/auth/login left the more expensive door open, and CalDAV is on by
// default (migration v10). The username is spendable the same way there:
// checkCaldavAuth skips hashing for an unknown account, so "admin" is as good
// a guess as any and a fresh username buys nothing extra.
if (!takeLoginKdfToken(caldavIP)) {
res.writeHead(429, { 'Retry-After': '5' });
res.end('Too many login attempts. Try again shortly.');
return;
}
// Auth check — returns user account object or false
const caldavUser = checkCaldavAuth(req);
if (!caldavUser) {
if (caldavUsername) {
const caldavUserKey = caldavUsername.toLowerCase();
recordLoginFailure(caldavUserKey + '@' + caldavIP);
recordLoginFailurePerUser(caldavUserKey);
}
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Meisterpilze CalDAV", charset="UTF-8"' });
res.end('Unauthorized');
return;
}
// Clear attempts on successful auth
refundLoginKdfToken(caldavIP);
const successKey = caldavUser.username.toLowerCase();
clearLoginAttempts(successKey + '@' + caldavIP);
clearLoginAttemptsPerUser(successKey);
req.caldavUser = caldavUser;
req.caldavUserSlug = db.caldavSlug(caldavUser.username);
const method = req.method;
// Normalize path: /caldav/calendars/calname/file.ics
let rawPath;
try {
rawPath = decodeURIComponent(req.url.split('?')[0]).replace(/\/+/g, '/');
} catch (e) {
res.writeHead(400);
res.end('Bad Request: malformed URL encoding');
return;
}
const parts = rawPath
.replace(/^\/caldav\/?/, '')
.replace(/\/$/, '')
.split('/')
.filter(Boolean);
// parts: [] = root, ['calendars'] = calendar-home, ['calendars','name'] = calendar, ['calendars','name','file.ics'] = item
// Validate root path segment
if (parts.length > 0 && parts[0] !== 'calendars' && parts[0] !== 'principal') {
res.writeHead(400);
res.end('Invalid path');
return;
}
// Sanitize path parts to prevent directory traversal
for (let i = 1; i < parts.length; i++) {
const clean = sanitizePart(parts[i]);
if (!clean) {
res.writeHead(400);
res.end('Invalid path');
return;
}
parts[i] = clean;
}
if (method === 'OPTIONS') {
res.writeHead(200, {
Allow: 'OPTIONS, GET, PUT, DELETE, PROPFIND, REPORT, MKCALENDAR, PROPPATCH'
});
res.end();
return;
}
// Collect request body
let body = '';
let bodySize = 0;
req.on('data', (c) => {
bodySize += c.length;
if (bodySize > MAX_BODY_SIZE) {
res.writeHead(413, { 'Content-Type': 'text/plain' });
res.end('Request body too large');
req.destroy();
return;
}
body += c;
});
req.on('end', () => {
try {
if (method === 'PROPFIND') return handlePropfind(parts, body, req, res);
if (method === 'REPORT') {
// R-09: handleReport is now async — surface async rejections to the
// same error path and avoid an unhandledRejection on disk failures.
return Promise.resolve(handleReport(parts, body, req, res)).catch((e) => {
log('error', 'CalDAV request error', { error: e.message });
if (!res.writableEnded) {
res.writeHead(500);
res.end('Internal server error');
}
});
}
if (method === 'MKCALENDAR') return handleMkcalendar(parts, body, req, res);
if (method === 'PUT') return handlePut(parts, body, req, res);
if (method === 'GET') return handleGet(parts, req, res);
if (method === 'DELETE') return handleDelete(parts, req, res);
if (method === 'PROPPATCH') return handleProppatch(parts, body, req, res);
res.writeHead(405);
res.end('Method not allowed');
} catch (e) {
log('error', 'CalDAV request error', { error: e.message });
res.writeHead(500);
res.end('Internal server error');
}
});
}
function handlePropfind(parts, body, req, res) {
const depth = req.headers['depth'] || '1';
// Root /caldav/ — principal discovery
if (parts.length === 0) {
const xml = `<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">
<d:response>
<d:href>/caldav/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype><d:collection/></d:resourcetype>
<d:current-user-principal><d:href>/caldav/principal/</d:href></d:current-user-principal>
<d:displayname>Meistertracker</d:displayname>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>`;
res.writeHead(207, { 'Content-Type': 'application/xml; charset=utf-8' });
res.end(xml);
return;
}
// /caldav/principal/ — user principal resource (returns calendar-home-set)
if (parts.length === 1 && parts[0] === 'principal') {
const xml = `<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">
<d:response>
<d:href>/caldav/principal/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype><d:collection/><d:principal/></d:resourcetype>
<d:current-user-principal><d:href>/caldav/principal/</d:href></d:current-user-principal>
<d:principal-URL><d:href>/caldav/principal/</d:href></d:principal-URL>
<c:calendar-home-set><d:href>/caldav/calendars/</d:href></c:calendar-home-set>
<d:displayname>${escapeXml(req.caldavUser.username)}</d:displayname>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>`;
res.writeHead(207, { 'Content-Type': 'application/xml; charset=utf-8' });
res.end(xml);
return;
}
// /caldav/calendars/ — list all calendars
if (parts.length === 1 && parts[0] === 'calendars') {
const cals = listCalendars().filter((c) => checkCalendarAccess(req, c));
let responses = `<d:response>
<d:href>/caldav/calendars/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype><d:collection/></d:resourcetype>
<d:displayname>Calendars</d:displayname>
<d:current-user-principal><d:href>/caldav/principal/</d:href></d:current-user-principal>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>`;
if (depth !== '0') {
for (const cal of cals) {
const { displayName, color } = getCalDisplayInfo(cal);
const colorProp = color
? '\n <x:calendar-color xmlns:x="http://apple.com/ns/ical/">' + color + '</x:calendar-color>'
: '';
responses += `\n <d:response>
<d:href>/caldav/calendars/${encodeURIComponent(cal)}/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
<d:displayname>${escapeXml(displayName)}</d:displayname>
<c:supported-calendar-component-set><c:comp name="VTODO"/><c:comp name="VEVENT"/></c:supported-calendar-component-set>${colorProp}
<cs:getctag>${computeCtag(cal)}</cs:getctag>
<d:sync-token>http://meisterpilze/sync/${getSyncToken(cal)}</d:sync-token>
${SUPPORTED_REPORT_SET}
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>`;
}
}
const xml = `<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">
${responses}
</d:multistatus>`;
res.writeHead(207, { 'Content-Type': 'application/xml; charset=utf-8' });
res.end(xml);
return;
}
// /caldav/calendars/<cal>/ — list items in a calendar
if (parts.length === 2 && parts[0] === 'calendars') {
const calName = parts[1];
if (!checkCalendarAccess(req, calName)) {
res.writeHead(403);
res.end('Forbidden');
return;
}
const calDir = path.join(CAL_DIR, calName);
if (!fs.existsSync(calDir)) {
res.writeHead(404);
res.end('Calendar not found');
return;
}
const { displayName, color } = getCalDisplayInfo(calName);
const colorProp2 = color
? '\n <x:calendar-color xmlns:x="http://apple.com/ns/ical/">' + color + '</x:calendar-color>'
: '';
let responses = `<d:response>
<d:href>/caldav/calendars/${encodeURIComponent(calName)}/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
<d:displayname>${escapeXml(displayName)}</d:displayname>
<c:supported-calendar-component-set><c:comp name="VTODO"/><c:comp name="VEVENT"/></c:supported-calendar-component-set>${colorProp2}
<cs:getctag>${computeCtag(calName)}</cs:getctag>
<d:sync-token>http://meisterpilze/sync/${getSyncToken(calName)}</d:sync-token>
${SUPPORTED_REPORT_SET}
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>`;
if (depth !== '0') {
const files = listIcsFiles(calName);
for (const f of files) {
const etag = getEtag(calName, f);
if (!etag) continue;
let fileSize = 0;
try {
fileSize = fs.statSync(path.join(CAL_DIR, calName, f)).size;
} catch {}
responses += `\n <d:response>
<d:href>/caldav/calendars/${encodeURIComponent(calName)}/${encodeURIComponent(f)}</d:href>
<d:propstat>
<d:prop>
<d:getetag>${etag}</d:getetag>
<d:getcontenttype>text/calendar; charset=utf-8</d:getcontenttype>
<d:getcontentlength>${fileSize}</d:getcontentlength>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>`;
}
}
const xml = `<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">
${responses}
</d:multistatus>`;
res.writeHead(207, { 'Content-Type': 'application/xml; charset=utf-8' });
res.end(xml);
return;
}
// /caldav/calendars/<cal>/<file>.ics — single item props
if (parts.length === 3 && parts[0] === 'calendars' && parts[2].endsWith('.ics')) {
if (!checkCalendarAccess(req, parts[1])) {
res.writeHead(403);
res.end('Forbidden');
return;
}
const filePath = path.join(CAL_DIR, parts[1], parts[2]);
const etag = getEtag(parts[1], parts[2]);
if (!etag) {
res.writeHead(404);
res.end('Not found');
return;
}
let fileSize = 0;
try {
fileSize = fs.statSync(filePath).size;
} catch {}
const xml = `<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/caldav/calendars/${encodeURIComponent(parts[1])}/${encodeURIComponent(parts[2])}</d:href>
<d:propstat>
<d:prop>
<d:getetag>${etag}</d:getetag>
<d:getcontenttype>text/calendar; charset=utf-8</d:getcontenttype>
<d:getcontentlength>${fileSize}</d:getcontentlength>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>`;
res.writeHead(207, { 'Content-Type': 'application/xml; charset=utf-8' });
res.end(xml);
return;
}
res.writeHead(404);
res.end('Not found');
}
async function handleReport(parts, body, req, res) {
// calendar-multiget: client requests specific .ics files with their data
if (parts.length === 2 && parts[0] === 'calendars') {
const calName = parts[1];
if (!checkCalendarAccess(req, calName)) {
res.writeHead(403);
res.end('Forbidden');
return;
}
const calDir = path.join(CAL_DIR, calName);
if (!fs.existsSync(calDir)) {
res.writeHead(404);
res.end('Calendar not found');
return;
}
// ── RFC 6578 sync-collection REPORT ──
if (body.includes('sync-collection')) {
const tokenMatch = body.match(/<d:sync-token>([^<]*)<\/d:sync-token>/i);
const tokenStr = tokenMatch ? tokenMatch[1].trim() : '';
const reqToken = parseInt((tokenStr.match(/\/sync\/(\d+)$/) || [])[1] || '0', 10);
// Validate token — if stale or unknown, tell client to do full sync (RFC 6578 §3)
const currentToken = getSyncToken(calName);
const clog = changeLog.get(calName);
if (reqToken > 0) {
const invalid = !clog || (clog.size > 0 && reqToken < Math.min(...[...clog.values()].map((e) => e.token)));
if (invalid) {
const errXml = `<?xml version="1.0" encoding="utf-8"?>
<d:error xmlns:d="DAV:"><d:valid-sync-token/></d:error>`;
res.writeHead(403, { 'Content-Type': 'application/xml; charset=utf-8' });
res.end(errXml);
return;
}
}
let responses = '';
if (reqToken === 0) {
// Initial sync — return all items
const files = listIcsFiles(calName);
for (const f of files) {
const etag = getEtag(calName, f);
if (!etag) continue;
responses += `\n <d:response>
<d:href>/caldav/calendars/${encodeURIComponent(calName)}/${encodeURIComponent(f)}</d:href>
<d:propstat>
<d:prop><d:getetag>${etag}</d:getetag></d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>`;
}
} else {
// Incremental sync — only items changed since reqToken
for (const [fileName, entry] of clog.entries()) {
if (entry.token <= reqToken) continue;
if (entry.action === 'deleted') {
responses += `\n <d:response>
<d:href>/caldav/calendars/${encodeURIComponent(calName)}/${encodeURIComponent(fileName)}</d:href>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:response>`;
} else {
const etag = getEtag(calName, fileName);
if (etag) {
responses += `\n <d:response>
<d:href>/caldav/calendars/${encodeURIComponent(calName)}/${encodeURIComponent(fileName)}</d:href>
<d:propstat>
<d:prop><d:getetag>${etag}</d:getetag></d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>`;
}
}
}
}
const xml = `<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:sync-token>http://meisterpilze/sync/${currentToken}</d:sync-token>${responses}
</d:multistatus>`;
res.writeHead(207, { 'Content-Type': 'application/xml; charset=utf-8' });
res.end(xml);
return;
}
// Parse requested hrefs from the XML body — handle any namespace prefix (d:href, D:href, href, etc.)
// `(?:\s[^>]*)?` before the `>` made this catastrophically backtracking: on a
// body of repeated `<href ` the `[^>]*` scans to end-of-string and backtracks
// once per start position, so the match is O(n^2) in the body length. Measured
// 26.8 s at 400 KB and 112 s at 800 KB, i.e. over an hour at the 5 MB body cap
// — and Node is single-threaded, so that freezes the entire server (no HTTP,
// no SSE, no health check) for any account that can reach CalDAV.
// `[^<>]*` cannot cross the `>` that ends the tag, so each position fails in
// constant time and the scan is linear.
const hrefMatches = body.match(/<(?:[a-zA-Z0-9]+:)?href(?:\s[^<>]*)?>([^<]+)<\/(?:[a-zA-Z0-9]+:)?href>/gi) || [];
let responses = '';
// If calendar-multiget with specific hrefs
if (hrefMatches.length > 0) {
// R-09: parallelize disk reads. Build the work list first, then read
// all files concurrently with Promise.all so a 100-item REPORT runs at
// the speed of one read instead of N.
const targets = [];
for (const hrefTag of hrefMatches) {
const href = hrefTag.replace(/<\/?(?:[a-zA-Z0-9]+:)?href(?:\s[^>]*)?>/gi, '');
const filename = sanitizePart(decodeURIComponent(href.split('/').pop()));
if (!filename) continue;
const filePath = path.join(calDir, filename);
if (fs.existsSync(filePath) && filename.endsWith('.ics')) {
targets.push({ href, filename, filePath });
}
}
const reads = await Promise.all(targets.map((t) => fs.promises.readFile(t.filePath, 'utf8').catch(() => null)));
for (let i = 0; i < targets.length; i++) {
const content = reads[i];
if (content == null) continue; // file vanished between exists() and read()
const t = targets[i];
const etag = getEtag(calName, t.filename);
responses += `\n <d:response>
<d:href>${escapeXml(t.href)}</d:href>
<d:propstat>
<d:prop>
<d:getetag>${etag}</d:getetag>
<c:calendar-data>${escapeXml(content)}</c:calendar-data>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>`;
}
} else {
// calendar-query: return all items
const files = listIcsFiles(calName);
const reads = await Promise.all(
files.map((f) => fs.promises.readFile(path.join(calDir, f), 'utf8').catch(() => null))
);
for (let i = 0; i < files.length; i++) {
const content = reads[i];
if (content == null) continue;
const f = files[i];
const etag = getEtag(calName, f);
responses += `\n <d:response>
<d:href>/caldav/calendars/${encodeURIComponent(calName)}/${encodeURIComponent(f)}</d:href>
<d:propstat>
<d:prop>
<d:getetag>${etag}</d:getetag>
<c:calendar-data>${escapeXml(content)}</c:calendar-data>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>`;
}
}
const xml = `<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
${responses}
</d:multistatus>`;
res.writeHead(207, { 'Content-Type': 'application/xml; charset=utf-8' });
res.end(xml);
return;
}
res.writeHead(404);
res.end('Not found');
}
function handleMkcalendar(parts, body, req, res) {
if (req.caldavUser.role !== 'admin') {
res.writeHead(403);
res.end('Forbidden: only admins can create calendars');
return;
}
if (parts.length === 2 && parts[0] === 'calendars') {
const calName = parts[1];
// Block UUID-like calendar names (auto-created by CalDAV clients like iOS)
const stripped = calName.replace(/-/g, '');
if (/^[0-9a-f]{32}$/i.test(stripped)) {
log('info', 'CalDAV: blocked MKCALENDAR for UUID-like name', { name: calName, actor: req.caldavUser.username });
res.writeHead(201); // Return 201 to avoid client retries
res.end();
return;
}
if (!checkCalendarAccess(req, calName)) {
res.writeHead(403);
res.end('Forbidden');
return;
}
ensureCalDir(calName);
res.writeHead(201);
res.end();
log('info', 'CalDAV calendar created', { name: calName, actor: req.caldavUser.username });
return;
}
res.writeHead(403);
res.end('Forbidden');
}
// S-24: whose record it is — asked of the record, not of the URL.
//
// The CalDAV sync-back paths look a row up by the UID carried in the request
// body (PUT) or in the file being removed (DELETE) and then write or delete
// that row. Every guard above them is about the *calendar*: checkCalendarAccess,
// and "only from your own calendar, or from the shared one if you are admin".
// None of them says anything about the row the UID names, and a worker may
// write into their own personal calendar — so PUT an .ics carrying a
// colleague's task UID there, DELETE it again, and the colleague's task goes
// with it. The UIDs are not a secret: GET /api/data hands out caldavUid for
// every task.
//
// The HTTP twins of these operations already answer this question —
// PATCH /api/tasks/:id calls db.canUserModifyTask and returns "You are not
// allowed to modify this task", and DELETE /api/calendar-events/:id is
// requireAdmin. This asks those same two, so the two doors into the same room
// stop disagreeing.
//
// Anything the UID does not resolve to a row for is allowed through: creating a
// new task from a calendar client is a normal thing to do, and it takes nothing
// from anyone.
function caldavRecordAllowed(req, ics) {
// ⚠️ **Anchored, and that is not tidiness.** `/UID:(.*)/` matches inside any
// property whose name ends in UID — `X-DECOY-UID:harmless` in the first line
// fed this guard a name that resolves to nothing, so it waved the request
// through while the sync-back below read the real UID out of the VEVENT and
// wrote it. One header line walked around the whole check. The text arrives
// unfolded, so a line anchor is the same thing the writers see.
const uidMatch = String(ics || '').match(/^UID:(.*)$/m);
if (!uidMatch) return true;
const uid = uidMatch[1].trim();
const isAdmin = !!(req.caldavUser && req.caldavUser.role === 'admin');
// A custom calendar event. Matched before the -event suffix is stripped, or
// an event id that ends in "-event" would be mangled into a different one.
if (/^cev-(.+)@meisterpilze$/.test(uid)) return isAdmin;
// ⚠️ And the ones that do not wear that shape. The UID is only built as
// `cev-<id>@meisterpilze` when the row has no caldav_uid of its own; an event
// that arrived from a calendar client keeps the client's UID, and the pattern
// above walks straight past it. That left the withdraw direction open for
// exactly the windows an admin had created from their own calendar app.
// Asking the database costs one indexed lookup and cannot be fooled by a
// naming convention.
//
// ⚠️ **Only the pickup category.** The first version of this line returned
// isAdmin for every calendar row that carried a caldav_uid, which took
// meetings, deliveries and maintenance away from everyone over CalDAV — the
// exact opposite of what its own commit said it did. What is being protected
// is the shop's collection times, not the calendar.
const ereignis = db.readCalendarEventByCaldavUid(database, uid);
if (ereignis) return ereignis.category === 'pickup' ? isAdmin : true;
// A task, either by its own uid or by its companion due-date event's.
const taskUid = uid.replace(/-event$/, '');
// ⚠️ **No charset pre-check here.** There was one, and it failed open on
// exactly the values it rejected: isValidCaldavUid caps a uid at 120
// characters, the sync-back's own regex does not, so a 150-character uid was
// waved through by this guard and then happily resolved to a row and written.
// The lookup answers the only question that matters — does this name a row? —
// and cannot disagree with anything.
const task = db.readTaskByCaldavUid(database, taskUid);
if (!task) return true;
return db.canUserModifyTask(database, req.caldavUser && req.caldavUser.username, task.id, isAdmin);
}
function handlePut(parts, body, req, res) {
// PUT /caldav/calendars/<cal>/<uid>.ics
if (parts.length === 3 && parts[0] === 'calendars' && parts[2].endsWith('.ics')) {
const calName = parts[1];
if (!checkCalendarAccess(req, calName)) {
res.writeHead(403);
res.end('Forbidden');
return;
}
const fileName = parts[2];
const dir = ensureCalDir(calName);
const filePath = path.join(dir, fileName);
const existed = fs.existsSync(filePath);
// If-None-Match: * means "create only, fail if exists"
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch === '*' && existed) {
res.writeHead(412);
res.end('Precondition Failed');
return;
}
// If-Match: check etag matches current version (conflict detection)
const ifMatch = req.headers['if-match'];
if (ifMatch && existed) {
const stat = fs.statSync(filePath);
const currentEtag = '"' + stat.mtimeMs.toString(36) + '"';
if (ifMatch !== currentEtag) {
res.writeHead(412);
res.end('Precondition Failed');
return;
}
}
// Bidirectional sync: parse incoming content and update DB
const unfolded = unfoldIcs(body);
// S-24: asked before the write, not after it. The sync-back below is what
// touches the row, but a refusal that still left the caller's .ics sitting
// in a shared calendar would be a refusal in name only.
if (!caldavRecordAllowed(req, unfolded)) {
log('warn', 'CalDAV PUT: refused a record the caller may not modify', {
calendar: calName,
file: fileName,
actor: req.caldavUser && req.caldavUser.username
});
res.writeHead(403);
res.end('Forbidden');
return;
}
fs.writeFileSync(filePath, body, 'utf8');
invalidateCtag(calName);
recordChange(calName, fileName, 'changed');
// ── VTODO sync-back: task text, priority, completion, due date ──
if (unfolded.includes('VTODO')) {
try {
const uidMatch = unfolded.match(/UID:(.*)/);
if (uidMatch) {
const uid = uidMatch[1].trim();
if (/^[A-Za-z0-9\-_.@]+$/.test(uid)) {
const task = db.readTaskByCaldavUid(database, uid);
if (task) {
const fields = {};
const sumMatch = unfolded.match(/SUMMARY:(.*)/);
if (sumMatch) {
const t = sumMatch[1].trim().replace(/\\n/g, '\n');
if (t !== task.text) fields.text = t;
}
const prioMatch = unfolded.match(/PRIORITY:(\d+)/);
if (prioMatch) {
const p = CALDAV_PRIO_MAP[prioMatch[1]] || 'med';
if (p !== task.priority) fields.priority = p;
}
const statusMatch = unfolded.match(/STATUS:(.*)/);
if (statusMatch) {
const done = statusMatch[1].trim() === 'COMPLETED';
if (done !== task.done) fields.done = done;
}
const dueDateTimeMatch = unfolded.match(/DUE(?:;[^:]*)?:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?/);
const dueMatch = unfolded.match(/DUE;VALUE=DATE:(\d{4})(\d{2})(\d{2})/);
if (dueDateTimeMatch) {
const d = dueDateTimeMatch[1] + '-' + dueDateTimeMatch[2] + '-' + dueDateTimeMatch[3];
const t = dueDateTimeMatch[4] + ':' + dueDateTimeMatch[5];
if (d !== (task.dueDate || '').slice(0, 10)) fields.dueDate = d;
if (t !== (task.dueTime || '')) fields.dueTime = t;
} else if (dueMatch) {
const d = dueMatch[1] + '-' + dueMatch[2] + '-' + dueMatch[3];
if (d !== (task.dueDate || '').slice(0, 10)) fields.dueDate = d;
if (task.dueTime) {
fields.dueTime = null;
fields.dueEndTime = null;
}
} else if (!unfolded.includes('DUE') && task.dueDate) {
fields.dueDate = null;
if (task.dueTime) {
fields.dueTime = null;
fields.dueEndTime = null;
}
}
const descMatch = unfolded.match(/DESCRIPTION:(.*)/);
if (descMatch) {
const d = descMatch[1].trim().replace(/\\n/g, '\n');
if (d !== (task.description || '')) fields.description = d;
}
const assigneeMatch = unfolded.match(/X-MEISTERPILZE-ASSIGNEE:(.*)/);
if (assigneeMatch) {
const a = assigneeMatch[1].trim();
if (a !== (task.assignee || '')) fields.assignee = a;
}
if (Object.keys(fields).length > 0) {
db.updateTaskById(database, task.id, fields);
// Re-read and push to all calendars for consistency
const updated = db.readTaskById(database, task.id);
if (updated) autoPushTaskCaldav(updated);
broadcastSSE();
}
} else {
// New task created from external CalDAV client
const sumMatch = unfolded.match(/SUMMARY:(.*)/);
const text = sumMatch ? sumMatch[1].trim().replace(/\\n/g, '\n') : '(kein Titel)';
const prioMatch = unfolded.match(/PRIORITY:(\d+)/);
const priority = prioMatch ? CALDAV_PRIO_MAP[prioMatch[1]] || 'med' : 'med';
const statusMatch = unfolded.match(/STATUS:(.*)/);
const done = statusMatch ? statusMatch[1].trim() === 'COMPLETED' : false;
const dueDateTimeMatch2 = unfolded.match(/DUE(?:;[^:]*)?:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?/);
const dueMatch = unfolded.match(/DUE;VALUE=DATE:(\d{4})(\d{2})(\d{2})/);
let dueDate = null;
let dueTime = null;
if (dueDateTimeMatch2) {
dueDate = dueDateTimeMatch2[1] + '-' + dueDateTimeMatch2[2] + '-' + dueDateTimeMatch2[3];
dueTime = dueDateTimeMatch2[4] + ':' + dueDateTimeMatch2[5];
} else if (dueMatch) {
dueDate = dueMatch[1] + '-' + dueMatch[2] + '-' + dueMatch[3];
}
const descMatch = unfolded.match(/DESCRIPTION:(.*)/);
const description = descMatch ? descMatch[1].trim().replace(/\\n/g, '\n') : null;
db.insertTask(database, {
text,
priority,
done,
created: new Date().toISOString(),
dueDate,
dueTime,
description,
caldavUid: uid,
caldavSynced: new Date().toISOString()
});
broadcastSSE();
}
}
}
} catch (e) {
log('error', 'CalDAV VTODO bidirectional sync error', { error: e.message });
}
}
// ── VEVENT sync-back: batch due dates, task due dates, custom events ──
if (unfolded.includes('VEVENT')) {
try {
const veventBlock = extractVeventBlock(unfolded);
const typeMatch = veventBlock.match(/X-MEISTERPILZE-TYPE:(.*)/);
const evType = typeMatch ? typeMatch[1].trim() : null;
if (evType === 'batch-due' || evType === 'task-due') {
const dtMatch = veventBlock.match(/DTSTART;VALUE=DATE:(\d{4})(\d{2})(\d{2})/);
if (dtMatch) {
const newDate = dtMatch[1] + '-' + dtMatch[2] + '-' + dtMatch[3];
if (evType === 'batch-due') {
const batchMatch = veventBlock.match(/X-MEISTERPILZE-BATCH:(.*)/);
if (batchMatch) {
const batchId = batchMatch[1].trim();
if (/^[A-Za-z0-9\-_.]+$/.test(batchId)) {
db.updateBatchDue(database, batchId, newDate + 'T12:00:00.000Z');
broadcastSSE();
} else {
log('warn', 'CalDAV PUT rejected invalid batchId', { batchId });
}
}
} else {
const uidMatch = veventBlock.match(/UID:(.*)/);
if (uidMatch) {
const taskUid = uidMatch[1].trim().replace(/-event$/, '');
if (/^[A-Za-z0-9\-_.@]+$/.test(taskUid)) {
db.updateTaskDueDate(database, taskUid, newDate);
broadcastSSE();
} else {
log('warn', 'CalDAV PUT rejected invalid taskUid', { taskUid });
}
}
}
}
} else if (evType === 'custom-event') {
// Custom event edited from external CalDAV client
const uidMatch = veventBlock.match(/UID:(.*)/);
if (uidMatch) {
const uid = uidMatch[1].trim();
const idMatch = uid.match(/^cev-(.+)@meisterpilze$/);
if (idMatch) {
const fields = {};
const sumMatch = veventBlock.match(/SUMMARY:(.*)/);
if (sumMatch) fields.title = sumMatch[1].trim().replace(/\\n/g, '\n');
const descMatch = veventBlock.match(/DESCRIPTION:(.*)/);
if (descMatch) fields.description = descMatch[1].trim().replace(/\\n/g, '\n');
const catMatch = veventBlock.match(/CATEGORIES:(.*)/);
if (catMatch) {
const cat = catMatch[1].trim().toLowerCase();
if (KNOWN_CATEGORIES[cat]) fields.category = cat;
}
// Parse DTSTART from VEVENT block only (avoids VTIMEZONE false matches)
const dtAllDay = veventBlock.match(/DTSTART;VALUE=DATE:(\d{4})(\d{2})(\d{2})/);
const dtTimed = veventBlock.match(/DTSTART(?:;TZID=[^:]+)?:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
if (dtAllDay) {
fields.startDate = dtAllDay[1] + '-' + dtAllDay[2] + '-' + dtAllDay[3];
fields.allDay = true;
fields.startTime = null;
} else if (dtTimed) {
fields.startDate = dtTimed[1] + '-' + dtTimed[2] + '-' + dtTimed[3];
fields.startTime = dtTimed[4] + ':' + dtTimed[5];
fields.allDay = false;
}
// Parse DTEND from VEVENT block only
const deAllDay = veventBlock.match(/DTEND;VALUE=DATE:(\d{4})(\d{2})(\d{2})/);
const deTimed = veventBlock.match(/DTEND(?:;TZID=[^:]+)?:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
if (deAllDay) {
// RFC 5545: DTEND for all-day is exclusive, so the last day is the one before
fields.endDate = _allDayLastDate(deAllDay[1], deAllDay[2], deAllDay[3]);
fields.endTime = null;
} else if (deTimed) {
fields.endDate = deTimed[1] + '-' + deTimed[2] + '-' + deTimed[3];
fields.endTime = deTimed[4] + ':' + deTimed[5];
}
if (Object.keys(fields).length > 0) {
db.updateCalendarEvent(database, idMatch[1], fields);
broadcastSSE();
}
}
}
} else if (!evType && calName === 'meisterpilze') {
// New VEVENT from external CalDAV client — create as calendar event
const uidMatch = veventBlock.match(/UID:(.*)/);
if (uidMatch) {
const uid = uidMatch[1].trim();
// Check it doesn't already exist in DB
const existing = db.readCalendarEventByCaldavUid(database, uid);
if (!existing) {
const sumMatch = veventBlock.match(/SUMMARY:(.*)/);
const title = sumMatch ? sumMatch[1].trim().replace(/\\n/g, '\n') : '(kein Titel)';
const descMatch = veventBlock.match(/DESCRIPTION:(.*)/);
const description = descMatch ? descMatch[1].trim().replace(/\\n/g, '\n') : null;
const catMatch = veventBlock.match(/CATEGORIES:(.*)/);
let category = 'custom';
if (catMatch) {
const c = catMatch[1].trim().toLowerCase();
if (KNOWN_CATEGORIES[c]) category = c;
}
// The second door to the same thing. Any user may mint a CalDAV
// app password, and checkCalendarAccess lets everyone write into
// the shared calendar — so without this, the permission asked for
// on POST /api/calendar-events is answered by putting the same
// event in an .ics instead. Editing an existing one this way was
// already admin-only: caldavRecordAllowed returns isAdmin for any
// cev-…@meisterpilze UID. Only creating a fresh one was open.
//
// The already-parsed `category` is used rather than reading
// CATEGORIES a third time: two spellings of the same rule is how
// one of them stops matching, and the loose one would read the
// whole file instead of this VEVENT block.
if (category === 'pickup' && req.caldavUser.role !== 'admin') {
log('warn', 'CalDAV PUT: refused a pickup window from a non-admin', {
calendar: calName,
actor: req.caldavUser.username
});
res.writeHead(403);
res.end('Forbidden');
return;
}
let startDate = null,
endDate = null,
startTime = null,
endTime = null,
allDay = true;
const dtAllDay = veventBlock.match(/DTSTART;VALUE=DATE:(\d{4})(\d{2})(\d{2})/);
const dtTimed = veventBlock.match(/DTSTART(?:;TZID=[^:]+)?:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
if (dtAllDay) {
startDate = dtAllDay[1] + '-' + dtAllDay[2] + '-' + dtAllDay[3];
} else if (dtTimed) {
startDate = dtTimed[1] + '-' + dtTimed[2] + '-' + dtTimed[3];
startTime = dtTimed[4] + ':' + dtTimed[5];
allDay = false;
}
if (!startDate) startDate = new Date().toISOString().split('T')[0];
const deAllDay = veventBlock.match(/DTEND;VALUE=DATE:(\d{4})(\d{2})(\d{2})/);
const deTimed = veventBlock.match(/DTEND(?:;TZID=[^:]+)?:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
if (deAllDay) {
endDate = _allDayLastDate(deAllDay[1], deAllDay[2], deAllDay[3]);
} else if (deTimed) {
endDate = deTimed[1] + '-' + deTimed[2] + '-' + deTimed[3];
endTime = deTimed[4] + ':' + deTimed[5];
}
const eventId = 'cev-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
const caldavUid = uid;
db.insertCalendarEvent(
database,
{
id: eventId,
title,
description,
startDate,
endDate,
allDay,
startTime,
endTime,
category,
caldavUid,
caldavSynced: new Date().toISOString()
},
null
);
// Re-write .ics with X-MEISTERPILZE-TYPE marker so future syncs recognize it
const ev = db.readCalendarEventByCaldavUid(database, caldavUid);
if (ev) {
ev.assignees = [];
const { ics } = customEventToVEVENT(ev);
fs.writeFileSync(filePath, ics, 'utf8');
invalidateCtag(calName);
recordChange(calName, fileName, 'changed');
}
broadcastSSE();
}
}
}
} catch (e) {
log('error', 'CalDAV VEVENT bidirectional sync error', { error: e.message });
}
}
const stat = fs.statSync(filePath);
const etag = '"' + stat.mtimeMs.toString(36) + '"';
res.writeHead(existed ? 204 : 201, { ETag: etag });
res.end();
return;
}
res.writeHead(403);
res.end('Forbidden');
}
function handleGet(parts, req, res) {
// GET /caldav/calendars/<cal>/<uid>.ics
if (parts.length === 3 && parts[0] === 'calendars' && parts[2].endsWith('.ics')) {
if (!checkCalendarAccess(req, parts[1])) {
res.writeHead(403);
res.end('Forbidden');
return;
}
const filePath = path.join(CAL_DIR, parts[1], parts[2]);
if (!fs.existsSync(filePath)) {
res.writeHead(404);
res.end('Not found');
return;
}
const content = fs.readFileSync(filePath, 'utf8');
const etag = getEtag(parts[1], parts[2]);
res.writeHead(200, {
'Content-Type': 'text/calendar; charset=utf-8',
ETag: etag
});
res.end(content);
return;
}
// GET on a calendar collection — return empty (clients use PROPFIND/REPORT)
if (parts.length <= 2) {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Meistertracker CalDAV Server');
return;
}
res.writeHead(404);
res.end('Not found');
}
function handleDelete(parts, req, res) {
// DELETE /caldav/calendars/<cal>/<uid>.ics
if (parts.length === 3 && parts[0] === 'calendars' && parts[2].endsWith('.ics')) {
const calName = parts[1];
const fileName = parts[2];
if (!checkCalendarAccess(req, calName)) {
res.writeHead(403);
res.end('Forbidden');
return;
}
// Only allow deleting from own calendar or shared calendar if admin
const userSlug = req.caldavUserSlug;
if (calName !== userSlug && calName !== 'meisterpilze' && req.caldavUser.role !== 'admin') {
res.writeHead(403);
res.end("Forbidden: cannot delete from other users' calendars");
return;
}
if (calName === 'meisterpilze' && req.caldavUser.role !== 'admin') {
res.writeHead(403);
res.end('Forbidden: only admins can delete from shared calendar');
return;
}
const filePath = path.join(CAL_DIR, calName, fileName);
if (!fs.existsSync(filePath)) {
res.writeHead(404);
res.end('Not found');
return;
}
// Read content before deleting to sync back to DB
let content = '';
try {
content = unfoldIcs(fs.readFileSync(filePath, 'utf8'));
} catch {}
// S-24: the calendar guards above allow deleting from your own calendar.
// What is deleted from the *database* is decided by the UID inside the
// file, which the caller put there — so the row gets its own question.
if (!caldavRecordAllowed(req, content)) {
log('warn', 'CalDAV DELETE: refused a record the caller may not modify', {
calendar: calName,
file: fileName,
actor: req.caldavUser && req.caldavUser.username
});
res.writeHead(403);
res.end('Forbidden');
return;
}
const typeMatch = content.match(/X-MEISTERPILZE-TYPE:(.*)/);
const evType = typeMatch ? typeMatch[1].trim() : null;
if (evType === 'batch-due') {
// Batch due dates are mandatory — ignore delete, autoSync will recreate the file
} else if (evType === 'task-due') {
// Clear task due date (don't delete the task)
const uidMatch = content.match(/UID:(.*)/);
if (uidMatch) {
const taskUid = uidMatch[1].trim().replace(/-event$/, '');
if (/^[A-Za-z0-9\-_.@]+$/.test(taskUid)) {
try {
db.updateTaskDueDate(database, taskUid, null);
broadcastSSE();
} catch (e) {
log('warn', 'CalDAV DELETE task-due sync failed', { taskUid, error: e.message });
}
}
}
} else if (evType === 'custom-event') {
// Delete custom calendar event from DB
const uidMatch = content.match(/UID:(.*)/);
if (uidMatch) {
const uid = uidMatch[1].trim();
const idMatch = uid.match(/^cev-(.+)@meisterpilze$/);
if (idMatch) {
try {
db.deleteCalendarEvent(database, idMatch[1]);
broadcastSSE();
} catch (e) {
log('warn', 'CalDAV DELETE custom-event sync failed', { uid, error: e.message });
}
}
}
} else if (!evType && content.includes('VTODO')) {
// Task VTODO — delete the task from DB
const uidMatch = content.match(/UID:(.*)/);
if (uidMatch) {
const uid = uidMatch[1].trim();
const task = db.readTaskByCaldavUid(database, uid);
if (task) {
try {
db.deleteTaskById(database, task.id);
// Clean up companion due-date VEVENT (check aufgaben + legacy meisterpilze)
for (const cal of ['aufgaben', 'meisterpilze']) deleteIcsFile(cal, uid + '-event.ics');
// Clean up mirror in shared/personal calendar
if (calName !== 'meisterpilze') {
deleteIcsFile('meisterpilze', uid + '.ics');
}
if (task.assignee) {
const slug = db.caldavSlug(task.assignee);
if (calName !== slug) {
deleteIcsFile(slug, uid + '.ics');
}
}
broadcastSSE();
} catch (e) {
log('warn', 'CalDAV DELETE VTODO sync failed', { uid, error: e.message });
}
}
}
}
// External events (no X-MEISTERPILZE-TYPE, no VTODO) — just delete file
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
invalidateCtag(calName);
recordChange(calName, fileName, 'deleted');
}
res.writeHead(204);
res.end();
return;
}
res.writeHead(403);
res.end('Forbidden');
}
function handleProppatch(parts, body, req, res) {
// Minimal PROPPATCH support — just acknowledge
const href = '/caldav/' + parts.join('/') + (parts.length > 0 ? '/' : '');
const xml = `<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>${escapeXml(href)}</d:href>
<d:propstat>
<d:prop/>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>`;
res.writeHead(207, { 'Content-Type': 'application/xml; charset=utf-8' });
res.end(xml);
}
// ══════════════════════════════════════════════════════════════
// ── HTTP SERVER ──────────────────────────────────────────────
// ══════════════════════════════════════════════════════════════
// ── RATE LIMITING ────────────────────────────────────────────
const RATE_WINDOW_MS = 60000;
// 300 a minute per IP is generous for people and tight for a machine, which is
// the point. MT_RATE_MAX raises it for one case only: the measuring stand walks
// twelve pages at ninety-five widths on two pointer axes, and every station it
// opens makes the app fetch. A number, not an on/off, so a mistyped environment
// still leaves a limit standing.
const RATE_MAX_REQUESTS = parseInt(process.env.MT_RATE_MAX, 10) > 0 ? parseInt(process.env.MT_RATE_MAX, 10) : 300;
const httpRateLimits = new Map();
function checkRateLimit(ip) {
const now = Date.now();
let entry = httpRateLimits.get(ip);
if (!entry || now - entry.start > RATE_WINDOW_MS) {
entry = { start: now, count: 0 };
httpRateLimits.set(ip, entry);
}
entry.count++;
return entry.count <= RATE_MAX_REQUESTS;
}
setInterval(() => {
const now = Date.now();
for (const [ip, entry] of httpRateLimits) {
if (now - entry.start > RATE_WINDOW_MS) httpRateLimits.delete(ip);
}
}, RATE_WINDOW_MS);
// ── LOGIN BRUTE-FORCE PROTECTION ────────────────────────────
const LOGIN_MAX_ATTEMPTS = 5; // per username+IP
const LOGIN_LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
const loginAttempts = new Map(); // username@IP → { count, firstAttempt, lockedUntil }
const loginAttemptsPerUser = new Map(); // username → { count, firstAttempt }
// S-19: the per-user tier used to be a hard lock — twenty failures from
// anywhere and the account was unreachable for fifteen minutes. That is real
// protection against distributed password spraying, and simultaneously a
// weapon: twenty wrong guesses against a known username ("admin" is a good
// guess) keeps that account out indefinitely at four requests a minute, spread
// over as many source addresses as the attacker likes, and the locked-out admin
// has no self-service way back in.
//
// It is an escalating delay instead. The per-*source* tier keeps its hard lock,
// because an attacker can only ever aim that at their own address. Spraying one
// account still collapses — each failure past the threshold doubles the wait, to
// a five-second ceiling — while the person who actually knows the password
// always gets in, just slowly, and one correct login clears the counter.
//
// Pending delays are counted and capped so the delay cannot itself be turned
// into a way to pin open sockets. Past the cap the answer is an immediate 429,
// which clears the moment the flood stops instead of fifteen minutes later.
const LOGIN_DELAY_AFTER = 5; // failures for one account before the wait starts
const LOGIN_DELAY_STEP_MS = 250;
const LOGIN_DELAY_MAX_MS = 5000;
const LOGIN_DELAY_MAX_PENDING = 64;
let loginDelaysPending = 0;
function loginDelayForUser(username) {
const entry = loginAttemptsPerUser.get(username);
if (!entry) return 0;
if (Date.now() - entry.firstAttempt > LOGIN_LOCKOUT_MS) {
loginAttemptsPerUser.delete(username);
return 0;
}
const over = entry.count - LOGIN_DELAY_AFTER;
if (over <= 0) return 0;
return Math.min(LOGIN_DELAY_STEP_MS * 2 ** (over - 1), LOGIN_DELAY_MAX_MS);
}
// Runs `next` once this account's current penalty has elapsed. Returns false
// when the pending-delay budget is spent — the caller answers 429 then.
function afterLoginDelay(username, next) {
const delay = loginDelayForUser(username);
if (delay <= 0) {
next();
return true;
}
if (loginDelaysPending >= LOGIN_DELAY_MAX_PENDING) return false;
loginDelaysPending++;
setTimeout(() => {
loginDelaysPending--;
next();
}, delay);
return true;
}
// The per-source lock is short and grows. Five wrong passwords used to shut
// the person at the keyboard out for a quarter of an hour — the one who mistyped
// is far likelier than an attacker to be standing there, and the per-account
// wait and the hashing budget below already hold off guessing at speed. Now the
// first lock is half a minute; only a source that keeps getting it wrong right
// after each lock waits longer, doubling up to the old fifteen minutes.
const LOGIN_SOURCE_LOCK_MS = 30 * 1000;
function loginLockRemainingMs(key) {
const entry = loginAttempts.get(key);
if (!entry || !entry.lockedUntil) return 0;
return Math.max(0, entry.lockedUntil - Date.now());
}
function checkLoginAllowed(key) {
const entry = loginAttempts.get(key);
if (!entry || !entry.lockedUntil) return true;
if (Date.now() < entry.lockedUntil) return false;
// Lock over: a fresh round of attempts, but the number of locks so far is
// kept, so the next one is longer. A correct password clears both.
entry.lockedUntil = null;
entry.count = 0;
return true;
}
function recordLoginFailure(key) {
const now = Date.now();
let entry = loginAttempts.get(key);
if (!entry) {
entry = { count: 0, locks: 0, firstAttempt: now, lockedUntil: null };
loginAttempts.set(key, entry);
}
entry.count++;
entry.lastAttempt = now;
if (entry.count >= LOGIN_MAX_ATTEMPTS) {
entry.locks = (entry.locks || 0) + 1;
entry.lockedUntil = now + Math.min(LOGIN_SOURCE_LOCK_MS * 2 ** (entry.locks - 1), LOGIN_LOCKOUT_MS);
log('warn', 'Login locked due to too many failed attempts', {
key,
attempts: entry.count,
seconds: Math.round((entry.lockedUntil - now) / 1000)
});
}
}
// What the login page shows instead of "try again in 15 minutes".
function loginLockMessage(key) {
const s = Math.max(1, Math.ceil(loginLockRemainingMs(key) / 1000));
const wann = s < 90 ? s + ' Sekunden' : Math.ceil(s / 60) + ' Minuten';
return 'Zu oft das falsche Passwort. In ' + wann + ' noch einmal versuchen.';
}
function recordLoginFailurePerUser(username) {
const now = Date.now();
let entry = loginAttemptsPerUser.get(username);
if (!entry) {
entry = { count: 0, firstAttempt: now };
loginAttemptsPerUser.set(username, entry);
}
entry.count++;
// Logged once, when the wait first bites, so a spray against one account is
// visible without a line per attempt.
if (entry.count === LOGIN_DELAY_AFTER + 1) {
log('warn', 'Repeated failed logins for one account — throttling it', { username, attempts: entry.count });
}
}
// ── S-22: what an unauthenticated caller may make this process compute ──────
//
// finishLogin runs a full scrypt whether or not the account exists, because the
// constant-time answer requires it, and S-14 raised that to N=2^17: ~276 ms of
// synchronous work on the only thread there is. Both older tiers key on a string
// the caller chooses — loginAttempts on username@IP, loginAttemptsPerUser on the
// username — so a fresh username per request lands in a fresh bucket, neither
// counter ever engages, and every request bought a full hash. At the
// 300-a-minute ceiling that is 82 s of CPU demanded of every 60 s of wall clock,
// from one address, with no account.
//
// Three things this must NOT be, each learned the hard way:
//
// 1. **Not a delay.** A wait that still ends in a hash only queues the work;
// 64 pending delays releasing 17 s of hashing every 5 s is the same overload
// with extra steps.
//
// 2. **Not a hard lock.** The first version of this was one, and it repeated
// S-19's mistake one level down. S-19 removed the per-account lock because
// twenty wrong guesses could keep a named account out; a lock keyed on the
// address does the same to everyone who shares one — an office NAT, a mobile
// carrier, or the documented Path B nginx, where without TRUST_PROXY every
// request arrives from 127.0.0.1. Worse, it could not be cleared: the refusal
// ran before finishLogin, so the "one correct login clears it" escape was
// unreachable once tripped. This budget refills on its own instead, so the
// worst case is a wait measured in seconds.
//
// 3. **Not one global pot.** That was the second version, and it turned a CPU
// exhaustion into a login outage: a flood that keeps the shared bucket empty
// costs the attacker nothing and locks out everyone arriving without a
// session, invisibly. A per-source budget cannot be spent on somebody else's
// behalf, which is the whole property that was missing.
//
// What is left is a rate, not a verdict: an address may buy LOGIN_KDF_BURST
// hashes at once and one more every LOGIN_KDF_REFILL_MS. One address therefore
// holds about 5% of the thread, and a correct password hands its token straight
// back, so honest use costs nothing at all.
//
// A distributed flood across many addresses is not answered here and is not
// pretended to be: that is what the 300-a-minute limiter and a firewall are for.
// What this closes is one caller freezing the lab.
const LOGIN_KDF_BURST = 5;
const LOGIN_KDF_REFILL_MS = 5000;
const LOGIN_KDF_MAX_QUELLEN = 5000;
const loginKdfTokens = new Map(); // Adresse → { tokens, stand }
function _loginKdfEintrag(quelle, jetzt) {
let e = loginKdfTokens.get(quelle);
if (!e) {
// Bounded, or the map is a memory leak with a rate limit in front of it.
// Oldest-first: the entries that matter are the ones spending right now.
if (loginKdfTokens.size >= LOGIN_KDF_MAX_QUELLEN) {
const aeltester = loginKdfTokens.keys().next().value;
if (aeltester !== undefined) loginKdfTokens.delete(aeltester);
}
e = { tokens: LOGIN_KDF_BURST, stand: jetzt };
loginKdfTokens.set(quelle, e);
}
const dazu = Math.floor((jetzt - e.stand) / LOGIN_KDF_REFILL_MS);
if (dazu > 0) {
e.tokens = Math.min(LOGIN_KDF_BURST, e.tokens + dazu);
e.stand += dazu * LOGIN_KDF_REFILL_MS;
}
return e;
}
/** One hash for this address, or false. Nothing is hashed on a false. */
function takeLoginKdfToken(quelle, jetzt = Date.now()) {
const e = _loginKdfEintrag(quelle, jetzt);
if (e.tokens <= 0) return false;
e.tokens--;
return true;
}
/** A correct password costs nothing — that is what keeps this off honest users. */
function refundLoginKdfToken(quelle, jetzt = Date.now()) {
const e = _loginKdfEintrag(quelle, jetzt);
e.tokens = Math.min(LOGIN_KDF_BURST, e.tokens + 1);
}
// An address that has not asked for a while is not worth remembering.
//
// ⚠️ **The count has to be refilled before it is judged.** This read `e.tokens`
// straight out of the entry, and that number is only ever brought up to date by
// a call — so an address that spent one token and never came back sat at
// burst-1 for ever and was never swept. The map then only shrank through the
// LRU eviction at LOGIN_KDF_MAX_QUELLEN, which exists as a backstop and was
// doing the whole job. Measured against the real block: 50 entries an hour old,
// 50 deleted, none.
setInterval(
() => {
const jetzt = Date.now();
const alt = LOGIN_KDF_BURST * LOGIN_KDF_REFILL_MS;
for (const [quelle, e] of loginKdfTokens) {
if (jetzt - e.stand <= alt) continue;
const voll = Math.min(LOGIN_KDF_BURST, e.tokens + Math.floor((jetzt - e.stand) / LOGIN_KDF_REFILL_MS));
if (voll >= LOGIN_KDF_BURST) loginKdfTokens.delete(quelle);
}
},
10 * 60 * 1000
).unref();
function clearLoginAttempts(key) {
loginAttempts.delete(key);
}
function clearLoginAttemptsPerUser(username) {
loginAttemptsPerUser.delete(username);
}
// The half of /api/auth/login that runs once the per-account wait has elapsed.
// Split out of the route handler for S-19: the delay is asynchronous, and this
// is what it defers.
function finishLogin(req, res, { username, password, userKey, throttleKey, sourceKey }) {
const user = db.getUserByUsernameCaseInsensitive(database, username);
// Constant-time login: always run scrypt, even when the username is
// unknown — falling back to a process-local dummy hash keeps the
// response time independent of whether the account exists. The
// !user check below still rejects unknown accounts, but does so at
// the same latency as a real user with a wrong password.
const candidateHash = user ? user.hash : DUMMY_PASSWORD_HASH;
const candidateSalt = user ? user.salt : DUMMY_PASSWORD_SALT;
const passwordOk = db.verifyPassword(candidateHash, candidateSalt, password);
if (!user || !passwordOk) {
recordLoginFailure(throttleKey);
recordLoginFailurePerUser(userKey);
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid credentials' }));
return;
}
// One correct password clears the account's penalty outright, which is what
// keeps S-19's delay from being a lockout with extra steps.
clearLoginAttempts(throttleKey);
clearLoginAttemptsPerUser(userKey);
// S-22: a correct password costs this address nothing, which is what keeps
// the budget off the people it exists to protect.
refundLoginKdfToken(sourceKey);
// S-14: login is the only moment the plaintext password is in hand, so
// it is the only chance to re-hash a row that still uses the old scrypt
// cost. Best effort — a failure here must not cost the user their login.
if (db.passwordNeedsUpgrade(user.salt)) {
try {
const upgraded = db.hashPassword(password);
db.updateUserPassword(database, user.id, upgraded.hash, upgraded.salt);
log('info', 'Upgraded password hash to current KDF parameters', { username: user.username });
} catch (upgradeErr) {
log('warn', 'Password hash upgrade failed', { username: user.username, error: upgradeErr.message });
}
}
// Rotate: invalidate any pre-existing session token presented in
// the request before minting a new one. Defends against stolen
// pre-auth cookies surviving the legitimate user's login.
const oldToken = getSessionToken(req);
if (oldToken) {
try {
db.deleteSession(database, oldToken);
} catch (_) {
/* best effort — token may not exist anymore */
}
}
const token = db.createSession(database, user.id);
setSessionCookie(res, token);
jsonOk(res, { username: user.username, role: user.role });
}
setInterval(() => {
const now = Date.now();
// A source is forgotten a quarter of an hour after its last attempt, once no
// lock is running. Dropping it the moment a lock ran out would also drop the
// count of locks, and every lock would be the first, short one.
for (const [key, entry] of loginAttempts) {
if (entry.lockedUntil && now < entry.lockedUntil) continue;
if (now - (entry.lastAttempt || entry.firstAttempt) > LOGIN_LOCKOUT_MS) loginAttempts.delete(key);
}
for (const [key, entry] of loginAttemptsPerUser) {
if (now - entry.firstAttempt > LOGIN_LOCKOUT_MS) loginAttemptsPerUser.delete(key);
}
}, 60000);
// R-18: opt-in access logging. Default OFF — logs are noisy and we already
// have structured logs for everything that matters. Operators flip
// LOG_ACCESS=true when they need a request trail (incident response,
// debugging a misbehaving client). Skips SSE long-pollers and unauth health
// probes so we don't drown the log file.
const LOG_ACCESS = process.env.LOG_ACCESS === 'true' || process.env.LOG_ACCESS === '1';
function handleRequest(req, res) {
const clientIP = getClientIP(req);
if (!checkRateLimit(clientIP)) {
res.writeHead(429, { 'Content-Type': 'application/json' });
res.end('{"error":"Too many requests"}');
return;
}
// R-18: optional access logging — wires up at request entry so we capture
// the round-trip even when downstream code throws before res.end().
if (LOG_ACCESS) {
const reqStart = Date.now();
res.once('finish', () => {
try {
const url = req.url || '';
// Skip SSE long-polls (would log once per 30 min connection close)
// and skip unauth /api/health probes (every prober hits this every
// few seconds).
if (url.startsWith('/api/events')) return;
const isAuthed = !!req.authUser;
if (url === '/api/health' && !isAuthed) return;
log('info', 'http', {
method: req.method,
url,
status: res.statusCode,
ms: Date.now() - reqStart,
user: req.authUser ? req.authUser.username : null,
ip: clientIP
});
} catch (_) {
/* never let access logging break a response */
}
});
}
// ── Security headers ──
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
// base-uri/form-action/object-src close injection pivots that the rest of the
// policy leaves open: a <base> tag rewriting every relative script URL, a form
// retargeted to an attacker origin, and plugin/embed content. script-src still
// needs 'unsafe-inline' because the app uses inline event handlers (onclick=)
// throughout index.html/app.js — removing it requires converting those to
// addEventListener first, or every one of them silently stops working.
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; report-uri /api/csp-reports"
);
if (protocol === 'https') res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// Permissions-Policy: explicitly drop powerful APIs the app doesn't use.
// camera=(self) is required for the html5-qrcode scanner; fullscreen=(self)
// is left open for any future scanner-fullscreen UI. Everything else is
// off — closes attack-surface that a future XSS could otherwise pivot
// into (e.g. exfiltrating geolocation, capturing the microphone).
res.setHeader(
'Permissions-Policy',
'camera=(self), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=(), midi=(), autoplay=(), fullscreen=(self)'
);
// ── Well-known CalDAV discovery (RFC 6764) ──
// Use 308 (Permanent Redirect) instead of 301 so that iOS/CalDAV clients
// preserve the PROPFIND method across the redirect (301 allows changing to GET).
if (req.url.startsWith('/.well-known/caldav')) {
res.writeHead(308, { Location: '/caldav/' });
res.end();
return;
}
// ── CalDAV requests ──
if (req.url.startsWith('/caldav')) {
// S-19: the per-account wait, applied before the handler runs. CalDAV
// re-authenticates on every request, so this is the same throttle the login
// route uses, on the same counters — a stale password in a calendar client
// slows itself down instead of locking the person out of the web UI.
const caldavUser = extractBasicAuthUsername(req);
if (!caldavUser) return handleCaldav(req, res);
const accepted = afterLoginDelay(caldavUser.toLowerCase(), () => handleCaldav(req, res));
if (!accepted) {
res.writeHead(429, { 'Content-Type': 'text/plain', 'Retry-After': '2' });
res.end('Too many login attempts. Try again shortly.');
}
return;
}
// ── OAuth 2.0 well-known endpoints (public, no auth) ──
// CORS preflight for well-known endpoints (MCP clients send MCP-Protocol-Version header)
if (req.method === 'OPTIONS' && req.url.startsWith('/.well-known/oauth-')) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, MCP-Protocol-Version');
res.writeHead(204);
res.end();
return;
}
// RFC 9728: serve protected resource metadata at both path-aware and root URLs
const MCP_SCOPE = 'mcp:full';
if (
req.method === 'GET' &&
(req.url === '/.well-known/oauth-protected-resource/mcp' || req.url === '/.well-known/oauth-protected-resource')
) {
const base = getBaseUrl(req);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, MCP-Protocol-Version');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
resource: base + '/mcp',
authorization_servers: [base],
bearer_methods_supported: ['header'],
scopes_supported: [MCP_SCOPE]
})
);
return;
}
if (req.method === 'GET' && req.url === '/.well-known/oauth-authorization-server') {
const base = getBaseUrl(req);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, MCP-Protocol-Version');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
issuer: base,
authorization_endpoint: base + '/oauth/authorize',
token_endpoint: base + '/oauth/token',
registration_endpoint: base + '/oauth/register',
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['none', 'client_secret_post'],
code_challenge_methods_supported: ['S256'],
scopes_supported: [MCP_SCOPE]
})
);
return;
}
// ── OAuth 2.0 endpoints (before auth gate) ──
if (req.url.startsWith('/oauth/')) {
// CORS for token + register endpoints
if (req.url === '/oauth/token' || req.url === '/oauth/register') {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, MCP-Protocol-Version');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
}
// Dynamic Client Registration (RFC 7591) — required by MCP OAuth spec
if (req.method === 'POST' && req.url === '/oauth/register') {
if (!checkOAuthRate(req)) {
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '60' });
res.end('{"error":"too_many_requests"}');
return;
}
jsonBody(req, res, (e, data) => {
if (e) return;
try {
const redirectUris = data.redirect_uris;
if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end('{"error":"invalid_client_metadata","error_description":"redirect_uris required"}');
return;
}
// S-25: bounds, because this route is unauthenticated and every call
// writes a row that nothing used to collect. Until here the only
// limits were checkOAuthRate (20/min per address) and MAX_BODY_SIZE —
// five megabytes of client_name, stored verbatim and logged verbatim,
// twenty times a minute, for ever.
//
// The numbers are deliberately far above any real client: a name is a
// label in an admin list, and RFC 7591 registrations carry one or two
// redirect URIs, not fifty.
if (redirectUris.length > OAUTH_MAX_REDIRECT_URIS) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end('{"error":"invalid_client_metadata","error_description":"too many redirect_uris"}');
return;
}
// Typed as well as bounded. `new URL(uri)` below throws on a
// non-string, and that lands in safeErr as a 500 — our fault by the
// response code, the caller's by the facts.
for (const uri of redirectUris) {
if (typeof uri !== 'string' || uri.length > OAUTH_MAX_URI_LENGTH) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(
'{"error":"invalid_client_metadata","error_description":"redirect_uri must be a string within length limits"}'
);
return;
}
}
if (typeof data.client_name === 'string' && data.client_name.length > OAUTH_MAX_CLIENT_NAME) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end('{"error":"invalid_client_metadata","error_description":"client_name too long"}');
return;
}
// Validate redirect URIs. RFC 8252 (OAuth 2.0 for Native Apps):
// public clients must use https:// or loopback http://. Plain http
// to a public host would let an attacker register a phishing
// redirect — PKCE alone does not prevent that since the attacker
// *is* the registered client. Accept exactly:
// - https://... (any host)
// - http://127.0.0.1[:port][/...]
// - http://[::1][:port][/...]
// - http://localhost[:port][/...] (dev convenience)
for (const uri of redirectUris) {
let u;
try {
u = new URL(uri);
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end('{"error":"invalid_client_metadata","error_description":"invalid redirect_uri"}');
return;
}
const isHttps = u.protocol === 'https:';
const isLoopbackHttp =
u.protocol === 'http:' &&
(u.hostname === '127.0.0.1' ||
u.hostname === '[::1]' ||
u.hostname === '::1' ||
u.hostname === 'localhost');
if (!isHttps && !isLoopbackHttp) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(
'{"error":"invalid_client_metadata","error_description":"redirect_uri must be https:// or http://(127.0.0.1|[::1]|localhost) per RFC 8252"}'
);
return;
}
if (u.hash) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(
'{"error":"invalid_client_metadata","error_description":"redirect_uri must not contain a fragment"}'
);
return;
}
}
const clientId = crypto.randomUUID();
const clientName = typeof data.client_name === 'string' ? data.client_name : '';
db.registerOAuthClient(database, { clientId, clientName, redirectUris });
log('info', 'OAuth client registered via DCR', { clientId, clientName });
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
client_id: clientId,
client_name: clientName,
redirect_uris: redirectUris,
grant_types: data.grant_types || ['authorization_code', 'refresh_token'],
response_types: data.response_types || ['code'],
token_endpoint_auth_method: 'none'
})
);
} catch (err) {
safeErr(res, err);
}
});
return;
}
// Token endpoint — public
if (req.method === 'POST' && req.url === '/oauth/token') {
if (!checkOAuthRate(req)) {
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '60' });
res.end('{"error":"too_many_requests"}');
return;
}
const ct = (req.headers['content-type'] || '').split(';')[0].trim();
const parser = ct === 'application/json' ? jsonBody : formBody;
parser(req, res, (e, data) => {
if (e) return;
try {
// Validate client_secret if client has one
const tokenClient = db.getOAuthClient(database, data.client_id);
if (!tokenClient) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end('{"error":"invalid_client"}');
return;
}
if (tokenClient.hasSecret) {
if (!data.client_secret) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end('{"error":"invalid_client","error_description":"client_secret required"}');
return;
}
if (!db.verifyOAuthClientSecret(database, data.client_id, data.client_secret)) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end('{"error":"invalid_client"}');
return;
}
}
if (data.grant_type === 'authorization_code') {
if (!data.code || !data.code_verifier || !data.client_id || !data.redirect_uri) {
return jsonErr(res, 400, 'missing required parameters');
}
const codeHash = crypto.createHash('sha256').update(data.code).digest('hex');
const codeRow = db.getOAuthCode(database, codeHash);
if (!codeRow) {
log('warn', 'OAuth token failed: invalid code', { clientId: data.client_id });
return jsonErr(res, 400, 'invalid_grant');
}
if (codeRow.clientId !== data.client_id) {
log('warn', 'OAuth token failed: client mismatch', { clientId: data.client_id });
return jsonErr(res, 400, 'invalid_grant');
}
if (codeRow.redirectUri !== data.redirect_uri) {
log('warn', 'OAuth token failed: redirect mismatch', { clientId: data.client_id });
return jsonErr(res, 400, 'invalid_grant');
}
if (!verifyPkce(data.code_verifier, codeRow.codeChallenge)) {
log('warn', 'OAuth token failed: PKCE mismatch', { clientId: data.client_id });
return jsonErr(res, 400, 'invalid_grant');
}
// RFC 8707: validate resource indicator if both sides provided one
if (data.resource && codeRow.resource && data.resource !== codeRow.resource) {
log('warn', 'OAuth token failed: resource mismatch', {
clientId: data.client_id,
expected: codeRow.resource,
got: data.resource
});
return jsonErr(res, 400, 'invalid_grant');
}
const accessToken = crypto.randomBytes(32).toString('hex');
const refreshToken = crypto.randomBytes(32).toString('hex');
const accessHash = crypto.createHash('sha256').update(accessToken).digest('hex');
const refreshHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
// I-08: atomic exchange — burn the auth code and issue both tokens in one txn
// so a failed INSERT can't leave the code unusable while the access/refresh
// tokens are missing (or vice versa).
database.exec('BEGIN');
try {
db.markOAuthCodeUsed(database, codeHash);
db.createOAuthToken(database, {
token: accessHash,
tokenType: 'access',
clientId: data.client_id,
userId: codeRow.userId,
expiresInSeconds: 3600,
refreshTokenRef: refreshHash
});
db.createOAuthToken(database, {
token: refreshHash,
tokenType: 'refresh',
clientId: data.client_id,
userId: codeRow.userId,
expiresInSeconds: 30 * 24 * 3600
});
database.exec('COMMIT');
} catch (txErr) {
database.exec('ROLLBACK');
throw txErr;
}
log('info', 'OAuth token issued', { clientId: data.client_id });
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(
JSON.stringify({
access_token: accessToken,
token_type: 'bearer',
expires_in: 3600,
refresh_token: refreshToken
})
);
} else if (data.grant_type === 'refresh_token') {
if (!data.refresh_token || !data.client_id) return jsonErr(res, 400, 'missing required parameters');
const refreshHash = crypto.createHash('sha256').update(data.refresh_token).digest('hex');
const refreshRow = db.getOAuthRefreshToken(database, refreshHash);
if (!refreshRow) return jsonErr(res, 400, 'invalid_grant');
if (refreshRow.clientId !== data.client_id) return jsonErr(res, 400, 'invalid_grant');
const newAccessToken = crypto.randomBytes(32).toString('hex');
const newRefreshToken = crypto.randomBytes(32).toString('hex');
const newAccessHash = crypto.createHash('sha256').update(newAccessToken).digest('hex');
const newRefreshHash = crypto.createHash('sha256').update(newRefreshToken).digest('hex');
// I-08: atomic rotation — revoke previous pair and issue new pair in one txn
// so a failed INSERT can't leave the user without working tokens.
database.exec('BEGIN');
try {
db.revokeOAuthTokensByRefresh(database, refreshHash);
db.createOAuthToken(database, {
token: newAccessHash,
tokenType: 'access',
clientId: data.client_id,
userId: refreshRow.userId,
expiresInSeconds: 3600,
refreshTokenRef: newRefreshHash
});
db.createOAuthToken(database, {
token: newRefreshHash,
tokenType: 'refresh',
clientId: data.client_id,
userId: refreshRow.userId,
expiresInSeconds: 30 * 24 * 3600
});
database.exec('COMMIT');
} catch (txErr) {
database.exec('ROLLBACK');
throw txErr;
}
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(
JSON.stringify({
access_token: newAccessToken,
token_type: 'bearer',
expires_in: 3600,
refresh_token: newRefreshToken
})
);
} else {
jsonErr(res, 400, 'unsupported_grant_type');
}
} catch (err) {
safeErr(res, err);
}
});
return;
}
// Authorization endpoint — requires session cookie
if (req.url.startsWith('/oauth/authorize')) {
const parsedUrl = new URL(req.url, 'http://localhost');
if (req.method === 'GET') {
const authUser = checkAuth(req);
if (!authUser) {
res.writeHead(302, { Location: '/login.html?redirect=' + encodeURIComponent(req.url) });
res.end();
return;
}
const clientId = parsedUrl.searchParams.get('client_id') || '';
const redirectUri = parsedUrl.searchParams.get('redirect_uri') || '';
const state = parsedUrl.searchParams.get('state') || '';
const codeChallenge = parsedUrl.searchParams.get('code_challenge') || '';
const codeChallengeMethod = parsedUrl.searchParams.get('code_challenge_method') || '';
const responseType = parsedUrl.searchParams.get('response_type') || '';
const resource = parsedUrl.searchParams.get('resource') || '';
if (responseType !== 'code') {
jsonErr(res, 400, 'unsupported_response_type');
return;
}
if (codeChallengeMethod !== 'S256') {
jsonErr(res, 400, 'invalid code_challenge_method');
return;
}
if (!codeChallenge) {
jsonErr(res, 400, 'code_challenge required');
return;
}
const client = db.getOAuthClient(database, clientId);
if (!client) {
// A browser is always the reader here — this route is where a client
// sends a person — so the answer is a page and not the JSON the
// machine endpoints return. It is also the far end of a trap worth
// naming. A proxy that caches its registration (mcp-remote keeps one
// per server under ~/.mcp-auth, for ever) hands over an id that
// deleteExpiredOAuthData has since swept, and this error reaches only
// the tab, never the proxy: it goes on presenting the same dead id at
// every start, and opens a new tab each time. Told
// {"error":"invalid client_id"} nobody guesses the fix is a file on
// their own disk, so the page says so.
log('warn', 'OAuth authorize: unknown client_id', { user: authUser.username });
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Registration Expired – Meistertracker</title><link rel="icon" href="/favicon.ico">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:#f8fafc;color:#1e293b;font-size:15px;display:flex;align-items:center;justify-content:center;min-height:100vh}
.card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:36px;width:100%;max-width:460px;margin:16px;box-shadow:0 4px 6px -1px rgba(0,0,0,.07)}
.logo{text-align:center;margin-bottom:8px;color:#d97706}
h1{font-size:20px;font-weight:700;margin-bottom:4px;text-align:center}
.sub{color:#64748b;font-size:14px;text-align:center;margin-bottom:24px}
.note{background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:12px 16px;margin-bottom:20px;font-size:13px;color:#92400e}
ol{font-size:14px;color:#334155}
ol li{margin:10px 0 10px 20px}
code{background:#f1f5f9;border-radius:4px;padding:1px 5px;font-size:13px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
</style></head><body>
<div class="card">
<div class="logo"><svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><path d="M12 7v6M12 16.5v.5"/></svg></div>
<h1>Registration Expired</h1>
<p class="sub">The app that sent you here is using a client registration this server no longer has.</p>
<div class="note">A registration that never gets this far is removed after a day. The app kept its copy and has no way to notice that this one is gone, so it will keep sending you here — nothing on this page can revive it.</div>
<ol>
<li>Delete the app's cached registration. <code>mcp-remote</code> keeps one per server under <code>~/.mcp-auth</code>; most other clients drop theirs when you remove and re-add the server.</li>
<li>Start the app again and open the fresh authorization link it prints.</li>
<li>Finish that one — click Allow — while you are still signed in here.</li>
</ol>
</div></body></html>`);
return;
}
if (!client.redirectUris.includes(redirectUri)) {
jsonErr(res, 400, 'invalid redirect_uri');
return;
}
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const clientName = client.clientName || clientId;
// Getting an authenticated person as far as this screen is what makes a
// registration real, so it counts as use. Marked here and not only when
// a code or a token is issued, because the gap between the two is where
// clients got stuck: close the tab, think it over until tomorrow, and
// deleteExpiredOAuthData swept a registration the client had already
// cached and would never re-request. The bound S-25 put on
// POST /oauth/register survives intact — reaching this line needs
// checkAuth, and unauthenticated registration spam never can.
db.touchOAuthClient(database, clientId);
log('info', 'OAuth consent shown', { clientId, clientName, user: authUser.username });
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Authorize – Meistertracker</title><link rel="icon" href="/favicon.ico">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:#f8fafc;color:#1e293b;font-size:15px;display:flex;align-items:center;justify-content:center;min-height:100vh}
.card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:36px;width:100%;max-width:420px;margin:16px;box-shadow:0 4px 6px -1px rgba(0,0,0,.07)}
.logo{text-align:center;margin-bottom:8px;color:#16a34a}
h1{font-size:20px;font-weight:700;margin-bottom:4px;text-align:center}
.sub{color:#64748b;font-size:14px;text-align:center;margin-bottom:24px}
.client{font-weight:600;color:#1e293b}
.perms{background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:12px 16px;margin-bottom:24px;font-size:13px;color:#166534}
.perms li{margin:4px 0;list-style:disc inside}
.btns{display:flex;gap:12px}
.btn{flex:1;padding:12px;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;transition:background .15s}
.btn-allow{background:#16a34a;color:#fff}.btn-allow:hover{background:#15803d}
.btn-deny{background:#e2e8f0;color:#475569}.btn-deny:hover{background:#cbd5e1}
</style></head><body>
<div class="card">
<div class="logo"><svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="5"/><path d="M12 13v5M8 21h8M9 3c0 0-2-2-2-3M15 3c0 0 2-2 2-3"/></svg></div>
<h1>Authorize Access</h1>
<p class="sub"><span class="client">${esc(clientName)}</span> wants to access<br>Meistertracker</p>
<ul class="perms">
<li>Read batches, tasks, calendar, inventory</li>
<li>Create and update batches, tasks, events</li>
<li>Log harvests and bag movements</li>
</ul>
<form method="POST" action="/oauth/authorize">
<input type="hidden" name="client_id" value="${esc(clientId)}">
<input type="hidden" name="redirect_uri" value="${esc(redirectUri)}">
<input type="hidden" name="state" value="${esc(state)}">
<input type="hidden" name="code_challenge" value="${esc(codeChallenge)}">
<input type="hidden" name="code_challenge_method" value="${esc(codeChallengeMethod)}">
<input type="hidden" name="response_type" value="code">
<input type="hidden" name="resource" value="${esc(resource)}">
<div class="btns">
<button type="submit" name="action" value="deny" class="btn btn-deny">Deny</button>
<button type="submit" name="action" value="allow" class="btn btn-allow">Allow</button>
</div>
</form>
</div></body></html>`);
return;
}
if (req.method === 'POST') {
const authUser = checkAuth(req);
if (!authUser) {
jsonErr(res, 401, 'unauthorized');
return;
}
formBody(req, res, (e, data) => {
if (e) return;
try {
const redirectUri = data.redirect_uri || '';
const state = data.state || '';
const clientId = data.client_id || '';
// Validate client and redirect_uri BEFORE any redirect (prevents open redirect)
const client = db.getOAuthClient(database, clientId);
if (!client || !client.redirectUris.includes(redirectUri)) {
jsonErr(res, 400, 'invalid client or redirect_uri');
return;
}
if (data.action === 'deny') {
const sep = redirectUri.includes('?') ? '&' : '?';
res.writeHead(302, {
Location:
redirectUri + sep + 'error=access_denied' + (state ? '&state=' + encodeURIComponent(state) : '')
});
res.end();
return;
}
log('info', 'OAuth authorization granted', { clientId, user: authUser.username });
const code = crypto.randomBytes(32).toString('hex');
const codeHash = crypto.createHash('sha256').update(code).digest('hex');
db.createOAuthCode(database, {
code: codeHash,
clientId,
userId: authUser.user_id,
redirectUri,
codeChallenge: data.code_challenge || '',
codeChallengeMethod: data.code_challenge_method || 'S256',
resource: data.resource || ''
});
const sep = redirectUri.includes('?') ? '&' : '?';
res.writeHead(302, {
Location:
redirectUri +
sep +
'code=' +
encodeURIComponent(code) +
(state ? '&state=' + encodeURIComponent(state) : '')
});
res.end();
} catch (err) {
log('error', 'OAuth authorize POST failed', { error: err.message });
safeErr(res, err);
}
});
return;
}
}
// Unknown /oauth/ endpoint
jsonErr(res, 404, 'not found');
return;
}
// ── MCP endpoint (own CORS + bearer auth, before cookie auth gate) ──
if (req.url === '/mcp' || req.url.startsWith('/mcp?')) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version');
res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
const mcpCfg = db.getMcpCfg(database);
if (!mcpCfg.enabled) {
res.writeHead(404);
res.end();
return;
}
const mcpAuth = checkMcpAuth(req);
if (!mcpAuth) {
const base = getBaseUrl(req);
res.writeHead(401, {
'Content-Type': 'application/json',
'WWW-Authenticate':
'Bearer resource_metadata="' + base + '/.well-known/oauth-protected-resource/mcp", scope="mcp:full"'
});
res.end('{"error":"unauthorized"}');
return;
}
if (!checkMcpRate(req)) {
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '60' });
res.end('{"error":"rate limit exceeded"}');
return;
}
const sessionId = req.headers['mcp-session-id'];
// S-01 follow-up: createMcpServer bakes the caller's role into the cached
// server at creation, so a reused session ignores the current request's
// token role. Bind each session to the identity that created it — a later
// request bearing a different token, or the same user after a role change,
// must not ride on the original session's privileges. Returns true when the
// presented auth no longer matches, so the caller rejects (client re-inits).
const mcpSessionAuthMismatch = (session) =>
!session.auth || session.auth.userId !== mcpAuth.userId || session.auth.role !== mcpAuth.role;
if (req.method === 'POST') {
anyJsonBody(req, res, async (e, body) => {
if (e) return;
try {
let session = sessionId ? mcpSessions.get(sessionId) : null;
if (session && mcpSessionAuthMismatch(session)) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end('{"error":"session belongs to a different identity"}');
return;
}
if (!session) {
// S-01: pass the caller's auth context (userId, role) into
// the MCP server so destructive tools can require admin role.
const server = createMcpServer(database, () => broadcastSSE(null), {
printZPL,
checkPrinterAvailable,
auth: mcpAuth
});
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: (sid) => {
mcpSessions.set(sid, { transport, server, lastActive: Date.now(), auth: mcpAuth });
}
});
transport.onclose = () => {
const sid = transport.sessionId;
if (sid) mcpSessions.delete(sid);
server.close().catch(() => {});
};
await server.connect(transport);
session = { transport, server, lastActive: Date.now(), auth: mcpAuth };
} else {
session.lastActive = Date.now();
}
await session.transport.handleRequest(req, res, body);
} catch (err) {
// Without this, a rejection from server.connect()/handleRequest()
// becomes an unhandledRejection and the client never gets a response.
log('error', 'MCP request handling failed', { error: err && err.message });
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end('{"error":"internal error"}');
} else {
res.destroy();
}
}
});
return;
}
if (req.method === 'GET') {
const session = sessionId ? mcpSessions.get(sessionId) : null;
if (!session) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end('{"error":"no session"}');
return;
}
if (mcpSessionAuthMismatch(session)) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end('{"error":"session belongs to a different identity"}');
return;
}
session.lastActive = Date.now();
session.transport.handleRequest(req, res);
return;
}
if (req.method === 'DELETE') {
const session = sessionId ? mcpSessions.get(sessionId) : null;
if (!session) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end('{"error":"no session"}');
return;
}
if (mcpSessionAuthMismatch(session)) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end('{"error":"session belongs to a different identity"}');
return;
}
session.transport.handleRequest(req, res);
return;
}
res.writeHead(405);
res.end();
return;
}
// CORS — only allow same-origin requests (no cross-origin API access)
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
const url = req.url.split('?')[0];
// ── Auth endpoints (public) ───────────────────────────────
if (url === '/api/auth/setup-required' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ setupRequired: db.countUsers(database) === 0 }));
return;
}
if (url === '/api/auth/setup' && req.method === 'POST') {
if (db.countUsers(database) > 0) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'setup already completed' }));
return;
}
// Defence: a fresh public-facing deployment should not allow ANY
// remote unauthenticated caller to claim admin. Two acceptable paths:
// 1. The request originates from loopback (operator on the box).
// 2. The request carries the in-memory setup token printed to logs
// on first start (operator who can read PM2/journald).
const ip = req.socket.remoteAddress || '';
const isLoopback = setupFromLoopback(ip, TRUST_PROXY);
const presentedToken = req.headers['x-setup-token'] || '';
let tokenOk = false;
if (SETUP_TOKEN && typeof presentedToken === 'string' && presentedToken.length === SETUP_TOKEN.length) {
try {
tokenOk = crypto.timingSafeEqual(Buffer.from(presentedToken, 'utf8'), Buffer.from(SETUP_TOKEN, 'utf8'));
} catch (_) {
tokenOk = false;
}
}
if (!isLoopback && !tokenOk) {
log('warn', 'Remote setup attempt rejected', { ip });
jsonErr(res, 403, 'setup must run from localhost or with a valid X-Setup-Token header');
return;
}
jsonBody(req, res, (e, data) => {
if (e) {
jsonErr(res, 400, e.message);
return;
}
try {
const { username, password } = data;
if (!username || !password || password.length < 8 || password.length > PASSWORD_MAX_LENGTH) {
jsonErr(res, 400, 'Username and password (min 8 chars) required');
return;
}
if (!USERNAME_RE.test(username)) {
jsonErr(res, 400, 'username must be alphanumeric with . _ - (max 64 chars)');
return;
}
const user = db.createUser(database, username, password, 'admin');
const dbUser = db.getUserByUsername(database, username);
const token = db.createSession(database, dbUser.id);
setSessionCookie(res, token);
// Setup completed — invalidate the in-memory token so a stolen
// log line can't be reused after the admin is created.
SETUP_TOKEN = null;
jsonOk(res, { username: user.username, role: 'admin' });
} catch (err) {
safeErr(res, err);
}
});
return;
}
if (url === '/api/auth/login' && req.method === 'POST') {
jsonBody(req, res, (e, data) => {
if (e) {
jsonErr(res, 400, e.message);
return;
}
try {
const { username, password } = data;
if (!username || !password) {
jsonErr(res, 400, 'Username and password required');
return;
}
// S-16: before userKey is built, because that string becomes a key in
// loginAttempts and loginAttemptsPerUser and stays there for the whole
// lockout window. No account can have a name that fails this, so the
// answer is the same one a wrong password gets.
if (typeof username !== 'string' || !USERNAME_RE.test(username)) {
// N