Add setup and restore flow

This commit is contained in:
2026-08-06 08:22:33 +02:00
parent 19e4621aeb
commit 72229cf14c
15 changed files with 511 additions and 94 deletions
+109 -1
View File
@@ -27,7 +27,7 @@ const frontendDist = path.resolve(__dirname, "../../frontend/dist");
const serveFrontend = process.env.SERVE_FRONTEND !== "false";
app.use(cors());
app.use(express.json({ limit: "1mb" }));
app.use(express.json({ limit: "50mb" }));
app.get("/api/health", async (_req, res) => {
await query("SELECT 1");
@@ -84,6 +84,11 @@ function parseOptionalBilledMinutes(value: unknown) {
return minutes;
}
async function hasAdminUser() {
const result = await query<{ exists: boolean }>("SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin') AS exists;");
return Boolean(result.rows[0]?.exists);
}
function parseZammadBaseUrl(value: unknown) {
const raw = requireString(value, "baseUrl").replace(/\/+$/, "");
@@ -1162,6 +1167,63 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke
};
}
app.get("/api/setup/status", async (_req, res) => {
res.json({ required: !(await hasAdminUser()) });
});
app.post("/api/setup/admin", async (req, res) => {
if (await hasAdminUser()) {
res.status(409).json({ error: "Setup wurde bereits abgeschlossen" });
return;
}
const username = parseUsername(req.body.username);
const displayName = requireString(req.body.displayName, "displayName");
const password = requireString(req.body.password, "password");
if (password.length < 6) {
throw badRequest("password must be at least 6 characters");
}
try {
const result = await query<{
id: string;
username: string;
display_name: string;
role: "admin" | "user";
active: boolean;
}>(
`
INSERT INTO users (username, display_name, password_hash, role, active)
VALUES ($1, $2, $3, 'admin', true)
RETURNING id, username, display_name, role, active;
`,
[username, displayName, await hashPassword(password)]
);
await createAuthSession(res, result.rows[0].id);
res.status(201).json({ user: result.rows[0] });
} catch (error: any) {
if (error?.code === "23505") {
res.status(409).json({ error: "Benutzername ist bereits vergeben" });
return;
}
throw error;
}
});
app.post("/api/setup/restore", async (req, res) => {
if (await hasAdminUser()) {
res.status(409).json({ error: "Setup wurde bereits abgeschlossen. Restore ist im Adminbereich verfügbar." });
return;
}
const sql = requireString(req.body.sql, "sql");
const result = await restoreTicketTrackerBackup(sql);
res.status(201).json(result);
});
app.post("/api/auth/login", async (req, res) => {
const username = requireString(req.body.username, "username");
const password = requireString(req.body.password, "password");
@@ -1671,6 +1733,40 @@ async function exportTableSql(table: string) {
return lines;
}
function managedRestoreBody(sql: string) {
const trimmed = sql.replace(/^\uFEFF/, "").trim();
if (!trimmed.includes("TicketTracker Datenbankexport")) {
throw badRequest("Nur TicketTracker-SQL-Backups können eingespielt werden");
}
return trimmed
.replace(/^\s*BEGIN\s*;\s*/im, "")
.replace(/\s*COMMIT\s*;\s*$/im, "")
.replace(/TRUNCATE\s+TABLE[\s\S]*?;\s*/i, "")
.trim();
}
async function restoreTicketTrackerBackup(sql: string) {
const body = managedRestoreBody(sql);
await withTransaction(async (client) => {
await client.query(`TRUNCATE TABLE ${[...databaseExportTables].reverse().map(quoteIdentifier).join(", ")} RESTART IDENTITY CASCADE;`);
if (body) {
await client.query(body);
}
const adminResult = await client.query<{ count: string }>("SELECT COUNT(*) AS count FROM users WHERE role = 'admin';");
if (Number(adminResult.rows[0]?.count ?? 0) !== 1) {
throw badRequest("Restore muss genau einen Admin-Benutzer enthalten");
}
});
return { ok: true };
}
app.get("/api/admin/export/database", requireAdmin, async (_req, res) => {
const lines = [
"-- TicketTracker Datenbankexport",
@@ -1692,6 +1788,18 @@ app.get("/api/admin/export/database", requireAdmin, async (_req, res) => {
res.send(lines.join("\n"));
});
app.post("/api/admin/restore/database", requireAdmin, async (req, res) => {
const sql = requireString(req.body.sql, "sql");
const result = await restoreTicketTrackerBackup(sql);
res.clearCookie("tickettracker_session", {
sameSite: "lax",
secure: process.env.COOKIE_SECURE === "true",
path: "/"
});
res.json(result);
});
function csvCell(value: unknown) {
if (value === null || value === undefined) {
return "";
+32 -37
View File
@@ -1,42 +1,25 @@
import { query } from "./db.js";
import { hashPassword } from "./auth.js";
async function ensureMigrationAdmin() {
const username = process.env.ADMIN_USERNAME?.trim() || "admin";
const password = process.env.ADMIN_PASSWORD ?? "admin";
const displayName = process.env.ADMIN_DISPLAY_NAME?.trim() || "Admin";
const existingUsers = await query<{ count: string }>("SELECT COUNT(*) AS count FROM users;");
if (Number(existingUsers.rows[0]?.count ?? 0) === 0) {
await query(
`
INSERT INTO users (username, display_name, password_hash, role)
VALUES ($1, $2, $3, 'admin');
`,
[username, displayName, await hashPassword(password)]
);
if (!process.env.ADMIN_PASSWORD) {
console.warn("Initialer Admin wurde mit admin/admin angelegt. Bitte Passwort über ADMIN_PASSWORD setzen oder im Adminbereich ändern.");
}
}
async function findMigrationAdmin() {
const adminResult = await query<{ id: string }>("SELECT id FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1;");
if (adminResult.rows.length === 0) {
throw new Error("At least one admin user is required");
}
return adminResult.rows[0].id;
return adminResult.rows[0]?.id ?? null;
}
async function ensureClosureOwnership(table: string, column: string, primaryKeyColumns: string[], adminId: string) {
async function ensureClosureOwnership(table: string, column: string, primaryKeyColumns: string[], adminId: string | null) {
await query(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id);`);
await query(`UPDATE ${table} SET user_id = $1 WHERE user_id IS NULL;`, [adminId]);
await query(`ALTER TABLE ${table} ALTER COLUMN user_id SET NOT NULL;`);
await query(`ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${table}_pkey;`);
await query(`ALTER TABLE ${table} ADD CONSTRAINT ${table}_pkey PRIMARY KEY (${primaryKeyColumns.join(", ")}, user_id);`);
if (adminId) {
await query(`UPDATE ${table} SET user_id = $1 WHERE user_id IS NULL;`, [adminId]);
}
await query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM ${table} WHERE user_id IS NULL) THEN
ALTER TABLE ${table} ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${table}_pkey;
ALTER TABLE ${table} ADD CONSTRAINT ${table}_pkey PRIMARY KEY (${primaryKeyColumns.join(", ")}, user_id);
END IF;
END $$;
`);
await query(`CREATE INDEX IF NOT EXISTS idx_${table}_${column}_user ON ${table}(${column}, user_id);`);
}
@@ -74,9 +57,12 @@ export async function migrate() {
await query("CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id);");
await query("CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);");
const adminId = await ensureMigrationAdmin();
const adminId = await findMigrationAdmin();
if (adminId) {
await query("UPDATE users SET role = 'user', updated_at = now() WHERE role = 'admin' AND id <> $1;", [adminId]);
}
await query("UPDATE users SET role = 'user', updated_at = now() WHERE role = 'admin' AND id <> $1;", [adminId]);
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_single_admin ON users ((role)) WHERE role = 'admin';");
await query(`
@@ -170,8 +156,17 @@ export async function migrate() {
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_billing_slot_id BIGINT;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_date DATE;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_key TEXT;");
await query("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]);
await query("ALTER TABLE sessions ALTER COLUMN user_id SET NOT NULL;");
if (adminId) {
await query("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]);
}
await query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM sessions WHERE user_id IS NULL) THEN
ALTER TABLE sessions ALTER COLUMN user_id SET NOT NULL;
END IF;
END $$;
`);
await query(`
CREATE TABLE IF NOT EXISTS recurring_billings (