Files
jobbi-bewerbung/lib/config.js
T
thomasandClaude 0371aa85a5 Multi-User-Plattform: jeder Benutzer hat eigene, isolierte Daten
- Auth via Session-Cookie + Login-Seite (scrypt, lib/password.js, sessions-Tabelle)
- AsyncLocalStorage (lib/context.js) propagiert aktuellen Benutzer durch alle Libs
- user_id auf allen Datentabellen (FK->users ON DELETE CASCADE), per-user PK/UNIQUE
  (app_state, settings, prompts, design, jobangebote) und per-user Dateispeicher
  (data/<dir>/<userId>/)
- Alle Queries in server.js + lib/api.js nach user_id scope-iert
- Pro-Benutzer-Konfiguration (Ollama/Mail/CalDAV/API-Token) in app_state,
  Live gelesen via config.get(); Hintergrund-Loops (IMAP/CalDAV) iterieren alle Benutzer
- REST-API /api/v1: X-API-Key loest den Token zu einem Benutzer auf, Anfragen
  operieren nur auf dessen Daten
- Admin-Panel /admin: Benutzer anlegen, Passwort zuruecksetzen, loeschen (mit Daten)
- Idempotente Migration (lib/migrate-multiuser.js + scripts/migrate-to-multiuser.js):
  bestehende Daten werden dem Benutzer admin:admin zugeordnet

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 21:57:50 +02:00

194 lines
8.4 KiB
JavaScript

// 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, init() migrates any
// still-present env value into the *admin* user's config once. After that the
// database is the single source of truth; process.env is only a fallback for keys
// that were never saved.
const { currentUser } = require('./context');
const DEFAULTS = {
// Ollama Cloud (KI text generation + chat).
OLLAMA_API_KEY: '',
OLLAMA_MODEL: 'gpt-oss:120b',
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: gpt-oss:120b' },
{ 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“. Anfragen operieren auf den Daten dieses Benutzers. Ohne Token antwortet die API (bis auf /health) mit 503. Swagger unter /swagger.',
items: [
{ key: 'API_TOKEN', label: 'API-Token (X-API-Key)', secret: true, help: 'Leer = API deaktiviert' },
],
},
];
const PREFIX = 'cfg:';
// Per-user cache: Map<userId, Object<string,string>>. 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;
function envOrDefault(key) {
const e = process.env[key];
return e && e.length ? e : 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. Falls back to process.env (pre-migration
// / never saved) then to the built-in default. Outside a request context (boot)
// only the env/default fallback applies — callers that need a specific user must
// run inside the user context (see lib/context.js).
function get(key) {
const u = currentUser();
const userObj = u ? cache.get(u.id) : null;
if (userObj && userObj[key] !== undefined) return userObj[key];
return envOrDefault(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') || 'gpt-oss:120b',
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,
};