Einstellungen-Seite: .env-Werte in DB (app_state), keine .env mehr

Neue /einstellungen-Seite (Zahnrad im Header) mit allen bisherigen .env-Werten
(Ollama, E-Mail, CalDAV, REST-API), gespeichert in SQLite (app_state, cfg:-Prefix).
Libs lesen per lib/config.js zur Laufzeit statt beim Start -> Aenderungen
wirken sofort, kein Neustart. Bestehende .env wird beim ersten Start einmalig
migriert.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-13 20:57:31 +02:00
co-authored by Claude
parent 41887dd56c
commit 97b48f9841
12 changed files with 410 additions and 72 deletions
+13 -6
View File
@@ -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();
+8 -5
View File
@@ -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,
};
}
+11 -9
View File
@@ -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 };
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; },
};
+163
View File
@@ -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 };
+16 -18
View File
@@ -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);
+11 -8
View File
@@ -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,
};