Add setup and restore flow
This commit is contained in:
+109
-1
@@ -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
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user