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
+5 -2
View File
@@ -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=
+13 -12
View File
@@ -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
+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,
};
+44 -9
View File
@@ -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 {
+11
View File
@@ -52,6 +52,17 @@
<span class="hidden sm:inline">Vorlagen</span>
</a>
<!-- Settings (formerly .env — now in the database) link -->
<a href="/einstellungen"
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
title="Einstellungen (Ollama, E-Mail, Kalender, REST-API)">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span class="hidden sm:inline">Einstellungen</span>
</a>
<!-- Notification bell: unread received e-mails (incl. auto-assigned replies) -->
<div class="relative" id="notifWrap">
<button id="notifBtn" type="button"
+112
View File
@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="de">
<head>
<%- include('partials/head') %>
</head>
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
<%- include('partials/header') %>
<main class="flex-1 container mx-auto px-4 py-8 max-w-4xl">
<!-- Back link -->
<a href="/" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Zurück zur Übersicht
</a>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<div class="flex items-start gap-3">
<svg class="w-6 h-6 text-gray-500 dark:text-gray-400 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<div>
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Einstellungen</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 max-w-2xl">
Hier liegen alle Werte, die früher über die <code class="px-1 rounded bg-gray-100 dark:bg-gray-700">.env</code>-Datei
gesetzt wurden Ollama, E-Mail, Kalender und die REST-API. Gespeichert wird in der Datenbank;
Änderungen wirken <em>sofort</em>, ein Neustart ist nicht nötig. Lediglich die Abruf-/Sync-Intervalle
(E-Mail &amp; Kalender) greifen erst nach einem Neustart.
</p>
</div>
</div>
</div>
<form action="/einstellungen" method="POST" class="space-y-6">
<% felder.forEach(function(sektion){ %>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-base font-semibold text-gray-800 dark:text-white"><%= sektion.titel %></h3>
<% if (sektion.beschreibung) { %>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-5 max-w-2xl"><%= sektion.beschreibung %></p>
<% } else { %>
<div class="mb-5"></div>
<% } %>
<div class="grid gap-4 sm:grid-cols-2">
<% sektion.items.forEach(function(f){
var val = werte[f.key] != null ? werte[f.key] : '';
var inputType = f.secret ? 'password' : 'text';
var inputClass = 'w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm pr-10';
%>
<div class="<%= f.secret ? 'sm:col-span-2' : '' %>">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="cfg-<%= f.key %>">
<%= f.label %>
<code class="ml-1 text-[11px] font-normal text-gray-400 dark:text-gray-500"><%= f.key %></code>
</label>
<div class="relative">
<input id="cfg-<%= f.key %>" name="<%= f.key %>" type="<%= inputType %>" autocomplete="off"
spellcheck="false" class="<%= inputClass %>" value="<%= val %>">
<% if (f.secret) { %>
<button type="button" data-toggle="cfg-<%= f.key %>"
class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
title="Wert anzeigen/verbergen" tabindex="-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
</button>
<% } %>
</div>
<% if (f.help) { %>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1"><%= f.help %></p>
<% } %>
</div>
<% }); %>
</div>
</section>
<% }); %>
<div class="flex items-center justify-end gap-3">
<a href="/einstellungen" class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">
Verwerfen
</a>
<button type="submit"
class="inline-flex items-center gap-2 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
Speichern
</button>
</div>
</form>
</main>
<%- include('partials/footer') %>
<script>
(function () {
const cy = document.getElementById('currentYear');
if (cy) cy.textContent = new Date().getFullYear();
// Toggle reveal for secret (password) fields.
document.querySelectorAll('[data-toggle]').forEach(function (btn) {
btn.addEventListener('click', function () {
const input = document.getElementById(btn.getAttribute('data-toggle'));
if (input) input.type = input.type === 'password' ? 'text' : 'password';
});
});
})();
</script>
</body>
</html>
+3 -3
View File
@@ -32,9 +32,9 @@
</svg>
<div class="text-sm text-amber-800 dark:text-amber-200">
<p class="font-semibold">Kein API-Schlüssel konfiguriert</p>
<p>Setze die Umgebungsvariable <code class="px-1 rounded bg-amber-100 dark:bg-amber-800">OLLAMA_API_KEY</code>
(z. B. in einer <code class="px-1 rounded bg-amber-100 dark:bg-amber-800">.env</code>-Datei),
damit Bewerbungsunterlagen automatisch generiert werden können.</p>
<p>Trage den Ollama-API-Schlüssel unter
<a href="/einstellungen" class="underline font-medium hover:no-underline">Einstellungen</a>
ein, damit Bewerbungsunterlagen automatisch generiert werden können.</p>
</div>
</div>
</div>