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:
@@ -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 };
|
||||
Reference in New Issue
Block a user