// Centralized, per-user configuration store. // // Multi-user: every setting (Ollama, E-Mail, CalDAV, API-Token) is owned by a // user. The values live in the SQLite app_state table (key prefixed "cfg:"), // now keyed by (user_id, key). The /einstellungen page edits the *current* user's // values; the libs (mailer, caldav, chat, documents) read through config.get() / // config.ollama() at call time, picking up the current user from the per-request // context (lib/context.js). An edit therefore takes effect immediately, scoped // to the user who made it — no restart, no .env file. // // On first start of an install that previously used .env (or that still passes // the values as container env vars), the still-present env values are imported // into the *admin* user's rows once — see importEnvIntoAdmin(), called from // server.js at boot. After that the database is the single source of truth. // // process.env is deliberately NOT a read fallback: env config belongs to the // admin, so falling back to it would hand every freshly created user the admin's // mailbox, calendar, Ollama key and API token. Unset keys resolve to the // built-in defaults instead, which leaves a new user's settings empty. const { currentUser } = require('./context'); const DEFAULTS = { // Ollama Cloud (KI text generation + chat). OLLAMA_API_KEY: '', OLLAMA_MODEL: 'glm-5.2:cloud', OLLAMA_HOST: 'https://ollama.com', OLLAMA_TIMEOUT_MS: '300000', // E-Mail (SMTP submission + IMAP receive). MAIL_HOST: '', MAIL_SMTP_PORT: '587', MAIL_IMAP_PORT: '993', MAIL_USER: '', MAIL_PASSWORD: '', MAIL_FROM_NAME: '', MAIL_FROM: '', MAIL_IMAP_MAILBOX: 'INBOX', MAIL_POLL_MS: '180000', // Bewerbungskalender (CalDAV, z. B. SOGo). CALDAV_URL: '', CALDAV_ALARM_MIN: '60', CALDAV_POLL_MS: '300000', // REST-API für Drittanbietersoftware (/api/v1, Header X-API-Key). API_TOKEN: '', }; // UI metadata for the /einstellungen page: grouped sections with input types. // `secret` fields render as password inputs with a reveal toggle. const FIELDS = [ { titel: 'Ollama Cloud (KI-Generierung + Chat)', beschreibung: 'Steuerung der KI, die Bewerbungsunterlagen erzeugt und den KI-Chat beantwortet. Ohne API-Schlüssel sind diese Funktionen deaktiviert.', items: [ { key: 'OLLAMA_API_KEY', label: 'API-Schlüssel', secret: true, help: 'Schlüssel von https://ollama.com/settings/keys' }, { key: 'OLLAMA_MODEL', label: 'Modell', help: 'Standard: glm-5.2:cloud' }, { key: 'OLLAMA_HOST', label: 'Host', help: 'Standard: https://ollama.com — lokaler Ollama: http://localhost:11434' }, { key: 'OLLAMA_TIMEOUT_MS', label: 'Timeout (ms)', help: 'Standard: 300000 (5 Min.)' }, ], }, { titel: 'E-Mail (SMTP-Versand + IMAP-Empfang)', beschreibung: 'Versand läuft über den eigenen Mailserver (DKIM/SPF/DMARC-Alignment). Ohne Host/Benutzer/Passwort ist der E-Mail-Teil deaktiviert. Pro Benutzer eigenes Postfach.', items: [ { key: 'MAIL_HOST', label: 'SMTP/IMAP Host' }, { key: 'MAIL_SMTP_PORT', label: 'SMTP-Port', help: '587 = STARTTLS, 465 = implicit TLS' }, { key: 'MAIL_IMAP_PORT', label: 'IMAP-Port', help: 'Standard: 993 (implicit TLS)' }, { key: 'MAIL_USER', label: 'Benutzername (Login)', help: 'Auch CalDAV-Login' }, { key: 'MAIL_PASSWORD', label: 'Passwort', secret: true, help: 'Auch CalDAV-Passwort' }, { key: 'MAIL_FROM_NAME', label: 'Absendername' }, { key: 'MAIL_FROM', label: 'Absenderadresse', help: 'Leer = Benutzername' }, { key: 'MAIL_IMAP_MAILBOX', label: 'IMAP-Postfach', help: 'Standard: INBOX' }, { key: 'MAIL_POLL_MS', label: 'Abrufintervall (ms)', help: 'Standard: 180000 (3 Min.) — greift nach Neustart' }, ], }, { titel: 'Bewerbungskalender (CalDAV)', beschreibung: 'Voll-URL der Kalender-Sammlung (mit abschließendem /). Authentifizierung läuft über MAIL_USER/MAIL_PASSWORD. Ohne URL sind die Kalender-Funktionen deaktiviert.', items: [ { key: 'CALDAV_URL', label: 'Kalender-URL', help: 'z. B. https://mail.example.com/SOGo/dav/name@…/Calendar/XXXX/' }, { key: 'CALDAV_ALARM_MIN', label: 'Erinnerung (Minuten vor Termin)', help: 'Standard: 60' }, { key: 'CALDAV_POLL_MS', label: 'Sync-Intervall (ms)', help: 'Standard: 300000 (5 Min.) — greift nach Neustart' }, ], }, { titel: 'REST-API für Drittanbietersoftware', beschreibung: 'Ist ein Token gesetzt, ist /api/v1 für diesen Benutzer aktiv und erwartet den Wert im Header „X-API-Key“. Der Token identifiziert den Benutzer: Anfragen sehen und ändern ausschließlich dessen eigene Daten. Ohne Token antwortet die API (bis auf /health) mit 401. Swagger unter /swagger.', items: [ { key: 'API_TOKEN', label: 'API-Token (X-API-Key)', secret: true, generate: true, help: 'Leer = API deaktiviert. Über das Auge einblenden, „Neu generieren“ erzeugt einen zufälligen Token — danach speichern. Ein neuer Token macht den alten sofort ungültig.', }, ], }, ]; const PREFIX = 'cfg:'; // Per-user cache: Map>. A user is loaded lazily on // first access (ensureLoaded) and stays cached for the process lifetime. Edits // via saveAll() update the cache in place so subsequent reads are consistent. const cache = new Map(); // userId -> { key: value } const loaded = new Set(); // userIds whose cfg rows have been read from the DB let dbAllFn = null; let dbRunFn = null; // The built-in default for a key. Deliberately NOT a process.env lookup: env // values belong to the admin (they are imported into the admin's rows once at // boot, see importEnvIntoAdmin in lib/migrate-multiuser.js). If get() fell back // to process.env, every user without their own row would silently inherit the // admin's credentials — their own mailbox, calendar, Ollama key and API token. // The defaults below are non-secret standards only (model, host, ports, poll // intervals); every credential defaults to empty, so a new user starts with // Ollama/E-Mail/CalDAV/API switched off until they configure their own. function builtinDefault(key) { return DEFAULTS[key]; } // Load one user's cfg rows from the DB into the cache. No-op if already loaded. async function ensureLoaded(userId) { if (!userId || loaded.has(userId)) return; const rows = await dbAllFn('SELECT key, value FROM app_state WHERE user_id = ?', [userId]); const obj = Object.create(null); for (const r of rows) obj[r.key.slice(PREFIX.length)] = r.value; cache.set(userId, obj); loaded.add(userId); } // Drop the cached rows for a user so the next read reloads from the DB. Used // after a direct DB write outside saveAll() (e.g. the one-time env migration). function invalidate(userId) { loaded.delete(userId); cache.delete(userId); } // Synchronous read for the *current user only*. A key the user has not stored // falls back to the built-in default — never to another user's value and never // to process.env. A user who has configured nothing therefore reads as "empty" // (no mail host, no keys), which is exactly what isolates them: mailer/caldav // isConfigured() turns false and their background jobs stay idle. // // Outside a request context (boot) there is no user, so only the defaults apply. // Callers that need a specific user's values must run inside that user's context // (see lib/context.js) and must have called ensureLoaded(userId) first. function get(key) { const u = currentUser(); const userObj = u ? cache.get(u.id) : null; if (userObj && userObj[key] !== undefined) return userObj[key]; return builtinDefault(key); } // All keys with their effective values for the current user — used by the // settings UI. Must be called within a request context. function getAll() { const out = {}; for (const key of Object.keys(DEFAULTS)) out[key] = get(key); return out; } // Ollama bundle (shared by lib/documents.js + lib/chat.js), for the current user. function ollama() { return { host: (get('OLLAMA_HOST') || 'https://ollama.com').replace(/\/+$/, ''), model: get('OLLAMA_MODEL') || 'glm-5.2:cloud', timeoutMs: Number(get('OLLAMA_TIMEOUT_MS')) || 300000, apiKey: get('OLLAMA_API_KEY') || '', }; } // Wire up DB helpers. The one-time .env migration now runs per-admin at boot // from server.js (it needs the admin user id); this init only stores the fns. async function init({ dbAll, dbRun }) { dbAllFn = dbAll; dbRunFn = dbRun; } // Persist every key for the current user (writes all rows, including empty // strings, so a cleared field is stored as empty and no longer falls back to // env/default). Must be called within a request context. async function saveAll(values) { if (!dbRunFn) throw new Error('Konfigurationsspeicher nicht initialisiert.'); const u = currentUser(); if (!u) throw new Error('Kein Benutzerkontext für Konfigurationsspeicherung.'); const obj = cache.get(u.id) || Object.create(null); for (const key of Object.keys(DEFAULTS)) { const v = values && values[key] != null ? String(values[key]) : ''; await dbRunFn( 'INSERT INTO app_state (user_id, key, value) VALUES (?, ?, ?) ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value', [u.id, PREFIX + key, v] ); obj[key] = v; } cache.set(u.id, obj); loaded.add(u.id); } // Write a single key for an explicit user (used by the one-time env migration, // which runs outside a request context). Updates the cache if loaded. async function setForUser(userId, key, value) { if (!dbRunFn) throw new Error('Konfigurationsspeicher nicht initialisiert.'); await dbRunFn( 'INSERT INTO app_state (user_id, key, value) VALUES (?, ?, ?) ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value', [userId, PREFIX + key, String(value)] ); if (loaded.has(userId)) { const obj = cache.get(userId) || Object.create(null); obj[key] = String(value); cache.set(userId, obj); } } module.exports = { DEFAULTS, FIELDS, PREFIX, get, getAll, ollama, init, saveAll, setForUser, ensureLoaded, invalidate, };