Security: - H1: Stored-XSS-Fix — Upload-Pfad-Whitelist (Server + Frontend-Guard safeFileUrl) - H2: Transaktionen repariert — txDb-Contract in db.js (PG + SQLite), Rollback funktioniert - H3-Vorbereitung: SESSION_SECRET wird in Compose durchgereicht (Fix M3) - registerLimiter exportiert (Crash-Bug: Route.post ohne Callback) - LDAP-Sync: PG-Transaktionsabbruch bei UNIQUE-Verstoß behoben (Precheck-Selects) - LDAP-Filter: nur echte Benutzerkonten (keine Computer/Service-Accounts, Bit 512) - Rollen app-seitig: Sync ändert nie role/status, neue User immer user+inaktiv - DB-Cleanup: 82 Computer-/Service-Accounts aus lokaler User-Tabelle entfernt UX: - Dashboard: Vorlagen als Table-Liste + Column-Chart (Top 5 in %), 2 gleich große Spalten - Table-Listen (Dashboard/Vorlageneditor/Aufgaben) scrollbar bis Seitenende - Pagination 10/Seite im Dashboard, Sidebar-Label Dashboard
224 lines
11 KiB
JavaScript
224 lines
11 KiB
JavaScript
/**
|
|
* Database initialization and migrations module.
|
|
*
|
|
* Punkt 4: Supports both SQLite and PostgreSQL.
|
|
* Migrations are tracked in a _migrations table to avoid re-running.
|
|
*
|
|
* Note: SQLite mode is synchronous, PostgreSQL mode is async.
|
|
* The initDatabase function handles both cases.
|
|
*/
|
|
const db = require('./db');
|
|
|
|
const isPostgres = db._type === 'postgres';
|
|
|
|
// Helper: convert SQLite SQL to PostgreSQL-compatible SQL
|
|
function toPg(sql) {
|
|
return sql
|
|
.replace(/INTEGER PRIMARY KEY AUTOINCREMENT/g, 'SERIAL PRIMARY KEY')
|
|
.replace(/TIMESTAMP DEFAULT CURRENT_TIMESTAMP/g, 'TIMESTAMP DEFAULT NOW()')
|
|
.replace(/`/g, '"');
|
|
}
|
|
|
|
function execSql(sql) {
|
|
if (isPostgres) {
|
|
return db.exec(toPg(sql));
|
|
}
|
|
return db.exec(sql);
|
|
}
|
|
|
|
async function initDatabase() {
|
|
// Create migrations tracking table
|
|
const migrationsTableSql = isPostgres
|
|
? `CREATE TABLE IF NOT EXISTS _migrations (id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL, applied_at TIMESTAMP DEFAULT NOW())`
|
|
: `CREATE TABLE IF NOT EXISTS _migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`;
|
|
await execSql(migrationsTableSql);
|
|
|
|
const appliedRows = await db.prepare('SELECT name FROM _migrations').all();
|
|
const applied = new Set(appliedRows.map(r => r.name));
|
|
|
|
async function migrate(name, sql) {
|
|
if (applied.has(name)) return;
|
|
console.log(`[Migration] ${name}...`);
|
|
await execSql(sql);
|
|
await db.prepare('INSERT INTO _migrations (name) VALUES (?)').run(name);
|
|
console.log(`[Migration] ${name} done.`);
|
|
}
|
|
|
|
// Base schema
|
|
const baseSchema = isPostgres ? `
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id SERIAL PRIMARY KEY,
|
|
email TEXT UNIQUE NOT NULL,
|
|
password TEXT NOT NULL,
|
|
name TEXT NOT NULL DEFAULT '',
|
|
role TEXT CHECK(role IN ('admin', 'user')) DEFAULT 'user',
|
|
status TEXT CHECK(status IN ('aktiv', 'inaktiv')) DEFAULT 'inaktiv',
|
|
source TEXT CHECK(source IN ('local', 'ad')) DEFAULT 'local',
|
|
username TEXT,
|
|
failed_login_attempts INTEGER DEFAULT 0,
|
|
locked_until TIMESTAMP DEFAULT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS templates (
|
|
id SERIAL PRIMARY KEY, name TEXT NOT NULL, description TEXT,
|
|
is_assignable INTEGER DEFAULT 0, allows_file_upload INTEGER DEFAULT 0, ad_create INTEGER DEFAULT 0
|
|
);
|
|
CREATE TABLE IF NOT EXISTS template_steps (
|
|
id SERIAL PRIMARY KEY, template_id INTEGER NOT NULL, page_num INTEGER NOT NULL,
|
|
label TEXT NOT NULL, type TEXT CHECK(type IN ('checkbox','text_input','file_upload','email','dropdown','ad_password','ad_displayname')) NOT NULL,
|
|
step_order INTEGER NOT NULL, email_domain TEXT, email_source_fields TEXT, dropdown_options TEXT,
|
|
ad_field TEXT, ad_prefix TEXT, hidden INTEGER DEFAULT 0,
|
|
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE
|
|
);
|
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
id SERIAL PRIMARY KEY, template_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
|
|
title TEXT NOT NULL, status TEXT CHECK(status IN ('offen','erledigt')) DEFAULT 'offen',
|
|
file_path TEXT, created_at TIMESTAMP DEFAULT NOW(),
|
|
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
CREATE TABLE IF NOT EXISTS task_values (
|
|
id SERIAL PRIMARY KEY, task_id INTEGER NOT NULL, step_id INTEGER, value TEXT, is_checked INTEGER DEFAULT 0,
|
|
file_path TEXT, custom_label TEXT, custom_type TEXT DEFAULT 'text_input',
|
|
custom_dropdown_options TEXT, custom_ad_field TEXT, custom_hidden INTEGER DEFAULT 0, custom_email_source_fields TEXT,
|
|
snap_label TEXT, snap_type TEXT, snap_page_num INTEGER, snap_ad_field TEXT, snap_ad_prefix TEXT,
|
|
snap_dropdown_options TEXT, snap_email_source_fields TEXT, snap_hidden INTEGER DEFAULT 0,
|
|
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (step_id) REFERENCES template_steps(id) ON DELETE CASCADE
|
|
);
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, token TEXT UNIQUE NOT NULL,
|
|
created_at TIMESTAMP DEFAULT NOW(), expires_at TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
id SERIAL PRIMARY KEY, user_id INTEGER, action TEXT NOT NULL, entity_type TEXT, entity_id INTEGER,
|
|
details TEXT, ip_address TEXT, user_agent TEXT, created_at TIMESTAMP DEFAULT NOW(),
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
|
);
|
|
` : `
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, name TEXT NOT NULL DEFAULT '',
|
|
role TEXT CHECK(role IN ('admin','user')) DEFAULT 'user',
|
|
status TEXT CHECK(status IN ('aktiv','inaktiv')) DEFAULT 'inaktiv',
|
|
source TEXT CHECK(source IN ('local','ad')) DEFAULT 'local',
|
|
username TEXT, failed_login_attempts INTEGER DEFAULT 0, locked_until TIMESTAMP DEFAULT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS templates (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT,
|
|
is_assignable INTEGER DEFAULT 0, allows_file_upload INTEGER DEFAULT 0, ad_create INTEGER DEFAULT 0
|
|
);
|
|
CREATE TABLE IF NOT EXISTS template_steps (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, template_id INTEGER NOT NULL, page_num INTEGER NOT NULL,
|
|
label TEXT NOT NULL, type TEXT CHECK(type IN ('checkbox','text_input','file_upload','email','dropdown','ad_password','ad_displayname')) NOT NULL,
|
|
step_order INTEGER NOT NULL, email_domain TEXT, email_source_fields TEXT, dropdown_options TEXT,
|
|
ad_field TEXT, ad_prefix TEXT, hidden INTEGER DEFAULT 0,
|
|
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE
|
|
);
|
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, template_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
|
|
title TEXT NOT NULL, status TEXT CHECK(status IN ('offen','erledigt')) DEFAULT 'offen',
|
|
file_path TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
CREATE TABLE IF NOT EXISTS task_values (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL, step_id INTEGER, value TEXT, is_checked INTEGER DEFAULT 0,
|
|
file_path TEXT, custom_label TEXT, custom_type TEXT DEFAULT 'text_input',
|
|
custom_dropdown_options TEXT, custom_ad_field TEXT, custom_hidden INTEGER DEFAULT 0, custom_email_source_fields TEXT,
|
|
snap_label TEXT, snap_type TEXT, snap_page_num INTEGER, snap_ad_field TEXT, snap_ad_prefix TEXT,
|
|
snap_dropdown_options TEXT, snap_email_source_fields TEXT, snap_hidden INTEGER DEFAULT 0,
|
|
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (step_id) REFERENCES template_steps(id) ON DELETE CASCADE
|
|
);
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, token TEXT UNIQUE NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, action TEXT NOT NULL, entity_type TEXT, entity_id INTEGER,
|
|
details TEXT, ip_address TEXT, user_agent TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
|
);
|
|
`;
|
|
|
|
await execSql(baseSchema);
|
|
|
|
// Indexes
|
|
const indexes = [
|
|
'idx_sessions_token', 'idx_sessions_user_id', 'idx_sessions_expires',
|
|
'idx_tasks_user_id', 'idx_tasks_template_id', 'idx_tasks_status',
|
|
'idx_task_values_task_id', 'idx_task_values_step_id',
|
|
'idx_audit_log_created', 'idx_audit_log_user', 'idx_users_email', 'idx_users_source',
|
|
];
|
|
for (const idx of indexes) {
|
|
const table = idx.replace('idx_', '').replace('_id', '').replace('_at', '_created').replace('_token', '_token');
|
|
// Build CREATE INDEX statement
|
|
let col;
|
|
if (idx === 'idx_sessions_token') col = 'sessions(token)';
|
|
else if (idx === 'idx_sessions_user_id') col = 'sessions(user_id)';
|
|
else if (idx === 'idx_sessions_expires') col = 'sessions(expires_at)';
|
|
else if (idx === 'idx_tasks_user_id') col = 'tasks(user_id)';
|
|
else if (idx === 'idx_tasks_template_id') col = 'tasks(template_id)';
|
|
else if (idx === 'idx_tasks_status') col = 'tasks(status)';
|
|
else if (idx === 'idx_task_values_task_id') col = 'task_values(task_id)';
|
|
else if (idx === 'idx_task_values_step_id') col = 'task_values(step_id)';
|
|
else if (idx === 'idx_audit_log_created') col = 'audit_log(created_at)';
|
|
else if (idx === 'idx_audit_log_user') col = 'audit_log(user_id)';
|
|
else if (idx === 'idx_users_email') col = 'users(email)';
|
|
else if (idx === 'idx_users_source') col = 'users(source)';
|
|
await migrate(idx, `CREATE INDEX IF NOT EXISTS ${idx} ON ${col}`);
|
|
}
|
|
|
|
// Seed admin user
|
|
const bcrypt = require('bcryptjs');
|
|
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@workflow.local';
|
|
const ADMIN_INIT_PASSWORD = process.env.ADMIN_INIT_PASSWORD || '';
|
|
const existingAdmin = await db.prepare('SELECT id FROM users WHERE email = ?').get(ADMIN_EMAIL);
|
|
if (!existingAdmin) {
|
|
if (!ADMIN_INIT_PASSWORD) {
|
|
console.warn('WARNUNG: Kein ADMIN_INIT_PASSWORD gesetzt - kein Admin-Account erstellt.');
|
|
} else {
|
|
const hash = bcrypt.hashSync(ADMIN_INIT_PASSWORD, 12);
|
|
await db.prepare('INSERT INTO users (email, password, name, role, status, source) VALUES (?, ?, ?, \'admin\', \'aktiv\', \'local\')').run(ADMIN_EMAIL, hash, 'Superadmin');
|
|
console.log('Superadmin erstellt: ' + ADMIN_EMAIL);
|
|
}
|
|
}
|
|
|
|
// Periodic session cleanup
|
|
setInterval(async () => {
|
|
try {
|
|
const cleanupSql = isPostgres ? "DELETE FROM sessions WHERE expires_at < NOW()" : "DELETE FROM sessions WHERE expires_at < datetime('now')";
|
|
await db.prepare(cleanupSql).run();
|
|
} catch (err) {
|
|
console.error('Session cleanup error:', err.message);
|
|
}
|
|
}, 60 * 60 * 1000);
|
|
|
|
// H4: uploads table — track file ownership for authorization on download
|
|
await migrate('add_uploads_table', isPostgres
|
|
? `CREATE TABLE IF NOT EXISTS uploads (
|
|
id SERIAL PRIMARY KEY, filename TEXT UNIQUE NOT NULL, user_id INTEGER NOT NULL,
|
|
original_name TEXT, size INTEGER, created_at TIMESTAMP DEFAULT NOW(),
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)`
|
|
: `CREATE TABLE IF NOT EXISTS uploads (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT UNIQUE NOT NULL, user_id INTEGER NOT NULL,
|
|
original_name TEXT, size INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)`
|
|
);
|
|
await migrate('idx_uploads_filename', isPostgres
|
|
? `CREATE INDEX IF NOT EXISTS idx_uploads_filename ON uploads(filename)`
|
|
: `CREATE INDEX IF NOT EXISTS idx_uploads_filename ON uploads(filename)`
|
|
);
|
|
await migrate('idx_uploads_user_id', isPostgres
|
|
? `CREATE INDEX IF NOT EXISTS idx_uploads_user_id ON uploads(user_id)`
|
|
: `CREATE INDEX IF NOT EXISTS idx_uploads_user_id ON uploads(user_id)`
|
|
);
|
|
|
|
console.log('Datenbanktabellen initialisiert.');
|
|
}
|
|
|
|
module.exports = { initDatabase }; |