diff --git a/.env.example b/.env.example index 8c1e1ff..d7fb723 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,8 @@ -# Kopiere diese Datei nach .env und trage deinen Schlüssel ein. -# .env wird NICHT eingecheckt (siehe .gitignore). +# HINWEIS: Diese Werte werden seit dem Umzug auf die Datenbank-basierte +# Konfiguration primär in der Weboberfläche unter „Einstellungen“ (/einstellungen) +# gepflegt. Eine bestehende .env-Datei wird beim ersten Start einmalig in die +# Datenbank migriert; danach ist die Datenbank die einzige Quelle und .env wird +# nicht mehr benötigt. Diese Datei dient nur noch als Vorlage/Referenz. # Ollama Cloud API-Schlüssel – https://ollama.com/settings/keys OLLAMA_API_KEY= diff --git a/README.md b/README.md index 3b7714c..6217199 100644 --- a/README.md +++ b/README.md @@ -30,20 +30,21 @@ Ablauf: ### Konfiguration +Alle Einstellungen (Ollama, E-Mail, Kalender, REST-API) werden in der Weboberfläche +unter **„Einstellungen“** (`/einstellungen`, Zahnrad-Symbol im Header) gepflegt und +in der Datenbank gespeichert. Änderungen wirken sofort, ein Neustart ist nicht nötig. + Für die KI-Generierung wird ein Ollama-Cloud-API-Schlüssel benötigt -([ollama.com/settings/keys](https://ollama.com/settings/keys)). Am einfachsten über -eine (nicht eingecheckte) `.env`-Datei – kopiere `.env.example` nach `.env`: +([ollama.com/settings/keys](https://ollama.com/settings/keys)) – trage ihn nach dem +ersten Start unter **Einstellungen → Ollama Cloud** ein. ```bash -cp .env.example .env -# in .env eintragen: -# OLLAMA_API_KEY=... -# OLLAMA_MODEL=gpt-oss:120b # optional, Standard -# OLLAMA_HOST=https://ollama.com # optional; z. B. http://localhost:11434 für lokale Ollama npm start +# dann im Browser http://localhost:3000/einstellungen öffnen ``` -Alternativ als Umgebungsvariable: `export OLLAMA_API_KEY=...`. +Bestehende Installationen, die bisher eine `.env`-Datei nutzen, werden beim ersten +Start einmalig in die Datenbank migriert; danach wird `.env` nicht mehr benötigt. Ohne Schlüssel wird die Bewerbung trotzdem als Entwurf angelegt; die Generierung schlägt dann kontrolliert mit einem Hinweis fehl und kann später per @@ -217,10 +218,10 @@ das Rohdokument unter **`/swagger.json`**. ### Authentifizierung Jeder Endpunkt (außer `GET /api/v1/health`) erfordert einen API-Key im Header -`X-API-Key`. Der Schlüssel wird über die Umgebungsvariable `API_TOKEN` konfiguriert -(z. B. in `.env`, siehe `.env.example`). Ist `API_TOKEN` nicht gesetzt, antwortet -die API mit `503` – sie gibt nie ungeschützt Daten heraus. Swagger/UI sind -auch ohne Token erreichbar (die Dokumentation enthält keine sensiblen Daten). +`X-API-Key`. Der Schlüssel wird unter **Einstellungen → REST-API** (`API_TOKEN`) +konfiguriert. Ist `API_TOKEN` nicht gesetzt, antwortet die API mit `503` – sie gibt +nie ungeschützt Daten heraus. Swagger/UI sind auch ohne Token erreichbar (die +Dokumentation enthält keine sensiblen Daten). ```bash curl -H "X-API-Key: $API_TOKEN" http://localhost:3000/api/v1/applications diff --git a/lib/api.js b/lib/api.js index 7dc7fe0..bbe4671 100644 --- a/lib/api.js +++ b/lib/api.js @@ -1,9 +1,10 @@ // Third-party REST API (v1) for the Bewerbungs-Tracker. // // Mounted under /api/v1 in server.js. All endpoints except /health require an -// API key (env API_TOKEN) sent in the X-API-Key header. Reuses the server's -// existing DB helpers, sanitizer, duplicate guard, generation runner and -// attachment directories so behaviour stays consistent with the web UI. +// API key (API_TOKEN, editable via /einstellungen) sent in the X-API-Key +// header. Reuses the server's existing DB helpers, sanitizer, duplicate guard, +// generation runner and attachment directories so behaviour stays consistent +// with the web UI. const express = require('express'); const path = require('path'); @@ -41,16 +42,22 @@ function createExternalApi(deps) { const router = express.Router(); + // `apiToken` may be a string (static) or a function () => string (dynamic, + // read from the DB on each request so an edit on /einstellungen takes effect + // without a restart). + const resolveToken = () => (typeof apiToken === 'function' ? apiToken() : apiToken); + // --- API key auth -------------------------------------------------- // /health is public so monitoring tools can probe availability; everything // else returns 401 when the header is missing/wrong or the token isn't set. router.use((req, res, next) => { if (req.path === '/health') return next(); - if (!apiToken) { - return res.status(503).json({ error: 'API-Token nicht konfiguriert (API_TOKEN-Umgebungsvariable fehlt).' }); + const token = resolveToken(); + if (!token) { + return res.status(503).json({ error: 'API-Token nicht konfiguriert (in den Einstellungen setzen).' }); } const provided = req.get('X-API-Key'); - if (!provided || provided !== apiToken) { + if (!provided || provided !== token) { return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' }); } next(); diff --git a/lib/caldav.js b/lib/caldav.js index a8336ba..b1b78e9 100644 --- a/lib/caldav.js +++ b/lib/caldav.js @@ -4,7 +4,7 @@ // creates / updates / deletes events (PUT / DELETE with iCalendar VEVENTs). // Auth is HTTP Basic over HTTPS using the same mail account as lib/mailer. // -// Config (env): +// Config (DB, editable via /einstellungen — see lib/config.js): // CALDAV_URL full URL of the calendar collection (must end with /) // MAIL_USER login (shared with mail) // MAIL_PASSWORD password (shared with mail) @@ -14,15 +14,18 @@ // Europe/Berlin. Wall-clock input from the UI is converted with wallToUtc(). const crypto = require('crypto'); +const config = require('./config'); const TZ = 'Europe/Berlin'; +// Read fresh on every call so an edit on the /einstellungen page takes effect +// immediately (values live in the DB now, not in process.env/.env). function cfg() { return { - url: (process.env.CALDAV_URL || '').trim(), - user: process.env.MAIL_USER || '', - pass: process.env.MAIL_PASSWORD || '', - alarmMin: Number(process.env.CALDAV_ALARM_MIN) || 60, + url: (config.get('CALDAV_URL') || '').trim(), + user: config.get('MAIL_USER') || '', + pass: config.get('MAIL_PASSWORD') || '', + alarmMin: Number(config.get('CALDAV_ALARM_MIN')) || 60, }; } diff --git a/lib/chat.js b/lib/chat.js index a2251bd..2eecbe4 100644 --- a/lib/chat.js +++ b/lib/chat.js @@ -11,14 +11,12 @@ // replies as text, token by token, so the UI can render progressively. const promptStore = require('./prompts'); +const config = require('./config'); -const OLLAMA_HOST = (process.env.OLLAMA_HOST || 'https://ollama.com').replace(/\/+$/, ''); -const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'gpt-oss:120b'; -const OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS || 300000); const MAX_TOOL_ROUNDS = 4; function isConfigured() { - return Boolean(process.env.OLLAMA_API_KEY); + return Boolean(config.ollama().apiKey); } // Stream a single chat completion from Ollama. `messages` = [{role, content}] @@ -28,8 +26,8 @@ function isConfigured() { // fires with each incremental text chunk. Returns { content, toolCalls }. // Aborts cleanly via `signal`. async function streamChat({ system, messages, tools, onToken, temperature = 0.6, signal }) { - const apiKey = process.env.OLLAMA_API_KEY; - if (!apiKey) throw new Error('OLLAMA_API_KEY ist nicht gesetzt.'); + const { host: ollamaHost, model: ollamaModel, apiKey } = config.ollama(); + if (!apiKey) throw new Error('OLLAMA_API_KEY ist nicht gesetzt (unter „Einstellungen“ konfigurieren).'); const allMessages = []; if (system) allMessages.push({ role: 'system', content: system }); @@ -43,7 +41,7 @@ async function streamChat({ system, messages, tools, onToken, temperature = 0.6, } const body = { - model: OLLAMA_MODEL, + model: ollamaModel, stream: true, options: { temperature }, messages: allMessages, @@ -52,7 +50,7 @@ async function streamChat({ system, messages, tools, onToken, temperature = 0.6, let res; try { - res = await fetch(`${OLLAMA_HOST}/api/chat`, { + res = await fetch(`${ollamaHost}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, body: JSON.stringify(body), @@ -216,4 +214,8 @@ function buildContextPrompt(ctx) { return parts.join('\n\n'); } -module.exports = { isConfigured, streamChat, runChat, buildContextPrompt, OLLAMA_MODEL, MAX_TOOL_ROUNDS }; \ No newline at end of file +module.exports = { + isConfigured, streamChat, runChat, buildContextPrompt, MAX_TOOL_ROUNDS, + // Dynamic so an edit on /einstellungen is reflected without a restart. + get OLLAMA_MODEL() { return config.ollama().model; }, +}; \ No newline at end of file diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..94ba478 --- /dev/null +++ b/lib/config.js @@ -0,0 +1,163 @@ +// Centralized configuration store. +// +// Replaces the .env file: all settings live in the SQLite app_state table +// (rows prefixed "cfg:") and are editable via the /einstellungen page. The +// libs read values through config.get() / config.ollama() at *call* time, so +// an edit in the UI takes effect immediately — no restart, no .env file. +// +// On first start of an install that previously used .env, init() migrates any +// still-present env value into the DB once, so existing config is not lost. +// After that the database is the single source of truth; process.env is only a +// fallback for keys that were never saved (and for the one-time migration). + +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.', + 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 aktiv und erwartet den Wert im Header „X-API-Key“. 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:'; +const cache = Object.create(null); // key -> string (only keys present in the DB) +let dbAllFn = null; +let dbRunFn = null; + +function envOrDefault(key) { + const e = process.env[key]; + return e && e.length ? e : DEFAULTS[key]; +} + +// Synchronous read. Falls back to process.env (pre-migration / never saved) +// then to the built-in default. After init() the DB value is cached and wins. +function get(key) { + const v = cache[key]; + if (v !== undefined) return v; + return envOrDefault(key); +} + +// All keys with their effective values, keyed by name — used by the settings UI. +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). +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') || '', + }; +} + +async function load() { + if (!dbAllFn) return; + const rows = await dbAllFn('SELECT key, value FROM app_state WHERE key LIKE ?', [PREFIX + '%']); + for (const r of rows) cache[r.key.slice(PREFIX.length)] = r.value; +} + +// Wire up DB helpers, load the cached rows, then one-time-migrate any env +// value that isn't yet in the DB (so an existing .env install keeps its config). +async function init({ dbAll, dbRun }) { + dbAllFn = dbAll; + dbRunFn = dbRun; + await load(); + const toMigrate = []; + for (const key of Object.keys(DEFAULTS)) { + if (cache[key] === undefined) { + const e = process.env[key]; + if (e && e.length) toMigrate.push([key, e]); + } + } + if (toMigrate.length) { + for (const [k, v] of toMigrate) { + await dbRunFn( + 'INSERT INTO app_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', + [PREFIX + k, v] + ); + cache[k] = v; + } + console.log(`Konfiguration aus .env in die Datenbank migriert (${toMigrate.length} Werte) — .env wird nicht mehr benötigt.`); + } +} + +// Persist every key (writes all rows, including empty strings, so a cleared +// field is stored as empty and no longer falls back to env/default). +async function saveAll(values) { + if (!dbRunFn) throw new Error('Konfigurationsspeicher nicht initialisiert.'); + for (const key of Object.keys(DEFAULTS)) { + const v = values && values[key] != null ? String(values[key]) : ''; + await dbRunFn( + 'INSERT INTO app_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', + [PREFIX + key, v] + ); + cache[key] = v; + } +} + +module.exports = { DEFAULTS, FIELDS, get, getAll, ollama, init, load, saveAll }; \ No newline at end of file diff --git a/lib/documents.js b/lib/documents.js index eaf7242..8598e41 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -11,6 +11,7 @@ const fs = require('fs'); const path = require('path'); const promptStore = require('./prompts'); const designStore = require('./design'); +const config = require('./config'); // Embedded typefaces (all SIL Open Font License — see lib/fonts/OFL*.txt): // Lato — the classic layout's workhorse @@ -47,10 +48,8 @@ function makeDoc(t) { return doc; } -// Ollama Cloud API (https://ollama.com). Override host/model via env if needed. -const OLLAMA_HOST = (process.env.OLLAMA_HOST || 'https://ollama.com').replace(/\/+$/, ''); -const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'gpt-oss:120b'; -const OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS || 300000); +// Ollama Cloud API (https://ollama.com). Host/model/timeout come from the DB +// (editable via /einstellungen) and are read fresh per call via config.ollama(). // =========================================================================== // 1. LLM call — produce tailored TEXT for the template @@ -177,12 +176,11 @@ function buildOutputSchema(dokumente) { async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen = '', zusatzAnlagen = [], prompts = null, dokumente = null }) { const doks = normalizeDokumente(dokumente); - const apiKey = process.env.OLLAMA_API_KEY; + const { host: ollamaHost, model: ollamaModel, timeoutMs: ollamaTimeoutMs, apiKey } = config.ollama(); if (!apiKey) { throw new Error( - 'OLLAMA_API_KEY ist nicht gesetzt. Bitte den API-Schlüssel als ' + - 'Umgebungsvariable (z. B. in einer .env-Datei) hinterlegen, damit ' + - 'Bewerbungsunterlagen generiert werden können.' + 'Kein Ollama-API-Schlüssel konfiguriert. Bitte unter „Einstellungen“ den ' + + 'API-Schlüssel eintragen, damit Bewerbungsunterlagen generiert werden können.' ); } if (!basisDokumente || basisDokumente.length === 0) { @@ -428,15 +426,15 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen `JSON-Objekt, ohne Markdown, ohne Code-Fences, ohne weiteren Text.`; const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), OLLAMA_TIMEOUT_MS); + const timeout = setTimeout(() => controller.abort(), ollamaTimeoutMs); let res; try { - res = await fetch(`${OLLAMA_HOST}/api/chat`, { + res = await fetch(`${ollamaHost}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, body: JSON.stringify({ - model: OLLAMA_MODEL, + model: ollamaModel, stream: false, format: buildOutputSchema(doks), options: { temperature: 0.4 }, @@ -449,7 +447,7 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen }); } catch (err) { if (err.name === 'AbortError') { - throw new Error(`Zeitüberschreitung bei der KI-Anfrage (> ${Math.round(OLLAMA_TIMEOUT_MS / 1000)}s).`); + throw new Error(`Zeitüberschreitung bei der KI-Anfrage (> ${Math.round(ollamaTimeoutMs / 1000)}s).`); } throw new Error(`Verbindung zur Ollama-API fehlgeschlagen: ${err.message}`); } finally { @@ -1942,17 +1940,17 @@ async function generateApplicationDocuments({ job, basisDokumente, settings, zus // Small shared Ollama JSON call (used by the reply drafter). async function ollamaChatJSON({ system, user, schema, temperature = 0.5 }) { - const apiKey = process.env.OLLAMA_API_KEY; - if (!apiKey) throw new Error('OLLAMA_API_KEY ist nicht gesetzt.'); + const { host: ollamaHost, model: ollamaModel, timeoutMs: ollamaTimeoutMs, apiKey } = config.ollama(); + if (!apiKey) throw new Error('Kein Ollama-API-Schlüssel konfiguriert (unter „Einstellungen“ eintragen).'); const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), OLLAMA_TIMEOUT_MS); + const timeout = setTimeout(() => controller.abort(), ollamaTimeoutMs); let res; try { - res = await fetch(`${OLLAMA_HOST}/api/chat`, { + res = await fetch(`${ollamaHost}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, body: JSON.stringify({ - model: OLLAMA_MODEL, + model: ollamaModel, stream: false, format: schema, options: { temperature }, @@ -1964,7 +1962,7 @@ async function ollamaChatJSON({ system, user, schema, temperature = 0.5 }) { signal: controller.signal, }); } catch (err) { - if (err.name === 'AbortError') throw new Error(`Zeitüberschreitung bei der KI-Anfrage (> ${Math.round(OLLAMA_TIMEOUT_MS / 1000)}s).`); + if (err.name === 'AbortError') throw new Error(`Zeitüberschreitung bei der KI-Anfrage (> ${Math.round(ollamaTimeoutMs / 1000)}s).`); throw new Error(`Verbindung zur Ollama-API fehlgeschlagen: ${err.message}`); } finally { clearTimeout(timeout); diff --git a/lib/mailer.js b/lib/mailer.js index 04f703b..ac30847 100644 --- a/lib/mailer.js +++ b/lib/mailer.js @@ -11,18 +11,21 @@ const nodemailer = require('nodemailer'); const { ImapFlow } = require('imapflow'); const { simpleParser } = require('mailparser'); +const config = require('./config'); +// Read fresh on every call so an edit on the /einstellungen page takes effect +// immediately (values live in the DB now, not in process.env/.env). function cfg() { - const smtpPort = Number(process.env.MAIL_SMTP_PORT || 587); + const smtpPort = Number(config.get('MAIL_SMTP_PORT') || 587); return { - host: process.env.MAIL_HOST || '', + host: config.get('MAIL_HOST') || '', smtpPort, - imapPort: Number(process.env.MAIL_IMAP_PORT || 993), - user: process.env.MAIL_USER || '', - pass: process.env.MAIL_PASSWORD || '', - fromName: process.env.MAIL_FROM_NAME || '', - fromAddr: process.env.MAIL_FROM || process.env.MAIL_USER || '', - mailbox: process.env.MAIL_IMAP_MAILBOX || 'INBOX', + imapPort: Number(config.get('MAIL_IMAP_PORT') || 993), + user: config.get('MAIL_USER') || '', + pass: config.get('MAIL_PASSWORD') || '', + fromName: config.get('MAIL_FROM_NAME') || '', + fromAddr: config.get('MAIL_FROM') || config.get('MAIL_USER') || '', + mailbox: config.get('MAIL_IMAP_MAILBOX') || 'INBOX', // secure=true means implicit TLS (465); 587 uses STARTTLS (requireTLS). smtpSecure: smtpPort === 465, }; diff --git a/server.js b/server.js index 33302b6..baf330f 100644 --- a/server.js +++ b/server.js @@ -40,6 +40,7 @@ const { buildOpenApiSpec } = require('./lib/openapi'); const blacklist = require('./lib/blacklist'); const caldav = require('./lib/caldav'); const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./lib/labels'); +const config = require('./lib/config'); const app = express(); const PORT = process.env.PORT || 3000; @@ -1065,9 +1066,14 @@ function initializeDatabase() { } // Initialize and start server -initializeDatabase().then(() => { +initializeDatabase().then(async () => { console.log('Database initialized successfully'); - + + // Load configuration from the DB (migrates any still-present .env values + // once). Must run before the boot checks below (mailer/caldav configured?) and + // before any route that reads config — values live in the DB now, not in .env. + await config.init({ dbAll, dbRun }); + // Routes app.get('/', async (req, res) => { try { @@ -1976,7 +1982,7 @@ initializeDatabase().then(() => { hasSignatur: Boolean(currentSignaturFile()), hasFoto: Boolean(currentFotoFile()), basisTypOptions: BASIS_TYP_OPTIONS, - hasApiKey: Boolean(process.env.OLLAMA_API_KEY), + hasApiKey: Boolean(config.get('OLLAMA_API_KEY')), hideSettings: true, }); } catch (error) { @@ -2653,6 +2659,34 @@ initializeDatabase().then(() => { } }); + // ----- Einstellungen (früher .env — jetzt in der Datenbank) ----- + // Alle Konfigurationswerte (Ollama, E-Mail, CalDAV, REST-API) liegen in der + // app_state-Tabelle und sind hier editierbar. Speichern wirkt sofort, ein + // Neustart ist nicht nötig (die Libs lesen per config.get() zur Laufzeit). + app.get('/einstellungen', (req, res) => { + res.render('settings', { + felder: config.FIELDS, + werte: config.getAll(), + hideSettings: true, + }); + }); + + app.post('/einstellungen', async (req, res) => { + try { + const werte = {}; + for (const sektion of config.FIELDS) { + for (const f of sektion.items) { + werte[f.key] = req.body[f.key] != null ? String(req.body[f.key]) : ''; + } + } + await config.saveAll(werte); + res.redirect('/einstellungen'); + } catch (error) { + console.error('Error saving settings:', error); + res.status(500).send('Serverfehler'); + } + }); + // ----- Conversational KI-Chat (Ollama, streaming) ----- // Gated behind OLLAMA_API_KEY. Threads + messages persist in SQLite; the // assistant answer is streamed back via Server-Sent Events. @@ -2804,7 +2838,7 @@ initializeDatabase().then(() => { // Chat page: list threads + render the active thread (or a fresh empty one). app.get('/chat', async (req, res) => { - if (!chat.isConfigured()) return res.status(503).send('KI-Chat deaktiviert – OLLAMA_API_KEY fehlt.'); + if (!chat.isConfigured()) return res.status(503).send('KI-Chat deaktiviert – kein Ollama-API-Schlüssel konfiguriert (unter „Einstellungen“ eintragen).'); try { const threads = await dbAll( 'SELECT id, titel, updated_at FROM chat_threads ORDER BY updated_at DESC' @@ -2965,8 +2999,9 @@ initializeDatabase().then(() => { // ----- Third-party REST API (/api/v1) + OpenAPI/Swagger ----- // API key for third-party software. When unset, the API responds 503 on - // every endpoint except /health — it never silently exposes data. - const apiToken = process.env.API_TOKEN || ''; + // every endpoint except /health — it never silently exposes data. Read from + // the DB via config so an edit on /einstellungen is picked up live. + const apiToken = () => config.get('API_TOKEN') || ''; app.use('/api/v1', createExternalApi({ dbGet, dbAll, @@ -3036,7 +3071,7 @@ initializeDatabase().then(() => { // Start server app.listen(PORT, () => { console.log(`Server läuft auf http://localhost:${PORT}`); - if (apiToken) console.log('REST-API (/api/v1) aktiv – Swagger unter /swagger'); + if (apiToken()) console.log('REST-API (/api/v1) aktiv – Swagger unter /swagger'); else console.log('REST-API deaktiviert – API_TOKEN fehlt (Swagger unter /swagger weiterhin verfügbar)'); }); @@ -3045,7 +3080,7 @@ initializeDatabase().then(() => { mailer.verify() .then(() => console.log(`E-Mail aktiv: Versand über ${mailer.config().host} als ${mailer.fromAddress()}`)) .catch((e) => console.warn('E-Mail SMTP-Verbindung nicht verifizierbar:', e.message)); - const pollMs = Math.max(60000, Number(process.env.MAIL_POLL_MS) || 180000); + const pollMs = Math.max(60000, Number(config.get('MAIL_POLL_MS')) || 180000); setTimeout(() => { pollInbox().catch(() => {}); }, 8000); // initial fetch after boot setInterval(() => { pollInbox().catch(() => {}); }, pollMs); // periodic fetch } else { @@ -3055,7 +3090,7 @@ initializeDatabase().then(() => { // Calendar: reconcile our appointments with the SOGo CalDAV calendar. if (caldav.isConfigured()) { console.log(`Kalender aktiv: CalDAV ${caldav.collectionUrl()}`); - const calPoll = Math.max(60000, Number(process.env.CALDAV_POLL_MS) || 300000); + const calPoll = Math.max(60000, Number(config.get('CALDAV_POLL_MS')) || 300000); setTimeout(() => { refreshCaldav().catch(() => {}); }, 10000); // initial sync after boot setInterval(() => { refreshCaldav().catch(() => {}); }, calPoll); // periodic reconcile } else { diff --git a/views/partials/header.ejs b/views/partials/header.ejs index 3083be7..f186220 100644 --- a/views/partials/header.ejs +++ b/views/partials/header.ejs @@ -52,6 +52,17 @@ + + + + + + + + +
+ <% } %> +
+ <% if (f.help) { %> +

<%= f.help %>

+ <% } %> + + <% }); %> + + + <% }); %> + +
+ + Verwerfen + + +
+ + + + <%- include('partials/footer') %> + + + + \ No newline at end of file diff --git a/views/vorlagen.ejs b/views/vorlagen.ejs index 3a1d7dd..dc68a6b 100644 --- a/views/vorlagen.ejs +++ b/views/vorlagen.ejs @@ -32,9 +32,9 @@

Kein API-Schlüssel konfiguriert

-

Setze die Umgebungsvariable OLLAMA_API_KEY - (z. B. in einer .env-Datei), - damit Bewerbungsunterlagen automatisch generiert werden können.

+

Trage den Ollama-API-Schlüssel unter + Einstellungen + ein, damit Bewerbungsunterlagen automatisch generiert werden können.