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 ` 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: `. // 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.` 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//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 ? '' : ''; return `${label}
${icon}

${safe}

Zurück zur App

${redirect}
`; } // 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=` 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.` leftovers if a process // crashed between writeFileSync and renameSync, and the `.tmp-` 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-.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_.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//.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, '"'); } // 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 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 = ''; // 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 // 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 = ` /caldav/ /caldav/principal/ Meistertracker HTTP/1.1 200 OK `; 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 = ` /caldav/principal/ /caldav/principal/ /caldav/principal/ /caldav/calendars/ ${escapeXml(req.caldavUser.username)} HTTP/1.1 200 OK `; 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 = ` /caldav/calendars/ Calendars /caldav/principal/ HTTP/1.1 200 OK `; if (depth !== '0') { for (const cal of cals) { const { displayName, color } = getCalDisplayInfo(cal); const colorProp = color ? '\n ' + color + '' : ''; responses += `\n /caldav/calendars/${encodeURIComponent(cal)}/ ${escapeXml(displayName)} ${colorProp} ${computeCtag(cal)} http://meisterpilze/sync/${getSyncToken(cal)} ${SUPPORTED_REPORT_SET} HTTP/1.1 200 OK `; } } const xml = ` ${responses} `; res.writeHead(207, { 'Content-Type': 'application/xml; charset=utf-8' }); res.end(xml); return; } // /caldav/calendars// — 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 ' + color + '' : ''; let responses = ` /caldav/calendars/${encodeURIComponent(calName)}/ ${escapeXml(displayName)} ${colorProp2} ${computeCtag(calName)} http://meisterpilze/sync/${getSyncToken(calName)} ${SUPPORTED_REPORT_SET} HTTP/1.1 200 OK `; 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 /caldav/calendars/${encodeURIComponent(calName)}/${encodeURIComponent(f)} ${etag} text/calendar; charset=utf-8 ${fileSize} HTTP/1.1 200 OK `; } } const xml = ` ${responses} `; res.writeHead(207, { 'Content-Type': 'application/xml; charset=utf-8' }); res.end(xml); return; } // /caldav/calendars//.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 = ` /caldav/calendars/${encodeURIComponent(parts[1])}/${encodeURIComponent(parts[2])} ${etag} text/calendar; charset=utf-8 ${fileSize} HTTP/1.1 200 OK `; 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>/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 = ` `; 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 /caldav/calendars/${encodeURIComponent(calName)}/${encodeURIComponent(f)} ${etag} HTTP/1.1 200 OK `; } } 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 /caldav/calendars/${encodeURIComponent(calName)}/${encodeURIComponent(fileName)} HTTP/1.1 404 Not Found `; } else { const etag = getEtag(calName, fileName); if (etag) { responses += `\n /caldav/calendars/${encodeURIComponent(calName)}/${encodeURIComponent(fileName)} ${etag} HTTP/1.1 200 OK `; } } } } const xml = ` http://meisterpilze/sync/${currentToken}${responses} `; 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 `]*` 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 ${escapeXml(t.href)} ${etag} ${escapeXml(content)} HTTP/1.1 200 OK `; } } 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 /caldav/calendars/${encodeURIComponent(calName)}/${encodeURIComponent(f)} ${etag} ${escapeXml(content)} HTTP/1.1 200 OK `; } } const xml = ` ${responses} `; 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-@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//.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//.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//.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 = ` ${escapeXml(href)} HTTP/1.1 200 OK `; 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 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(` Registration Expired – Meistertracker

Registration Expired

The app that sent you here is using a client registration this server no longer has.

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.
  1. Delete the app's cached registration. mcp-remote keeps one per server under ~/.mcp-auth; most other clients drop theirs when you remove and re-add the server.
  2. Start the app again and open the fresh authorization link it prints.
  3. Finish that one — click Allow — while you are still signed in here.
`); return; } if (!client.redirectUris.includes(redirectUri)) { jsonErr(res, 400, 'invalid redirect_uri'); return; } const esc = (s) => s.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(` Authorize – Meistertracker

Authorize Access

${esc(clientName)} wants to access
Meistertracker

  • Read batches, tasks, calendar, inventory
  • Create and update batches, tasks, events
  • Log harvests and bag movements
`); 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)) { // Nothing is recorded against the throttle maps here: the whole point // is that this string never becomes a key in them, and a failure // logged under a shape that checkLoginAllowed never queries would be // the same leak wearing a hat. res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid credentials' })); return; } const userKey = username.toLowerCase(); const throttleKey = userKey + '@' + clientIP; // Per-source: still a hard lock. An attacker can only point that at // their own address, so it is not usable against anyone else. if (!checkLoginAllowed(throttleKey)) { res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(Math.max(1, Math.ceil(loginLockRemainingMs(throttleKey) / 1000))) }); res.end(JSON.stringify({ error: loginLockMessage(throttleKey) })); return; } // S-22: a password big enough to be its own denial of service never // reaches the KDF. No account can hold one, so the answer is the one a // wrong password gets. if (typeof password !== 'string' || password.length > PASSWORD_MAX_LENGTH) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid credentials' })); return; } // S-22: this address's budget for hashing. Taken here rather than after // the wait, so a queued request reserves its hash instead of reaching // the end of the wait to find there is no room for it. if (!takeLoginKdfToken(clientIP)) { res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '2' }); res.end(JSON.stringify({ error: 'Too many login attempts. Try again shortly.' })); return; } // Per-account: a wait rather than a lock (S-19). The password check // happens on the other side of it. const accepted = afterLoginDelay(userKey, () => { try { finishLogin(req, res, { username, password, userKey, throttleKey, sourceKey: clientIP }); } catch (err) { refundLoginKdfToken(clientIP); safeErr(res, err); } }); if (!accepted) { // Nothing was hashed, so the reservation goes back. refundLoginKdfToken(clientIP); res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '2' }); res.end(JSON.stringify({ error: 'Too many login attempts. Try again shortly.' })); } } catch (err) { safeErr(res, err); } }); return; } if (url === '/api/auth/logout' && req.method === 'POST') { const token = getSessionToken(req); if (token) db.deleteSession(database, token); clearSessionCookie(res); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); return; } if (url === '/api/auth/me' && req.method === 'GET') { const session = checkAuth(req); if (!session) { sendUnauthorized(res, true); return; } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ userId: session.user_id, username: session.username, role: session.role, tileOrder: db.getUserTileOrder(database, session.user_id) }) ); return; } // ── Auth gate ───────────────────────────────────────────── const isLoginPage = url === '/login.html'; const isPublicAsset = !!url.match(/^\/(login\.js|icon-\d+\.png|favicon\.ico|icon\.svg|manifest\.json|sw\.js)$/); // The GitHub webhook authenticates itself via an HMAC signature; GitHub // cannot send a session cookie, so leaving it behind the session gate made // every delivery 401 (the whole auto-deploy chain was dead code). Its own // handler verifies GITHUB_WEBHOOK_SECRET + signature and refuses in worktree // mode, so it is safe to exempt here. const isWebhook = req.method === 'POST' && url === '/api/webhook/github'; // The eBay/Etsy OAuth callback arrives as a cross-site top-level redirect from the // provider, so the SameSite=Strict session cookie isn't sent and it can't pass the // session gate. Its handler authenticates via the one-time `state` we minted in the // admin-only /oauth/start, so it's safe to exempt here like the GitHub webhook. const isChannelOAuthCb = req.method === 'GET' && /^\/api\/channels\/(etsy|ebay)\/oauth\/callback(?:\?|$)/.test(url); // eBay's marketplace account-deletion endpoint. eBay calls it unauthenticated // from its own infrastructure — there is no session and no way to give it one — // and refuses to activate the production keyset until it answers. The GET is // proven by the challenge hash (which needs the shared verification token), and // the POST only ever erases data for a buyer eBay says has closed their account. const isEbayDeletion = (req.method === 'GET' || req.method === 'POST') && /^\/api\/channels\/ebay\/deletion(?:\?|$)/.test(url); // S-22: both of these were written as public endpoints and both sat behind // this gate, which runs first — so both answered 401 to everyone and neither // did what its own code says it does. // // /api/health calls checkAuth itself and reveals nothing but status, db, // uptime, version and the worktree flag without a session; platform, Node // version, memory and backup freshness stay admin-only. Behind the gate the // Dockerfile's HEALTHCHECK could never pass, so the container reported // unhealthy forever, and the admin branch inside the handler was unreachable. // // /api/csp-reports is sent by the browser without credentials by definition, // so behind the gate it silently discarded every violation report — the one // signal that would tell us an injected script had run. const isHealth = req.method === 'GET' && url === '/api/health'; const isCspReport = req.method === 'POST' && url === '/api/csp-reports'; if ( !isLoginPage && !isPublicAsset && !isWebhook && !isChannelOAuthCb && !isEbayDeletion && !isHealth && !isCspReport ) { if (db.countUsers(database) === 0) { if (url.startsWith('/api/')) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'setup_required' })); } else { res.writeHead(302, { Location: '/login.html' }); res.end(); } return; } const authUser = checkAuth(req); if (!authUser) { sendUnauthorized(res, url.startsWith('/api/')); return; } req.authUser = authUser; } // ── Own start-screen tile order (any authenticated user, only their own) ── // body { order: ["move", "harvest", …] } or { order: null } for the default. if (url === '/api/me/tile-order' && req.method === 'POST') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const order = data && data.order === null ? null : data && data.order; try { jsonOk(res, { tileOrder: db.setUserTileOrder(database, req.authUser.user_id, order) }); } catch (err) { if (err.message === 'invalid tile order') jsonErr(res, 400, err.message); else safeErr(res, err); } }); return; } // ── Username list (any authenticated user) ──────────────── if (url === '/api/usernames' && req.method === 'GET') { const users = db.listUsersPublic(database); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(users)); return; } // ── User management (admin only) ────────────────────────── if (url === '/api/users' && req.method === 'GET') { if (!req.authUser || req.authUser.role !== 'admin') { res.writeHead(403, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'admin required' })); return; } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(db.listUsers(database))); return; } if (url === '/api/users' && req.method === 'POST') { if (!req.authUser || req.authUser.role !== 'admin') { res.writeHead(403, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'admin required' })); return; } jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { const { username, password, role } = 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, role || 'user'); log('info', 'User created', { actor: req.authUser.username, newUser: username, role: role || 'user' }); jsonOk(res, user); } catch (e) { safeErr(res, e); } }); return; } if (url.match(/^\/api\/users\/\d+$/) && req.method === 'DELETE') { if (!req.authUser || req.authUser.role !== 'admin') { res.writeHead(403, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'admin required' })); return; } const userId = parseInt(url.split('/').pop()); if (userId === req.authUser.user_id) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Cannot delete yourself' })); return; } db.deleteUser(database, userId); clearCaldavAuthCache(); log('info', 'User deleted', { actor: req.authUser.username, deletedUserId: userId }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); return; } // PATCH /api/users/:id — admin sets per-user capabilities (can_ship, can_release) if (url.match(/^\/api\/users\/\d+$/) && req.method === 'PATCH') { if (requireAdmin(req, res)) return; const userId = parseInt(url.split('/').pop(), 10); jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { if (data && data.canShip !== undefined) { db.setUserCanShip(database, userId, !!data.canShip); log('info', 'User shipping permission updated', { actor: req.authUser.username, userId, canShip: !!data.canShip }); } if (data && data.canRelease !== undefined) { db.setUserCanRelease(database, userId, !!data.canRelease); // Logged for the same reason the shipping grant is: this one decides who // may change a number the public sees. log('info', 'User release permission updated', { actor: req.authUser.username, userId, canRelease: !!data.canRelease }); } jsonOk(res, { ok: true }); } catch (err) { safeErr(res, err); } }); return; } // PATCH /api/auth/password — change own password (any authenticated user) if (url === '/api/auth/password' && req.method === 'PATCH') { const session = checkAuth(req); if (!session) { sendUnauthorized(res, true); return; } jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } if (!data || !data.currentPassword || !data.newPassword) { jsonErr(res, 400, 'currentPassword and newPassword required'); return; } // S-22: currentPassword is verified with scrypt too. Behind a login, so // not the flood from /api/auth/login — but the same cap, because two // bounds that disagree are how one of them stops being true. if (typeof data.currentPassword !== 'string' || data.currentPassword.length > PASSWORD_MAX_LENGTH) { jsonErr(res, 400, 'currentPassword and newPassword required'); return; } if (data.newPassword.length < 8 || data.newPassword.length > PASSWORD_MAX_LENGTH) { jsonErr(res, 400, 'New password must be at least 8 characters'); return; } const user = db.getUserByUsername(database, session.username); if (!user) { jsonErr(res, 404, 'User not found'); return; } if (!db.verifyPassword(user.hash, user.salt, data.currentPassword)) { jsonErr(res, 401, 'Current password is incorrect'); return; } const { salt, hash } = db.hashPassword(data.newPassword); db.updateUserPassword(database, user.id, hash, salt); // S-11: kill every credential that speaks for this user, not just the // browser sessions — an OAuth refresh token outlives a password change by // 30 days otherwise, and /mcp honours it with the user's live role. Then // issue a fresh session so the person who just changed it stays logged in. db.revokeUserCredentials(database, user.id); clearCaldavAuthCache(); const newToken = db.createSession(database, user.id); setSessionCookie(res, newToken); jsonOk(res); }); return; } // PATCH /api/users/:id/password — admin reset any user's password if (url.match(/^\/api\/users\/\d+\/password$/) && req.method === 'PATCH') { if (requireAdmin(req, res)) return; const userId = parseInt(url.split('/')[3]); jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } if (!data || !data.newPassword) { jsonErr(res, 400, 'newPassword required'); return; } if (data.newPassword.length < 8 || data.newPassword.length > PASSWORD_MAX_LENGTH) { jsonErr(res, 400, 'New password must be at least 8 characters'); return; } db.resetUserPassword(database, userId, data.newPassword); // S-11: sessions *and* OAuth grants — see the self-service path above. // An admin resetting somebody else's password is the stronger case: they // are locking an account they no longer trust. db.revokeUserCredentials(database, userId); clearCaldavAuthCache(); jsonOk(res); }); return; } // POST /api/csp-reports — log CSP violations. Public endpoint (the // browser sends these without credentials), so we hard-cap body, rate- // limit per IP, and never echo the body in errors. if (req.method === 'POST' && req.url === '/api/csp-reports') { if (!checkRate(req, 30)) { res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '60' }); res.end('{"error":"too_many_requests"}'); return; } const CSP_BODY_MAX = 10000; let body = ''; let aborted = false; req.on('data', (c) => { if (aborted) return; // Reject BEFORE appending so a single oversized chunk can't land // in memory just to be discarded. if (body.length + c.length > CSP_BODY_MAX) { aborted = true; try { res.writeHead(413, { 'Content-Type': 'application/json' }); res.end('{"error":"payload_too_large"}'); } catch (_) { /* response may already be in flight */ } req.destroy(); return; } body += c; }); req.on('end', () => { if (aborted) return; try { const report = JSON.parse(body); // S-13: log the three fields a CSP report is actually read for, as // strings, rather than whatever object the client sent. log() refuses // to have its own fields overwritten now, but an unbounded set of // attacker-chosen keys in the log stream is still noise worth not // having — and a report is a fixed shape, so nothing is lost. log('warn', 'CSP violation', cspReportFields(report['csp-report'] || report)); } catch (e) {} res.writeHead(204); res.end(); }); return; } // POST /api/lab-history/timestamp — ein Admin holt den Zeitstempel gleich, // statt auf den naechsten Tag zu warten. Nichts Neues seit dem letzten: es // bleibt bei dem. if (req.method === 'POST' && req.url === '/api/lab-history/timestamp') { if (requireAdmin(req, res)) return; labStamps.run({ force: true }).then( (r) => jsonOk(res, r), (e) => safeErr(res, e) ); return; } // GET /api/lab-book/archive — das Laborbuch als ZIP fuers Archiv, lesbar und // pruefbar ohne diese Anwendung (scripts/lab-archive.js): dieselbe Auswahl // wie /api/lab-book, dazu die Fotos samt Originalen, der ganze Verlauf mit // seiner Pruefkette und die Zeitstempel. Nur Admins, und nur eins zur Zeit: // es liest jedes Foto zweimal und kann Gigabytes gross werden. if (req.method === 'GET' && url === '/api/lab-book/archive') { if (requireAdmin(req, res)) return; const q = new URL(req.url, 'http://localhost').searchParams; const frage = {}; for (const name of ['from', 'to']) { const tag = q.get(name); if (tag === null) continue; const vd = validateDay(tag, name); if (vd) { jsonErr(res, 400, vd); return; } frage[name] = tag; } const paket = q.get('workPackage'); if (paket !== null) { if (!/^\d{1,15}$/.test(paket)) { jsonErr(res, 400, 'workPackage must be a whole number'); return; } frage.workPackageId = Number(paket); } if (frage.from && frage.to && frage.from > frage.to) { jsonErr(res, 400, 'from must not be after to'); return; } if (labArchiveRunning) { jsonErr(res, 409, 'an archive is already being written'); return; } // Was schiefgehen kann, bevor das erste Byte rausgeht, bekommt eine // richtige Antwort; danach kann der Browser nur noch einen Abbruch sehen. let buch; let name; try { buch = db.labBook(database, frage); name = labArchive.fileName(frage, new Date(), buch.workPackages); } catch (err) { safeErr(res, err); return; } labArchiveRunning = true; res.writeHead(200, { 'Content-Type': 'application/zip', 'Content-Disposition': 'attachment; filename="' + name + '"', 'Cache-Control': 'no-store' }); labArchive .writeLabArchive({ out: res, dbApi: db, database, dataDir: path.join(DIR, 'data'), opts: frage, book: buch, createdBy: req.authUser.username }) .then( (r) => { res.end(); log('info', 'Lab book archive written', { entries: r.entries, bytes: r.bytes, photosMismatched: r.photos.mismatched.length, photosMissing: r.photos.missing.length, actor: req.authUser.username }); }, (e) => { // Die Koepfe sind schon raus; ein abgebrochener Download ist das // Einzige, was der Browser noch erfahren kann. Hat ihn der Browser // selbst abgebrochen, ist das kein Fehler des Servers. if (e.message === 'zip: output closed') { log('info', 'Lab book archive download aborted', { actor: req.authUser.username }); } else { log('error', 'Lab book archive failed', { error: e.message }); } res.destroy(); } ) .finally(() => { labArchiveRunning = false; }); return; } // GET /api/health if (req.method === 'GET' && req.url === '/api/health') { // S-22: reachable without a session now, so it gets the same per-IP budget // /api/csp-reports has. It touches the database on every call. if (!checkRate(req, 60)) { res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '60' }); res.end('{"error":"too_many_requests"}'); return; } let dbOk = false; try { database.prepare('SELECT 1').get(); dbOk = true; } catch (e) { log('error', 'Health check: database unreachable', { error: e.message }); } const mem = process.memoryUsage(); // Public: minimal status only. Detailed info (Node version, platform, // memory, backup status) is admin-only — workers don't need to know // exact Node versions to triage their own work, and that detail aids // targeted exploitation if a CVE later hits a specific version. const authUser = checkAuth(req); const health = { status: dbOk ? 'ok' : 'degraded', db: dbOk ? 'connected' : 'error', uptime: Math.round(process.uptime()), version: require('./package.json').version, worktree: WORKTREE_MODE }; if (authUser && authUser.role === 'admin') { health.platform = process.platform; health.nodeVersion = process.version; health.code = codeState; health.sseClients = sseClients.size; health.memory = { rss: Math.round(mem.rss / 1024 / 1024), heapUsed: Math.round(mem.heapUsed / 1024 / 1024), heapTotal: Math.round(mem.heapTotal / 1024 / 1024) }; // Backup freshness — degraded if last successful backup is older than // 26h or if the most recent attempt failed. const backupStatus = readBackupStatus(); const hasSuccess = backupStatus.lastSuccess && backupStatus.lastSuccess.time; let ageHours = null; let fresh = false; if (hasSuccess) { ageHours = (Date.now() - new Date(backupStatus.lastSuccess.time).getTime()) / 3600000; fresh = ageHours < 26; } const lastAttemptOk = !backupStatus.lastAttempt || backupStatus.lastAttempt.success !== false; health.backup = { status: fresh && lastAttemptOk ? 'ok' : 'stale', lastSuccess: backupStatus.lastSuccess || null, lastFailure: backupStatus.lastFailure || null, lastAttempt: backupStatus.lastAttempt || null, ageHours: ageHours === null ? null : Math.round(ageHours * 10) / 10 }; // R-06: surface the off-site sync marker so external monitors can flag // a stale off-site copy. The marker is written by the rsync cron (see // DEPLOYMENT.md → Off-site backups). Missing marker = "unknown" — the // operator may not have configured off-site sync yet. const offSite = { lastSync: null, ageMinutes: null, target: null, bytes: null }; try { if (fs.existsSync(OFFSITE_MARKER_FILE)) { const parsed = JSON.parse(fs.readFileSync(OFFSITE_MARKER_FILE, 'utf8')); if (parsed && parsed.time) { offSite.lastSync = parsed.time; offSite.ageMinutes = Math.round((Date.now() - new Date(parsed.time).getTime()) / 60000); offSite.target = parsed.target || null; offSite.bytes = typeof parsed.bytes === 'number' ? parsed.bytes : null; } } } catch (e) { log('warn', 'Could not read off-site sync marker', { error: e.message }); } health.backup.offSite = offSite; // The nightly second copy of the photos (backups/photos) and the stick. // The stick also writes the off-site marker above, so a stale stick // backup shows there as well. health.backup.photos = backupStatus.photos || null; health.backup.usb = usbBackup.health(); // Ob der Verlauf des Laborbuchs noch so ist, wie er geschrieben wurde, und // der juengste Zeitstempel darauf. Eine gebrochene Kette heisst: jemand // hat die Datei an der Anwendung vorbei geaendert. try { health.labChain = labChainHealth(); } catch (e) { // Eine Datenbank, die gerade nicht antwortet, soll den Rest der // Antwort nicht mitnehmen; sie ist dann ohnehin degraded. health.labChain = { ok: false, error: e.message }; } // Degraded bei einer gebrochenen Kette und bei einem Zeitstempel, dessen // Signatur nicht haelt. Nicht bei einem, den die Anwendung nur nicht // pruefen kann (unchecked): dann bringt Node bloss die Wurzel der Stelle // nicht mit. const labSignaturen = health.labChain.signatures; if ((!health.labChain.ok || (labSignaturen && labSignaturen.bad > 0)) && health.status === 'ok') { health.status = 'degraded'; } if (health.backup.status !== 'ok' && health.status === 'ok') { health.status = 'degraded'; } // Off-site marker > 26h old (or missing) is a soft degraded signal — // we still surface it but only flip top-level status if backups themselves // are otherwise OK. If backups are already stale, the existing status // already reflects that. if (offSite.ageMinutes !== null && offSite.ageMinutes > 26 * 60 && health.status === 'ok') { health.status = 'degraded'; } } res.writeHead(dbOk ? 200 : 503, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(health)); return; } // R-17: GET /api/health/full — admin-only ops dashboard payload. // Each section is wrapped in try/catch so a single broken probe (e.g. // statfsSync on Windows) doesn't blow up the whole endpoint; the field // becomes null instead. Public /api/health stays minimal. if (req.method === 'GET' && req.url === '/api/health/full') { if (requireAdmin(req, res)) return; const out = { status: 'ok', uptime: Math.round(process.uptime()), version: require('./package.json').version, code: codeState, nodeVersion: process.version, platform: process.platform, timestamp: new Date().toISOString() }; out.labChain = (() => { try { return labChainHealth(); } catch (e) { return { error: e.message }; } })(); // disk: backup dir + photos dir free/used MB out.disk = (() => { const probe = (dir) => { try { if (!fs.existsSync(dir)) return null; const stats = fs.statfsSync(dir); const free = Number(stats.bavail) * Number(stats.bsize); const total = Number(stats.blocks) * Number(stats.bsize); return { freeMB: Math.round(free / 1e6), totalMB: Math.round(total / 1e6), usedMB: Math.round((total - free) / 1e6) }; } catch (e) { return { error: e.message }; } }; let photoUsed = null; try { photoUsed = Math.round(_ensurePhotoDirSize(false) / 1e6); } catch (_) { /* ignore */ } const photos = probe(PHOTO_DIR); return { backups: probe(BACKUP_DIR), photos, photosUsedMB: photoUsed, photosCapMB: Math.round(PHOTO_DIR_MAX_BYTES / 1e6), // What has to stay free after an original, and whether the disk is // below twice that, where the log starts to warn. photosReserveMB: Math.round(PHOTO_MIN_FREE_BYTES / 1e6), photosLow: photos && typeof photos.freeMB === 'number' ? photos.freeMB * 1e6 < 2 * PHOTO_MIN_FREE_BYTES : null }; })(); // db: file size, WAL size, last vacuum age out.db = (() => { try { const dbStats = fs.statSync(DB_FILE); let walMB = null; try { walMB = Math.round(fs.statSync(DB_FILE + '-wal').size / 1e6); } catch (_) { /* WAL file may have been checkpointed away */ } const backupStatus = readBackupStatus(); const lastVacuum = backupStatus.lastSuccess && backupStatus.lastSuccess.time; const lastVacuumAgeHours = lastVacuum ? Math.round(((Date.now() - new Date(lastVacuum).getTime()) / 3600000) * 10) / 10 : null; return { sizeMB: Math.round(dbStats.size / 1e6), walMB, lastVacuum: lastVacuum || null, lastVacuumAgeHours, ok: true }; } catch (e) { return { ok: false, error: e.message }; } })(); // backup: reuse the same shape as /api/health out.backup = (() => { try { const bs = readBackupStatus(); const hasSuccess = bs.lastSuccess && bs.lastSuccess.time; const ageHours = hasSuccess ? Math.round(((Date.now() - new Date(bs.lastSuccess.time).getTime()) / 3600000) * 10) / 10 : null; let offSite = null; if (fs.existsSync(OFFSITE_MARKER_FILE)) { try { const parsed = JSON.parse(fs.readFileSync(OFFSITE_MARKER_FILE, 'utf8')); if (parsed && parsed.time) { offSite = { lastSync: parsed.time, ageMinutes: Math.round((Date.now() - new Date(parsed.time).getTime()) / 60000), target: parsed.target || null, bytes: typeof parsed.bytes === 'number' ? parsed.bytes : null }; } } catch (_) { /* corrupted marker; ignore */ } } return { lastSuccess: bs.lastSuccess || null, lastFailure: bs.lastFailure || null, lastAttempt: bs.lastAttempt || null, ageHours, offSite }; } catch (e) { return { error: e.message }; } })(); // tls: certificate expiry parsed from certs/server.crt out.tls = (() => { try { if (!fs.existsSync(CERT_CRT)) return { configured: false }; const certPem = fs.readFileSync(CERT_CRT, 'utf8'); const x509 = new crypto.X509Certificate(certPem); const validTo = x509.validTo; const expiryMs = new Date(validTo).getTime(); const daysLeft = Number.isFinite(expiryMs) ? Math.round((expiryMs - Date.now()) / 86400000) : null; const isLE = LE_ISSUER.test(x509.issuer || ''); return { configured: true, expiry: validTo, daysLeft, type: isLE ? 'lets-encrypt' : 'self-signed' }; } catch (e) { return { error: e.message }; } })(); // printBridge: configured flag + last-known cached printer state. // R-20: also surface queueStuck so monitors can flag spooler stalls // (paper out, jam, paused job, OfflineLogonRequired) even when the // printer itself reports online. out.printBridge = (() => { try { const cfg = getEffectiveBridgeConfig(); const queueStuck = Number.isFinite(_printerQueueStuckCache) ? _printerQueueStuckCache : 0; return { configured: !!cfg, source: cfg ? cfg.source : null, lastReachable: _printerStatusCacheTime > 0 ? new Date(_printerStatusCacheTime).toISOString() : null, printerOnline: _printerStatusCache === true, queueStuck }; } catch (e) { return { error: e.message }; } })(); // R-20: queueStuck > 0 is a degraded signal — paper out / jam / blocked // job all sit silently in the spooler with PrinterStatus still reporting // online. Only flip when the bridge is actually configured (otherwise // queueStuck stays 0 and this branch never fires). if ( out.printBridge && out.printBridge.configured && Number.isFinite(out.printBridge.queueStuck) && out.printBridge.queueStuck > 0 && out.status === 'ok' ) { out.status = 'degraded'; } // caldav: filesystem-based — count calendars + best-effort last-sync out.caldav = (() => { try { const cals = listCalendars(); let lastSync = null; try { const row = database .prepare('SELECT MAX(caldav_synced) AS m FROM manual_tasks WHERE caldav_synced IS NOT NULL') .get(); if (row && row.m) lastSync = row.m; } catch (_) { /* schema variation; ignore */ } return { calendars: cals.length, lastSync }; } catch (e) { return { error: e.message }; } })(); // mcp: live session count out.mcp = (() => { try { return { sessions: mcpSessions.size }; } catch (e) { return { error: e.message }; } })(); // notifications: backlog + how many older-than-30d unread out.notifications = (() => { try { const total = database.prepare('SELECT COUNT(*) AS c FROM notifications').get(); const oldUnread = database .prepare("SELECT COUNT(*) AS c FROM notifications WHERE read = 0 AND created < datetime('now', '-30 days')") .get(); return { total: total ? total.c : 0, unreadOlderThan30d: oldUnread ? oldUnread.c : 0 }; } catch (e) { return { error: e.message }; } })(); // auth: active lockouts + expired-session count out.auth = (() => { try { const now = Date.now(); let activeLockouts = 0; for (const entry of loginAttempts.values()) { if (entry.lockedUntil && entry.lockedUntil > now) activeLockouts++; } for (const entry of loginAttemptsPerUser.values()) { if (entry.lockedUntil && entry.lockedUntil > now) activeLockouts++; } const expiredRow = database.prepare("SELECT COUNT(*) AS c FROM sessions WHERE expires < datetime('now')").get(); const totalSessions = database.prepare('SELECT COUNT(*) AS c FROM sessions').get(); return { activeLockouts, expiredSessions: expiredRow ? expiredRow.c : 0, totalSessions: totalSessions ? totalSessions.c : 0 }; } catch (e) { return { error: e.message }; } })(); // deploy: last webhook auto-deploy attempt sentinel (R-14) try { const dep = readDeployState(); if (dep) out.deploy = dep; } catch (_) { /* ignore */ } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(out)); return; } // SSE endpoint for real-time sync if (req.method === 'GET' && req.url === '/api/events') { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); // Der Stand der App-Dateien reist mit. Nach einem Deploy startet der Server // neu, jedes offene Telefon verbindet sich wieder und erfährt es hier, // ohne auf die nächste Abfrage zu warten. res.write('data: ' + JSON.stringify({ type: 'connected', build: appBuild() }) + '\n\n'); sseClients.add(res); req.on('close', () => sseClients.delete(res)); return; } // POST /api/internal/notify — MCP server triggers SSE broadcast, localhost only if (req.method === 'POST' && url === '/api/internal/notify') { const ip = req.socket.remoteAddress; if (ip !== '127.0.0.1' && ip !== '::1' && ip !== '::ffff:127.0.0.1') { res.writeHead(403); res.end(); return; } broadcastSSE(null); res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('ok'); return; } // GET /api/data if (req.method === 'GET' && url === '/api/data') { // P-04: ETag short-circuit. The DB already maintains a monotonic // data_version counter (db.incrementDataVersion is fired on every write). // We expose it as ETag "v" and honor If-None-Match so phones polling // every 30 s skip the entire readAll + JSON.stringify when nothing changed. // The notification unread count is per-user and not part of data_version, // so we mix the user_id and the unread count into the ETag to keep it // accurate per client. (Otherwise a notification arriving for user A // would be missed by user B's still-cached response.) let unread = 0; try { unread = db.countUnreadNotifications(database, req.authUser.user_id); } catch { /* meta absent — fall through to 0 */ } let version = 0; try { version = db.getDataVersion(database); } catch { /* meta absent — fall through to 0 */ } // Der Stand der App-Dateien gehört mit hinein. Ohne ihn bekäme ein Telefon // nach einem Deploy weiter 304 — die Daten sind ja dieselben —, und die // Nachricht "hier läuft alter Code" käme nie an. const build = appBuild(); const etag = '"v' + version + '-u' + (req.authUser.user_id || 0) + '-n' + unread + '-b' + build + '"'; if (req.headers['if-none-match'] === etag) { res.writeHead(304, { ETag: etag, 'Cache-Control': 'private, no-cache' }); res.end(); return; } const payload = readData(); // The tasks somebody marked "only visible to the assigned person". The // checkbox has to be honoured here, not only in autoPushTaskCaldav's // decision to skip the shared calendar. readData() selects every row // without a filter, so one GET would return every private note to any // logged-in account, and notes on a task list can be personal. The client // does not consult the flag outside the edit dialog either, so nothing // downstream covers for it. // // Filtered here and not inside readAll(): the admin write-back path reads // the very same function and needs the rows whole. The ETag above already // carries the user id, so a per-user payload does not need a cache change. if (Array.isArray(payload.manualTasks)) { const isAdmin = req.authUser && req.authUser.role === 'admin'; const wer = req.authUser && req.authUser.username; payload.manualTasks = payload.manualTasks.filter((t) => db.canUserSeeTask(database, t, wer, isAdmin)); } // Per-user unread notification count so the bell badge can update // on every sync without a separate request. payload.notifications = { unread }; // Expose label dimensions so the client's ZPL builder can adapt // without a separate round-trip. Static for the process lifetime. payload.labelDims = { widthDots: LABEL_WIDTH_DOTS, heightDots: LABEL_HEIGHT_DOTS }; // One boolean, so the harvest form knows whether to offer "release for sale". // It rides along here rather than on its own endpoint because the form opens // from a barcode scan — a request at that moment costs the very second the // scan is meant to save. // // Only the permission is left to ask about. Release itself is no longer a // mode that can be off: what may be sold is always a hand-entered decision. // A worker without the permission would otherwise get a field that fails on // every save — the same "input that does nothing" the field was designed to // avoid. This is presentation only; the check that matters runs in // addHarvestRelease. payload.harvestRelease = { on: db.mayRelease(req.authUser) }; // Welcher Stand der App hier ausgeliefert wird; das Telefon vergleicht ihn // mit dem, den es geladen hat (siehe appBuild()). payload.build = build; res.writeHead(200, { 'Content-Type': 'application/json', ETag: etag, 'Cache-Control': 'private, no-cache' }); res.end(JSON.stringify(payload)); return; } // GET /api/scan-log — paginated scan log history if (req.method === 'GET' && url === '/api/scan-log') { const params = new URL(req.url, 'http://x').searchParams; const limit = Math.max(1, Math.min(parseInt(params.get('limit'), 10) || 200, 1000)); const offset = Math.max(0, parseInt(params.get('offset'), 10) || 0); const batch = params.get('batch') || null; const action = params.get('action') || null; let where = '1=1'; const args = []; if (batch) { where += ' AND s.batch=?'; args.push(batch); } if (action) { where += ' AND s.action=?'; args.push(action); } const total = database.prepare('SELECT COUNT(*) as total FROM scan_log s WHERE ' + where).get(...args).total; const rows = database .prepare( 'SELECT s.*, u.username FROM scan_log s LEFT JOIN users u ON s.user_id=u.id WHERE ' + where + ' ORDER BY s.id DESC LIMIT ? OFFSET ?' ) .all(...args, limit, offset); jsonOk(res, { items: rows.map((r) => ({ id: r.id, time: r.time, action: r.action, batch: r.batch, bag: r.bag, from: r.from, to: r.to, species: r.species, strain: r.strain, userId: r.user_id, user: r.username || null })), total, limit, offset }); return; } // Blockbuch: gute Blöcke und Notizen je Block, mit der Herkunft jedes Blocks. // Offen für jeden angemeldeten Benutzer wie das Ernten selbst; eine Notiz // löschen darf, wer sie geschrieben hat, oder ein Admin. if (req.method === 'GET' && url === '/api/block-book') { try { jsonOk(res, { entries: db.getBlockBook(database) }); } catch (err) { safeErr(res, err); } return; } if (req.method === 'POST' && url === '/api/block-notes/good') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } if (!data || typeof data.bag !== 'string' || !data.bag.trim()) { jsonErr(res, 400, 'bag required'); return; } try { if (!db.setBlockGood(database, data.bag, data.on !== false, req.authUser && req.authUser.username)) { jsonErr(res, 404, 'bag not found'); return; } broadcastSSE(res); jsonOk(res, { ok: true }); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'POST' && url === '/api/block-notes/comments') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const text = data && typeof data.text === 'string' ? data.text.trim() : ''; if (!data || typeof data.bag !== 'string' || !data.bag.trim() || !text) { jsonErr(res, 400, 'bag and text required'); return; } if (text.length > 2000) { jsonErr(res, 400, 'text too long (max 2000)'); return; } const flush = data.flush == null ? null : parseInt(data.flush, 10); if (flush !== null && !(flush >= 1 && flush <= 100)) { jsonErr(res, 400, 'flush must be 1-100'); return; } try { const id = db.addBlockComment(database, data.bag, text, req.authUser && req.authUser.username, flush); if (!id) { jsonErr(res, 404, 'bag not found'); return; } broadcastSSE(res); jsonOk(res, { ok: true, id }); } catch (err) { safeErr(res, err); } }); return; } const blockCommentMatch = url.match(/^\/api\/block-notes\/comments\/(\d+)$/); if (req.method === 'DELETE' && blockCommentMatch) { try { const c = db.getBlockComment(database, parseInt(blockCommentMatch[1], 10)); if (!c) { jsonErr(res, 404, 'not found'); return; } const isAdmin = req.authUser && req.authUser.role === 'admin'; const isOwner = !!(c.by && req.authUser && req.authUser.username === c.by); if (!isAdmin && !isOwner) { jsonErr(res, 403, 'forbidden'); return; } db.deleteBlockComment(database, c.id); broadcastSSE(res); jsonOk(res, { ok: true }); } catch (err) { safeErr(res, err); } return; } // GET /api/harvests — paginated harvest history if (req.method === 'GET' && url === '/api/harvests') { const params = new URL(req.url, 'http://x').searchParams; const limit = Math.max(1, Math.min(parseInt(params.get('limit'), 10) || 200, 1000)); const offset = Math.max(0, parseInt(params.get('offset'), 10) || 0); const batch = params.get('batch') || null; let where = '1=1'; const args = []; if (batch) { where += ' AND batch=?'; args.push(batch); } const total = database.prepare('SELECT COUNT(*) as total FROM harvests WHERE ' + where).get(...args).total; const rows = database .prepare('SELECT * FROM harvests WHERE ' + where + ' ORDER BY id DESC LIMIT ? OFFSET ?') .all(...args, limit, offset); jsonOk(res, { items: rows.map((r) => ({ id: r.id, time: r.time, batch: r.batch, bag: r.bag, species: r.species, strain: r.strain, grams: r.grams, flush: r.flush })), total, limit, offset }); return; } // GET /api/kpi-snapshots — historical KPI data for trend analysis if (req.method === 'GET' && url === '/api/kpi-snapshots') { const params = new URL(req.url, 'http://x').searchParams; const limit = params.get('limit') ? Math.max(1, Math.min(parseInt(params.get('limit'), 10) || 90, 365)) : null; const rows = db.getKpiSnapshots(database, limit); jsonOk(res, { items: rows }); return; } // POST /api/kpi-snapshots/now — manually trigger a KPI snapshot for today if (req.method === 'POST' && url === '/api/kpi-snapshots/now') { if (requireAdmin(req, res)) return; try { const result = db.snapshotDailyKPIs(database, { force: true }); jsonOk(res, result); } catch (e) { jsonErr(res, 500, e.message); } return; } // POST /api/data — full-state save (used by client saveData()) if (req.method === 'POST' && url === '/api/data') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { writeData(data, req.authUser); const version = db.getDataVersion(database); broadcastSSE(res); jsonOk(res, { version }); try { autoSyncAllCaldav(data); } catch (ce) { log('error', 'CalDAV auto-sync failed', { error: ce.message }); } } catch (err) { safeErr(res, err); } }); return; } // ── ATOMIC REST ENDPOINTS ──────────────────────────────────── // -- Mushroom Strains -- if (req.method === 'GET' && url === '/api/mushroom-strains') { try { jsonOk(res, db.listMushroomStrains(database)); } catch (err) { safeErr(res, err); } return; } if (req.method === 'POST' && url === '/api/mushroom-strains') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, ['name', 'kuerzel']); if (vr) { jsonErr(res, 400, vr); return; } const vs = validateMushroomStrain(data); if (vs) { jsonErr(res, 400, vs); return; } try { const id = db.createMushroomStrain(database, data); broadcastSSE(res); jsonOk(res, { id }); } catch (err) { safeErr(res, err); } }); return; } const msMatch = url.match(/^\/api\/mushroom-strains\/(\d+)$/); if (req.method === 'PATCH' && msMatch) { const id = parseInt(msMatch[1], 10); jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vs = validateMushroomStrain(data); if (vs) { jsonErr(res, 400, vs); return; } try { db.updateMushroomStrain(database, id, data); broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'DELETE' && msMatch) { if (requireAdmin(req, res)) return; const id = parseInt(msMatch[1], 10); try { db.deleteMushroomStrain(database, id); broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } return; } // -- Stämme je Pilzsorte (v87) -- // Anlegen und Umbenennen darf jeder, der auch Kulturen anlegt: mitten in der // Inventur steht das Glas in der Hand, und ein Umweg über den Admin wäre der // Grund, doch wieder frei zu tippen. Löschen bleibt beim Admin, wie bei der // Sorte. const stammMatch = url.match(/^\/api\/staemme\/(\d+)$/); if (req.method === 'GET' && url === '/api/staemme') { try { jsonOk(res, db.listStaemme(database)); } catch (err) { safeErr(res, err); } return; } if (req.method === 'POST' && url === '/api/staemme') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const fehler = validateRequired(data, ['strainId', 'name']) || validateTypes(data, { strainId: 'number', name: 'string', notes: 'string' }) || validateLengths(data, { name: 60, notes: 500 }); if (fehler) { jsonErr(res, 400, fehler); return; } try { const id = db.createStamm(database, data); broadcastSSE(res); jsonOk(res, { id }); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'PATCH' && stammMatch) { const id = parseInt(stammMatch[1], 10); jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } // validateRequired ohne Pflichtfelder prüft nur, dass ein Objekt ankam — // ein Rumpf "null" ließe validateTypes sonst werfen. const fehler = validateRequired(data, []) || validateTypes(data, { name: 'string', notes: 'string' }) || validateLengths(data, { name: 60, notes: 500 }); if (fehler) { jsonErr(res, 400, fehler); return; } try { db.updateStamm(database, id, data); broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'DELETE' && stammMatch) { if (requireAdmin(req, res)) return; try { db.deleteStamm(database, parseInt(stammMatch[1], 10)); broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } return; } // -- Substrate batches (v67) -- // The mix is priced and booked entirely here: the client sends a recipe and a // target amount, never a composition or a set of inventory deltas. POST // /api/batches still accepts client-computed deltas for the older per-bag path, // which means a browser can post a Charge whose booked consumption disagrees // with its own recipe. There is no reason to carry that forward into the mix. // Anchored, not a prefix match: /api/substrate-batches/SUB-01 is a different // route and a startsWith would have answered it with the whole list. if (req.method === 'GET' && /^\/api\/substrate-batches(\?|$)/.test(req.url)) { try { const openOnly = /[?&]open=1(&|$)/.test(req.url); jsonOk(res, db.listSubstrateBatches(database, { openOnly })); } catch (err) { safeErr(res, err); } return; } // The mixes the Ansatz form offers. Read-only here; they are kept in the // recipes table. if (req.method === 'GET' && req.url === '/api/recipes') { try { jsonOk(res, db.getAllRecipes(database)); } catch (err) { safeErr(res, err); } return; } // Restfeuchte der Sackware (Pellets, Kleie). Lesen darf jeder, der ansetzt; // ändern nur ein Admin, weil es das Wasser jedes künftigen Ansatzes ändert. if (req.method === 'GET' && req.url === '/api/substrate-moisture') { try { jsonOk(res, db.getResidualMoisture(database)); } catch (err) { safeErr(res, err); } return; } if (req.method === 'POST' && req.url === '/api/substrate-moisture') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { const r = db.setResidualMoisture(database, data || {}); log('info', 'Residual moisture updated', { actor: req.authUser.username }); broadcastSSE(res); jsonOk(res, r); } catch (err) { if (/Restfeuchte/.test(err.message)) { jsonErr(res, 400, err.message); return; } safeErr(res, err); } }); return; } const subGetMatch = req.url.match(/^\/api\/substrate-batches\/([^/?]+)$/); if (req.method === 'GET' && subGetMatch) { try { const one = db.getSubstrateBatch(database, decodeURIComponent(subGetMatch[1])); if (!one) { jsonErr(res, 404, 'not found'); return; } jsonOk(res, one); } catch (err) { safeErr(res, err); } return; } const subOffMatch = req.url.match(/^\/api\/substrate-batches\/([^/?]+)\/write-off$/); if (req.method === 'POST' && subOffMatch) { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vlen = validateLengths(data || {}, { note: 500 }); if (vlen) { jsonErr(res, 400, vlen); return; } try { const userId = req.authUser ? req.authUser.user_id : null; const r = db.writeOffSubstrateBatch( database, decodeURIComponent(subOffMatch[1]), (data && data.note) || null, userId ); if (!r) { jsonErr(res, 404, 'not found'); return; } broadcastSSE(res); jsonOk(res, r); } catch (err) { safeErr(res, err); } }); return; } // Der Kommentar am Ansatz. Eigene Route wie die Messung nebenan: er wird // nachgetragen, wenn etwas aufgefallen ist, und nicht beim Anlegen mitgeschickt. const subNoteMatch = req.url.match(/^\/api\/substrate-batches\/([^/?]+)\/notes$/); if (req.method === 'POST' && subNoteMatch) { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vlen = validateLengths(data || {}, { notes: 500 }); if (vlen) { jsonErr(res, 400, vlen); return; } try { const r = db.setSubstrateNotes(database, decodeURIComponent(subNoteMatch[1]), data ? data.notes : ''); if (!r) { jsonErr(res, 404, 'not found'); return; } broadcastSSE(res); jsonOk(res, r); } catch (err) { safeErr(res, err); } }); return; } // Die nachgemessene Feuchte. Eigene Route statt eines Feldes im Ansatz-POST: // gemessen wird nach dem Mischen, oft von jemand anderem und manchmal erst am // nächsten Tag, und der Ansatz selbst ist zu dem Zeitpunkt längst gebucht. const subRhMatch = req.url.match(/^\/api\/substrate-batches\/([^/?]+)\/moisture$/); if (req.method === 'POST' && subRhMatch) { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { const userId = req.authUser ? req.authUser.user_id : null; // Ein fehlendes Feld löscht die Messung — das ist der Weg zurück, wenn // sie an den falschen Ansatz geriet. Die Grenzen prüft die DB-Schicht, // damit sie für jeden Aufrufer gelten und nicht nur für diesen. const r = db.setSubstrateMoisture( database, decodeURIComponent(subRhMatch[1]), data && 'actualRhPct' in data ? data.actualRhPct : null, userId ); if (!r) { jsonErr(res, 404, 'not found'); return; } broadcastSSE(res); jsonOk(res, r); } catch (err) { safeErr(res, err); } }); return; } // Wie viel Wasser tatsächlich hineinging. Mit der gemessenen Feuchte zusammen // lernt daraus der Vorschlag für die nächsten Ansätze (db.waterPlan). const subWaterMatch = req.url.match(/^\/api\/substrate-batches\/([^/?]+)\/water$/); if (req.method === 'POST' && subWaterMatch) { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { const r = db.setSubstrateWater( database, decodeURIComponent(subWaterMatch[1]), data && 'actualWaterL' in data ? data.actualWaterL : null ); if (!r) { jsonErr(res, 404, 'not found'); return; } broadcastSSE(res); jsonOk(res, r); } catch (err) { if (/Wassermenge/.test(err.message)) { jsonErr(res, 400, err.message); return; } safeErr(res, err); } }); return; } // Sterilisierzeit nachtragen. Ein fehlendes oder leeres Feld setzt auf "nicht // eingetragen" zurück; die Grenzen prüft die DB-Schicht. const subSteriMatch = req.url.match(/^\/api\/substrate-batches\/([^/?]+)\/steri$/); if (req.method === 'POST' && subSteriMatch) { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { const r = db.setSubstrateSteriMin( database, decodeURIComponent(subSteriMatch[1]), data && 'steriMin' in data ? data.steriMin : null ); if (!r) { jsonErr(res, 404, 'not found'); return; } broadcastSSE(res); jsonOk(res, r); } catch (err) { safeErr(res, err); } }); return; } const subBurstMatch = req.url.match(/^\/api\/substrate-batches\/([^/?]+)\/burst$/); if (req.method === 'POST' && subBurstMatch) { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { // Ein fehlendes Feld setzt auf 0 zurück — der Weg zurück, wenn die Zahl // am falschen Ansatz landete. Die Grenzen prüft die DB-Schicht, damit // sie für jeden Aufrufer gelten und nicht nur für diesen. const r = db.setSubstrateBurstBags( database, decodeURIComponent(subBurstMatch[1]), data && 'burstBags' in data ? data.burstBags : 0 ); if (!r) { jsonErr(res, 404, 'not found'); return; } broadcastSSE(res); jsonOk(res, r); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'POST' && req.url === '/api/substrate-batches/preview') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, ['targetKg']) || mixSourceMissing(data); if (vr) { jsonErr(res, 400, vr); return; } const vt = validateTypes(data, { recipeId: 'number', recipeStrainId: 'number', targetKg: 'number' }); if (vt) { jsonErr(res, 400, vt); return; } const vrng = validateRanges(data, { targetKg: { min: 0.1, max: 100000 } }); if (vrng) { jsonErr(res, 400, vrng); return; } const vmix = validateMixOverride(data.mix); if (vmix) { jsonErr(res, 400, vmix); return; } try { const found = db.resolveMix(database, data); if (!found) { jsonOk(res, { hasRecipe: false }); return; } const adj = db.mixWithOverride(found.recipe, data.mix); const mix = db.computeMixBatch(adj.recipe, data.targetKg, found.opts); jsonOk(res, { hasRecipe: true, mix, water: db.waterPlan(database, mix.waterL, found.opts.flowLmin), adjusted: adj.adjusted, recipeLabel: found.label, // Der Name, den der Ansatz beim Anlegen bekommt — gebaut wie dort, // damit die Bestätigung sagt, was gleich in der Liste steht. label: db.mixLabel(found.label, found.recipe, mix) }); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'POST' && req.url === '/api/substrate-batches') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, ['subId', 'targetKg']) || mixSourceMissing(data); if (vr) { jsonErr(res, 400, vr); return; } const vt = validateTypes(data, { subId: 'string', recipeId: 'number', recipeStrainId: 'number', targetKg: 'number' }); if (vt) { jsonErr(res, 400, vt); return; } const vrng = validateRanges(data, { targetKg: { min: 0.1, max: 100000 } }); if (vrng) { jsonErr(res, 400, vrng); return; } const vlen = validateLengths(data, { subId: 100, notes: 10000 }); if (vlen) { jsonErr(res, 400, vlen); return; } if (!/^[A-Za-z0-9_\-@.:]{1,100}$/.test(data.subId)) { jsonErr(res, 400, 'subId must be alphanumeric with - _ @ . : (max 100 chars)'); return; } const vmixc = validateMixOverride(data.mix); if (vmixc) { jsonErr(res, 400, vmixc); return; } try { const userId = req.authUser ? req.authUser.user_id : null; const r = db.createSubstrateBatch(database, data, userId); broadcastSSE(res); jsonOk(res, { ok: true, subId: r.subId, mix: r.mix, water: r.water }); } catch (err) { safeErr(res, err); } }); return; } const subDelMatch = req.url.match(/^\/api\/substrate-batches\/([^/]+)$/); if (req.method === 'DELETE' && subDelMatch) { if (requireAdmin(req, res)) return; try { const userId = req.authUser ? req.authUser.user_id : null; const ok = db.deleteSubstrateBatch(database, decodeURIComponent(subDelMatch[1]), userId); if (!ok) { jsonErr(res, 404, 'not found'); return; } broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } return; } // A Charge portioned out of an existing mix. Separate from POST /api/batches // because almost nothing about it is the same: no composition, no deltas, and // the substrate comes out of a named mix rather than off the shelf. if (req.method === 'POST' && req.url === '/api/batches/from-substrate') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, ['batchId', 'subId', 'strainId', 'qty']); if (vr) { jsonErr(res, 400, vr); return; } // noBooking: Bestand aus der Inventur — angelegt, aber nichts gezogen und // nichts abgebucht. Nur ein echtes true schaltet das Buchen ab. // sourceId: die Kultur aus "Beimpft mit" im geführten Ablauf, sonst keine. const vt = validateTypes(data, { batchId: 'string', subId: 'string', strainId: 'number', qty: 'number', noBooking: 'boolean', sourceId: 'string' }); if (vt) { jsonErr(res, 400, vt); return; } const vrng = validateRanges(data, { qty: { min: 1, max: 10000 } }); if (vrng) { jsonErr(res, 400, vrng); return; } const vlen = validateLengths(data, { batchId: 100, subId: 100, strain: 200, notes: 10000, strainText: 200, sourceId: 100 }); if (vlen) { jsonErr(res, 400, vlen); return; } if (!/^[A-Za-z0-9_\-@.:]{1,100}$/.test(data.batchId)) { jsonErr(res, 400, 'batchId must be alphanumeric with - _ @ . : (max 100 chars)'); return; } // Eine Charge, die schon stand, bevor sie in der App war (Inventur), bringt // ihr Datum mit. Ohne Angabe gilt jetzt, wie bisher. const vd = validateDate(data.created, 'created'); if (vd) { jsonErr(res, 400, vd); return; } try { const userId = req.authUser ? req.authUser.user_id : null; const r = db.createBagBatchFromSubstrate(database, data, userId); // due is computed server-side from the Sorte's incubation days; without // passing it on the calendar push silently drops the batch. autoPushBatchCaldav({ batchId: data.batchId, due: r.due }); broadcastSSE(res); jsonOk(res, { ok: true, batchId: data.batchId, drawKg: r.drawKg, spawnKg: r.spawnKg, remainingKg: r.remainingKg, over: r.over, bagBarcodes: r.bagBarcodes || {} }); } catch (err) { safeErr(res, err); } }); return; } // -- Batches -- if (req.method === 'POST' && req.url === '/api/batches') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } // strainId replaces species+strain; species still required when no strainId const requiredFields = data.strainId ? ['batchId', 'qty', 'days', 'created', 'due'] : ['batchId', 'species', 'qty', 'days', 'created', 'due']; const vr = validateRequired(data, requiredFields); if (vr) { jsonErr(res, 400, vr); return; } const vt = validateTypes(data, { qty: 'number', days: 'number', batchId: 'string', ...(data.species ? { species: 'string' } : {}) }); if (vt) { jsonErr(res, 400, vt); return; } const vrng = validateRanges(data, { qty: { min: 1, max: 10000 }, days: { min: 1, max: 3650 } }); if (vrng) { jsonErr(res, 400, vrng); return; } const vlen = validateLengths(data, { batchId: 100, species: 200, strain: 200, notes: 10000, strainText: 200 }); if (vlen) { jsonErr(res, 400, vlen); return; } if (!/^[A-Za-z0-9_\-@.:]{1,100}$/.test(data.batchId)) { jsonErr(res, 400, 'batchId must be alphanumeric with - _ @ . : (max 100 chars)'); return; } // S-10: batch_type was the one field on this route with no validation at // all — it went straight into the INSERT, and it is read back as an // object key by the production-pipeline rollup. The accumulators there // are null-prototype now; this pins the column to the three kinds the // app actually knows as well. Omitting it is still fine — the column // defaults to 'block'. const vbt = validateEnum(data.batchType, BATCH_TYPES, 'batchType'); if (vbt) { jsonErr(res, 400, vbt); return; } let vd = validateDate(data.created, 'created'); if (vd) { jsonErr(res, 400, vd); return; } vd = validateDate(data.due, 'due'); if (vd) { jsonErr(res, 400, vd); return; } // Validate optional inventory deltas — applied atomically with the batch. // Shape: [{ mat: 'hardwood'|'wheatbran'|'gypsum'|'grain', deltaKg: number, type?: string, ref?: string }, ...] let deltas = null; if (data.deltas != null) { if (!Array.isArray(data.deltas)) { jsonErr(res, 400, 'deltas must be an array'); return; } for (const d of data.deltas) { if (!d || typeof d !== 'object') { jsonErr(res, 400, 'deltas entries must be objects'); return; } if (!['hardwood', 'wheatbran', 'gypsum', 'grain', 'coir'].includes(d.mat)) { jsonErr(res, 400, 'deltas[].mat must be hardwood/wheatbran/gypsum/grain/coir'); return; } if (typeof d.deltaKg !== 'number' || !Number.isFinite(d.deltaKg)) { jsonErr(res, 400, 'deltas[].deltaKg must be a finite number'); return; } } deltas = data.deltas; } try { // I-22: thread the authenticated user_id so inventory_log rows record the actor. const userId = req.authUser ? req.authUser.user_id : null; const result = db.insertBatch(database, data, deltas, userId); autoPushBatchCaldav(data); broadcastSSE(res); jsonOk(res, { ok: true, bagBarcodes: result ? result.bagBarcodes : {} }); } catch (err) { safeErr(res, err); } }); return; } const batchMatch = req.url.match(/^\/api\/batches\/([^/]+)\/bags$/); if (req.method === 'PATCH' && batchMatch) { const id = decodeURIComponent(batchMatch[1]); jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { // I-22 + I-23: thread user_id and let addBagsToBatch deduct inventory // for the new bags inside the same transaction. const userId = req.authUser ? req.authUser.user_id : null; // Körnerbrut wird gewogen, und eine Charge darf mehr als eine Größe // tragen — also darf der Aufrufer sagen, wie schwer die ergänzten // Beutel sind. Ohne Angabe bleibt es beim Gewicht der Charge, wie // bisher. Die Zahl geht in eine Bestandsbuchung ein, also wird sie hier // geprüft und nicht erst von SQLite entgegengenommen. let bagKg; if (data.bagKg != null) { bagKg = Number(data.bagKg); if (!Number.isFinite(bagKg) || bagKg <= 0 || bagKg > 1000) { jsonErr(res, 400, 'bagKg must be a positive number (max 1000)'); return; } } const result = db.addBagsToBatch(database, id, data.add || [], data.newQty, bagKg, userId); broadcastSSE(res); jsonOk(res, { ok: true, bagBarcodes: result ? result.bagBarcodes : {} }); } catch (err) { safeErr(res, err); } }); return; } const batchIdMatch = req.url.match(/^\/api\/batches\/([^/]+)$/); if (req.method === 'PATCH' && batchIdMatch) { const id = decodeURIComponent(batchIdMatch[1]); jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { db.updateBatchField(database, id, data); if (data.due) { const b = db.readBatchById(database, id); if (b) autoPushBatchCaldav(b); } broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'DELETE' && batchIdMatch) { if (requireAdmin(req, res)) return; const id = decodeURIComponent(batchIdMatch[1]); try { // I-22: forward acting user so the inventory_log credit-back records the actor. db.deleteBatchById(database, id, req.authUser ? req.authUser.user_id : null); try { autoDeleteBatchCaldav(id); } catch (ce) { log('warn', 'CalDAV cleanup failed after batch delete', { batchId: id, error: ce.message }); } broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } return; } const batchRenameMatch = req.url.match(/^\/api\/batches\/([^/]+)\/rename$/); if (req.method === 'POST' && batchRenameMatch) { if (requireAdmin(req, res)) return; const oldId = decodeURIComponent(batchRenameMatch[1]); jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const newId = (data.newId || '').trim(); if (!newId) { jsonErr(res, 400, 'newId is required'); return; } if (!/^[A-Za-z0-9_\-@.:]{1,100}$/.test(newId)) { jsonErr(res, 400, 'newId must be alphanumeric with - _ @ . : (max 100 chars)'); return; } try { db.renameBatch(database, oldId, newId, req.authUser); broadcastSSE(res); jsonOk(res, { ok: true, oldId, newId }); } catch (err) { safeErr(res, err); } }); return; } // -- Scan Log -- if (req.method === 'POST' && req.url === '/api/scan-log') { const sess = checkAuth(req); const userId = sess ? sess.user_id : null; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { const entries = data.entries || []; const ve = validateScanEntries(entries); if (ve) { jsonErr(res, 400, ve); return; } // I-12: optimistic concurrency for offline-queue replays. If a MOVE // entry was queued offline based on a stale view (the bag has since // been moved by another user), reject the whole POST with 409 so the // client can discard the queued entry rather than trampling the more // recent move. expected_current_zone is OPTIONAL — entries without // it skip the check (preserves backward compat with older clients). for (const e of entries) { if (e.action !== 'MOVE' && e.action !== 'MOVE_BATCH') continue; if (!e.expected_current_zone) continue; if (!e.bag) continue; const last = database .prepare( "SELECT action, \"to\" FROM scan_log WHERE bag = ? AND action IN ('ADD', 'MOVE', 'MOVE_BATCH', 'REMOVE') ORDER BY id DESC LIMIT 1" ) .get(e.bag); // currentZone = the zone of the last placement. last.to may be a // rack id (underscores, e.g. INC_R1) — resolve it back to its zone // so it matches the client's toZone(expected_current_zone). A plain // split(':') would leave the rack id intact and 409 every rack move. let currentZone = null; if (last && last.action !== 'REMOVE' && last.to) { currentZone = db.zoneIdOfLocation(database, last.to); } if (currentZone !== e.expected_current_zone) { res.writeHead(409, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ error: 'zone_mismatch', bag: e.bag, current_zone: currentZone, expected: e.expected_current_zone }) ); return; } } const ids = db.appendScanEntries(database, entries, userId); broadcastSSE(res); jsonOk(res, { ids }); } catch (err) { safeErr(res, err); } }); return; } const scanLastMatch = req.url.match(/^\/api\/scan-log\/last\/(\d+)$/); if (req.method === 'DELETE' && scanLastMatch) { if (requireAdmin(req, res)) return; try { db.deleteLastScanEntries(database, parseInt(scanLastMatch[1])); broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } return; } const scanIdMatch = req.url.match(/^\/api\/scan-log\/(\d+)$/); if (req.method === 'DELETE' && scanIdMatch) { // S-03: workers should be able to undo their own scans, but // not delete scans authored by other users. Owner-or-admin ACL, // mirroring the contamination-report pattern. The user_id column on // scan_log is nullable for legacy rows — only enforce ownership when // the column is set; otherwise fall back to admin-only. const id = parseInt(scanIdMatch[1]); try { const entry = db.getScanEntryById(database, id); if (!entry) { jsonErr(res, 404, 'not found'); return; } const isAdmin = req.authUser && req.authUser.role === 'admin'; const ownerKnown = entry.user_id !== null && entry.user_id !== undefined; const isOwner = ownerKnown && req.authUser && entry.user_id === req.authUser.user_id; if (!isAdmin && ownerKnown && !isOwner) { jsonErr(res, 403, 'forbidden'); return; } if (!isAdmin && !ownerKnown) { jsonErr(res, 403, 'forbidden'); return; } const ok = db.deleteScanEntryById(database, id); broadcastSSE(res); jsonOk(res, { deleted: ok }); } catch (err) { safeErr(res, err); } return; } if (req.method === 'DELETE' && req.url === '/api/scan-log') { if (requireAdmin(req, res)) return; try { db.clearScanLog(database); broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } return; } // -- Clean-slate reset (admin) -- // Irreversible, so it is guarded three ways: admin only, the client must echo // a typed confirmation phrase, and a verified backup is written first. The // backup deliberately does NOT use BACKUP_PREFIX so the auto-backup rotation // never prunes it. Configuration (zones, Sorten, users, suppliers, inventory, // settings) is always kept — only the categories asked for are cleared. if (req.method === 'POST' && req.url === '/api/admin/reset') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } if (!data || data.confirm !== RESET_CONFIRM_PHRASE) { jsonErr(res, 400, 'confirmation phrase required'); return; } const scopes = { growing: !!data.growing, orders: !!data.orders, planning: !!data.planning, stock: !!data.stock }; if (!scopes.growing && !scopes.orders && !scopes.planning && !scopes.stock) { jsonErr(res, 400, 'nothing selected'); return; } const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const dest = path.join(BACKUP_DIR, 'pre-reset-' + stamp + '.db'); db.backupDb(database, dest) .then(() => { const verifyError = verifyBackupFile(dest); if (verifyError) throw new Error('pre-reset backup failed verification: ' + verifyError); const counts = db.resetOperationalData(database, scopes, req.authUser); // Contamination photo files are on disk, not in the DB — drop them too // so they don't hold the photo-dir cap against freshly cleared reports. // Only those: experiment photos share the directory, and the reset // keeps experiments. It used to remove the whole directory, which // left every experiment pointing at photos that were gone. let photosRemoved = false; if (scopes.growing) { try { photoCap.removeContaminationPhotos(PHOTO_DIR); photosRemoved = true; } catch (fileErr) { log('warn', 'Reset: photo cleanup failed', { error: fileErr.message }); } // Recount either way. A removal that fails partway has still taken // files away, and without the rescan their bytes kept counting // against the photo-dir cap until the next one, up to five minutes. _ensurePhotoDirSize(true); } log('warn', 'Clean-slate reset performed', { user: req.authUser ? req.authUser.username : null, scopes, counts, backup: dest }); broadcastSSE(res); jsonOk(res, { counts, backup: path.basename(dest), photosRemoved }); }) .catch((err) => safeErr(res, err)); }); return; } // -- Contamination reports -- if (req.method === 'GET' && req.url === '/api/contamination-types') { try { jsonOk(res, db.listContaminationTypes(database)); } catch (err) { safeErr(res, err); } return; } if (req.method === 'POST' && req.url === '/api/contamination-reports') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } // Der Typ ist freiwillig. Am Regal sieht man, dass ein Beutel hin ist, // nicht immer, woran; wer ihn nicht wählt, meldet unter "Unbekannt / // Sonstiges" — die Spalte bleibt NOT NULL, und die Liste zeigt das so. const vt = validateTypes(data, { type_id: 'number', photos: 'array' }); if (vt) { jsonErr(res, 400, vt); return; } const ve = validateEnum(data.severity, ['minor', 'major', 'lost'], 'severity'); if (ve) { jsonErr(res, 400, ve); return; } const vl = validateLengths(data, { notes: 2000, bag_id: 100, batch_id: 100, zone_id: 100, report_uuid: 100 }); if (vl) { jsonErr(res, 400, vl); return; } if (data.bags !== undefined && (!Array.isArray(data.bags) || data.bags.length === 0)) { jsonErr(res, 400, 'bags must be a non-empty array'); return; } if (Array.isArray(data.bags) && data.bags.length > CONTAM_MAX_TARGETS) { jsonErr(res, 400, 'bags: at most ' + CONTAM_MAX_TARGETS + ' bags per report'); return; } const targets = contamReportTargets(data); const vbl = targets .map((tg) => validateLengths(tg, { bag_id: 100, batch_id: 100, report_uuid: 100 })) .find(Boolean); if (vbl) { jsonErr(res, 400, vbl); return; } // Track disk-side photo files written so we can best-effort delete them // if the surrounding DB transaction rolls back. Disk writes themselves // are not transactional, so on rollback we accept that orphaned files // may remain — TODO: write to a temp staging dir and atomically move on // commit if this becomes load-bearing. const writtenPhotoFiles = []; const reportIds = []; const photoIds = []; const autoMovedScanIds = []; try { if (data.type_id === undefined || data.type_id === null) { const unknown = database.prepare("SELECT id FROM contamination_types WHERE key = 'unknown_other'").get(); if (!unknown) { jsonErr(res, 400, 'type_id is required'); return; } data.type_id = unknown.id; } database.exec('BEGIN'); try { for (const target of targets) { const cr = db.createContaminationReport(database, { ...data, bag_id: target.bag_id, batch_id: target.batch_id, report_uuid: target.report_uuid, user_id: req.authUser ? req.authUser.user_id : null }); const reportId = cr.id; reportIds.push(reportId); // On an offline-replay duplicate (same report_uuid) the report, photos // and auto-MOVE already committed the first time — skip re-running the // side effects so a lost 200 can't create a second report or CONTAM move. // // Jeder Beutel bekommt seine eigene Kopie der Fotos. Eine Datei // zwischen Berichten zu teilen wäre sparsamer und ginge beim ersten // Löschen kaputt: der Löschweg hängt die Dateien ab, auf die seine // eigenen Zeilen zeigen, ohne zu fragen, wer sonst noch darauf zeigt. const photos = !cr.duplicate && Array.isArray(data.photos) ? data.photos.slice(0, 4) : []; for (const p of photos) { const saved = savePhotoToDisk(reportId, p, req.authUser); if (saved) { writtenPhotoFiles.push(path.join(DIR, 'data', saved.rel_path)); writtenPhotoFiles.push(path.join(DIR, 'data', saved.thumb_path)); photoIds.push(db.addContaminationPhoto(database, reportId, saved)); } } // Auto-MOVE-to-CONTAM lifecycle: insert a MOVE scan-log entry that // moves the bag to the first zone with role='contaminated', and stamp // contamination_reports.scan_log_id to link the two records together. // // auto_move: true zieht um, wie schwer es auch ist: ein kontaminierter // Block gehört aus dem Bestand, und das Formular schickt das jetzt als // Vorgabe. Vorher zog nur "Größer" oder "Total" um, und das Formular // stand auf "Klein" — gemeldet wurde, umgezogen nie, und der Block // zählte weiter in der Inkubation. Ohne Angabe (ältere Clients, die // Warteschlange) bleibt es bei major/lost; false heißt nein. // Skipped silently when there is no bag_id (whole-batch report), no // contam zone, or the bag already stands in one. const wantAutoMove = !cr.duplicate && (data.auto_move === true || (data.auto_move !== false && (data.severity === 'major' || data.severity === 'lost'))); if (wantAutoMove && target.bag_id) { const contamZone = database .prepare("SELECT id FROM zones WHERE role = 'contaminated' ORDER BY sort_order, id LIMIT 1") .get(); if (contamZone) { const lastLoc = database .prepare( "SELECT \"to\" AS toLoc FROM scan_log WHERE bag = ? AND action IN ('ADD','MOVE') ORDER BY id DESC LIMIT 1" ) .get(target.bag_id); // Schon in einer Kontaminationszone (oder einem ihrer Regale): // ein zweiter Umzug dorthin wäre nur ein doppelter Eintrag. const schonDa = lastLoc && database .prepare( `SELECT 1 FROM zones WHERE id = ? AND role = 'contaminated' UNION SELECT 1 FROM racks r JOIN zones z ON z.id = r.zone_id WHERE r.id = ? AND z.role = 'contaminated'` ) .get(lastLoc.toLoc, lastLoc.toLoc); if (schonDa) continue; const typeRow = database.prepare('SELECT key FROM contamination_types WHERE id = ?').get(data.type_id); const reasonKey = typeRow ? 'contam_' + typeRow.key : 'contam_unknown'; // Use the no-txn variant so this insert is part of the surrounding transaction. const ids = db.appendScanEntriesNoTxn( database, [ { time: new Date().toISOString(), action: 'MOVE', batch: target.batch_id || null, bag: target.bag_id, from: lastLoc ? lastLoc.toLoc : null, to: contamZone.id, reason: reasonKey } ], req.authUser ? req.authUser.user_id : null ); if (ids && ids.length) { autoMovedScanIds.push(ids[0]); db.setContaminationReportScanLogId(database, reportId, ids[0]); } } } } database.exec('COMMIT'); } catch (innerErr) { database.exec('ROLLBACK'); // appendScanEntriesNoTxn above mutated the in-memory bag-zone cache; // the rollback undid the scan rows but not the cache — rebuild on next read. db.invalidateBagZoneCache(database); // Best-effort cleanup of disk files written during the failed transaction. _discardPhotoFiles(writtenPhotoFiles); throw innerErr; } broadcastSSE(res); // `id` und `autoMovedScanId` bleiben stehen: ein Bericht über einen // Beutel ist weiter der Normalfall, und die Quittung, die der Client // dafür zeigt, liest genau diese zwei Felder. jsonOk(res, { id: reportIds[0] || null, ids: reportIds, photoIds, autoMovedScanId: autoMovedScanIds[0] || null, autoMovedCount: autoMovedScanIds.length }); } catch (err) { // R-15: photo-cap errors map to 507 Insufficient Storage so the // client can display a useful "directory full" message instead of a // generic 400. if (err && err.name === 'PhotoCapError') { jsonErr(res, 507, err.message); } else { safeErr(res, err); } } }); return; } if (req.method === 'GET' && req.url.startsWith('/api/contamination-reports')) { const photoMatch = req.url.match(/^\/api\/contamination-reports\/(\d+)\/photos\/([a-f0-9-]+)(?:\?.*)?$/); if (photoMatch) { const reportId = parseInt(photoMatch[1], 10); const uuid = photoMatch[2]; const wantThumb = /[?&]thumb=1/.test(req.url); try { const photo = db.getContaminationPhotoByUuid(database, uuid); if (!photo || photo.report_id !== reportId) { jsonErr(res, 404, 'photo not found'); return; } const rel = wantThumb ? photo.thumb_path : photo.rel_path; const abs = path.join(DIR, 'data', rel); // Defence-in-depth: refuse to serve anything that isn't a child of data/photos if (!abs.startsWith(path.join(DIR, 'data', 'photos') + path.sep)) { jsonErr(res, 400, 'invalid path'); return; } if (!fs.existsSync(abs)) { jsonErr(res, 404, 'photo file missing'); return; } // Ask every time instead of serving from the browser cache for a year // unasked: a photo of a deleted report kept opening from the browser // history, and on a shared device after logging out as well, because no // request reached the server and the session check never ran. A picture // never changes under its uuid, so asking costs a 304 without a body. const etag = '"' + photo.uuid + (wantThumb ? '-t' : '') + '"'; if (etagMatches(req.headers['if-none-match'], etag)) { res.writeHead(304, { ETag: etag, 'Cache-Control': 'private, no-cache' }); res.end(); return; } res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'private, no-cache', ETag: etag }); // pipe() does not forward the read stream's 'error' (e.g. a concurrent // report DELETE unlinking the file after the existsSync check), which // would otherwise leave the response hanging forever. Headers are // already sent, so just tear down the socket. const photoStream = fs.createReadStream(abs); photoStream.on('error', (e) => { log('warn', 'Photo stream error', { error: e.message }); res.destroy(); }); photoStream.pipe(res); } catch (err) { safeErr(res, err); } return; } const singleMatch = req.url.match(/^\/api\/contamination-reports\/(\d+)$/); if (singleMatch) { try { const r = db.getContaminationReportById(database, parseInt(singleMatch[1], 10)); if (!r) { jsonErr(res, 404, 'not found'); return; } jsonOk(res, r); } catch (err) { safeErr(res, err); } return; } if (req.url === '/api/contamination-reports' || req.url.startsWith('/api/contamination-reports?')) { try { const u = new URL(req.url, 'http://x'); const filters = { batchId: u.searchParams.get('batchId') || undefined, bagId: u.searchParams.get('bagId') || undefined, typeId: u.searchParams.get('typeId') ? parseInt(u.searchParams.get('typeId'), 10) : undefined, severity: u.searchParams.get('severity') || undefined, zoneId: u.searchParams.get('zoneId') || undefined, startDate: u.searchParams.get('start') || undefined, endDate: u.searchParams.get('end') || undefined, status: u.searchParams.get('status') || undefined, limit: u.searchParams.get('limit') ? parseInt(u.searchParams.get('limit'), 10) : undefined }; jsonOk(res, db.listContaminationReports(database, filters)); } catch (err) { safeErr(res, err); } return; } } // Resolve / reopen — sets or clears resolved_at, resolved_by, resolution. // Reporter or admin only (mirror delete ACL). const contamResolveMatch = req.url.match(/^\/api\/contamination-reports\/(\d+)\/resolve$/); if (req.method === 'PATCH' && contamResolveMatch) { const reportId = parseInt(contamResolveMatch[1], 10); jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { const existing = db.getContaminationReportById(database, reportId); if (!existing) { jsonErr(res, 404, 'not found'); return; } const isOwner = req.authUser && existing.user_id === req.authUser.user_id; const isAdmin = req.authUser && req.authUser.role === 'admin'; if (!isOwner && !isAdmin) { jsonErr(res, 403, 'forbidden'); return; } if (data && data.resolution) { const ve = validateEnum(data.resolution, ['autoclaved', 'discarded', 'recovered', 'other'], 'resolution'); if (ve) { jsonErr(res, 400, ve); return; } db.resolveContaminationReport( database, reportId, req.authUser ? req.authUser.user_id : null, data.resolution ); } else { // Empty/null resolution = reopen db.unresolveContaminationReport(database, reportId); } broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } const contamDeleteMatch = req.url.match(/^\/api\/contamination-reports\/(\d+)$/); if (req.method === 'DELETE' && contamDeleteMatch) { const reportId = parseInt(contamDeleteMatch[1], 10); try { const existing = db.getContaminationReportById(database, reportId); if (!existing) { jsonErr(res, 404, 'not found'); return; } // Reporter or admin can delete (mirror manual-tasks ACL) const isOwner = req.authUser && existing.user_id === req.authUser.user_id; const isAdmin = req.authUser && req.authUser.role === 'admin'; if (!isOwner && !isAdmin) { jsonErr(res, 403, 'forbidden'); return; } const photoPaths = db.deleteContaminationReport(database, reportId); // Best-effort unlink — DB is the source of truth, orphan files are harmless. // Only paths under data/photos, whatever a row says. Through // _discardPhotoFiles, so the freed bytes leave the cached total now instead // of counting against the photo-dir cap until the next rescan. const left = _discardPhotoFiles( photoPaths .flatMap((p) => [p.rel_path, p.thumb_path]) .map((rel) => path.join(DIR, 'data', rel)) .filter((abs) => abs.startsWith(path.join(DIR, 'data', 'photos') + path.sep)) ); // The rows are gone, so nothing names these files any more except this line. if (left.length) { log('warn', 'Contamination report delete left photo files on disk', { reportId, files: left.map((abs) => path.relative(path.join(DIR, 'data'), abs)) }); } broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } return; } // -- Harvests -- if (req.method === 'POST' && req.url === '/api/harvests') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, ['time', 'grams']); if (vr) { jsonErr(res, 400, vr); return; } const vt = validateTypes(data, { grams: 'number', flush: 'number', release: 'number' }); if (vt) { jsonErr(res, 400, vt); return; } const vrng = validateRanges(data, { grams: { min: 0, max: 1000000 }, flush: { min: 1, max: 100 }, release: { min: 0, max: 1000000 } }); if (vrng) { jsonErr(res, 400, vrng); return; } // You cannot set aside more than you just weighed. The crate can hold more // than this one bag, but the field says "of this, release" — so a number // above the harvest is a slipped comma, not an intention, and it would // publish produce nobody has. // // The decision itself lives in harvest-feed.js so a test can reach it; only // the choice of status code belongs to the route. const relProblem = harvestFeed.releaseProblem(data); if (relProblem && relProblem.fatal) { jsonErr(res, 400, relProblem.reason); return; } const vd = validateDate(data.time, 'time'); if (vd) { jsonErr(res, 400, vd); return; } try { const id = db.insertHarvest(database, data); // The release is a second, independent write, and it must not be able to // lose the harvest. A harvest that was weighed is a fact; a release is a // decision about it. So the insert happens first and its own errors // travel normally, while a failing release is reported alongside the id // rather than in place of it. let released = null; let releaseError = null; if (data.release > 0) { if (relProblem) releaseError = relProblem.reason; else try { // Release mode, the permission check and the freshness window all // come from inside addHarvestRelease now, so every caller gets the // same gates without having to remember any of them. // // `actor` is what closes the hole this route opened: POST /api/harvests // is open to every authenticated user by design — weighing bags is // what the lab does all day — while every other write to // harvest_release sits behind requireAdmin. Passing the session // through means the release obeys that boundary while the harvest // itself stays open. released = db.addHarvestRelease(database, { species: data.species, grams: data.release, actor: req.authUser }); } catch (err) { // Same discipline as safeErr(): only a whitelisted message goes to // the client, anything else is logged and generalised. Without the // log an unexpected release failure left no trace at all, which is // the worst case here — the crate is packed and nothing says why the // shop never heard about it. const msg = String((err && err.message) || ''); if (db.isSafeError(msg)) releaseError = msg.replace('addHarvestRelease: ', ''); else { log('error', 'Harvest release failed', { error: msg, stack: err && err.stack, species: data.species }); releaseError = 'release failed — see server log'; } } } // Only when something was actually set aside. A plain harvest changes // nothing the feed publishes, and a push per weighed bag would be a // request every few seconds on a picking afternoon. if (released) pushHarvestFeedNow('release from harvest'); broadcastSSE(res); jsonOk(res, { id, released, releaseError }); } catch (err) { safeErr(res, err); } }); return; } // -- Cultures -- if (req.method === 'POST' && req.url === '/api/cultures') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { const arr = data.cultures || []; for (const c of arr) { const vr = validateRequired(c, ['id', 'type', 'created']); if (vr) { jsonErr(res, 400, vr); return; } if (typeof c.id !== 'string' || !/^[A-Za-z0-9_\-@.:]{1,200}$/.test(c.id)) { jsonErr(res, 400, 'culture id must be alphanumeric with - _ @ . : (max 200 chars)'); return; } // `id` was pinned here from the start; `type` and `status` never were, // and both are rendered as badges in the client. The MCP path has // enforced these enums all along — this is its forgotten twin. // Belt to the client's braces: the escaping in ctBadge/csBadge is what // actually stops the injection, this keeps the data clean. // // Five types, not the MCP tool's four: GS is a real type in // VALID_CULTURE_PARENT_TYPES, and validating against the shorter list // would start rejecting cultures the lab already has. // `created` kommt vom Gerät — seit der Inventur auch mit einem Tag in der // Vergangenheit — und wird an jeder Kultur sortiert und angezeigt. const ve = validateEnum(c.type, CULTURE_TYPES, 'culture type') || validateEnum(c.status, CULTURE_STATUSES, 'culture status') || validateDate(c.created, 'created'); if (ve) { jsonErr(res, 400, ve); return; } } const result = db.insertCultures(database, arr); broadcastSSE(res); jsonOk(res, { ok: true, cultureBarcodes: result ? result.cultureBarcodes : {} }); } catch (err) { safeErr(res, err); } }); return; } const cultureRenameMatch = req.url.match(/^\/api\/cultures\/([^/]+)\/rename$/); if (req.method === 'POST' && cultureRenameMatch) { const oldId = decodeURIComponent(cultureRenameMatch[1]); jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const newId = (data.newId || '').trim(); if (!newId) { jsonErr(res, 400, 'newId is required'); return; } if (!/^[A-Za-z0-9_\-@.:]{1,200}$/.test(newId)) { jsonErr(res, 400, 'newId must be alphanumeric with - _ @ . : (max 200 chars)'); return; } try { db.renameCulture(database, oldId, newId, req.authUser); broadcastSSE(res); jsonOk(res, { ok: true, oldId, newId }); } catch (err) { safeErr(res, err); } }); return; } const cultureMatch = req.url.match(/^\/api\/cultures\/([^/]+)$/); if (req.method === 'PATCH' && cultureMatch) { const id = decodeURIComponent(cultureMatch[1]); jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { db.updateCulture(database, id, data); broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'DELETE' && cultureMatch) { // S-03: deleting a culture something was made from orphans its children's // parent_id and batches.source_id, so only an admin removes one of those. // Anyone may delete a culture nothing came from: a plate that fell or // cannot be found any more otherwise sat in the stock until an admin got // round to it. const id = decodeURIComponent(cultureMatch[1]); const isAdmin = req.authUser && req.authUser.role === 'admin'; if (!isAdmin && db.cultureHasOffspring(database, id)) { jsonErr(res, 403, 'Aus ' + id + ' ist schon etwas angesetzt worden. Löschen kann das nur ein Admin.'); return; } try { const deleted = db.deleteCulture(database, id, req.authUser); broadcastSSE(res); jsonOk(res, { deleted }); } catch (err) { safeErr(res, err); } return; } // ── Order hub (Phase 0): orders, products, demand, customers ────────────── // Auth is enforced by the global gate above (req.authUser is always set here). // Operational reads/writes = any authed user; catalog/mapping/merge = admin. // Orders — list (?status= &channel= &limit=) if (req.method === 'GET' && url === '/api/orders') { try { const params = new URL(req.url, 'http://x').searchParams; const items = db.listOrders(database, { status: params.get('status') || undefined, channel: params.get('channel') || undefined, limit: parseInt(params.get('limit'), 10) || 200 }); // PII: the list carries customerName just like the order-detail route, // which gates it behind requireShipping. Mirror that gate here — users // without ship permission still see every order, just without the // customer's name (mirrors requireShipping: admin OR can_ship). const canSeeCustomer = req.authUser && (req.authUser.role === 'admin' || req.authUser.can_ship === 1); if (!canSeeCustomer) { for (const o of items) delete o.customerName; } jsonOk(res, { items }); } catch (err) { safeErr(res, err); } return; } // Orders — packing board: what to pack today, what to make first (must // precede /api/orders/:id). Read from what the Billbee sync stored; no // customer data is in it, so any signed-in user may see it. if (req.method === 'GET' && url === '/api/orders/board') { try { const since = new Date(Date.now() - orderBoard.BOARD_WINDOW_DAYS * 86400000).toISOString(); // Everyone who packs needs to see whose parcel is whose, so the // recipient's name is shown to every signed-in user here. It is the only // personal datum on the board: addresses, e-mails and the customer pages // stay behind requireShipping as before. const showNames = !!req.authUser; const board = orderBoard.buildOrderBoard(db.listBoardOrders(database, { sinceIso: since }), { marksSince: db.orderMarksSince(database), showNames }); const bb = db.listChannelConfigs(database).find((c) => c.channel === 'billbee') || {}; board.sync = { lastSync: bb.lastSync || null, problem: orderBoard.syncProblem(bb), canSync: !!(req.authUser && req.authUser.role === 'admin' && bb.enabled && bb.connected) }; jsonOk(res, board); } catch (err) { safeErr(res, err); } return; } // Orders — packing board marks: "Gepackt" / "Hergestellt" ({id, kind, on}). // Local to this app, with who and when; Billbee is never written to. if (req.method === 'POST' && url === '/api/orders/board/mark') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const id = parseInt(data && data.id, 10); if (!Number.isFinite(id) || (data.kind !== 'packed' && data.kind !== 'made')) { jsonErr(res, 400, 'id and kind (packed|made) required'); return; } try { const ok = db.setOrderMark(database, id, data.kind, data.on !== false, req.authUser && req.authUser.username); if (!ok) { jsonErr(res, 404, 'not found'); return; } broadcastSSE(res); jsonOk(res, { ok: true }); } catch (err) { safeErr(res, err); } }); return; } // Orders — production-demand rollup (must precede /api/orders/:id) if (req.method === 'GET' && url === '/api/orders/demand') { try { jsonOk(res, { items: db.computeProductionDemand(database) }); } catch (err) { safeErr(res, err); } return; } // Orders — manual / CSV import (single object, or {orders:[...]}, or [...]) if (req.method === 'POST' && url === '/api/orders/import') { // S-09: import is an upsert, not just a create — upsertOrder matches on // channel + channelOrderId and COALESCE-replaces ship_* / customer_* on the // existing row. Without this gate any authed user could read channel + // channelOrderId from GET /api/orders and rewrite the delivery address of // somebody else's order, so the next label buy sends the parcel to them. // Rewriting a ship-to address is not an "operational write" — same gate as // the other customer-PII routes. if (requireShipping(req, res)) return; anyJsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { const incoming = Array.isArray(data) ? data : Array.isArray(data && data.orders) ? data.orders : [data]; const ids = []; for (const o of incoming) { if (!o || !o.channel || o.channelOrderId == null) { jsonErr(res, 400, 'each order needs channel + channelOrderId'); return; } ids.push(db.upsertOrder(database, o)); } broadcastSSE(res); jsonOk(res, { imported: ids.length, ids }); } catch (err) { safeErr(res, err); } }); return; } // Orders — reserve demand against a batch ({batchId, allocations:[{orderItemId, qty}]}) if (req.method === 'POST' && url === '/api/orders/reserve') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { db.reserveDemand(database, { batchId: data.batchId || null, allocations: data.allocations || [] }); broadcastSSE(res); jsonOk(res, { ok: true }); } catch (err) { safeErr(res, err); } }); return; } // Orders — detail / set status const orderMatch = url.match(/^\/api\/orders\/(\d+)$/); if (req.method === 'GET' && orderMatch) { // Order detail carries customer name + email (PII) — gate like the ship routes. if (requireShipping(req, res)) return; try { const o = db.getOrder(database, parseInt(orderMatch[1], 10)); if (!o) { jsonErr(res, 404, 'not found'); return; } jsonOk(res, o); } catch (err) { safeErr(res, err); } return; } if (req.method === 'PATCH' && orderMatch) { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const allowed = ['new', 'in_production', 'ready', 'shipped', 'cancelled']; if (!allowed.includes(data.status)) { jsonErr(res, 400, 'invalid status'); return; } try { const changed = db.setOrderStatus(database, parseInt(orderMatch[1], 10), data.status); broadcastSSE(res); jsonOk(res, { changed }); } catch (err) { safeErr(res, err); } }); return; } // Products — unmapped queue (must precede /api/products/:id) + list if (req.method === 'GET' && url === '/api/products/unmapped') { try { jsonOk(res, { items: db.listUnmappedItems(database) }); } catch (err) { safeErr(res, err); } return; } if (req.method === 'GET' && url === '/api/products') { try { const params = new URL(req.url, 'http://x').searchParams; jsonOk(res, { items: db.listProducts(database, { activeOnly: params.get('active') === '1' }) }); } catch (err) { safeErr(res, err); } return; } // Products — create (admin) if (req.method === 'POST' && url === '/api/products') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, ['name']); if (vr) { jsonErr(res, 400, vr); return; } try { const id = db.upsertProduct(database, data); broadcastSSE(res); jsonOk(res, { id }); } catch (err) { safeErr(res, err); } }); return; } // Products — map a channel listing → internal product (admin) // The standing mappings of one channel, so they can be seen and corrected // without waiting for an order to carry the listing in. if (req.method === 'GET' && url === '/api/products/mappings') { // Readable by everyone who can open the screen, like /api/products and // /api/products/unmapped right above — it is the same page, and an admin-only // read here put a red error box in front of every worker who opened it. // // "catalog/mapping/merge = admin" at the top of this section is about the // *writes*: POST /api/products, POST /api/products/map and the customer merge // all check requireAdmin, while every read beside them is open to any authed // user. This row set is product names and channel article numbers — nothing // /api/products does not already hand out. try { const channel = new URL(req.url, 'http://x').searchParams.get('channel') || ''; jsonOk(res, { items: channel ? db.listChannelMappings(database, channel) : [] }); } catch (err) { safeErr(res, err); } return; } if (req.method === 'POST' && url === '/api/products/map') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, ['channel', 'productId']); if (vr) { jsonErr(res, 400, vr); return; } try { db.mapListing(database, data); broadcastSSE(res); jsonOk(res, { ok: true }); } catch (err) { safeErr(res, err); } }); return; } // Products — detail (authed) / update + delete (admin) const productMatch = url.match(/^\/api\/products\/(\d+)$/); if (req.method === 'GET' && productMatch) { try { const p = db.getProduct(database, parseInt(productMatch[1], 10)); if (!p) { jsonErr(res, 404, 'not found'); return; } jsonOk(res, p); } catch (err) { safeErr(res, err); } return; } if (req.method === 'PATCH' && productMatch) { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, ['name']); if (vr) { jsonErr(res, 400, vr); return; } try { const id = db.upsertProduct(database, { ...data, id: parseInt(productMatch[1], 10) }); broadcastSSE(res); jsonOk(res, { id }); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'DELETE' && productMatch) { if (requireAdmin(req, res)) return; try { const deleted = db.deleteProduct(database, parseInt(productMatch[1], 10)); broadcastSSE(res); jsonOk(res, { deleted }); } catch (err) { safeErr(res, err); } return; } // Customers — list (admin or shipping) / merge two records (admin). Names, // emails and spend are PII, so the list is gated like the other ship routes. if (req.method === 'GET' && url === '/api/customers') { if (requireShipping(req, res)) return; try { const params = new URL(req.url, 'http://x').searchParams; jsonOk(res, { items: db.listCustomers(database, { limit: parseInt(params.get('limit'), 10) || 200 }) }); } catch (err) { safeErr(res, err); } return; } // POST /api/customers/erase — the authenticated counterpart to the eBay // account-closure notification. That endpoint is unauthenticated by necessity // (eBay calls it from its own infrastructure) and its POST carries no // verification we validate, so it only records the request; the irreversible // erase happens here, behind the admin gate, where we know who asked. if (req.method === 'POST' && url === '/api/customers/erase') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { // validateRequired only rejects undefined/null/'' — 0 and an unknown id // both sailed through, eraseCustomer short-circuited, and the route then // logged "erased" and answered ok, so the audit trail collected entries for // erasures that never happened and the admin closed the ticket. const id = Number(data.customerId); if (!Number.isInteger(id) || id <= 0) { jsonErr(res, 400, 'customerId must be a positive integer'); return; } try { const r = db.eraseCustomer(database, id); if (!r.found) { jsonErr(res, 404, 'no such customer'); return; } // eraseCustomer hands back who it erased, captured before the write. // Logging only the id left the one durable record of an irreversible // erase unable to say whose data it was — by the time this line runs, // nothing in the database resolves that id back to a person. log('warn', 'customer data erased by admin', { customerId: id, subject: r.subject, orders: r.orders, by: req.authUser.username }); broadcastSSE(res); jsonOk(res, { ok: true, orders: r.orders }); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'POST' && url === '/api/customers/merge') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, ['primaryId', 'secondaryId']); if (vr) { jsonErr(res, 400, vr); return; } try { db.mergeCustomers(database, data.primaryId, data.secondaryId); broadcastSSE(res); jsonOk(res, { ok: true }); } catch (err) { safeErr(res, err); } }); return; } // -- Tasks -- if (req.method === 'POST' && req.url === '/api/tasks') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, ['text', 'created']); if (vr) { jsonErr(res, 400, vr); return; } const vt = validateTypes(data, { text: 'string', priority: 'string', assignee: 'string', description: 'string', recurrence: 'string' }); if (vt) { jsonErr(res, 400, vt); return; } const vlen = validateLengths(data, { text: 2000, description: 10000, assignee: 200 }); if (vlen) { jsonErr(res, 400, vlen); return; } const ve = validateEnum(data.priority, ['low', 'med', 'high'], 'priority'); if (ve) { jsonErr(res, 400, ve); return; } if (data.recurrence) { const vr2 = validateEnum(data.recurrence, ['daily', 'weekly', 'monthly'], 'recurrence'); if (vr2) { jsonErr(res, 400, vr2); return; } } if (data.dueDate) { const vd = validateDate(data.dueDate, 'dueDate'); if (vd) { jsonErr(res, 400, vd); return; } } if (data.dueTime) { const vt2 = validateTimeOfDay(data.dueTime, 'dueTime'); if (vt2) { jsonErr(res, 400, vt2); return; } } if (data.dueEndTime) { const vt2 = validateTimeOfDay(data.dueEndTime, 'dueEndTime'); if (vt2) { jsonErr(res, 400, vt2); return; } } if (data.recurrenceUntil) { const vd = validateDate(data.recurrenceUntil, 'recurrenceUntil'); if (vd) { jsonErr(res, 400, vd); return; } } try { const id = db.insertTask(database, data); if (data.dueDate) { const t = db.readTaskById(database, id); if (t) autoPushTaskCaldav(t); } const assigneeIds = resolveUsernamesToIds(parseTaskAssigneeCsv(data.assignee)); if (assigneeIds.length) { notifyTaskAssignees({ id, text: data.text, dueDate: data.dueDate }, assigneeIds, req.authUser); } broadcastSSE(res); jsonOk(res, { id }); } catch (err) { safeErr(res, err); } }); return; } const taskMatch = req.url.match(/^\/api\/tasks\/(\d+)$/); if (req.method === 'PATCH' && taskMatch) { const id = parseInt(taskMatch[1]); const isAdmin = req.authUser && req.authUser.role === 'admin'; if (!db.canUserModifyTask(database, req.authUser && req.authUser.username, id, isAdmin)) { jsonErr(res, 403, 'You are not allowed to modify this task'); return; } jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { const prevTask = db.readTaskById(database, id); const prevAssigneeIds = prevTask ? resolveUsernamesToIds(parseTaskAssigneeCsv(prevTask.assignee)) : []; db.updateTaskById(database, id, data); const t = db.readTaskById(database, id); if (t) autoPushTaskCaldav(t); if (Object.prototype.hasOwnProperty.call(data, 'assignee') && t) { const newIds = resolveUsernamesToIds(parseTaskAssigneeCsv(t.assignee)); const prevSet = new Set(prevAssigneeIds); const added = newIds.filter((uid) => !prevSet.has(uid)); if (added.length) notifyTaskAssignees(t, added, req.authUser); } broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'DELETE' && taskMatch) { const id = parseInt(taskMatch[1]); const isAdmin = req.authUser && req.authUser.role === 'admin'; if (!db.canUserModifyTask(database, req.authUser && req.authUser.username, id, isAdmin)) { jsonErr(res, 403, 'You are not allowed to delete this task'); return; } try { const task = db.readTaskById(database, id); db.deleteTaskById(database, id); try { autoDeleteTaskCaldav(task); } catch (ce) { log('warn', 'CalDAV cleanup failed after task delete', { taskId: id, error: ce.message }); } broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } return; } // ── Versuche und ihre Ansaetze ───────────────────────────── // // Angelegt wird ein Versuch, darunter haengen die Ansaetze. Geloescht wird ein // Versuch nicht: er wird abgeschlossen und archiviert, und das ist ein PATCH. // Ein Laborbuch, aus dem man Seiten herausreissen kann, ist keins. const versuchTreffer = url.match(/^\/api\/experiments\/(\d+)$/); const ansatzTreffer = url.match(/^\/api\/experiment-runs\/(\d+)$/); if (req.method === 'POST' && url === '/api/experiments') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, ['code', 'title']); if (vr) { jsonErr(res, 400, vr); return; } const vp = versuchPruefen(data); if (vp) { jsonErr(res, 400, vp); return; } const vl = validateLengths(data, { code: 20, title: 200, recipe: 20000, outcome: 20000 }); if (vl) { jsonErr(res, 400, vl); return; } try { const id = db.insertExperiment(database, data, req.authUser); broadcastSSE(res); jsonOk(res, { id }); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'PATCH' && versuchTreffer) { const id = parseInt(versuchTreffer[1]); jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vp = versuchPruefen(data); if (vp) { jsonErr(res, 400, vp); return; } const vl = validateLengths(data, { title: 200, recipe: 20000, outcome: 20000 }); if (vl) { jsonErr(res, 400, vl); return; } try { // Gesendet wird nur, was sich geaendert hat: ein PATCH mit dem Wert, der // schon dasteht, schreibt nichts und muss niemanden nachladen lassen. if (db.updateExperiment(database, id, data, req.authUser)) broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } // Der Verlauf eines Versuchs: wer wann was geaendert hat, an ihm, seinen // Gefaessen und seinen Fotos. Nur lesen. Eine Route, die ihn aendert oder // loescht, gibt es nicht, und die Tabelle liesse es auch nicht zu. // Seitenweise: ohne Angabe die juengsten Eintraege, mit ?before=id aeltere, // mit ?after=id nur, was seitdem dazukam (siehe experimentHistoryPage). const verlaufTreffer = url.match(/^\/api\/experiments\/(\d+)\/history$/); if (req.method === 'GET' && verlaufTreffer) { const q = new URL(req.url, 'http://localhost').searchParams; const zahl = (name) => { const v = q.get(name); if (v === null) return undefined; return /^\d{1,15}$/.test(v) ? Number(v) : NaN; }; const seite = { after: zahl('after'), before: zahl('before'), limit: zahl('limit') }; if (Object.values(seite).some((v) => Number.isNaN(v))) { jsonErr(res, 400, 'after, before and limit must be whole numbers'); return; } try { jsonOk(res, db.experimentHistoryPage(database, parseInt(verlaufTreffer[1], 10), seite)); } catch (err) { safeErr(res, err); } return; } // Das Laborbuch zum Drucken: ein Versuch (?experiment=id) oder die Versuche // eines Zeitraums (?from=JJJJ-MM-TT&to=JJJJ-MM-TT), ohne beides alle. Mit // allem, was an ihnen haengt, auch dem Entfernten, dem Verlauf und den // Pruefsummen der Fotos (siehe labBook). Nur lesen. if (req.method === 'GET' && url === '/api/lab-book') { const q = new URL(req.url, 'http://localhost').searchParams; const buchFrage = {}; const versuch = q.get('experiment'); if (versuch !== null) { if (!/^\d{1,15}$/.test(versuch)) { jsonErr(res, 400, 'experiment must be a whole number'); return; } buchFrage.experimentId = Number(versuch); } for (const name of ['from', 'to']) { const tag = q.get(name); if (tag === null) continue; const vd = validateDay(tag, name); if (vd) { jsonErr(res, 400, vd); return; } buchFrage[name] = tag; } const paket = q.get('workPackage'); if (paket !== null) { if (!/^\d{1,15}$/.test(paket)) { jsonErr(res, 400, 'workPackage must be a whole number'); return; } buchFrage.workPackageId = Number(paket); } if (buchFrage.from && buchFrage.to && buchFrage.from > buchFrage.to) { jsonErr(res, 400, 'from must not be after to'); return; } try { const buch = db.labBook(database, buchFrage); if (buchFrage.experimentId != null && !buch.experiments.length) { jsonErr(res, 404, 'unknown experiment'); return; } jsonOk(res, buch); } catch (err) { safeErr(res, err); } return; } // ── Arbeitspakete ────────────────────────────────────────── // // Die Gliederung eines Vorhabens in Arbeitspakete (siehe db.js). Anlegen und // aendern darf ein Admin; einen Versuch einem Paket zuordnen darf jeder, das // ist ein PATCH am Versuch. Geloescht wird nicht, nur ausgeblendet, und wer // was angelegt oder geaendert hat, steht im Verlauf des Pakets. const paketTreffer = url.match(/^\/api\/work-packages\/(\d+)$/); if (req.method === 'POST' && url === '/api/work-packages') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, ['code', 'title']); if (vr) { jsonErr(res, 400, vr); return; } const vt = validateTypes(data, { code: 'string', title: 'string' }); if (vt) { jsonErr(res, 400, vt); return; } const vl = validateLengths(data, { code: 20, title: 200 }); if (vl) { jsonErr(res, 400, vl); return; } try { const id = db.insertWorkPackage(database, data, req.authUser); broadcastSSE(res); jsonOk(res, { id }); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'PATCH' && paketTreffer) { if (requireAdmin(req, res)) return; const id = parseInt(paketTreffer[1], 10); jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, []); if (vr) { jsonErr(res, 400, vr); return; } const vt = validateTypes(data, { code: 'string', title: 'string', archived: 'boolean' }); if (vt) { jsonErr(res, 400, vt); return; } const vl = validateLengths(data, { code: 20, title: 200 }); if (vl) { jsonErr(res, 400, vl); return; } try { if (db.updateWorkPackage(database, id, data, req.authUser)) broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'POST' && url === '/api/experiment-runs') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, ['experimentId', 'code']); if (vr) { jsonErr(res, 400, vr); return; } const vt = validateTypes(data, { ...ANSATZ_TYPEN, experimentId: 'number' }); if (vt) { jsonErr(res, 400, vt); return; } const vs = ansatzPruefen(data); if (vs) { jsonErr(res, 400, vs); return; } const vl = validateLengths(data, { code: 40, what: 200, result: 1000 }); if (vl) { jsonErr(res, 400, vl); return; } try { const id = db.insertExperimentRun(database, data, req.authUser); broadcastSSE(res); jsonOk(res, { id }); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'PATCH' && ansatzTreffer) { const id = parseInt(ansatzTreffer[1]); jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vt = validateTypes(data, ANSATZ_TYPEN); if (vt) { jsonErr(res, 400, vt); return; } const vs = ansatzPruefen(data); if (vs) { jsonErr(res, 400, vs); return; } const vl = validateLengths(data, { code: 40, what: 200, result: 1000 }); if (vl) { jsonErr(res, 400, vl); return; } try { if (db.updateExperimentRun(database, id, data, req.authUser)) broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } // Ein Gefaess wird auch nicht geloescht, sondern entfernt: DELETE nimmt es aus // der Liste, die Zeile bleibt, und /restore holt es zurueck. if (req.method === 'DELETE' && ansatzTreffer) { try { if (db.removeExperimentRun(database, parseInt(ansatzTreffer[1], 10), req.authUser)) broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } return; } const ansatzZurueckTreffer = url.match(/^\/api\/experiment-runs\/(\d+)\/restore$/); if (req.method === 'POST' && ansatzZurueckTreffer) { try { if (db.restoreExperimentRun(database, parseInt(ansatzZurueckTreffer[1], 10), req.authUser)) broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } return; } // ── Fotos am Versuch ─────────────────────────────────────── // // Dieselbe Platte, dieselbe Deckelung, derselbe Weg wie die Fotos am // Kontaminationsbericht: data/photos/{Jahr}/{Monat}/exp-{id}/. Der Ordner // heisst nach dem Versuch, damit man ihn auch ausserhalb der Anwendung // wiederfindet — ein Karton je Versuch, nicht ein Haufen. const fotoTreffer = url.match(/^\/api\/experiments\/(\d+)\/photos\/([a-f0-9-]+)$/); if (req.method === 'GET' && fotoTreffer) { const versuchId = parseInt(fotoTreffer[1], 10); const wantThumb = /[?&]thumb=1/.test(req.url); const wantOriginal = !wantThumb && /[?&]original=1/.test(req.url); try { const foto = db.getExperimentPhotoByUuid(database, fotoTreffer[2]); if (!foto || foto.experiment_id !== versuchId) { jsonErr(res, 404, 'photo not found'); return; } if (!foto.rel_path) { jsonErr(res, 404, 'photo deleted for good'); return; } // Das Original gibt es nur bei Fotos, die eins mitgebracht haben. if (wantOriginal && !foto.original_path) { jsonErr(res, 404, 'no original for this photo'); return; } const abs = path.join( DIR, 'data', wantOriginal ? foto.original_path : wantThumb ? foto.thumb_path : foto.rel_path ); // Wie beim Kontaminationsbericht: nichts ausserhalb von data/photos, auch // wenn in der Zeile etwas anderes steht. if (!abs.startsWith(path.join(DIR, 'data', 'photos') + path.sep)) { jsonErr(res, 400, 'invalid path'); return; } if (!fs.existsSync(abs)) { jsonErr(res, 404, 'photo file missing'); return; } // Fragen statt ein Jahr lang ungefragt aus dem Browser-Cache: ein // endgueltig geloeschtes Foto kam sonst weiter aus dem Verlauf des // Browsers, und nach dem Abmelden an einem geteilten Geraet auch ohne // Anmeldung. Ein Bild aendert sich unter seiner uuid nie; die Frage kostet // eine 304 ohne Inhalt. const etag = '"' + foto.uuid + (wantThumb ? '-t' : wantOriginal ? '-o' : '') + '"'; if (etagMatches(req.headers['if-none-match'], etag)) { res.writeHead(304, { ETag: etag, 'Cache-Control': 'private, no-cache' }); res.end(); return; } res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'private, no-cache', ETag: etag }); const strom = fs.createReadStream(abs); strom.on('error', (e) => { log('warn', 'Photo stream error', { error: e.message }); res.destroy(); }); strom.pipe(res); } catch (err) { safeErr(res, err); } return; } if (req.method === 'POST' && url === '/api/experiment-photos') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, ['experimentId']); if (vr) { jsonErr(res, 400, vr); return; } const vt = validateTypes(data, { experimentId: 'number', photos: 'array', runId: 'number' }); if (vt) { jsonErr(res, 400, vt); return; } if (data.runId != null && !Number.isInteger(data.runId)) { jsonErr(res, 400, 'runId must be a whole number'); return; } if (!Array.isArray(data.photos) || !data.photos.length) { jsonErr(res, 400, 'photos must be a non-empty array'); return; } if (data.photos.length > EXP_MAX_PHOTOS_PER_CALL) { jsonErr(res, 400, 'photos: at most ' + EXP_MAX_PHOTOS_PER_CALL + ' per request'); return; } // Ein Eintrag, der kein Objekt ist, liess savePhotoToDisk null liefern, // und der Zugriff darauf endete als 500. Das ist eine kaputte Anfrage, // kein Fehler des Servers. if (!data.photos.every((p) => p && typeof p === 'object' && !Array.isArray(p))) { jsonErr(res, 400, 'photos: every entry must be an object'); return; } const versuch = database.prepare('SELECT id FROM experiments WHERE id=?').get(data.experimentId); if (!versuch) { jsonErr(res, 404, 'unknown experiment'); return; } // Auf der Platte gilt keine Transaktion. Was geschrieben wurde, merken wir // uns, damit ein Fehler in der Mitte keine verwaisten Dateien hinterlaesst. const geschrieben = []; const ids = []; try { database.exec('BEGIN'); try { for (const foto of data.photos) { const gespeichert = savePhotoToDisk(photoCap.experimentPhotoScope(data.experimentId), foto, req.authUser); geschrieben.push(path.join(DIR, 'data', gespeichert.rel_path)); geschrieben.push(path.join(DIR, 'data', gespeichert.thumb_path)); ids.push(db.addExperimentPhoto(database, data.experimentId, data.runId || null, gespeichert, req.authUser)); } database.exec('COMMIT'); } catch (inner) { database.exec('ROLLBACK'); throw inner; } broadcastSSE(res); jsonOk(res, { ids }); } catch (err) { // Wegraeumen und die Bytes der Deckelung zurueckgeben, wie beim // Kontaminationsbericht. _discardPhotoFiles(geschrieben); // Wie beim Kontaminationsbericht: eine volle Ablage ist 507, damit der // Client "voll" sagen kann statt "Serverfehler". if (err && err.name === 'PhotoCapError') jsonErr(res, 507, err.message); else safeErr(res, err); } }); return; } // Das Original zu einem Foto, in voller Aufloesung, als roher JPEG-Inhalt. // Die Oberflaeche schickt es gleich nach dem Foto, das sie verkleinert hat, // eins nach dem anderen; Base64 in JSON waeren ein Drittel mehr und eine // Anfrage, die mit acht Originalen 300 MB wiegt. Der Standort geht vor dem // Speichern heraus (scripts/jpeg-location.js), und die Pruefsumme ist die // der gespeicherten Datei. Einmal je Foto: ein Original wird nicht ersetzt. // // Lesen, pruefen, schreiben und eintragen laufen nach dem letzten Byte ohne // Unterbrechung; zwei Anfragen fuer dasselbe Foto koennen sich also nicht // ueberholen, die zweite findet das Original der ersten vor. const originalTreffer = url.match(/^\/api\/experiment-photos\/(\d+)\/original$/); if (req.method === 'PUT' && originalTreffer) { const fotoId = parseInt(originalTreffer[1], 10); const teile = []; let groesse = 0; let zuGross = false; // Hinter dem HTTPS-Port hat der Server keine eigene Wartezeit fuer eine // Anfrage: die Verbindungen reicht ihm der Port-Verteiler herein, und // Node stellt requestTimeout nur fuer einen Server, der selbst lauscht. // Ein Handy, das mitten im Hochladen aus dem WLAN faellt, schickt kein // Ende; bis zu 40 MB lagen dann in `teile` bis zum naechsten Neustart. // Kommt eine Minute lang nichts, wird die Anfrage beendet. req.setTimeout(ORIGINAL_IDLE_MS, () => { teile.length = 0; req.destroy(); }); req.on('data', (c) => { if (zuGross) return; groesse += c.length; if (groesse > ORIGINAL_MAX_BYTES) { zuGross = true; jsonErr(res, 413, 'original too large (max ' + Math.round(ORIGINAL_MAX_BYTES / (1024 * 1024)) + ' MB)'); req.destroy(); return; } teile.push(c); }); req.on('end', () => { if (zuGross) return; let geschrieben = null; try { const foto = db.getExperimentPhotoById(database, fotoId); if (!foto || !foto.rel_path) { jsonErr(res, 404, 'photo not found'); return; } if (foto.removed_at) { jsonErr(res, 409, 'photo is removed'); return; } // Wer das Foto hochgeladen hat, oder ein Admin: das Original belegt im // Laborbuch, was die Kamera aufgenommen hat, und laesst sich nicht // ersetzen. Offen fuer jedes Konto, haette jeder ein beliebiges Bild // als Original an ein fremdes, laengst gespeichertes Foto haengen // koennen. const user = req.authUser || {}; if (user.role !== 'admin' && !(foto.uploaded_by && foto.uploaded_by === user.user_id)) { jsonErr(res, 403, 'only whoever uploaded the photo, or an admin, can add its original'); return; } const roh = Buffer.concat(teile); if (!jpegLocation.isJpeg(roh)) { jsonErr(res, 415, 'an original must be a JPEG'); return; } // EXIF, das sich nicht bis zum Ende lesen laesst, ueberschreibt // scrubLocation ganz (bis auf die Ausrichtung): so kommt jedes // Original ohne Standort an, statt abgelehnt und verloren zu werden. const ohneOrt = jpegLocation.scrubLocation(roh); const daten = ohneOrt.buf; // Das Original muss zum Foto passen: dasselbe Seitenverhaeltnis wie // das verkleinerte Bild, das der Browser aus ihm gemacht hat (beide // aufrecht gemessen). Ein anderes Bild faellt so in aller Regel auf. const masse = jpegLocation.jpegSize(daten) || {}; if (masse.width && masse.height && foto.width && foto.height) { const r1 = masse.width / masse.height; const r2 = foto.width / foto.height; if (Math.abs(r1 - r2) / r2 > ORIGINAL_ASPECT_TOLERANCE) { jsonErr(res, 422, 'the original does not match this photo (' + masse.width + '×' + masse.height + ')'); return; } } if (foto.original_sha256) { // Dasselbe Original noch einmal ist kein Fehler: die Antwort auf // das erste ging unterwegs verloren (Netzwechsel, Handy im // Hintergrund), und die Wiederholung soll hoeren, dass es da ist. // Als 409 meldete die Oberflaeche ein gespeichertes Original als // nicht gespeichert. const sha = crypto.createHash('sha256').update(daten).digest('hex'); if (sha === foto.original_sha256) { jsonOk(res, { original: Object.assign({ already: true }, db.fotoOriginal(foto)) }); return; } jsonErr(res, 409, 'photo already has its original'); return; } photoCap.enforceCap(_ensurePhotoDirSize(false), daten.length, PHOTO_DIR_MAX_BYTES); const rel = path.posix.join(path.posix.dirname(foto.rel_path), foto.uuid + '_orig.jpg'); const ziel = path.join(DIR, 'data', rel); if (!ziel.startsWith(path.join(DIR, 'data', 'photos') + path.sep)) { jsonErr(res, 400, 'invalid path'); return; } fs.mkdirSync(path.dirname(ziel), { recursive: true }); // Die Platte, auf die das Original kommt: der Fotoordner oder ein Jahr // darin kann von einer anderen Platte hereinverlinkt sein. _ensurePhotoSpace(path.dirname(ziel), daten.length, PHOTO_MIN_FREE_BYTES); // 'w', nicht 'wx': eine Datei, die hier liegt, ohne dass die Zeile ein // Original kennt, stammt von einem Versuch, der vor dem Eintragen // abbrach. Sie wird ersetzt. Bricht das Schreiben selbst ab (Platte // voll, ein Virenscanner), raeumt der Rueckbau unten die halbe Datei // sofort wieder weg: keine Zeile nennt sie, und das endgueltige // Loeschen des Fotos faende sie sonst nur ueber ihren Namen. Er zieht // dabei ab, was er findet; gezaehlt wird darum auch ein Rest. geschrieben = ziel; try { fs.writeFileSync(ziel, daten); } catch (e) { const rest = fs.statSync(ziel, { throwIfNoEntry: false }); if (rest) _photoDirSizeBytes += rest.size; throw e; } _photoDirSizeBytes += daten.length; const original = { path: rel, bytes: daten.length, sha256: crypto.createHash('sha256').update(daten).digest('hex'), width: masse.width ?? null, height: masse.height ?? null }; db.setExperimentPhotoOriginal(database, fotoId, original, req.authUser); geschrieben = null; log('info', 'Experiment photo original stored', { photoId: fotoId, bytes: original.bytes, locationRemoved: ohneOrt.locationRemoved }); broadcastSSE(res); jsonOk(res, { original: { bytes: original.bytes, sha256: original.sha256, width: original.width, height: original.height, locationRemoved: ohneOrt.locationRemoved } }); } catch (err) { if (geschrieben && _discardPhotoFiles([geschrieben]).length) { log('error', 'Original left on disk after a failed save', { photoId: fotoId, file: path.relative(path.join(DIR, 'data'), geschrieben) }); } if (err && err.name === 'PhotoCapError') jsonErr(res, 507, err.message); else safeErr(res, err); } }); return; } // Entfernen, nicht loeschen, wie beim Gefaess. Die Datei bleibt auf der // Platte: ein entferntes Foto laesst sich zurueckholen, und der Verlauf nennt // seine Pruefsumme, die ohne die Datei nichts mehr belegen koennte. const fotoWegTreffer = url.match(/^\/api\/experiment-photos\/(\d+)$/); if (req.method === 'DELETE' && fotoWegTreffer) { try { if (db.removeExperimentPhoto(database, parseInt(fotoWegTreffer[1], 10), req.authUser)) broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } return; } // Endgueltig loeschen: nur ein Admin, nur ein entferntes Foto. Erst die Zeile // (Grabstein und Verlauf), dann die Dateien; scheitert das Loeschen einer // Datei, liegt sie verwaist da, statt dass eine Zeile auf nichts zeigt. const fotoEndgueltigTreffer = url.match(/^\/api\/experiment-photos\/(\d+)\/purge$/); if (req.method === 'POST' && fotoEndgueltigTreffer) { if (requireAdmin(req, res)) return; try { const fotoId = parseInt(fotoEndgueltigTreffer[1], 10); const pfade = db.purgeExperimentPhoto(database, fotoId, req.authUser); // Nur, was unter data/photos liegt, auch wenn in der Zeile etwas anderes // stand; die Bytes gehen an die Deckelung zurueck. // Das Original auch unter seinem Namen (_orig.jpg neben dem // Foto), wenn die Zeile keines kennt: eines, das ein abgebrochener // Versuch oder eine aeltere zurueckgespielte Datenbank hinterliess, // ueberlebte sonst das endgueltige Loeschen, im Spiegel und auf dem // Stick gleich mit. const original = pfade && (pfade.original_path || (/\.jpg$/.test(pfade.rel_path) ? pfade.rel_path.replace(/\.jpg$/, '_orig.jpg') : null)); const uebrig = _discardPhotoFiles( (pfade ? [pfade.rel_path, pfade.thumb_path, original].filter(Boolean) : []) .map((rel) => path.join(DIR, 'data', rel)) .filter((abs) => abs.startsWith(path.join(DIR, 'data', 'photos') + path.sep)) ); // Die Zeile ist jetzt ein Grabstein und nennt die Dateien nicht mehr; // wer den Rest von Hand loescht, findet ihn nur hier. Und der Admin soll // nicht "geloescht" lesen, waehrend das Bild noch auf der Platte liegt. if (uebrig.length) { log('error', 'Photo purge left files on disk', { photoId: fotoId, files: uebrig.map((abs) => path.relative(path.join(DIR, 'data'), abs)) }); } if (pfade) broadcastSSE(res); jsonOk(res, uebrig.length ? { ok: true, leftoverFiles: uebrig.length } : undefined); } catch (err) { safeErr(res, err); } return; } const fotoZurueckTreffer = url.match(/^\/api\/experiment-photos\/(\d+)\/restore$/); if (req.method === 'POST' && fotoZurueckTreffer) { try { if (db.restoreExperimentPhoto(database, parseInt(fotoZurueckTreffer[1], 10), req.authUser)) broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } return; } // ── Notizen je Vorhaben-Reiter ───────────────────────────── // // Formloser Text, der zu einem Reiter gehoert und zu keinem Datensatz. Eine // Notiz darf geloescht werden — anders als ein Versuch, der abgeschlossen // wird. Der Unterschied ist Absicht: ein Laborbuch bewahrt, ein Zettel nicht. const notizTreffer = url.match(/^\/api\/project-notes\/(\d+)$/); if (req.method === 'POST' && url === '/api/project-notes') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, ['tab', 'body']); if (vr) { jsonErr(res, 400, vr); return; } const vt = validateTypes(data, { tab: 'string', body: 'string' }); if (vt) { jsonErr(res, 400, vt); return; } const vl = validateLengths(data, { tab: 20, body: 20000 }); if (vl) { jsonErr(res, 400, vl); return; } try { const id = db.insertProjectNote(database, data); broadcastSSE(res); jsonOk(res, { id }); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'PATCH' && notizTreffer) { const id = parseInt(notizTreffer[1]); jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vt = validateTypes(data, { body: 'string' }); if (vt) { jsonErr(res, 400, vt); return; } const vl = validateLengths(data, { body: 20000 }); if (vl) { jsonErr(res, 400, vl); return; } try { if (db.updateProjectNote(database, id, data)) broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'DELETE' && notizTreffer) { try { if (db.deleteProjectNote(database, parseInt(notizTreffer[1], 10))) broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } return; } // ── Vorhaben-Texte ───────────────────────────────────────── // // Die Unterlagen hinter Forschung, Produkte und Bau. Lesen darf, wer die Seite // sieht; ersetzen nur ein Admin, und zwar reiterweise im Ganzen, aus derselben // Datei, die ausserhalb des Trackers gepflegt wird. Eigener Abruf statt eines // Feldes in /api/data: die Texte sind lang und aendern sich selten. if (req.method === 'GET' && url === '/api/project-docs') { try { jsonOk(res, { docs: db.getProjectDocs(database) }); } catch (err) { safeErr(res, err); } return; } if (req.method === 'PUT' && url === '/api/project-docs') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { const tabs = db.setProjectDocs(database, data, req.authUser.user_id); broadcastSSE(res); jsonOk(res, { tabs }); } catch (err) { safeErr(res, err); } }); return; } // -- Team Members -- if (req.method === 'POST' && req.url === '/api/team') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, ['name']); if (vr) { jsonErr(res, 400, vr); return; } const vt = validateTypes(data, { name: 'string', role: 'string' }); if (vt) { jsonErr(res, 400, vt); return; } const vlen = validateLengths(data, { name: 100, role: 100 }); if (vlen) { jsonErr(res, 400, vlen); return; } try { const id = db.insertMember(database, data); broadcastSSE(res); jsonOk(res, { id }); } catch (err) { safeErr(res, err); } }); return; } const teamMatch = req.url.match(/^\/api\/team\/(\d+)$/); if (req.method === 'DELETE' && teamMatch) { if (requireAdmin(req, res)) return; const id = parseInt(teamMatch[1]); try { db.deleteMember(database, id); broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } return; } // -- Zones -- if (req.method === 'POST' && req.url === '/api/zones') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, ['id', 'name', 'role', 'color']); if (vr) { jsonErr(res, 400, vr); return; } const vlen = validateLengths(data, { name: 50 }); if (vlen) { jsonErr(res, 400, vlen); return; } if (!/^[A-Z][A-Z0-9_]{0,19}$/.test(data.id)) { jsonErr(res, 400, 'Zone ID must be uppercase letters/digits/underscore, 1-20 chars'); return; } const ve = validateEnum(data.role, ['spawn', 'incubation', 'fruiting', 'contaminated'], 'role'); if (ve) { jsonErr(res, 400, ve); return; } if (!/^#[0-9a-fA-F]{6}$/.test(data.color)) { jsonErr(res, 400, 'Invalid color'); return; } if (data.racks && Array.isArray(data.racks)) { for (const r of data.racks) { if (!/^[A-Z][A-Z0-9_]{0,29}$/.test(r)) { jsonErr(res, 400, 'Invalid rack ID: ' + r); return; } } } if (data.maxCapacity !== undefined && data.maxCapacity !== null) { data.maxCapacity = parseInt(data.maxCapacity, 10); if (!Number.isFinite(data.maxCapacity) || data.maxCapacity < 1) { jsonErr(res, 400, 'maxCapacity must be a positive integer'); return; } } try { db.insertZone(database, data); broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'POST' && req.url === '/api/zones/reorder') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } if (!Array.isArray(data.order)) { jsonErr(res, 400, 'order must be an array of zone IDs'); return; } try { db.reorderZones(database, data.order); broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } const zoneMatch = req.url.match(/^\/api\/zones\/([^/]+)$/); if (req.method === 'DELETE' && zoneMatch) { if (requireAdmin(req, res)) return; const id = decodeURIComponent(zoneMatch[1]); if (!db.zoneExists(database, id)) { jsonErr(res, 404, 'Zone not found'); return; } try { db.deleteZone(database, id); broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } return; } // PATCH /api/zones/:id/name — rename zone display name only (ID unchanged) const zoneNameMatch = req.url.match(/^\/api\/zones\/([^/]+)\/name$/); if (req.method === 'PATCH' && zoneNameMatch) { if (requireAdmin(req, res)) return; const id = decodeURIComponent(zoneNameMatch[1]); jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } if (!data.name || !data.name.trim()) { jsonErr(res, 400, 'name is required'); return; } if (data.name.length > 50) { jsonErr(res, 400, 'Zone name too long (max 50 chars)'); return; } try { db.renameZoneName(database, id, data.name); broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } // PATCH /api/zones/:id/capacity — set or clear the bag limit (null = no limit) const zoneCapMatch = req.url.match(/^\/api\/zones\/([^/]+)\/capacity$/); if (req.method === 'PATCH' && zoneCapMatch) { if (requireAdmin(req, res)) return; const id = decodeURIComponent(zoneCapMatch[1]); jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const cap = data.maxCapacity; // Reject anything that is not null or a whole non-negative number before it // reaches the DB — '12abc' would coerce to NaN and silently clear the limit. if (cap !== null && cap !== undefined && !(Number.isInteger(cap) && cap >= 0)) { jsonErr(res, 400, 'maxCapacity must be null or a whole number of 0 or more'); return; } try { db.setZoneCapacity(database, id, cap === undefined ? null : cap); broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } // PUT /api/week-rhythm — replace the whole weekday → theme map at once if (req.method === 'PUT' && req.url === '/api/week-rhythm') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const map = data && data.rhythm; if (!map || typeof map !== 'object' || Array.isArray(map)) { jsonErr(res, 400, 'rhythm must be an object of weekday → theme'); return; } try { const n = db.setWeekRhythm(database, map); broadcastSSE(res); jsonOk(res, { ok: true, days: n }); } catch (err) { // A bad weekday or unknown theme is the caller's fault, not a 500. if (/Not a weekday|Unknown theme|must be an object/.test(err.message)) { jsonErr(res, 400, err.message); return; } safeErr(res, err); } }); return; } // POST /api/rhythm-task — set what ONE date is asking for. // // Bewusst nicht auf Admins beschränkt: das Wochenziel setzt, wer die Woche // arbeitet. Die Grenze verläuft nicht zwischen Rollen, sondern zwischen einer // Woche und dem Rhythmus: // // PUT /api/week-rhythm — der wiederkehrende Plan, welcher Wochentag was // ist. Admin, denn er gilt fortan. // POST /api/rhythm-start — ab wann überhaupt gezählt wird. Admin, denn er // gilt rückwirkend für die ganze Anlage. // POST /api/rhythm-task — die Zahl für einen Tag. Jeder Angemeldete. // // Und nur für einen Tag, den der Rhythmus schon zu einem Arbeitstag gemacht // hat: setRhythmTarget() wirft "No rhythm on", wenn für den Wochentag kein // Thema steht. Ein Mitarbeiter kann also eine Vorgabe ändern, aber keinen // Arbeitstag erfinden und keinen Wochentag umwidmen. // // Angemeldet sein muss er: alles außerhalb der öffentlichen Liste läuft oben // durch checkAuth(). // // Der Vorgänger dieses Kommentars begründete die Ausnahme mit dem Melden // erledigter Arbeit. Das gibt es nicht mehr — was gemacht wurde, wird // gezählt, nicht gemeldet — also stand hier eine Begründung für einen Zweig, // den es nicht mehr gab. if (req.method === 'POST' && req.url === '/api/rhythm-task') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const body = data || {}; // Either field, or both: adjusting what a day asks for and recording what // it produced are the same kind of edit to the same row. // doneQty is no longer accepted: what was made is counted from the // batches, cultures, moves and harvests themselves. A second figure that // somebody had to type was the one nobody kept up to date. if (body.targetQty === undefined) { jsonErr(res, 400, 'targetQty is required'); return; } try { const out = { ok: true }; // followsRhythm sagt, dass der Tag jetzt wieder der Vorlage folgt statt // eine eigene Zahl zu tragen. Der Client muss das wissen, sonst behielte // er eine Zeile, die es serverseitig nicht mehr gibt. const gesetzt = db.setRhythmTarget(database, body.date, body.targetQty); out.targetQty = gesetzt.targetQty; out.followsRhythm = gesetzt.followsRhythm; broadcastSSE(res); jsonOk(res, out); } catch (err) { // 'No planned work' warf nur setRhythmProgress(), und das ist fort. if (/Not a date|whole number|No rhythm on|implausibly large|Target/.test(err.message)) { jsonErr(res, 400, err.message); return; } safeErr(res, err); } }); return; } const zoneRackMatch = req.url.match(/^\/api\/zones\/([^/]+)\/racks$/); if (req.method === 'POST' && zoneRackMatch) { if (requireAdmin(req, res)) return; const zoneId = decodeURIComponent(zoneRackMatch[1]); jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } if (!data.id || !/^[A-Z][A-Z0-9_]{0,29}$/.test(data.id)) { jsonErr(res, 400, 'Invalid rack ID'); return; } if (!db.zoneExists(database, zoneId)) { jsonErr(res, 404, 'Zone not found'); return; } try { db.insertRack(database, { id: data.id, zoneId, sortOrder: data.sortOrder || 0 }); broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } const rackMatch = req.url.match(/^\/api\/racks\/([^/]+)$/); if (req.method === 'DELETE' && rackMatch) { if (requireAdmin(req, res)) return; const id = decodeURIComponent(rackMatch[1]); try { db.deleteRack(database, id); broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } return; } // -- CalDAV Config -- // ── CalDAV app-specific passwords (S-25) ────────────────── // Every user manages their own, including admins; there is deliberately no // route that touches somebody else's. The plaintext is shown exactly once, // at creation, and never stored. if (req.url === '/api/caldav/app-passwords' && req.method === 'GET') { try { jsonOk(res, { items: db.listCaldavAppPasswords(database, req.authUser.user_id) }); } catch (err) { safeErr(res, err); } return; } if (req.url === '/api/caldav/app-passwords' && req.method === 'POST') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { const created = db.createCaldavAppPassword(database, req.authUser.user_id, data && data.label); log('info', 'CalDAV app password created', { actor: req.authUser.username, label: created.label }); // `password` is in this response and nowhere else, ever. jsonOk(res, created); } catch (err) { safeErr(res, err); } }); return; } const caldavPwMatch = req.url.match(/^\/api\/caldav\/app-passwords\/(\d+)$/); if (caldavPwMatch && req.method === 'DELETE') { try { const gone = db.deleteCaldavAppPassword(database, req.authUser.user_id, parseInt(caldavPwMatch[1], 10)); if (!gone) { jsonErr(res, 404, 'not found'); return; } clearCaldavAuthCache(); log('info', 'CalDAV app password revoked', { actor: req.authUser.username }); jsonOk(res, { ok: true }); } catch (err) { safeErr(res, err); } return; } if (req.method === 'POST' && req.url === '/api/caldav/config') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { const wasBefore = db.readCaldavConfig(database); db.updateCaldavCfg(database, data); log('info', 'CalDAV config updated', { actor: req.authUser.username }); // Trigger full sync when CalDAV is newly enabled if (data.enabled && !wasBefore.enabled) { try { autoSyncAllCaldav(readData()); log('info', 'CalDAV initial sync triggered on enable'); } catch (ce) { log('error', 'CalDAV initial sync failed', { error: ce.message }); } } jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } // -- Shipping (Versand / Phase 4) -- // Config (provider keys + defaults) is admin-only; the secret is masked on GET // and preserved on PATCH when the field is left blank (mirrors DuckDNS). if (req.method === 'GET' && req.url === '/api/ship/config') { if (requireAdmin(req, res)) return; try { const cfg = db.getShippingConfig(database); jsonOk(res, { provider: cfg.provider, enabled: cfg.enabled, mode: cfg.mode, publicKey: cfg.publicKey, hasSecret: !!cfg.secretKey, senderAddressId: cfg.senderAddressId, defaultMethod: cfg.defaultMethod, defaultWeightG: cfg.defaultWeightG }); } catch (err) { safeErr(res, err); } return; } if (req.method === 'PATCH' && req.url === '/api/ship/config') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { // Blank secret = keep the stored one (GET never reveals it). if (data.secretKey === '' || data.secretKey == null) delete data.secretKey; db.updateShippingConfig(database, data); broadcastSSE(res); const cfg = db.getShippingConfig(database); jsonOk(res, { provider: cfg.provider, enabled: cfg.enabled, mode: cfg.mode, publicKey: cfg.publicKey, hasSecret: !!cfg.secretKey, defaultMethod: cfg.defaultMethod, defaultWeightG: cfg.defaultWeightG }); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'GET' && req.url === '/api/ship/test') { if (requireAdmin(req, res)) return; (async () => { try { const cfg = db.getShippingConfig(database); if (!cfg.publicKey || !cfg.secretKey) { jsonErr(res, 400, 'Keine API-Keys konfiguriert'); return; } const r = await ship.getProvider(cfg).testConnection(cfg); jsonOk(res, r); } catch (err) { jsonErr(res, 502, err.message || 'connection failed'); } })(); return; } // GET /api/ship/methods?country=DE&weight=1000 if (req.method === 'GET' && req.url.startsWith('/api/ship/methods')) { if (requireShipping(req, res)) return; (async () => { try { const cfg = db.getShippingConfig(database); if (!cfg.publicKey || !cfg.secretKey) { jsonErr(res, 400, 'Versand nicht konfiguriert'); return; } const q = new URL(req.url, 'http://x').searchParams; const toCountry = (q.get('country') || 'DE').toUpperCase(); const weightG = parseInt(q.get('weight') || '', 10) || undefined; const methods = await ship.getProvider(cfg).listMethods(cfg, { toCountry, weightG }); jsonOk(res, { methods }); } catch (err) { jsonErr(res, 502, err.message || 'methods failed'); } })(); return; } // POST /api/ship/label { orderId, methodId, weightG, address:{...} } — buys a label. if (req.method === 'POST' && req.url === '/api/ship/label') { if (requireShipping(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } (async () => { try { const cfg = db.getShippingConfig(database); if (!cfg.publicKey || !cfg.secretKey) { jsonErr(res, 400, 'Versand nicht konfiguriert'); return; } const live = cfg.mode === 'live'; const orderId = parseInt(data.orderId, 10); if (!orderId) { jsonErr(res, 400, 'orderId required'); return; } if (!data.methodId) { jsonErr(res, 400, 'methodId required'); return; } // Idempotency: block a concurrent second buy for the same order so a // double-click / retry / replay can't bill the owner twice. if (_labelBuysInFlight.has(orderId)) { jsonErr(res, 409, 'Label-Kauf läuft bereits für diese Bestellung'); return; } _labelBuysInFlight.add(orderId); try { if (data.address && typeof data.address === 'object') { db.updateOrderShipAddress(database, orderId, data.address); } const order = db.getOrderForShipping(database, orderId); if (!order) { jsonErr(res, 404, 'order not found'); return; } // Idempotency across requests: if a real (billable) label already exists // for this order, never buy a second one. _labelBuysInFlight only blocks // truly concurrent requests; a later retry / replay / double-submit would // otherwise bill the owner twice. (A test announce is not billable.) if (live && db.getBilledShipment(database, orderId)) { jsonErr(res, 409, 'Für diese Bestellung wurde bereits ein Label gekauft'); return; } // Reject non-positive / garbage weights before they reach the carrier. const reqW = parseInt(data.weightG, 10); const weightG = (Number.isFinite(reqW) && reqW > 0 ? reqW : null) || order.shipWeightG || cfg.defaultWeightG || 1000; // In test mode only ANNOUNCE the parcel (request_label:false) — no // billable label is bought, so a misread "test" toggle never charges. const result = await ship .getProvider(cfg) .buyLabel(cfg, { order, methodId: data.methodId, weightG, requestLabel: live }); const id = db.insertShipment(database, { orderId, provider: cfg.provider, methodId: data.methodId, ...result }); // Push the Sendungsnummer back onto the order's sales channel (live only; // an announced test parcel has no real tracking). Best-effort: a write- // back failure never fails the (already-bought) label. let channelPushed = false; let pushError = null; if ( live && ['wix', 'ebay', 'etsy', 'billbee'].includes(order.channel) && db.getChannelConfig(database, order.channel).enabled ) { try { // enabled is checked above, so a disabled channel skips the token refresh. const { cfg: chanCfg, prov } = await withFreshChannelToken(order.channel); if (typeof prov.pushTracking === 'function') { const rawRow = database.prepare('SELECT raw_json FROM orders WHERE id = ?').get(orderId); const raw = rawRow && rawRow.raw_json ? JSON.parse(rawRow.raw_json) : null; await prov.pushTracking(chanCfg, { order, raw, trackingNumber: result.trackingNumber, trackingUrl: result.trackingUrl, carrier: result.carrier }); channelPushed = true; db.updateShipmentStatus(database, id, { channelPushed: true }); } } catch (e3) { pushError = e3.message || 'tracking push failed'; try { db.updateShipmentStatus(database, id, { error: 'tracking push: ' + pushError }); } catch (e4) { /* ignore */ } } } // A bought (live) label means the order is going out; a test announce does not. if (live) { try { database .prepare("UPDATE orders SET status='shipped', updated=? WHERE id=?") .run(new Date().toISOString(), orderId); } catch (e2) { /* status is best-effort */ } } broadcastSSE(res); jsonOk(res, { id, ...result, channelPushed, pushError, test: !live }); } finally { _labelBuysInFlight.delete(orderId); } } catch (err) { jsonErr(res, 502, err.message || 'label failed'); } })(); }); return; } // GET /api/ship/shipments?orderId= if (req.method === 'GET' && req.url.startsWith('/api/ship/shipments')) { if (requireShipping(req, res)) return; try { const orderId = new URL(req.url, 'http://x').searchParams.get('orderId'); const shipments = db.listShipments(database, orderId ? { orderId: parseInt(orderId, 10) } : {}); jsonOk(res, { shipments }); } catch (err) { safeErr(res, err); } return; } // GET /api/ship/label/:id/pdf — proxy the carrier label PDF (keeps API keys server-side). const shipPdfMatch = req.url.match(/^\/api\/ship\/label\/(\d+)\/pdf$/); if (req.method === 'GET' && shipPdfMatch) { if (requireShipping(req, res)) return; (async () => { try { const cfg = db.getShippingConfig(database); const sh = db.getShipmentById(database, parseInt(shipPdfMatch[1], 10)); if (!sh || !sh.labelUrl) { jsonErr(res, 404, 'label not found'); return; } const buf = await ship.getProvider(cfg).fetchLabelPdf(cfg, sh.labelUrl); res.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Disposition': 'inline; filename="label-' + sh.id + '.pdf"' }); res.end(buf); } catch (err) { jsonErr(res, 502, err.message || 'pdf failed'); } })(); return; } // GET /api/ship/order/:id — order ship-to fields (prefills the buy-label modal). const shipOrderMatch = req.url.match(/^\/api\/ship\/order\/(\d+)$/); if (req.method === 'GET' && shipOrderMatch) { if (requireShipping(req, res)) return; try { const o = db.getOrderForShipping(database, parseInt(shipOrderMatch[1], 10)); if (!o) { jsonErr(res, 404, 'order not found'); return; } jsonOk(res, o); } catch (err) { safeErr(res, err); } return; } // -- Sales channels (live order sync) -- if (req.method === 'GET' && req.url === '/api/channels') { if (requireAdmin(req, res)) return; try { jsonOk(res, { channels: db.listChannelConfigs(database), // The exact URLs to paste into the provider portals. They are derived from // PUBLIC_BASE_URL, and eBay's challenge hash covers the deletion URL, so a // mismatch fails verification — showing them removes the guesswork. urls: { ebayDeletion: PUBLIC_BASE_URL + '/api/channels/ebay/deletion', ebayCallback: _channelCallbackUrl('ebay'), etsyCallback: _channelCallbackUrl('etsy') } }); } catch (err) { safeErr(res, err); } return; } const chanCfgMatch = req.url.match(/^\/api\/channels\/(wix|etsy|ebay|billbee)$/); if (req.method === 'PATCH' && chanCfgMatch) { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { // Blank fields = keep the stored value, so a save with an empty input (e.g. // after a failed config load) can't wipe stored creds; secrets are never // revealed in the list view anyway. ['apiKey', 'clientSecret', 'accessToken', 'refreshToken', 'clientId', 'siteId', 'webhookSecret'].forEach( (k) => { if (data[k] === '' || data[k] == null) delete data[k]; } ); // Caught while somebody is still looking at the field they typed it in. // A colon breaks basic auth outright in the login, and Billbee documents // it as breaking the connection in the API password — either way it // arrives as a plain 401 that reads as "the key was never approved". if (chanCfgMatch[1] === 'billbee') { const bad = channels.billbeeCredentialProblem(data); if (bad) { jsonErr(res, 400, bad); return; } } db.updateChannelConfig(database, chanCfgMatch[1], data); broadcastSSE(res); jsonOk(res, { channels: db.listChannelConfigs(database) }); } catch (err) { safeErr(res, err); } }); return; } const chanTestMatch = req.url.match(/^\/api\/channels\/(wix|etsy|ebay|billbee)\/test$/); if (req.method === 'POST' && chanTestMatch) { if (requireAdmin(req, res)) return; (async () => { try { const { cfg, prov } = await withFreshChannelToken(chanTestMatch[1]); const r = await prov.testConnection(cfg); jsonOk(res, r); } catch (err) { jsonErr(res, 502, err.message || 'test failed'); } })(); return; } const chanSyncMatch = req.url.match(/^\/api\/channels\/(wix|etsy|ebay|billbee)\/sync$/); if (req.method === 'POST' && chanSyncMatch) { if (requireAdmin(req, res)) return; (async () => { const r = await syncChannelOrders(chanSyncMatch[1], res); // 409, not 502: nothing upstream failed. This channel is stood down // because Billbee is carrying it, and that is a conflict in this server's // own configuration, not a bad gateway. if (r.supersededBy) jsonErr(res, 409, r.error); else if (r.error) jsonErr(res, 502, r.error); else jsonOk(res, { imported: r.imported }); })(); return; } // -- Syringe stock → Billbee (see syringe-stock.js) -- const syringeStockState = () => { const bb = db.listChannelConfigs(database).find((c) => c.channel === 'billbee') || {}; return { enabled: db.syringeStockSyncEnabled(database), billbeeReady: !!(bb.enabled && bb.connected), worktree: WORKTREE_MODE, rows: db.listSyringeStock(database) }; }; if (req.method === 'GET' && req.url === '/api/syringe-stock') { if (requireAdmin(req, res)) return; try { jsonOk(res, syringeStockState()); } catch (err) { safeErr(res, err); } return; } if (req.method === 'PATCH' && req.url === '/api/syringe-stock') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { if (data.enabled !== undefined) db.setSyringeStockSyncEnabled(database, !!data.enabled); // broadcastSSE also asks for the recount, so switching on reports at once. broadcastSSE(res); jsonOk(res, syringeStockState()); } catch (err) { safeErr(res, err); } }); return; } // /api/syringe-stock//, die Petrischale unter //pd, ein Beutel // Koernerbrut unter //kb; ohne Groesse die 10er, wie vor der Wahl. const syringeSkuMatch = req.url.match(/^\/api\/syringe-stock\/(\d+)(?:\/(\d+|pd|kb\d+))?$/); if (req.method === 'PUT' && syringeSkuMatch) { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { db.setSyringeSku(database, Number(syringeSkuMatch[1]), syringeSkuMatch[2] || 10, data.sku); } catch (err) { // The refusals setSyringeSku makes are meant for the person typing. jsonErr(res, 400, err.message); return; } broadcastSSE(res); jsonOk(res, syringeStockState()); }); return; } // The same count for the Labor page, readable by anyone signed in: the lab // decides what to make next from it, and most of the lab is not admin. Only // what a shop sells (a row with a SKU); setting SKUs stays with the admin // routes above. const saleStockState = () => ({ enabled: db.syringeStockSyncEnabled(database), rows: db .listSyringeStock(database) .filter((r) => r.sku) .map((r) => ({ strainId: r.strainId, name: r.name, type: r.type, slot: r.slot, ml: r.ml, kg: r.kg, count: r.count, // Koernerbrut, die noch waechst, und wann der erste Beutel fertig ist. growing: r.growing || 0, readyAt: r.readyAt || null, // Offen bestellt, das Soll und was daraus fehlt (db.saleNeed). ordered: r.ordered, soll: r.soll, make: r.make, urgency: r.urgency, pushedCount: r.pushedCount, pushedAt: r.pushedAt, billbeeStock: r.billbeeStock, lastError: r.lastError })) }); // Der ganze Laborbestand je Art (db.labStock): was da ist, was fehlt, und für // die Artikel eines Shops, was Billbee zuletzt bekam. Die Laborseite und // Arbeitsgänge lesen nur das; gesetzt wird über die Sorte // (PATCH /api/mushroom-strains/) und für eine Verkaufsgröße hierunter. if (req.method === 'GET' && req.url === '/api/lab-stock') { try { jsonOk(res, { enabled: db.syringeStockSyncEnabled(database), species: db.labStock(database) }); } catch (err) { safeErr(res, err); } return; } if (req.method === 'GET' && req.url === '/api/sale-stock') { try { jsonOk(res, saleStockState()); } catch (err) { safeErr(res, err); } return; } // Das Soll eines Artikels: /api/sale-stock//, slot wie bei der // SKU (10, 20, 50, pd, kb1, kb2, kb5). Wie viele da sein sollen, entscheidet // das Labor, also darf jeder Angemeldete es setzen. const saleTargetMatch = req.url.match(/^\/api\/sale-stock\/(\d+)\/(\d+|pd|kb\d+)$/); if (req.method === 'PUT' && saleTargetMatch) { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { db.setSaleTarget(database, Number(saleTargetMatch[1]), saleTargetMatch[2], data.soll); } catch (err) { // The refusals setSaleTarget makes are meant for the person tapping. jsonErr(res, 400, err.message); return; } broadcastSSE(res); jsonOk(res, saleStockState()); }); return; } if (req.method === 'POST' && req.url === '/api/syringe-stock/push') { if (requireAdmin(req, res)) return; (async () => { const r = await syringeStock.run({ force: true }); const state = syringeStockState(); if (r.skipped === 'off') jsonErr(res, 409, 'Der Abgleich ist aus. Erst oben einschalten.'); else if (r.skipped === 'billbee') jsonErr(res, 409, 'Billbee ist nicht verbunden. Erst die Billbee-Karte oben ausfüllen.'); else if (r.skipped === 'worktree') jsonErr(res, 409, 'Diese Kopie des Servers meldet nichts an Billbee.'); else jsonOk(res, { ...r, ...state }); })(); return; } // -- Sales-channel OAuth (connect eBay / Etsy) -- // Admin starts the login; we return the provider's authorize URL for the browser // to open. (Token-based admin auth means we can't just 302 the top-level nav.) const chanOAuthStart = req.url.match(/^\/api\/channels\/(etsy|ebay)\/oauth\/start$/); if (req.method === 'GET' && chanOAuthStart) { if (requireAdmin(req, res)) return; try { const channel = chanOAuthStart[1]; const cfg = db.getChannelConfig(database, channel); if (!cfg.clientId) { jsonErr(res, 400, channel === 'etsy' ? 'Etsy Keystring fehlt' : 'eBay App-ID fehlt'); return; } if (channel === 'ebay' && (!cfg.clientSecret || !cfg.siteId)) { jsonErr(res, 400, 'eBay Cert-ID + RuName erforderlich'); return; } _oauthGc(); const state = crypto.randomBytes(16).toString('hex'); let codeVerifier = null; let codeChallenge = null; if (channel === 'etsy') { const p = channels.pkcePair(); codeVerifier = p.verifier; codeChallenge = p.challenge; } _channelOAuth.set(state, { channel, codeVerifier, created: Date.now() }); // Bind the callback to THIS browser: a SameSite=Lax cookie rides along on the // provider's top-level redirect back (unlike the Strict session cookie), so a // leaked `state` alone can't complete the connect from another browser/account. res.setHeader( 'Set-Cookie', 'mt_oauth=' + state + '; HttpOnly; SameSite=Lax; Path=/; Max-Age=900' + (protocol === 'https' ? '; Secure' : '') ); const url = channels.buildAuthorizeUrl(channel, cfg, { redirectUri: _channelCallbackUrl(channel), state, codeChallenge }); jsonOk(res, { url }); } catch (err) { safeErr(res, err); } return; } // The provider redirects the browser here after consent. Public (a top-level // redirect carries no admin token) — CSRF-protected by the one-time `state`. const chanOAuthCb = req.url.match(/^\/api\/channels\/(etsy|ebay)\/oauth\/callback(?:\?|$)/); if (req.method === 'GET' && chanOAuthCb) { const channel = chanOAuthCb[1]; const sendHtml = (ok, msg) => { res.writeHead(ok ? 200 : 400, { 'Content-Type': 'text/html; charset=utf-8', 'Set-Cookie': 'mt_oauth=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0' + (protocol === 'https' ? '; Secure' : '') }); res.end(_oauthResultHtml(channel, ok, msg)); }; (async () => { try { const q = new URL(req.url, 'http://x').searchParams; if (q.get('error')) { sendHtml(false, 'Login abgebrochen: ' + q.get('error')); return; } const code = q.get('code'); const state = q.get('state'); // The mt_oauth cookie (set in /oauth/start) must match the state — proves the // same browser initiated this flow, not someone replaying a stolen state. const cookieMatch = (req.headers.cookie || '').match(/(?:^|;\s*)mt_oauth=([a-f0-9]+)/); const pending = state ? _channelOAuth.get(state) : null; if (!code || !pending || pending.channel !== channel || !cookieMatch || cookieMatch[1] !== state) { sendHtml(false, 'Ungültiger oder abgelaufener Login-Status. Bitte erneut „Verbinden".'); return; } _channelOAuth.delete(state); const cfg = db.getChannelConfig(database, channel); const tok = await channels.exchangeCode(channel, cfg, { code, redirectUri: _channelCallbackUrl(channel), codeVerifier: pending.codeVerifier }); db.updateChannelConfig(database, channel, { accessToken: tok.accessToken, refreshToken: tok.refreshToken, tokenExpires: tok.tokenExpires, enabled: true }); broadcastSSE(res); sendHtml( true, (channel === 'etsy' ? 'Etsy' : 'eBay') + ' erfolgreich verbunden. Du kannst dieses Fenster schließen.' ); } catch (err) { sendHtml(false, (err && err.message) || 'Login fehlgeschlagen'); } })(); return; } // ── eBay Marketplace Account Deletion / Closure ───────────────────────────── // eBay requires every production application to expose this endpoint, and will // not activate the production keyset without it. Two jobs: // // GET ?challenge_code=X → prove we own the endpoint by returning // SHA-256(challengeCode + verificationToken + endpointUrl) // as hex, in JSON, with HTTP 200. // POST → a buyer closed their eBay account: erase their // personal data and acknowledge with 200 fast // (eBay retries on anything else). // // The verification token is a 32–80 char secret you invent, stored in the eBay // row's webhook_secret and pasted into the eBay developer portal alongside the // URL. The endpoint URL in the hash must byte-for-byte match what is registered // there — it is built from PUBLIC_BASE_URL, so that env var must be correct. if (/^\/api\/channels\/ebay\/deletion(?:\?|$)/.test(req.url)) { const endpointUrl = PUBLIC_BASE_URL + '/api/channels/ebay/deletion'; const token = (() => { try { return db.getChannelConfig(database, 'ebay').webhookSecret || ''; } catch (e) { return ''; } })(); if (req.method === 'GET') { const challenge = new URL(req.url, 'http://x').searchParams.get('challenge_code'); if (!challenge) { jsonErr(res, 400, 'challenge_code required'); return; } if (!token) { // Without the token the hash would be wrong anyway; fail loudly so the // eBay portal's "not verified" is traceable to the missing setup step. log('error', 'eBay deletion challenge but no verification token configured'); jsonErr(res, 503, 'eBay verification token not configured'); return; } const hash = crypto.createHash('sha256').update(challenge).update(token).update(endpointUrl).digest('hex'); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ challengeResponse: hash })); log('info', 'eBay deletion challenge answered', { endpoint: endpointUrl }); return; } // POST — acknowledge unconditionally and fast: eBay retries, and eventually // disables, an endpoint that errors or stalls. Read the body directly instead // of via jsonBody(), which answers malformed JSON with a 400 of its own. let body = ''; req.on('data', (c) => { body += c; if (body.length > MAX_BODY_SIZE) { log('error', 'eBay deletion notification oversized — dropped'); req.destroy(); } }); req.on('end', () => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); let data; try { data = JSON.parse(body); } catch (e) { log('error', 'eBay deletion notification unparseable', { error: e.message }); return; } try { const d = (data && data.notification && data.notification.data) || {}; const handle = d.username || d.userId || null; if (!handle) { log('warn', 'eBay deletion notification without a username'); return; } const customerId = db.findCustomerByIdentity(database, 'ebay', handle); if (!customerId) { // Normal: eBay notifies every subscriber about every closure, and most // closed accounts never bought from us. log('info', 'eBay deletion notification for an unknown buyer — nothing to erase'); return; } // This route is exempt from the session gate (eBay calls it from its own // infrastructure with no credential we can check) and the POST carries no // verification we currently validate: the shared token above proves only // the GET challenge, and eBay signs the POST with a rotating public key // we do not yet fetch. Erasing here therefore let anyone who could reach // the host destroy a customer's order history, shipping address and the // raw_json that could have restored it — irreversibly, one request per // customer, using a public eBay handle or (via findCustomerByIdentity's // email fallback) a plain email address. // // So: record the request, do NOT erase. GDPR allows 30 days and an admin // completes it through POST /api/customers/erase, while eBay still gets // its 200. Restore the automatic path only together with // x-ebay-signature verification. NOTE: there is no UI for this yet — the // admin notification and this log line are the entire surface. log('warn', 'eBay account-closure request recorded — NOT erased automatically (unverified sender)', { customerId, action: 'POST /api/customers/erase {"customerId":' + customerId + '} as an admin to complete it' }); try { for (const u of db.listUsers(database)) { if (u.role !== 'admin') continue; // S-20: once per admin per customer while it is still unread. This // endpoint takes no credential and matches customers by email, so // without the guard anyone who knows an address could bury the // notification list — and burying it is how the real one is missed. db.createNotificationOnce(database, { userId: u.id, type: 'ebay-deletion', title: 'eBay account closure', body: 'Customer #' + customerId + ' requested erasure. Complete it with POST /api/customers/erase.', linkType: 'customer', linkId: String(customerId) }); } } catch (e) { /* notifications are best-effort — the warn log above is the durable record */ } } catch (err) { log('error', 'eBay deletion handling failed', { error: err.message }); } }); return; } // -- DuckDNS Config -- if (req.method === 'POST' && req.url === '/api/duckdns/config') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } if (data.domain && !/^[a-zA-Z0-9-]+$/.test(data.domain)) { jsonErr(res, 400, 'Domain must contain only letters, numbers, and hyphens'); return; } if (data.token && !/^[a-f0-9-]+$/i.test(data.token)) { jsonErr(res, 400, 'Invalid DuckDNS token format'); return; } try { // An empty token field keeps the stored token, and a field the request // leaves out keeps its stored value (db.updateDuckdnsCfg). The settings // page still sends all of them: against a server without this, as during // a deploy, a request without `domain` would wipe it. if (!data.token) delete data.token; db.updateDuckdnsCfg(database, data); startDuckdnsUpdater(); log('info', 'DuckDNS config updated', { actor: req.authUser.username }); jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'GET' && req.url === '/api/duckdns/config') { if (requireAdmin(req, res)) return; try { const cfg = db.getDuckdnsCfg(database); cfg.hasToken = !!cfg.token; delete cfg.token; jsonOk(res, cfg); } catch (err) { safeErr(res, err); } return; } if (req.method === 'POST' && req.url === '/api/duckdns/update-ip') { if (requireAdmin(req, res)) return; duckdns .updateNow({ database, dbApi: db, log, skip: WORKTREE_MODE }) .then((r) => { if (!r.ok) return jsonErr(res, 500, r.reason); const cfg = db.getDuckdnsCfg(database); jsonOk(res, { lastIp: cfg.lastIp, lastIpUpdate: cfg.lastIpUpdate }); }) .catch((err) => safeErr(res, err)); return; } if (req.method === 'POST' && req.url === '/api/duckdns/request-cert') { if (requireAdmin(req, res)) return; requestLetsEncryptCert((err, result) => { if (err) jsonErr(res, 500, err.message); else jsonOk(res, result); }); return; } if (req.method === 'GET' && req.url === '/api/duckdns/status') { if (requireAdmin(req, res)) return; try { const cfg = db.getDuckdnsCfg(database); const health = duckdns.status(cfg.lastIpUpdate); let certInfo = { type: 'none', exists: false }; const disk = certOnDisk(); if (disk) certInfo = { type: disk.letsEncrypt ? 'letsencrypt' : 'self-signed', exists: true }; // The certificate's own expiry when Let's Encrypt issued it, so a restored // database without the note still shows the warning. const { expiry: leExpiry } = renewalExpiry(cfg.leExpiry, disk); jsonOk(res, { enabled: cfg.enabled, domain: cfg.domain ? cfg.domain + '.duckdns.org' : null, lastIpUpdate: cfg.lastIpUpdate, lastIp: cfg.lastIp, // Written only by scripts/duckdns-fallback.js, and kept separate from // lastIpUpdate on purpose: when this server's own updater dies, the // banner has to be able to say both "your updater is broken" and "the // record is being kept alive anyway". One shared column could only say // one of those, and it said the wrong one. fallbackLast: cfg.fallbackLast, leEnabled: cfg.leEnabled, leExpiry: leExpiry ? leExpiry.toISOString() : null, cert: certInfo, // `updaterRunning` alone said only that a timer object existed. The UI // painted a green banner off `lastIp` whenever one had ever been // recorded, so an updater that had been failing for days looked // identical to one that ran a minute ago. `health` carries what tells // those apart: when we last tried, what broke, and what the // authoritative nameservers are actually serving for this name. updaterRunning: health.running, health }); } catch (err) { safeErr(res, err); } return; } // -- Harvest Feed API -- // // The feed used to be environment-only, which meant a shell on the server and // a restart. Most people running this have neither to hand, and the settings // for every other integration live in the UI. if (req.method === 'GET' && req.url === '/api/harvest-feed/config') { if (requireAdmin(req, res)) return; try { const cfg = db.getHarvestFeedCfg(database); // The secret goes out as a yes/no. Handing it back to fill a form field // means it sits in a page anyone can read over a shoulder, and there is no // reason to read it — only to set it. const { secret, ...rest } = cfg; // Where the running config actually comes from. Without this the UI would // show an empty, disabled form on a server that is happily posting from // environment variables — and the obvious next move would be to fill the // form and point the feed somewhere else. let envActive = false; try { envActive = !cfg.enabled && !!harvestFeed.readConfig(process.env); } catch { // A broken env config is reported by start(); here it just means "not // a working env source". } // Canonical, not as stored. The stored text is a split away from whatever // is in the column, and the settings page must not offer a portion size // the feed would drop on the way out — a ticked box that changes nothing // is worse than no box. jsonOk(res, { ...rest, packSizes: harvestFeed.packSizes(cfg.packSizes), hasSecret: !!secret, envActive }); } catch (err) { safeErr(res, err); } return; } if (req.method === 'POST' && req.url === '/api/harvest-feed/config') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { const current = db.getHarvestFeedCfg(database); // No secret in the body means "leave it alone" — otherwise loading the // form and pressing Save would blank it, and the feed would stop with // nothing on screen to say why. const next = { enabled: !!data.enabled, url: String(data.url || '').trim(), secret: data.secret ? String(data.secret).trim() : current.secret, intervalMin: clampInt(data.intervalMin, 1, 1440, 15), freshDays: clampInt(data.freshDays, 0, 365, 3), plannedDays: clampInt(data.plannedDays, 0, 365, 28), leadDays: clampInt(data.leadDays, 0, 365, 0), strain: data.strain !== false, // Capped and stripped of control characters. It is a short name, it // goes out in every payload, and a receiver matches it literally — // a stray newline pasted in from a spreadsheet is then a different // farm as far as the other end is concerned, and nothing here would // have said so. site: String(data.site || '') .replace(/[\u0000-\u001f\u007f]/g, ' ') .trim() .slice(0, 64), // Normalised here rather than trusted: the field is a list of grams // and arrives as whatever the form or an API client sends. Anything // unusable is dropped, and the response carries the stored list back // so a dropped entry is visible instead of assumed saved. // // ⚠️ **Absent means "leave it alone", the same rule as `secret`.** An // empty list is a decision — it stops a shop taking orders — and a // client that has never heard of this field must not be able to make // it by omission. Anything that saved this config before the field // existed, or a curl one-liner changing the interval, would otherwise // clear the sizes and take the order button off the shop with it. To // clear them on purpose, send `packSizes: []`. packSizes: data.packSizes === undefined ? current.packSizes : harvestFeed.packSizes(data.packSizes) }; // Validated before saving, not at the next tick: a rejected URL that is // already stored would leave the feed off with the reason buried in a // log file. if (next.enabled) { if (!next.url) { jsonErr(res, 400, 'A URL is required to enable the feed'); return; } if (!next.secret) { jsonErr(res, 400, 'A shared secret is required — an unsigned feed is one anyone can forge'); return; } try { harvestFeed.storedConfig({ ...next, enabled: true }); } catch (bad) { jsonErr(res, 400, bad.message); return; } } db.updateHarvestFeedCfg(database, next); const running = startHarvestFeed(); log('info', 'Harvest feed config updated', { actor: req.authUser.username, enabled: next.enabled }); jsonOk(res, { running, packSizes: next.packSizes }); } catch (err) { safeErr(res, err); } }); return; } // Build the payload and show it, without sending anything. This is the answer // to "is this going to leak something?" — look at it before switching it on, // rather than trusting a description of what it contains. Same thing // `harvest-feed.js --dry-run` does at the command line. if (req.method === 'POST' && req.url === '/api/harvest-feed/preview') { if (requireAdmin(req, res)) return; let cfg; try { cfg = harvestFeed.resolveConfig({ database, env: process.env, dbApi: db }); } catch (bad) { jsonErr(res, 400, bad.message); return; } try { // No config yet is the normal state for someone looking before deciding. // Defaults let them see the shape without filling the form first. const shape = cfg || { freshDays: 3, plannedDays: 28, leadDays: 0, strain: true, site: '' }; const payload = harvestFeed.buildPayload(database, shape); jsonOk(res, { payload, harvested: payload.harvested.length, planned: payload.planned.length, released: payload.released ? payload.released.length : null }); } catch (err) { safeErr(res, err); } return; } // Send one now and report what came back. This is the difference between // "saved" and "working": a wrong secret or a typo in the host looks exactly // like a correct setup until something is actually delivered. if (req.method === 'POST' && req.url === '/api/harvest-feed/test') { if (requireAdmin(req, res)) return; let cfg; try { cfg = harvestFeed.resolveConfig({ database, env: process.env, dbApi: db }); } catch (bad) { jsonErr(res, 400, bad.message); return; } if (!cfg) { jsonErr(res, 400, 'The harvest feed is off — enable and save it first'); return; } // deliver(), not sendOnce(): a test that skipped storing and confirming // would exercise a path the timer never takes, and the half it skipped is // exactly the half people need to see working. Pressing this puts any // pickups the receiver is holding straight onto the Pickups page. harvestFeed .deliver({ database, cfg, dbApi: db, log }) .then((r) => { log('info', 'Harvest feed test', { actor: req.authUser.username, ok: r.ok }); jsonOk(res, { ok: r.ok, error: r.error || null, attempts: r.attempts, // What was actually sent, so "is this leaking something?" can be // answered by looking rather than by trusting the description. harvested: r.payload.harvested.length, planned: r.payload.planned.length, released: r.payload.released ? r.payload.released.length : null, payload: r.payload, // And what came back. Without this, a receiver that returns pickups // in a shape this end rejects is indistinguishable from one that // returns none — both look like a working feed and an empty page, // and there is nowhere else to see the difference. // // `reply.error` is the useful half: "reply is text/html, not JSON" // names a misconfigured proxy in five words. `dropped` names the // other failure, where the pickups arrived and did not survive // validation. reply: r.reply || null }); }) .catch((err) => safeErr(res, err)); return; } // -- Release for sale -- // // The amounts the feed may offer, and the species it could plausibly be about. // The second list is the point of the screen: after a harvest the question is // "how much of this goes to the shop", and it is a much easier question when // the species are already in front of you with what was weighed in next to // them. Typing them from memory is how a species ends up missing for a week. if (req.method === 'GET' && req.url === '/api/harvest-feed/release') { if (requireAdmin(req, res)) return; try { const cfg = db.getHarvestFeedCfg(database); const days = Math.max(0, Number(cfg.freshDays) || 0); const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10) + 'T00:00:00'; const recent = database .prepare( `SELECT species, SUM(grams) AS grams, MAX(time) AS last FROM harvests WHERE time >= ? AND species IS NOT NULL AND species <> '' GROUP BY species HAVING SUM(grams) > 0 ORDER BY species` ) .all(since) .map((r) => ({ species: r.species, grams: Math.round(r.grams), last: r.last })); // `promised` is what the table was missing: how much of each release is // already booked. Sent alongside rather than folded into `releases` — // a release is a number somebody typed, and the screen shows what is left // of it without editing it. const promised = db.pickupGramsBySpecies(database); jsonOk(res, { freshDays: days, releases: db.listHarvestReleases(database), recent, known: db.listKnownSpecies(database), promised: promised.bySpecies, promisedUnattributed: promised.unattributed, // So the page can say "the shop has not heard this yet" instead of // leaving somebody to guess. Both come from the feed's own record of // its last push. feed: (() => { const c = db.getHarvestFeedCfg(database); return { enabled: !!c.enabled || !!harvestFeed.readConfig(process.env), lastAt: c.lastAt || null, lastOk: c.lastOk === null || c.lastOk === undefined ? null : !!c.lastOk }; })() }); } catch (err) { safeErr(res, err); } return; } if (req.method === 'POST' && req.url === '/api/harvest-feed/release') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { const species = String(data.species || '').trim(); if (!species) { jsonErr(res, 400, 'species is required'); return; } if (data.remove) { db.deleteHarvestRelease(database, species); log('info', 'Harvest release removed', { actor: req.authUser.username, species }); pushHarvestFeedNow('release removed'); jsonOk(res, { removed: true, releases: db.listHarvestReleases(database) }); return; } const grams = Number(data.grams); if (!Number.isFinite(grams) || grams < 0) { jsonErr(res, 400, 'grams must be a number of 0 or more'); return; } // Ten tonnes of one species set aside for a shop is a slipped decimal // point, not a harvest. Rejecting beats clamping: a clamped number looks // like it was accepted and is wrong by an order of magnitude. if (grams > 10_000_000) { jsonErr(res, 400, 'grams looks like a typo — that is over ten tonnes'); return; } db.setHarvestRelease(database, { species, grams, validUntil: data.validUntil || null, note: data.note || '' }); log('info', 'Harvest release set', { actor: req.authUser.username, species, grams }); pushHarvestFeedNow('release set'); jsonOk(res, { releases: db.listHarvestReleases(database) }); } catch (err) { // setHarvestRelease throws on a malformed date; that is a bad request, // not a server fault, and the message says which field. if (/setHarvestRelease/.test(err.message)) jsonErr(res, 400, err.message.replace('setHarvestRelease: ', '')); else safeErr(res, err); } }); return; } // -- Pickups -- // // What the feed receiver reported back: who is collecting what, when and // where. Read-only on purpose — the receiver owns this list, it is the side // that took the booking, and a row edited here would be overwritten by the // next reply that repeats it. // // Any signed-in user, not admin-only. This is work to be prepared for, so the // person packing the crate needs it as much as the owner does, and the // protocol carries no name, address or contact of any kind. if (req.method === 'GET' && req.url.split('?')[0] === '/api/pickups') { try { // `?past=1` asks for the finished half. The default is the half that is // still work — see listPickups() for why that stopped being the same // thing as "all of them, by time". const past = /[?&]past=1(&|$)/.test(req.url); jsonOk(res, { items: db.listPickups(database, { past }), counts: db.countPickups(database), past }); } catch (err) { safeErr(res, err); } return; } // -- MCP Config API -- if (req.method === 'GET' && req.url === '/api/mcp/config') { if (requireAdmin(req, res)) return; try { const cfg = db.getMcpCfg(database); const host = req.headers.host || 'localhost:' + PORT; const connectorUrl = (protocol === 'https' ? 'https' : 'http') + '://' + host + '/mcp'; jsonOk(res, { enabled: cfg.enabled, hasToken: cfg.hasToken, connectorUrl }); } catch (err) { safeErr(res, err); } return; } if (req.method === 'POST' && req.url === '/api/mcp/config') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { db.updateMcpCfg(database, { enabled: !!data.enabled }); log('info', 'MCP config updated', { actor: req.authUser.username, enabled: !!data.enabled }); jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'POST' && req.url === '/api/mcp/generate-token') { if (requireAdmin(req, res)) return; try { const token = db.generateMcpToken(database); log('info', 'MCP API token generated', { actor: req.authUser.username }); jsonOk(res, { token }); } catch (err) { safeErr(res, err); } return; } // Revoke the static MCP API token. Soft-revoke: keeps the row+hash for // audit history (created_at, last_used_at) but flips revoked_at, which // causes db.getMcpToken to return '' so verifyMcpToken cannot match. // After this call /api/mcp/generate-token can mint a fresh token. if (req.method === 'DELETE' && req.url === '/api/mcp/token') { if (requireAdmin(req, res)) return; try { db.revokeMcpToken(database); log('info', 'MCP API token revoked', { actor: req.authUser.username }); jsonOk(res, { ok: true }); } catch (err) { safeErr(res, err); } return; } if (req.method === 'GET' && req.url === '/api/mcp/status') { if (requireAdmin(req, res)) return; try { const cfg = db.getMcpCfg(database); jsonOk(res, { enabled: cfg.enabled, hasToken: cfg.hasToken, activeSessions: mcpSessions.size }); } catch (err) { safeErr(res, err); } return; } // -- MCP Diagnostics -- if (req.method === 'GET' && req.url === '/api/mcp/diagnostics') { if (requireAdmin(req, res)) return; try { const cfg = db.getMcpCfg(database); const base = getBaseUrl(req); const clients = db.listOAuthClients(database); const autoClients = clients.filter((c) => c.autoRegistered); const manualClients = clients.filter((c) => !c.autoRegistered); // Check TLS const hasTls = protocol === 'https'; // Check discovery endpoints return correct data const prm = { resource: base + '/mcp', authorization_servers: [base], bearer_methods_supported: ['header'], scopes_supported: ['mcp:full'] }; const asm = { issuer: base, authorization_endpoint: base + '/oauth/authorize', token_endpoint: base + '/oauth/token', registration_endpoint: base + '/oauth/register' }; const diag = { mcpEnabled: cfg.enabled, hasApiToken: cfg.hasToken, protocol, baseUrl: base, connectorUrl: base + '/mcp', tls: hasTls, tlsRequired: 'OAuth requires HTTPS — Claude Desktop will not connect over plain HTTP', oauthClients: { auto: autoClients.length, manual: manualClients.length }, activeSessions: mcpSessions.size, protectedResourceMetadata: prm, authServerMetadata: asm, checks: { mcpEnabled: cfg.enabled ? 'PASS' : 'FAIL — enable MCP in settings', httpsActive: hasTls ? 'PASS' : 'FAIL — no TLS certificates found, OAuth requires HTTPS', registrationEndpoint: 'PASS — /oauth/register available', discoveryEndpoints: 'PASS — /.well-known/oauth-protected-resource/mcp and /.well-known/oauth-authorization-server available' }, hint: !hasTls ? "Claude Desktop requires HTTPS. Configure Let's Encrypt via DuckDNS or add TLS certificates." : autoClients.length === 0 ? 'No auto-registered clients yet. Claude Desktop will auto-register when it connects. Make sure the connector URL is reachable from your machine.' : 'Server looks configured correctly. If Claude still fails, check: 1) DuckDNS domain resolves to this server, 2) Port is reachable, 3) No firewall blocking.' }; jsonOk(res, diag); } catch (err) { safeErr(res, err); } return; } // -- OAuth Client Management API -- if (req.method === 'GET' && req.url === '/api/mcp/oauth-clients') { if (requireAdmin(req, res)) return; try { const clients = db.listOAuthClients(database); jsonOk(res, { clients }); } catch (err) { safeErr(res, err); } return; } if (req.method === 'DELETE' && req.url.startsWith('/api/mcp/oauth-clients/')) { if (requireAdmin(req, res)) return; try { const clientId = decodeURIComponent(req.url.split('/').pop()); const deleted = db.deleteOAuthClient(database, clientId); if (deleted === 0) { jsonErr(res, 404, 'client not found'); return; } log('info', 'OAuth client deleted', { actor: req.authUser.username, clientId }); jsonOk(res); } catch (err) { safeErr(res, err); } return; } // -- Server Restart -- if (req.method === 'POST' && req.url === '/api/server/restart') { if (requireAdmin(req, res)) return; log('info', 'Server restart requested via web UI', { actor: req.authUser.username }); jsonOk(res, { ok: true, message: 'Server is restarting...' }); setTimeout(() => { const scriptDir = path.resolve(__dirname); if (process.platform === 'win32') { spawn('cmd.exe', ['/c', 'START.bat'], { cwd: scriptDir, detached: true, stdio: 'ignore' }).unref(); } else { spawn('bash', ['update_server.sh'], { cwd: scriptDir, detached: true, stdio: 'ignore' }).unref(); } }, 500); return; } // -- GitHub Webhook (auto-restart on push to main) -- if (req.method === 'POST' && req.url === '/api/webhook/github') { // In worktree mode the auto-deploy chain would run `git fetch && git reset // --hard origin/main` in this worktree's directory and obliterate the // feature-branch commits a developer is iterating on. The webhook URL is // configured once per repo in GitHub, so this only fires here if the // worktree is reachable on the same hostname — but if someone tunnels in // for testing, the destructive reset is silent. Refuse it. if (WORKTREE_MODE) { jsonOk(res, { ok: true, msg: 'ignored: worktree mode' }); return; } const secret = process.env.GITHUB_WEBHOOK_SECRET; if (!secret) { jsonErr(res, 500, 'webhook secret not configured'); return; } // Buffers, not a string: the HMAC must be computed over the exact bytes // GitHub signed. `raw += c` decoded each chunk separately, so a push payload // over ~64 KiB containing a non-ASCII commit message got a U+FFFD at the // chunk seam, hashed to a different digest, and was rejected as "bad // signature" — silently killing the auto-deploy chain for that push. const rawChunks = []; let whSize = 0; let whAborted = false; req.on('data', (c) => { whSize += c.length; if (whSize > MAX_BODY_SIZE) { // Cap the body before buffering — the HMAC check only runs on 'end', // so without this an unbounded payload could exhaust memory first. whAborted = true; jsonErr(res, 413, 'Payload too large'); req.destroy(); return; } rawChunks.push(c); }); req.on('end', () => { if (whAborted) return; // Defence in depth: any throw inside this async callback would otherwise // bubble to `uncaughtException` and terminate the process. try { const rawBody = Buffer.concat(rawChunks); const sig = req.headers['x-hub-signature-256']; if (!sig) { jsonErr(res, 401, 'missing signature'); return; } const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); // Length check first — timingSafeEqual throws RangeError on mismatched // byte lengths, and the attacker controls the X-Hub-Signature-256 // header. Without this guard a single bad-length signature crashes // the server (S-02). const sigBuf = Buffer.from(sig); const expBuf = Buffer.from(expected); if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) { log('warn', 'GitHub webhook signature mismatch'); jsonErr(res, 401, 'bad signature'); return; } const event = req.headers['x-github-event']; if (event === 'ping') { jsonOk(res, { ok: true, msg: 'pong' }); return; } if (event !== 'push') { jsonOk(res, { ok: true, msg: 'ignored event: ' + event }); return; } let body; try { body = JSON.parse(rawBody.toString('utf8')); } catch (_) { jsonErr(res, 400, 'bad json'); return; } if (body.ref !== 'refs/heads/main') { jsonOk(res, { ok: true, msg: 'ignored ref: ' + body.ref }); return; } // Defense-in-depth: even though the webhook is HMAC-verified, pin the // pushed SHA to a strict hex whitelist before it is stored. It is a // label for the record only: what gets deployed is whatever origin/main // points at after the runner's own fetch, held to hex again there. const rawSha = body.after || (body.head_commit && body.head_commit.id) || ''; const targetSha = /^[a-f0-9]{7,40}$/i.test(rawSha) ? rawSha.slice(0, 40) : 'unknown'; const sender = body.sender && body.sender.login; log('info', 'GitHub webhook: push to main, deploying', { sender }); jsonOk(res, { ok: true, message: 'Restarting...' }); setTimeout(() => { deploys.deliver({ sha: targetSha, sender }).catch((e) => { log('error', 'Deploy runner failed', { error: e.message, sha: targetSha }); }); }, 500); } catch (err) { log('error', 'GitHub webhook handler crashed', { error: err.message }); try { jsonErr(res, 500, 'internal'); } catch (_) { /* response may already have been sent */ } } }); return; } // -- Notifications (per-user) -- if (req.method === 'GET' && url === '/api/notifications') { try { const items = db.listNotifications(database, req.authUser.user_id); const unread = db.countUnreadNotifications(database, req.authUser.user_id); jsonOk(res, { items, unread }); } catch (err) { safeErr(res, err); } return; } if (req.method === 'POST' && url === '/api/notifications/read') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { let changed; if (data && data.all === true) { changed = db.markNotificationsRead(database, req.authUser.user_id, null); } else if (data && Array.isArray(data.ids)) { const ids = data.ids.filter((n) => typeof n === 'number' && Number.isInteger(n)); changed = db.markNotificationsRead(database, req.authUser.user_id, ids); } else { jsonErr(res, 400, 'body must be { all: true } or { ids: [int, ...] }'); return; } broadcastSSE(res); jsonOk(res, { changed }); } catch (err) { safeErr(res, err); } }); return; } // -- Calendar Events -- if (req.method === 'POST' && req.url === '/api/calendar-events') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, ['id', 'title', 'startDate']); if (vr) { jsonErr(res, 400, vr); return; } const vt = validateTypes(data, { id: 'string', title: 'string', startDate: 'string', description: 'string' }); if (vt) { jsonErr(res, 400, vt); return; } const vlen = validateLengths(data, { id: 200, title: 500, description: 10000 }); if (vlen) { jsonErr(res, 400, vlen); return; } // A pickup window is a promise to customers, not a diary entry — see // touchesPickupWindow. Everything else on this route stays open. if (touchesPickupWindow(null, data) && requireAdmin(req, res)) return; if (!/^[A-Za-z0-9_\-@.:]{1,200}$/.test(data.id)) { jsonErr(res, 400, 'id must be alphanumeric with - _ @ . : (max 200 chars)'); return; } let vd = validateDate(data.startDate, 'startDate'); if (vd) { jsonErr(res, 400, vd); return; } if (data.endDate) { vd = validateDate(data.endDate, 'endDate'); if (vd) { jsonErr(res, 400, vd); return; } } if (Array.isArray(data.assignees)) { for (const uid of data.assignees) { if (typeof uid !== 'number' || !Number.isInteger(uid)) { jsonErr(res, 400, 'assignees must be integer user IDs'); return; } } } if (data.recurrence != null && !['weekly', 'monthly', 'daily', ''].includes(data.recurrence)) { jsonErr(res, 400, 'recurrence must be daily, weekly or monthly'); return; } if (data.recurrenceUntil) { const vd = validateDate(data.recurrenceUntil, 'recurrenceUntil'); if (vd) { jsonErr(res, 400, vd); return; } } if (data.teamAssignees != null && !Array.isArray(data.teamAssignees)) { jsonErr(res, 400, 'teamAssignees must be an array of names'); return; } try { const assignees = deriveEffectiveAssignees(data); db.insertCalendarEvent(database, data, assignees); autoSyncCalendarEvent(data); if (assignees && assignees.length) { notifyCalendarAssignees(data, assignees, req.authUser); } broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } const calEvMatch = req.url.split('?')[0].match(/^\/api\/calendar-events\/([^/]+)$/); if (req.method === 'PATCH' && calEvMatch) { const id = decodeURIComponent(calEvMatch[1]); jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { // The stored row as well as the body: a PATCH that moves a window // usually names no category, and one that sets `category: 'meeting'` // takes a published window off the shop without mentioning pickup. if (touchesPickupWindow(id, data) && requireAdmin(req, res)) return; // Atomically update the event row + refresh the assignees junction // table so the two can't drift apart on a mid-operation crash. const effectiveAssignees = deriveEffectiveAssignees(data); const prevAssignees = db.getCalendarEventAssignees(database, id); const applyUpdate = database.transaction(() => { db.updateCalendarEvent(database, id, data); if (effectiveAssignees) { db.setCalendarEventAssignees(database, id, effectiveAssignees); } }); applyUpdate(); let newlyAdded = null; if (effectiveAssignees) { const prevSet = new Set(prevAssignees); newlyAdded = effectiveAssignees.filter((uid) => !prevSet.has(uid)); } const fullEv = db.getCalendarEventById(database, id); if (fullEv) autoSyncCalendarEvent(fullEv); if (newlyAdded && newlyAdded.length && fullEv) { notifyCalendarAssignees(fullEv, newlyAdded, req.authUser); } broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'DELETE' && calEvMatch) { if (requireAdmin(req, res)) return; const id = decodeURIComponent(calEvMatch[1]); try { const ev = db.getCalendarEventById(database, id); if (!ev) { jsonErr(res, 404, 'event not found'); return; } // Optional ?occurrence=YYYY-MM-DD — when present and the event is recurring, // add an exception for just that date instead of deleting the series. const urlObj = new URL(req.url, 'http://x'); const occurrence = urlObj.searchParams.get('occurrence'); if (occurrence) { const vd = validateDate(occurrence, 'occurrence'); if (vd) { jsonErr(res, 400, vd); return; } if (ev.recurrence) { db.addCalendarEventException(database, id, occurrence); broadcastSSE(res); jsonOk(res); return; } } db.deleteCalendarEvent(database, id); try { autoDeleteCalendarEventCaldav(id, ev && ev.caldav_uid); } catch (ce) { log('warn', 'CalDAV cleanup failed after event delete', { eventId: id, error: ce.message }); } broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } return; } // -- Inventory Delta -- if (req.method === 'POST' && req.url === '/api/inventory/delta') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, ['mat', 'deltaKg']); if (vr) { jsonErr(res, 400, vr); return; } const vt = validateTypes(data, { mat: 'string', deltaKg: 'number' }); if (vt) { jsonErr(res, 400, vt); return; } const vrng = validateRanges(data, { deltaKg: { min: -100000, max: 100000 } }); if (vrng) { jsonErr(res, 400, vrng); return; } try { // I-22: record the acting user on every inventory delta. const userId = req.authUser ? req.authUser.user_id : null; const val = db.applyInventoryDelta( database, data.mat, data.deltaKg, data.type || null, data.ref || null, userId ); broadcastSSE(res); jsonOk(res, { value: val }); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'POST' && req.url === '/api/inventory/set') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, ['mat', 'value']); if (vr) { jsonErr(res, 400, vr); return; } const vt = validateTypes(data, { mat: 'string', value: 'number' }); if (vt) { jsonErr(res, 400, vt); return; } const vrng = validateRanges(data, { value: { min: 0, max: 1000000 } }); if (vrng) { jsonErr(res, 400, vrng); return; } try { // I-22: record the acting user on absolute inventory sets too. const userId = req.authUser ? req.authUser.user_id : null; const val = db.setInventoryAbsolute( database, data.mat, data.value, data.type || null, data.ref || null, userId ); broadcastSSE(res); jsonOk(res, { value: val }); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'POST' && req.url === '/api/inventory/config') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { db.updateInventoryConfig(database, data.thresholds, data.avgComposition); log('info', 'Inventory config updated', { actor: req.authUser.username }); broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } // -- Lab Thresholds -- // Wiederkehrende Aufgaben der Anlage: anlegen, ändern, löschen — und abhaken. // // Dieselbe Grenze wie beim Rhythmus, aus demselben Grund: WAS die Anlage // regelmässig tut, ist eine Festlegung für alle und fortan (Admin); DASS es // an einem Termin getan wurde, meldet, wer es getan hat (jeder Angemeldete). if (req.method === 'POST' && req.url === '/api/recurring-task') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { const id = db.saveRecurringTask(database, data || {}); log('info', 'Recurring task saved', { actor: req.authUser.username, id }); broadcastSSE(res); jsonOk(res, { ok: true, id }); } catch (err) { if (/name is required|Interval must be|Not a date|Not a task id|No such task/.test(err.message)) { jsonErr(res, 400, err.message); return; } safeErr(res, err); } }); return; } const recurringDelMatch = req.url.match(/^\/api\/recurring-task\/(\d+)$/); if (req.method === 'DELETE' && recurringDelMatch) { if (requireAdmin(req, res)) return; try { db.deleteRecurringTask(database, recurringDelMatch[1]); log('info', 'Recurring task deleted', { actor: req.authUser.username, id: recurringDelMatch[1] }); broadcastSSE(res); jsonOk(res, { ok: true }); } catch (err) { if (/No such task|Not a task id/.test(err.message)) { jsonErr(res, 404, err.message); return; } safeErr(res, err); } return; } if (req.method === 'POST' && req.url === '/api/recurring-done') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const body = data || {}; try { const done = db.setRecurringDone(database, body.taskId, body.date, body.done, req.authUser.username); broadcastSSE(res); jsonOk(res, { ok: true, done }); } catch (err) { // "Not a due date" ist eine Eingabe, kein Serverfehler: der Client hat // einen Tag angeboten, an dem die Aufgabe nicht fällt. if (/Not a task id|Not a date|No such task|Not a due date/.test(err.message)) { jsonErr(res, 400, err.message); return; } safeErr(res, err); } }); return; } // POST /api/week-rhythm-day -- die Menge EINES Wochentags. // // PUT /api/week-rhythm nimmt die Woche als Ganzes und ersetzt sie; das ist // fuer den Rhythmus-Editor richtig, wo alle sieben Zeilen vor einem liegen. // Der Tagesdialog zeigt eine Zahl, und eine Zahl soll er schreiben -- sonst // traegt er die sechs anderen Tage aus einem womoeglich veralteten Abbild // zurueck und ueberschreibt, was jemand anderes inzwischen geaendert hat. if (req.method === 'POST' && req.url === '/api/week-rhythm-day') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const body = data || {}; try { const qty = db.setWeekRhythmDay(database, body.weekday, body.targetQty); log('info', 'Week rhythm day updated', { actor: req.authUser.username, weekday: body.weekday }); broadcastSSE(res); jsonOk(res, { ok: true, targetQty: qty }); } catch (err) { if (/Not a weekday|whole number|implausibly large|No rhythm on/.test(err.message)) { jsonErr(res, 400, err.message); return; } safeErr(res, err); } }); return; } if (req.method === 'POST' && req.url === '/api/rhythm-start') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { const saved = db.updateRhythmStart(database, data.bagCountFrom); log('info', 'Rhythm start updated', { actor: req.authUser.username }); broadcastSSE(res); jsonOk(res, saved); } catch (err) { safeErr(res, err); } }); return; } if (req.method === 'POST' && req.url === '/api/lab-thresholds') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { db.updateLabThresholds(database, data.labThresholds); log('info', 'Lab thresholds updated', { actor: req.authUser.username }); broadcastSSE(res); jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } // -- Suppliers CRUD -- if (req.method === 'GET' && req.url === '/api/suppliers') { try { jsonOk(res, db.listSuppliers(database)); } catch (err) { safeErr(res, err); } return; } if (req.method === 'POST' && req.url === '/api/suppliers') { jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } if (!data.mat || !data.name) { jsonErr(res, 400, 'mat and name required'); return; } try { const id = db.upsertSupplier(database, data); broadcastSSE(res); jsonOk(res, { id }); } catch (err) { safeErr(res, err); } }); return; } const supDelMatch = req.method === 'DELETE' && req.url.match(/^\/api\/suppliers\/(\d+)$/); if (supDelMatch) { if (requireAdmin(req, res)) return; try { db.deleteSupplier(database, parseInt(supDelMatch[1])); broadcastSSE(res); jsonOk(res, { ok: true }); } catch (err) { safeErr(res, err); } return; } // -- Which pickup windows already have somebody coming -- // // The editor asks before it moves or deletes one. Its own key is the calendar // event plus the occurrence date; the booking's key is the published window // id. Joining the two is this end's job — the browser must not reimplement // that id, because two implementations of a key drift and a drifted key here // reads as "nobody has booked this". if (req.method === 'GET' && req.url === '/api/pickup-bookings') { try { const counts = db.pickupCountsBySlot(database); const booked = []; for (const w of harvestFeed.pickupWindowIndex(database, new Date())) { const n = counts[w.id]; if (n) booked.push({ event: w.event, date: w.date, count: n }); } jsonOk(res, { booked }); } catch (err) { safeErr(res, err); } return; } // -- Pickup locations CRUD -- if (req.method === 'GET' && req.url === '/api/pickup-locations') { try { jsonOk(res, db.listPickupLocations(database)); } catch (err) { safeErr(res, err); } return; } if (req.method === 'POST' && req.url === '/api/pickup-locations') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } const vr = validateRequired(data, ['name']); if (vr) { jsonErr(res, 400, vr); return; } const vt = validateTypes(data, { name: 'string', address: 'string' }); if (vt) { jsonErr(res, 400, vt); return; } // The name leaves the building on the harvest feed and ends up on a // customer's screen next to a time. Short enough to fit there. const vlen = validateLengths(data, { name: 120, address: 200 }); if (vlen) { jsonErr(res, 400, vlen); return; } try { const id = db.upsertPickupLocation(database, data); broadcastSSE(res); jsonOk(res, { id }); } catch (err) { safeErr(res, err); } }); return; } // DELETE retires rather than removes — see db.deactivatePickupLocation. const locDelMatch = req.method === 'DELETE' && req.url.match(/^\/api\/pickup-locations\/(\d+)$/); if (locDelMatch) { if (requireAdmin(req, res)) return; try { db.deactivatePickupLocation(database, parseInt(locDelMatch[1])); broadcastSSE(res); jsonOk(res, { ok: true }); } catch (err) { safeErr(res, err); } return; } // -- USB stick backup (scripts/usb-backup.js) -- // Admin only, every one of them: they show the PC's drives, write to them, // and one of them hands out the recovery key. if (req.method === 'GET' && url === '/api/usb-backup') { if (requireAdmin(req, res)) return; jsonOk(res, usbBackup.status()); return; } if (req.method === 'GET' && url === '/api/usb-backup/drives') { if (requireAdmin(req, res)) return; usbBackup.drives().then( (drives) => jsonOk(res, { drives }), (e) => usbBackupError(res, e) ); return; } if (req.method === 'POST' && url === '/api/usb-backup/setup') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { usbBackup.setup({ root: data.root, label: data.label }).then( (r) => { log('info', 'USB stick set up', { actor: req.authUser.username, stick: r.stick && r.stick.id, // The key comes back with every setup until it is confirmed; // whether this one created it is newKey. newKey: !!r.newKey }); jsonNoStore(res, r); }, (err) => usbBackupError(res, err) ); }); return; } if (req.method === 'POST' && url === '/api/usb-backup/connect') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { usbBackup.connect({ root: data.root, recoveryKey: data.recoveryKey }).then( (r) => { log('info', 'USB stick connected with its key', { actor: req.authUser.username, stick: r.stick && r.stick.id }); jsonOk(res, r); }, (err) => usbBackupError(res, err) ); }); return; } if (req.method === 'POST' && url === '/api/usb-backup/run') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { usbBackup.runNow(typeof data.id === 'string' ? data.id : null, { force: data.force === true }).then( (run) => jsonOk(res, { run }), (err) => usbBackupError(res, err) ); }); return; } if (req.method === 'POST' && url === '/api/usb-backup/key') { if (requireAdmin(req, res)) return; log('info', 'USB recovery key shown', { actor: req.authUser.username }); jsonNoStore(res, { recoveryKey: usbBackup.revealKey() }); return; } if (req.method === 'POST' && url === '/api/usb-backup/key/confirm') { if (requireAdmin(req, res)) return; // confirmKey saves, and a save can fail. Thrown out of the request // handler, that left the request unanswered for good: behind the HTTPS // port the server has no request timeout to end it. try { if (!usbBackup.confirmKey()) { jsonErr(res, 409, 'no recovery key yet'); return; } } catch (err) { usbBackupError(res, err); return; } log('info', 'USB recovery key confirmed as put away', { actor: req.authUser.username }); jsonOk(res, { ok: true }); return; } if (req.method === 'DELETE' && /^\/api\/usb-backup\/sticks\/[0-9a-f]{16}$/.test(url)) { if (requireAdmin(req, res)) return; try { const id = url.split('/').pop(); if (!usbBackup.forget(id)) { jsonErr(res, 404, 'unknown stick'); return; } log('info', 'USB stick forgotten', { actor: req.authUser.username, stick: id }); jsonOk(res, { ok: true }); } catch (err) { usbBackupError(res, err); } return; } // -- Backup Download (encrypted .db) -- // S-04: format upgraded. // v2 layout: magic(4) + version(1) + salt(32) + iv(12) + tag(16) + ciphertext // - magic = "MPLZ" (Meisterpilze) // - version = 0x02 // - scrypt = N=131072, r=8, p=4 (≈1s on a modern server) // - no outer HMAC: GCM auth tag is already AEAD over salt+iv+ct // Old format (no magic prefix) still decrypts via the legacy path on // restore so existing .enc files keep working. if (req.method === 'POST' && req.url === '/api/backup/download') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { let tmpDest; try { if (!data || !data.password || data.password.length < 12) { jsonErr(res, 400, 'Password required (min 12 characters)'); return; } // Create a fresh VACUUM INTO temp file for a consistent snapshot. // R-12: random suffix avoids collisions if two admins click download // in the same millisecond — Date.now() ms granularity is not enough. tmpDest = path.join(BACKUP_DIR, '_download_tmp_' + crypto.randomBytes(8).toString('hex') + '.db'); db.backupDb(database, tmpDest); const plain = fs.readFileSync(tmpDest); try { fs.unlinkSync(tmpDest); } catch (e) { log('warn', 'Failed to clean backup temp file', { error: e.message }); } tmpDest = null; const out = backupCrypto.encryptBackup(plain, data.password); const stamp = new Date().toISOString().slice(0, 10); res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Disposition': 'attachment; filename="' + BACKUP_PREFIX + stamp + '.enc"', 'Content-Length': out.length }); res.end(out); log('info', 'Backup downloaded', { actor: req.authUser.username, format: 'v2' }); } catch (err) { if (tmpDest) try { fs.unlinkSync(tmpDest); } catch (e) { log('warn', 'Failed to clean backup temp after error', { error: e.message }); } log('error', 'Backup download failed', { error: err.message }); jsonErr(res, 500, 'Backup download failed'); } }); return; } // -- Backup Restore (encrypted .db) -- if (req.method === 'POST' && req.url.startsWith('/api/backup/restore')) { if (requireAdmin(req, res)) return; // I-17: bail out early if another restore is in flight. Without this, // two admins racing on the close→rename→reopen sequence below could // corrupt the DB file or leave the process holding a stale handle. if (restoreInProgress) { jsonErr(res, 503, 'Another restore is in progress; please retry shortly.'); return; } restoreInProgress = true; const chunks = []; let sz = 0; let aborted = false; let ended = false; const MAX_BACKUP = 50 * 1024 * 1024; // 50 MB limit for backup files // I-17b: if the client drops the connection mid-upload, 'end' never fires, // so neither the oversize branch nor the 'end' finally releases the mutex — // every later restore would 503 forever. Release it on close when the 'end' // handler never ran. (No-op once a restore has completed.) req.on('close', () => { if (!ended) restoreInProgress = false; }); req.on('data', (c) => { sz += c.length; if (sz > MAX_BACKUP) { aborted = true; jsonErr(res, 413, 'Backup file too large'); req.destroy(); restoreInProgress = false; return; } chunks.push(c); }); req.on('end', () => { ended = true; if (aborted) { restoreInProgress = false; return; } let tmpPath; try { const raw = Buffer.concat(chunks); const password = req.headers['x-backup-password'] || ''; if (!password) { jsonErr(res, 400, 'Password required'); return; } // S-04: detect format by magic prefix. // v2 (new): "MPLZ" + 0x02 + salt(32) + iv(12) + tag(16) + ct — scripts/backup-crypto.js // v1 (legacy): salt(32) + iv(12) + tag(16) + ct [+ hmac(32)] let plain = null; if (backupCrypto.isEncryptedBackup(raw)) { if (raw.length < backupCrypto.HEADER_LEN) { jsonErr(res, 400, 'File too small to be a valid backup'); return; } plain = backupCrypto.decryptBackup(raw, password); // A snapshot off the USB stick is encrypted with the recovery key as // the stick writes it: upper case, in dashed groups of four. Typed // in lower case or without the dashes it is still that key. Tried // only after the password as given failed, so a downloaded backup // whose password merely looks like a key still opens. const asKey = plain ? null : usbStick.canonicalKey(password); if (asKey && asKey !== password) plain = backupCrypto.decryptBackup(raw, asKey); } else { // Legacy v1 path (no magic prefix) — kept verbatim so existing // backups still restore. tryDecrypt(true) handles backups that // include the redundant outer HMAC; tryDecrypt(false) handles // backups from before the HMAC was added. if (raw.length < 60 + 16) { jsonErr(res, 400, 'File too small to be a valid backup'); return; } const salt = raw.subarray(0, 32); const iv = raw.subarray(32, 44); const key = crypto.scryptSync(password, salt, 32, { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }); function tryDecrypt(withHmac) { const payload = withHmac ? raw.subarray(0, raw.length - 32) : raw; const pTag = payload.subarray(44, 60); const pCipher = payload.subarray(60); if (withHmac) { const storedHmac = raw.subarray(raw.length - 32); const expectedHmac = crypto.createHmac('sha256', key).update(payload).digest(); if (!crypto.timingSafeEqual(storedHmac, expectedHmac)) return null; } const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); decipher.setAuthTag(pTag); try { return Buffer.concat([decipher.update(pCipher), decipher.final()]); } catch (e) { return null; } } plain = tryDecrypt(true) || tryDecrypt(false); } if (!plain) { jsonErr(res, 401, 'Wrong password or corrupted file'); return; } // Validate SQLite header if (plain.length < 16 || plain.toString('utf8', 0, 15) !== 'SQLite format 3') { jsonErr(res, 400, 'Decrypted file is not a valid database'); return; } // Write to temp with restrictive permissions, validate schema. // Random suffix instead of Date.now() — predictable timestamps would // collide if two admins restored concurrently and the path-derived // race is benign but easy to remove (audit Section 3.3). tmpPath = path.join(BACKUP_DIR, '_restore_tmp_' + crypto.randomBytes(8).toString('hex') + '.db'); fs.writeFileSync(tmpPath, plain, { mode: 0o600 }); let tmpDb; // Was im Laborbuch-Verlauf verloren geht: jeder Eintrag der laufenden // Datei, den die Sicherung nicht hat, gegen ihre eigene Pruefkette // gezaehlt, je Versuch und je Arbeitspaket. Gelesen wird die Sicherung, // bevor openDb ihre Migrationen laufen laesst und Eintraege ohne Glied // an ihrem Ende in die Kette nimmt (historyDiscarded); gezaehlt wird // vor dem Tausch, danach steht es im Verlauf (markBackupRestore). let verworfen = null; try { try { verworfen = db.historyDiscarded(database, tmpPath); } catch (e) { log('warn', 'Restore: could not read the backup before migrating it', { error: e.message }); } tmpDb = db.openDb(tmpPath); // validates schema + runs migrations if (!verworfen) verworfen = db.historyDiscardedById(database, tmpDb); tmpDb.close(); } catch (valErr) { // Scheiterte erst das Zaehlen nach openDb, ist die Datei noch offen, // und unter Windows liesse sie sich so nicht loeschen. try { if (tmpDb) tmpDb.close(); } catch { /* schon zu */ } try { fs.unlinkSync(tmpPath); } catch (e) { log('warn', 'Failed to clean temp after validation error', { error: e.message }); } log('error', 'Backup validation failed', { error: valErr.message }); jsonErr(res, 400, 'Database validation failed'); return; } const getauscht = swapRestoredDatabase(tmpPath); if (!getauscht.ok) { fs.rmSync(tmpPath, { force: true }); tmpPath = null; jsonErr(res, getauscht.status, getauscht.error); return; } tmpPath = null; try { db.markBackupRestore(database, verworfen, req.authUser, getauscht.kept); } catch (e) { log('error', 'Failed to record the restore in the lab history', { error: e.message }); } // Trigger auto-sync of CalDAV after restore try { autoSyncAllCaldav(readData()); } catch (ce) { log('error', 'CalDAV post-restore sync failed', { error: ce.message }); } log('info', 'Backup restored successfully', { actor: req.authUser.username }); broadcastSSE(res); jsonOk(res); } catch (err) { if (tmpPath) try { fs.unlinkSync(tmpPath); } catch (e) { log('warn', 'Failed to clean restore temp after error', { error: e.message }); } log('error', 'Backup restore failed', { error: err.message }); jsonErr(res, 500, 'Backup restore failed'); } finally { // I-17: always release the mutex, even on early returns (decryption // fail / rollback / unexpected throw). restoreInProgress = false; } }); return; } // POST /api/print — body: { zpl: "^XA...^XZ" } if (req.method === 'POST' && req.url === '/api/print') { let body = ''; let bodySize = 0; let aborted = false; req.on('data', (c) => { bodySize += c.length; if (bodySize > MAX_BODY_SIZE) { aborted = true; jsonErr(res, 413, 'Payload too large'); req.destroy(); return; } body += c; }); req.on('end', () => { if (aborted) return; try { const { zpl } = JSON.parse(body); if (!zpl) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end('{"error":"no zpl"}'); return; } checkPrinterAvailable((_, found) => { if (!found) { res.writeHead(503, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ error: 'Printer not found. Please check that the Zebra printer is connected and powered on.' }) ); return; } printZPL(zpl, (err) => { if (err) { log('error', 'Print error', { error: err.message || err }); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: err })); } else { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{"ok":true,"labels":"printed"}'); } }); }); } catch { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end('{"error":"bad json"}'); } }); return; } // POST /api/caldav/sync — write all tasks to local calendar files // Reads all data from DB only — ignores request body to prevent client-supplied data injection if (req.method === 'POST' && req.url === '/api/caldav/sync') { try { const data = readData(); // syncAllTasksLocal only reads `data` and writes .ics files — it never // mutates it, so the writeData(data) that used to sit here round-tripped // the whole database through writeAll on every sync for nothing. That was // destructive: writeAll rebuilds scan_log with DELETE + re-INSERT under // AUTOINCREMENT, renumbering every id (which NULLs // contamination_reports.scan_log_id through ON DELETE SET NULL) and // resetting manual_tasks.sequence, defeating CalDAV change detection. const result = syncAllTasksLocal(data); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } catch (e) { log('error', 'CalDAV sync error', { error: e.message }); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: e.message })); } return; } // POST /api/caldav/push-one — write a single task to calendar file if (req.method === 'POST' && req.url === '/api/caldav/push-one') { let body = ''; let bodySize = 0; let aborted = false; req.on('data', (c) => { bodySize += c.length; if (bodySize > MAX_BODY_SIZE) { aborted = true; jsonErr(res, 413, 'Payload too large'); req.destroy(); return; } body += c; }); req.on('end', () => { if (aborted) return; try { const { task } = JSON.parse(body); const isPrivate = task.private === 1 || task.private === true; let uid; // Shared calendar: all non-private tasks if (!isPrivate) { uid = writeTaskToCalendar(task, 'meisterpilze'); } // Personal calendar: if assigned if (task.assignee) { const slug = db.caldavSlug(task.assignee); uid = writeTaskToCalendar(task, slug); } // Private + unassigned: no calendar to write to, just generate a UID if (!uid) { if (!task.caldavUid) task.caldavUid = generateUID(); uid = task.caldavUid; } const synced = task.caldavSynced || new Date().toISOString(); db.updateTaskCaldavUid(database, task.text, task.created, uid, synced); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, uid })); } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: e.message })); } }); return; } // POST /api/caldav/push-event — write a single custom event to calendar file if (req.method === 'POST' && req.url === '/api/caldav/push-event') { let body = ''; let bodySize = 0; let aborted = false; req.on('data', (c) => { bodySize += c.length; if (bodySize > MAX_BODY_SIZE) { aborted = true; jsonErr(res, 413, 'Payload too large'); req.destroy(); return; } body += c; }); req.on('end', () => { if (aborted) return; try { const { event } = JSON.parse(body); const { uid, ics } = customEventToVEVENT(event); writeIcsFile('meisterpilze', uid + '.ics', ics); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, uid })); } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: e.message })); } }); return; } // POST /api/caldav/push-batch — write a single batch due date to calendar file if (req.method === 'POST' && req.url === '/api/caldav/push-batch') { let body = ''; let bodySize = 0; let aborted = false; req.on('data', (c) => { bodySize += c.length; if (bodySize > MAX_BODY_SIZE) { aborted = true; jsonErr(res, 413, 'Payload too large'); req.destroy(); return; } body += c; }); req.on('end', () => { if (aborted) return; try { const { batch } = JSON.parse(body); const data = readData(); const { uid, ics } = batchToVEVENT(batch, data.scanLog || []); writeIcsFile('faelligkeiten', uid + '.ics', ics); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, uid })); } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: e.message })); } }); return; } // GET /api/caldav/import — read external events from calendar files if (req.method === 'GET' && req.url === '/api/caldav/import') { try { const imported = []; // Determine which calendar directories the caller may see. // Shared category calendars are visible to everyone, the user's own // slug is always visible, and admins see everything. const isAdmin = req.authUser && req.authUser.role === 'admin'; const callerSlug = req.authUser ? db.caldavSlug(req.authUser.username) : null; const canSeeDir = (dir) => { if (CALDAV_CATEGORY_CALS[dir]) return true; if (isAdmin) return true; return dir === callerSlug; }; if (fs.existsSync(CAL_DIR)) { const dirs = fs .readdirSync(CAL_DIR) .filter((d) => fs.statSync(path.join(CAL_DIR, d)).isDirectory()) .filter(canSeeDir); for (const dir of dirs) { const files = fs.readdirSync(path.join(CAL_DIR, dir)).filter((f) => f.endsWith('.ics')); for (const f of files) { try { const content = fs.readFileSync(path.join(CAL_DIR, dir, f), 'utf8'); // Skip meistertracker-generated events if (content.includes('X-MEISTERPILZE-TYPE')) continue; if (!content.includes('VEVENT') && !content.includes('VTODO')) continue; const uid = (content.match(/UID:(.*)/) || [])[1]?.trim() || f; const summary = (content.match(/SUMMARY:(.*)/) || [])[1]?.trim() || '(kein Titel)'; const dtRaw = (content.match(/DTSTART[^:]*:([\dT]+)/) || [])[1] || ''; let date = '', startTime = null, allDay = true; if (dtRaw.length === 8) { date = dtRaw.slice(0, 4) + '-' + dtRaw.slice(4, 6) + '-' + dtRaw.slice(6, 8); } else if (dtRaw.length >= 15) { date = dtRaw.slice(0, 4) + '-' + dtRaw.slice(4, 6) + '-' + dtRaw.slice(6, 8); startTime = dtRaw.slice(9, 11) + ':' + dtRaw.slice(11, 13); allDay = false; } if (!date) continue; const dtEndRaw = (content.match(/DTEND[^:]*:([\dT]+)/) || [])[1] || ''; let endTime = null; if (dtEndRaw.length >= 15) { endTime = dtEndRaw.slice(9, 11) + ':' + dtEndRaw.slice(11, 13); } const desc = (content.match(/DESCRIPTION:(.*)/) || [])[1]?.trim() || null; imported.push({ uid, summary: summary.replace(/\\n/g, ' '), date, startTime, endTime, allDay, description: desc, calendar: dir }); } catch (e) { /* skip broken files */ } } } } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(imported)); } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify([])); } return; } // GET /api/printer-status — returns rich state plus legacy `found` boolean if (req.method === 'GET' && req.url === '/api/printer-status') { getPrinterStatus((err, status) => { const out = status || { state: 'unknown', name: PRINTER_NAME, online: false }; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ...out, found: out.state === 'online' })); }); return; } // GET /api/printer/config — admin: full printer + bridge config for the // Settings → Drucker tab. Token is masked; only hasToken is exposed. if (req.method === 'GET' && req.url === '/api/printer/config') { if (requireAdmin(req, res)) return; try { const dbCfg = db.getPrintBridgeCfg(database); const effective = getEffectiveBridgeConfig(); jsonOk(res, { platform: process.platform, printerName: PRINTER_NAME, bridge: { enabled: dbCfg.enabled, url: dbCfg.url, hasToken: !!dbCfg.token, envUrl: PRINT_BRIDGE_URL_ENV || null, envHasToken: !!PRINT_BRIDGE_TOKEN_ENV, effectiveUrl: effective ? effective.url : null, effectiveSource: effective ? effective.source : null } }); } catch (err) { safeErr(res, err); } return; } // POST /api/printer/config — admin: save bridge config to DB. if (req.method === 'POST' && req.url === '/api/printer/config') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { const url = (data.url || '').trim(); if (data.enabled && !url) { jsonErr(res, 400, 'URL required when enabled'); return; } // Require https:// — the bridge installer (print-bridge.ps1 -Install) // sets up a self-signed TLS cert on the Windows side automatically. // Plain http:// is rejected so users don't accidentally send tokens // and label data in cleartext over the LAN. if (url && !/^https:\/\//i.test(url)) { jsonErr(res, 400, 'Bridge URL must start with https:// (the installer sets up TLS)'); return; } db.updatePrintBridgeCfg(database, { enabled: !!data.enabled, url, token: data.token || '' }); log('info', 'Print bridge config updated', { actor: req.authUser.username, enabled: !!data.enabled, url, hasToken: !!data.token }); // Invalidate the printer-status cache so the UI sees the new state // immediately. _printerStatusCache = null; _printerQueueStuckCache = 0; _printerStatusCacheTime = 0; jsonOk(res); } catch (err) { safeErr(res, err); } }); return; } // POST /api/printer/test — admin: send a small test ZPL through the // current effective config. Same code path as a real label print, so a // success here means the next real print will also succeed. if (req.method === 'POST' && req.url === '/api/printer/test') { if (requireAdmin(req, res)) return; const testZpl = '^XA\n' + '^FO50,40^A0N,30,30^FDMeisterTracker^FS\n' + '^FO50,80^A0N,22,22^FDPrint bridge test^FS\n' + '^FO50,120^A0N,18,18^FD' + new Date().toISOString().replace('T', ' ').slice(0, 19) + '^FS\n' + '^XZ'; printZPL(testZpl, (err) => { if (err) { log('warn', 'Print bridge test failed', { error: err, actor: req.authUser && req.authUser.username }); jsonErr(res, 502, err); } else { jsonOk(res); } }); return; } // GET /api/printer/bridge-script — admin: download the print-bridge.ps1 file // bundled with this deployment, so the user does not have to fetch it from // GitHub manually. Same source on both platforms (path.join makes it // portable). Always serves the version that ships with the running server, // so the bridge stays in sync with the server's protocol. if (req.method === 'GET' && req.url === '/api/printer/bridge-script') { if (requireAdmin(req, res)) return; const scriptPath = path.join(DIR, 'scripts', 'print-bridge.ps1'); fs.readFile(scriptPath, 'utf8', (err, content) => { if (err) { log('error', 'Bridge script read failed', { error: err.message, path: scriptPath }); jsonErr(res, 404, 'Bridge script not found in deployment'); return; } res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Disposition': 'attachment; filename="print-bridge.ps1"', 'Content-Length': Buffer.byteLength(content, 'utf8'), 'Cache-Control': 'no-store' }); res.end(content); }); return; } // ── Camera dashboard (admin-only WIP) ───────────────────────────────────── // Surfaces the data that the Python `mushroom_camera` module writes into // `camera_*` SQLite tables, plus admin-editable calibration values used by // that module. The Python module currently reads its calibration from env // vars; storing them here is a forward-compatible step so the dashboard can // become the source of truth without restarting the camera service. if (req.method === 'GET' && req.url === '/api/camera/dashboard') { if (requireAdmin(req, res)) return; try { jsonOk(res, { stats: db.getCameraDashboardStats(database), cameras: db.listCameras(database), flags: db.listOpenCameraFlags(database), calibration: db.getCameraCalibration(database), recentMeasurements: db.listRecentCameraMeasurements(database, 25) }); } catch (err) { safeErr(res, err); } return; } if (req.method === 'POST' && req.url === '/api/camera/cameras') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { const id = db.insertCamera(database, { name: data.name, rtspUrl: data.rtspUrl, zoneId: data.zoneId, enabled: data.enabled !== false }); log('info', 'Camera added', { actor: req.authUser.username, id, name: data.name }); jsonOk(res, { id }); } catch (err) { jsonErr(res, 400, err.message); } }); return; } const camMatch = req.url.match(/^\/api\/camera\/cameras\/(\d+)$/); if (camMatch && req.method === 'PUT') { if (requireAdmin(req, res)) return; const id = parseInt(camMatch[1], 10); jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { db.updateCamera(database, id, data); log('info', 'Camera updated', { actor: req.authUser.username, id }); jsonOk(res); } catch (err) { jsonErr(res, 400, err.message); } }); return; } if (camMatch && req.method === 'DELETE') { if (requireAdmin(req, res)) return; const id = parseInt(camMatch[1], 10); try { db.deleteCamera(database, id); log('info', 'Camera deleted', { actor: req.authUser.username, id }); jsonOk(res); } catch (err) { safeErr(res, err); } return; } if (req.method === 'POST' && req.url === '/api/camera/calibration') { if (requireAdmin(req, res)) return; jsonBody(req, res, (e, data) => { if (e) { jsonErr(res, 400, e.message); return; } try { db.updateCameraCalibration(database, data || {}); log('info', 'Camera calibration updated', { actor: req.authUser.username }); jsonOk(res, db.getCameraCalibration(database)); } catch (err) { jsonErr(res, 400, err.message); } }); return; } const flagMatch = req.url.match(/^\/api\/camera\/flags\/(harvest|fruiting)\/(\d+)\/resolve$/); if (flagMatch && req.method === 'POST') { if (requireAdmin(req, res)) return; try { db.resolveCameraFlag(database, flagMatch[1], parseInt(flagMatch[2], 10)); jsonOk(res); } catch (err) { jsonErr(res, 400, err.message); } return; } // Static files let filePath; if (url === '/' || url === '/index.html') filePath = path.join(DIR, 'index.html'); else if (url === '/login.html') filePath = path.join(DIR, 'login.html'); else if (url === '/login.js') filePath = path.join(DIR, 'login.js'); else if (url === '/styles.css') filePath = path.join(DIR, 'styles.css'); else if (url === '/app.js') filePath = path.join(DIR, 'app.js'); else if (url === '/sw.js') filePath = path.join(DIR, 'sw.js'); else if (url === '/manifest.json') filePath = path.join(DIR, 'manifest.json'); else if (url.startsWith('/lib/')) filePath = path.join(DIR, 'lib', path.basename(url)); else if (url.startsWith('/lang/') && /^\/lang\/[a-zA-Z-]+\.js$/.test(url)) filePath = path.join(DIR, 'lang', path.basename(url)); else if (url.match(/^\/(icon-\d+\.png|favicon\.ico|icon\.svg)$/)) filePath = path.join(DIR, url.slice(1)); else { res.writeHead(404); res.end('Not found'); return; } // Path traversal protection — ensure resolved path stays within project dir const resolved = path.resolve(filePath); if (!resolved.startsWith(path.resolve(DIR))) { res.writeHead(403); res.end('Forbidden'); return; } const ext = path.extname(filePath).toLowerCase(); // Die Dateien der App: mit ETag, damit "no-cache" nichts kostet, solange sich // nichts geändert hat — der Browser fragt nach, der Server antwortet 304 ohne // Inhalt. Und mit dem Stand der ganzen App (X-App-Build), den der Service // Worker der Seite meldet: so weiß sie, welchen Code sie wirklich ausführt, // auch wenn app.js aus seinem Zwischenspeicher kam. let shell = null; if (isShellUrl(url)) { try { shell = { etag: 'W/"' + fileTag(filePath) + '"', build: appBuild() }; } catch { shell = null; // unreadable — the 404 below says so } if (shell && etagMatches(req.headers['if-none-match'], shell.etag)) { res.writeHead(304, staticHeaders(ext, url, null, shell)); res.end(); return; } } // P-01: serve pre-compressed variant when client accepts it. Skip images // (already compressed) and any extension we didn't precompute (e.g. .ico). const acceptEncoding = req.headers['accept-encoding']; const compressible = COMPRESSIBLE_EXT.has(ext); const picked = compressible ? pickEncoding(acceptEncoding, filePath) : null; const sendPath = picked ? picked.path : filePath; fs.readFile(sendPath, (err, data) => { if (err) { // Compressed file disappeared mid-flight — fall back to raw if (picked) { fs.readFile(filePath, (err2, raw) => { if (err2) { res.writeHead(404); res.end('Not found'); return; } writeStaticResponse(res, raw, filePath, ext, url, null, shell); }); return; } res.writeHead(404); res.end('Not found'); return; } writeStaticResponse(res, data, filePath, ext, url, picked && picked.encoding, shell); }); } function writeStaticResponse(res, data, filePath, ext, url, encoding, shell) { res.writeHead(200, staticHeaders(ext, url, encoding, shell)); res.end(data); } // The headers of a static response, shared by the 200 and the 304 so a // revalidated copy carries the same caching rules and the same app stand. function staticHeaders(ext, url, encoding, shell) { const headers = { 'Content-Type': MIME[ext] || 'application/octet-stream' }; // Cache immutable vendor libs and per-locale lang files aggressively; // cache HTML/CSS/SW short-term. if (WORKTREE_MODE) { // Test/worktree instance: never cache static assets, so code changes show // up on a plain reload without service-worker / HTTP-cache gymnastics. headers['Cache-Control'] = 'no-store'; } else if (url === '/sw.js') { // The service worker is the killswitch path — must always revalidate // so a bad SW build can be rolled back within one navigation rather // than waiting up to 5 min + the browser's own 24 h SW-bypass cache. headers['Cache-Control'] = 'no-cache'; } else if (url.startsWith('/lib/')) { // Pinned vendor builds — the file at this path genuinely never changes. headers['Cache-Control'] = 'public, max-age=31536000, immutable'; } else if (shell) { // The app's own files: index.html, app.js, styles.css, sw.js, /lang/*.js. // None has a version in its URL, and each changes with the deploy. They // used to get five minutes of max-age (lang included — marked immutable it // was pinned for a year and t() printed raw keys), which meant a reload // right after a deploy could still get the previous app.js from the // browser's own cache. "no-cache" asks every time; the ETag set below keeps // the answer a body-less 304 while nothing has changed. headers['Cache-Control'] = 'no-cache'; } else if (ext === '.png' || ext === '.ico' || ext === '.svg') { headers['Cache-Control'] = 'public, max-age=86400'; } else if (ext === '.css' || ext === '.js') { headers['Cache-Control'] = 'public, max-age=300'; } else { headers['Cache-Control'] = 'no-cache'; } // Vary on Accept-Encoding so caches don't mix gzip / br / raw responses. if (COMPRESSIBLE_EXT.has(ext)) { headers['Vary'] = 'Accept-Encoding'; } if (encoding) { headers['Content-Encoding'] = encoding; } if (shell) { // Weak: the same file travels raw, as gzip and as brotli, and a weak tag // is what says "same content" across those three. headers['ETag'] = shell.etag; headers['X-App-Build'] = shell.build; } return headers; } // ── TLS HOT-RELOAD ────────────────────────────────────────── function reloadTlsCerts() { if (protocol !== 'https' || !server) return; try { const newKey = fs.readFileSync(CERT_KEY); const newCert = fs.readFileSync(CERT_CRT); server.setSecureContext({ key: newKey, cert: newCert }); log('info', 'TLS certificates reloaded'); } catch (e) { log('error', 'Failed to reload TLS certificates', { error: e.message }); } } // P-01: pre-compress static assets at startup (gzip + brotli) so each // request just streams cached bytes — no per-request CPU spent on zlib. try { precompressStaticAssets(); log('info', 'Static asset precompression complete'); } catch (e) { log('warn', 'Static asset precompression failed', { error: e.message }); } // ── SERVER CREATION (HTTPS with HTTP→HTTPS redirect, HTTP fallback if no certs) ── let server; // HTTPS (or plain HTTP in fallback) — keeps setSecureContext working for cert hot-reload let listenServer; // The listener actually bound to PORT — a TCP mux in HTTPS mode, else === server let legacyRedirectServer; // Port-80 redirect server (HTTPS mode only) if (fs.existsSync(CERT_KEY) && fs.existsSync(CERT_CRT)) { const tlsOpts = { key: fs.readFileSync(CERT_KEY), cert: fs.readFileSync(CERT_CRT), minVersion: 'TLSv1.2' }; server = https.createServer(tlsOpts, handleRequest); protocol = 'https'; // Same-port HTTP handler: any plain-HTTP request arriving on PORT is 301-redirected to its HTTPS equivalent. // This is what makes `http://host:3000` automatically upgrade to `https://host:3000` in a browser. const samePortRedirect = http.createServer((req, res) => { const host = (req.headers.host || 'localhost').replace(/:.*$/, ''); const target = 'https://' + host + (PORT === 443 ? '' : ':' + PORT) + req.url; res.writeHead(301, { Location: target }); res.end(); }); // TCP multiplexer: sniff the first byte of each connection. // 0x16 = TLS ClientHello record type → forward to the HTTPS server. // Anything else → forward to the plain-HTTP redirect server. // This lets PORT accept both HTTP and HTTPS so users who type `http://` don't hit a broken TLS handshake. listenServer = net.createServer((socket) => { socket.once('data', (buf) => { socket.pause(); socket.unshift(buf); const target = buf[0] === 0x16 ? server : samePortRedirect; target.emit('connection', socket); process.nextTick(() => socket.resume()); }); socket.on('error', () => {}); }); // Legacy redirect on port 80 so users who type `http://host` (no port) still get forwarded to HTTPS. legacyRedirectServer = http.createServer((req, res) => { const host = (req.headers.host || '').replace(/:.*$/, ''); // Allow localhost HTTP for local development — but decide that from the peer // address, never the Host header. The header is attacker-controlled, so // `Host: localhost` used to hand any remote client the full application over // cleartext: login, /api/auth/login, /oauth/*, /mcp, with no HSTS (that is // only set when protocol === 'https'). It also defeated the CalDAV plaintext // guard, whose host check is the only thing keeping Basic-auth credentials // off the wire. /api/internal/notify already does it this way. const peer = req.socket.remoteAddress || ''; const peerIsLocal = peer === '127.0.0.1' || peer === '::1' || peer === '::ffff:127.0.0.1'; if (peerIsLocal && (host === 'localhost' || host === '127.0.0.1')) { handleRequest(req, res); return; } const target = 'https://' + host + (PORT === 443 ? '' : ':' + PORT) + req.url; res.writeHead(301, { Location: target }); res.end(); }); const HTTP_REDIRECT_PORT = parseInt(process.env.HTTP_REDIRECT_PORT, 10) || 80; legacyRedirectServer .listen(HTTP_REDIRECT_PORT, HOST, () => { log('info', 'HTTP→HTTPS redirect active on port ' + HTTP_REDIRECT_PORT); }) .on('error', (e) => { if (e.code === 'EACCES' || e.code === 'EADDRINUSE') { log( 'warn', 'Could not start HTTP redirect on port ' + HTTP_REDIRECT_PORT + ' (' + e.code + ') — HTTPS-only mode' ); } }); } else { log('warn', 'TLS certificates not found — falling back to HTTP. Run: bash gen-cert.sh'); server = http.createServer(handleRequest); protocol = 'http'; listenServer = server; } listenServer.listen(PORT, HOST, () => { const ip = getLocalIP(); console.log(''); console.log(' Meistertracker is running!'); console.log(''); // Recommend an address the certificate actually vouches for, rather than // printing two and warning afterwards that both are wrong. // // gen-cert.* writes DNS:localhost, IP:127.0.0.1 and the LAN IP on purpose — // those are the addresses people open. A public CA will issue for none of // them, so an ACME certificate covers the public hostname and nothing else, // and the first restart after switching silently invalidates both of the // lines this banner used to print. What follows from that is not a warning // sign in the browser: it is a service worker, which has no click-through, so // the app loads from cache and then reports itself disconnected while the // server is healthy and serving. const uncovered = certUncoveredHosts(['localhost', ip]); // A name the certificate carries that is not one of the two we would // otherwise recommend — on an ACME install, the public hostname. const vouched = certCoveredHosts().filter( (c) => !c.startsWith('*.') && c !== 'localhost' && c !== '127.0.0.1' && c !== ip ); if (!uncovered.length) { // Self-signed: the certificate carries both, and there is nothing to explain. console.log(' Open on this PC: ' + protocol + '://localhost:' + PORT); console.log(' Open on phone/tablet: ' + protocol + '://' + ip + ':' + PORT); } else if (vouched.length) { console.log(' Open on any device: ' + protocol + '://' + vouched[0] + ':' + PORT); console.log(''); console.log(' ⚠ Not ' + uncovered.join(' or ') + ' — the certificate does not cover them.'); console.log(' A browser may let you click past that; a service worker will not, so the'); console.log(' app loads and then reports itself offline while this server is fine.'); console.log(''); console.log(' This machine reaches the name above once it is in the hosts file.'); console.log(' A phone has no hosts file, so the router has to answer that name'); console.log(' with ' + ip + ' — otherwise it resolves to the public address, which'); console.log(' most routers will not route back inside from your own wifi.'); } else { // Covered by nothing usable: say what it does carry rather than guess. console.log(' Open on this PC: ' + protocol + '://localhost:' + PORT); console.log(' Open on phone/tablet: ' + protocol + '://' + ip + ':' + PORT); console.log(''); console.log(' ⚠ The certificate covers neither: ' + uncovered.join(', ')); console.log(' It is valid for: ' + (certCoveredHosts().join(', ') || '(no subject alternative names)')); } if (protocol === 'http') { console.log(''); console.log(' ⚠ WARNING: Running without HTTPS — iOS camera will not work.'); console.log(' Run "bash gen-cert.sh" and restart to enable HTTPS.'); } console.log(''); console.log(' CalDAV server: ' + protocol + '://' + ip + ':' + PORT + '/caldav/calendars/'); console.log(''); console.log(' Printer: ' + PRINTER_NAME); console.log(' Printing via Windows spooler — works from any browser.'); console.log(''); console.log(' Data saved to: ' + DB_FILE); console.log(' Press Ctrl+C to stop.'); // Auto-sync CalDAV on startup if enabled try { const cfg = db.readCaldavConfig(database); if (cfg.enabled) { log('info', 'CalDAV sync enabled — running initial sync...'); autoSyncAllCaldav(readData()); log('info', 'CalDAV initial sync complete'); } } catch (e) { log('error', 'CalDAV startup sync failed', { error: e.message }); } }); // ── GRACEFUL SHUTDOWN ──────────────────────────────────────── function shutdown(signal) { log('info', 'Received ' + signal + ', shutting down...'); // End all long-lived SSE responses first. server.close() waits for open // connections to finish, but SSE streams never finish on their own // (heartbeats keep them active), so without this the close callbacks never // fire and every shutdown hits the 5 s force-exit(1) — making PM2/systemd // treat each routine stop as a failed shutdown. for (const c of sseClients) { try { c.end(); } catch (e) { /* ignore */ } } sseClients.clear(); // Stop the DNS loop before the drain window rather than letting an unref'd // timer fire against a database that is about to close. duckdns.stop(); const servers = [listenServer]; if (listenServer !== server) servers.push(server); if (legacyRedirectServer) servers.push(legacyRedirectServer); let remaining = servers.length; servers.forEach((s) => { s.close(() => { if (--remaining === 0) { database.close(); log('info', 'Server closed'); process.exit(0); } }); }); setTimeout(() => { database.close(); process.exit(1); }, 5000); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); // R-07: warn-only on both uncaughtException and unhandledRejection. // Rationale: uncaught exceptions in async paths are usually narrow correctness // bugs (e.g. malformed user input that escaped validation), and crash-looping // the whole server amplifies the damage. PM2 still catches genuinely fatal // errors (segfaults, OOM) via process exit codes — those bypass this handler. // SIGTERM/SIGINT keep their graceful-shutdown behaviour above. process.on('uncaughtException', (err) => { log('error', 'Uncaught exception (continuing)', { error: err.message, stack: err.stack }); }); process.on('unhandledRejection', (reason) => { log('error', 'Unhandled promise rejection', { error: reason instanceof Error ? reason.message : String(reason), stack: reason instanceof Error ? reason.stack : undefined }); });