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:
2026-07-13 21:57:50 +02:00
co-authored by Claude
parent 97b48f9841
commit 0371aa85a5
10 changed files with 1772 additions and 827 deletions
+311
View File
@@ -0,0 +1,311 @@
// Idempotent migration from the single-user schema to the multi-user schema.
//
// Used both at server boot (server.js calls runMigration after opening the DB)
// and by the standalone scripts/migrate-to-multiuser.js. Safe to run repeatedly:
// every step guards itself with "already done?" checks.
//
// What it does, in order:
// 1. Create the `users` + `sessions` tables.
// 2. Ensure an `admin` user exists (password "admin", scrypt hash). Record its id.
// 3. Add a `user_id` column to every per-user table (ALTER ADD COLUMN, nullable
// for upgraded installs — fresh installs create it NOT NULL directly) and
// backfill every existing row to the admin id.
// 4. Recreate the tables whose PRIMARY KEY / UNIQUE must include user_id so the
// constraint becomes per-user: app_state, settings, prompts, design,
// jobangebote. Old rows are copied to the admin user.
// 5. Move on-disk attachment files into a per-user subdirectory for the admin.
// 6. One-time migration of any still-present .env values into the admin's cfg.
//
// After this, the app boots against a fully multi-user schema and all pre-existing
// data is owned by the admin user.
const fs = require('fs');
const path = require('path');
const password = require('./password');
const config = require('./config');
// Tables that need a `user_id` column added (legacy upgrades). Fresh installs
// create these with user_id NOT NULL directly in initializeDatabase().
const USER_TABLES = [
'bewerbungen', 'status_verlauf', 'anhaenge', 'interne_anhaenge',
'emails', 'email_anhaenge', 'basis_dokumente', 'basis_anhaenge',
'termine', 'chat_threads', 'chat_messages',
'jobangebote_blacklist',
];
// jobangebote is NOT in USER_TABLES: its user_id column + per-user UNIQUE
// constraint are installed by the recreate in step 4 (which needs user_id to be
// absent so the rebuild fires). Adding it here first would make step 4's
// !hasColumn guard short-circuit and leave the legacy single-user UNIQUE in
// place, breaking per-user isolation on ingest.
const ADMIN_USERNAME = 'admin';
const ADMIN_DEFAULT_PASSWORD = 'admin';
async function runMigration({ db, dbAll, dbGet, dbRun }) {
// Wire the config store to the same DB so config.setForUser works during the
// one-time .env migration below.
await config.init({ dbAll, dbRun });
const exec = (sql) => new Promise((resolve, reject) => db.run(sql, (err) => err ? reject(err) : resolve()));
const tableExists = async (name) => {
const row = await dbGet("SELECT name FROM sqlite_master WHERE type='table' AND name=?", [name]);
return !!row;
};
const columnsOf = async (table) => {
const rows = await dbAll(`PRAGMA table_info(${table})`);
return rows.map((r) => r.name);
};
const hasColumn = async (table, col) => (await columnsOf(table)).includes(col);
// 1. users + sessions -------------------------------------------------
await exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await exec(`
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
await exec('CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)');
// 2. Ensure admin user (idempotent) -----------------------------------
let admin = await dbGet('SELECT id, password_hash FROM users WHERE username = ?', [ADMIN_USERNAME]);
if (!admin) {
const hash = password.hash(ADMIN_DEFAULT_PASSWORD);
const res = await dbRun(
'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 1)',
[ADMIN_USERNAME, hash]
);
admin = { id: res.lastID };
console.log(`Multi-User-Migration: Admin-Benutzer „${ADMIN_USERNAME}“ angelegt (Passwort „${ADMIN_DEFAULT_PASSWORD}“). Bitte nach erstem Login ändern.`);
}
const adminId = admin.id;
// 3. Add user_id column + backfill ------------------------------------
for (const tbl of USER_TABLES) {
if (!(await tableExists(tbl))) continue; // table may not exist yet on a partial install
if (!(await hasColumn(tbl, 'user_id'))) {
await exec(`ALTER TABLE ${tbl} ADD COLUMN user_id INTEGER`);
}
// Backfill every row that is not yet assigned (NULL) to the admin.
await dbRun(`UPDATE ${tbl} SET user_id = ? WHERE user_id IS NULL`, [adminId]);
}
// 4. Recreate tables whose PK/UNIQUE must be per-user -----------------
// app_state: PK(key) -> PK(user_id, key)
if (await tableExists('app_state')) {
if (!(await hasColumn('app_state', 'user_id'))) {
await recreate(db, dbAll, dbRun, 'app_state',
`CREATE TABLE app_state (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT,
PRIMARY KEY (user_id, key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`INSERT INTO app_state (user_id, key, value) SELECT ?, key, value FROM app_state_old`,
[adminId]
);
} else {
await dbRun('UPDATE app_state SET user_id = ? WHERE user_id IS NULL', [adminId]);
}
}
// prompts: PK(key) -> PK(user_id, key)
if (await tableExists('prompts') && !(await hasColumn('prompts', 'user_id'))) {
await recreate(db, dbAll, dbRun, 'prompts',
`CREATE TABLE prompts (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
inhalt TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`INSERT INTO prompts (user_id, key, inhalt, updated_at) SELECT ?, key, inhalt, updated_at FROM prompts_old`,
[adminId]
);
} else if (await tableExists('prompts')) {
await dbRun('UPDATE prompts SET user_id = ? WHERE user_id IS NULL', [adminId]);
}
// design: PK(key) -> PK(user_id, key)
if (await tableExists('design') && !(await hasColumn('design', 'user_id'))) {
await recreate(db, dbAll, dbRun, 'design',
`CREATE TABLE design (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`INSERT INTO design (user_id, key, value, updated_at) SELECT ?, key, value, updated_at FROM design_old`,
[adminId]
);
} else if (await tableExists('design')) {
await dbRun('UPDATE design SET user_id = ? WHERE user_id IS NULL', [adminId]);
}
// settings: single row CHECK(id=1) -> per-user row (user_id UNIQUE)
if (await tableExists('settings') && !(await hasColumn('settings', 'user_id'))) {
await recreate(db, dbAll, dbRun, 'settings',
`CREATE TABLE settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE,
name TEXT,
adresse TEXT,
kundennummer TEXT,
ort TEXT,
webseite TEXT,
email TEXT,
telefon TEXT,
geburtsdatum TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`INSERT INTO settings (user_id, name, adresse, kundennummer, ort, webseite, email, telefon, geburtsdatum)
SELECT ?, name, adresse, kundennummer, ort, webseite, email, telefon, geburtsdatum FROM settings_old`,
[adminId]
);
} else if (await tableExists('settings')) {
await dbRun('UPDATE settings SET user_id = ? WHERE user_id IS NULL', [adminId]);
}
// jobangebote: UNIQUE(quelle, external_id) -> UNIQUE(user_id, quelle, external_id)
if (await tableExists('jobangebote') && !(await hasColumn('jobangebote', 'user_id'))) {
// user_id was added in step 3 already; this branch only recreates for the
// per-user UNIQUE constraint. Rebuild preserving all columns.
const cols = (await columnsOf('jobangebote')).filter((c) => c !== 'user_id');
const colList = cols.join(', ');
await recreate(db, dbAll, dbRun, 'jobangebote',
`CREATE TABLE jobangebote (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
external_id TEXT,
quelle TEXT NOT NULL DEFAULT 'drittanbieter',
firma TEXT NOT NULL,
stelle TEXT NOT NULL,
ort TEXT,
adresse TEXT,
ansprechpartner TEXT,
gehalt TEXT,
beschreibung TEXT,
quelle_url TEXT,
art TEXT,
anzeige_datum DATE,
kontakt_email TEXT,
status TEXT NOT NULL DEFAULT 'offen',
verknuepfte_bewerbung_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
url_norm TEXT,
firma_slug TEXT,
labels TEXT,
UNIQUE (user_id, quelle, external_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (verknuepfte_bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL
)`,
`INSERT INTO jobangebote (user_id, ${colList}) SELECT ?, ${colList} FROM jobangebote_old`,
[adminId]
);
await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_url_norm ON jobangebote(url_norm)');
await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_firma_slug ON jobangebote(firma_slug)');
}
// Ensure a settings row exists for the admin (idempotent). On a fresh install
// the settings table does not exist yet at this point (it is created by the
// server's CREATE TABLE IF NOT EXISTS right after the migration), so skip —
// the row is upserted on the admin's first /vorlagen save anyway.
if (await tableExists('settings')) {
const srow = await dbGet('SELECT id FROM settings WHERE user_id = ?', [adminId]);
if (!srow) {
await dbRun(
`INSERT INTO settings (user_id, name, adresse, kundennummer, ort, webseite, email, telefon, geburtsdatum)
VALUES (?, 'Max Mustermann', 'Musterstraße 1, 12345 Musterstadt', '', '', '', '', '', '')`,
[adminId]
);
}
}
// 5. Move on-disk files into a per-user subdirectory for the admin -----
moveFilesIntoUserSubdir(adminId);
// 6. One-time .env -> admin cfg migration ------------------------------
await migrateEnvForAdmin(dbAll, adminId);
return { adminId };
}
// Rename `table` to `table_old`, create the new table from `newSchemaSql`,
// copy rows via `copySql` (with `copyParams`), then drop `table_old`.
async function recreate(db, dbAll, dbRun, table, newSchemaSql, copySql, copyParams) {
const exec = (sql) => new Promise((resolve, reject) => db.run(sql, (err) => err ? reject(err) : resolve()));
await exec(`ALTER TABLE ${table} RENAME TO ${table}_old`);
await exec(newSchemaSql);
await dbRun(copySql, copyParams || []);
await exec(`DROP TABLE ${table}_old`);
}
// Move every file in each per-user storage directory into a `<userId>/` subdir.
// Idempotent: if the subdir already contains files (already migrated), leave the
// top-level files alone (they would be a re-run left-overs) — but on first run the
// top level holds the legacy flat files, which we move in.
const STORAGE_DIRS = ['anhaenge', 'basis_anhaenge', 'interne_anhaenge', 'email_anhaenge', 'signatur', 'bewerberfoto'];
function moveFilesIntoUserSubdir(userId) {
const dataDir = path.join(__dirname, '..', 'data');
for (const dir of STORAGE_DIRS) {
const base = path.join(dataDir, dir);
if (!fs.existsSync(base)) continue;
const userDir = path.join(base, String(userId));
if (!fs.existsSync(userDir)) fs.mkdirSync(userDir, { recursive: true });
for (const name of fs.readdirSync(base)) {
if (name === String(userId)) continue;
const src = path.join(base, name);
if (!fs.statSync(src).isFile()) continue; // skip subdirectories
const dst = path.join(userDir, name);
if (!fs.existsSync(dst)) fs.renameSync(src, dst);
}
}
}
// Move any still-present .env value for a DEFAULT key that the admin hasn't got
// stored yet into the admin's cfg rows. Idempotent: only fills keys that are not
// yet present for the admin.
async function migrateEnvForAdmin(dbAll, adminId) {
// On a fresh install app_state does not exist yet at this point (created by
// the server right after the migration), so there is nothing to read from or
// write to — skip. The .env values, if any, then fall back via config.get().
try {
await dbAll('SELECT 1 FROM app_state LIMIT 1');
} catch (e) {
return;
}
const storedRows = await dbAll('SELECT key FROM app_state WHERE user_id = ?', [adminId]);
const stored = new Set(storedRows.map((r) => r.key.slice(config.PREFIX.length)));
const migrated = [];
for (const key of Object.keys(config.DEFAULTS)) {
if (stored.has(key)) continue; // already in the DB, never overwrite from .env
const envVal = process.env[key];
if (envVal && envVal.length) {
await config.setForUser(adminId, key, envVal);
migrated.push(key);
}
}
if (migrated.length) {
console.log(`Multi-User-Migration: ${migrated.length} Konfigurationswerte aus .env in den Admin-Benutzer migriert — .env wird nicht mehr benötigt.`);
}
}
module.exports = { runMigration, moveFilesIntoUserSubdir, STORAGE_DIRS, ADMIN_USERNAME, ADMIN_DEFAULT_PASSWORD };