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>
This commit is contained in:
+80
-49
@@ -1,14 +1,19 @@
|
||||
// Centralized configuration store.
|
||||
// Centralized, per-user 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.
|
||||
// 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 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).
|
||||
// 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).
|
||||
@@ -49,7 +54,7 @@ const FIELDS = [
|
||||
},
|
||||
{
|
||||
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.',
|
||||
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' },
|
||||
@@ -73,7 +78,7 @@ const FIELDS = [
|
||||
},
|
||||
{
|
||||
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.',
|
||||
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' },
|
||||
],
|
||||
@@ -81,7 +86,12 @@ const FIELDS = [
|
||||
];
|
||||
|
||||
const PREFIX = 'cfg:';
|
||||
const cache = Object.create(null); // key -> string (only keys present in the DB)
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -90,22 +100,43 @@ function envOrDefault(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.
|
||||
// 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 v = cache[key];
|
||||
if (v !== undefined) return v;
|
||||
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, keyed by name — used by the settings UI.
|
||||
// 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).
|
||||
// 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(/\/+$/, ''),
|
||||
@@ -115,49 +146,49 @@ function ollama() {
|
||||
};
|
||||
}
|
||||
|
||||
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).
|
||||
// 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;
|
||||
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).
|
||||
// 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 (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
|
||||
[PREFIX + key, v]
|
||||
'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]
|
||||
);
|
||||
cache[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, get, getAll, ollama, init, load, saveAll };
|
||||
module.exports = {
|
||||
DEFAULTS, FIELDS, PREFIX,
|
||||
get, getAll, ollama, init, saveAll, setForUser, ensureLoaded, invalidate,
|
||||
};
|
||||
Reference in New Issue
Block a user