diff --git a/backend/package.json b/backend/package.json index 9fd0ab7..2770440 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,6 +8,7 @@ "dev": "tsx watch src/index.ts", "build": "tsc -p tsconfig.json", "start": "node dist/index.js", + "test": "node --import tsx --test test/*.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { diff --git a/backend/src/databaseExport.ts b/backend/src/databaseExport.ts new file mode 100644 index 0000000..a27eca3 --- /dev/null +++ b/backend/src/databaseExport.ts @@ -0,0 +1,364 @@ +type QueryResult = { + rows: T[]; + rowCount: number | null; +}; + +export type QueryExecutor = { + query = Record>(text: string, params?: unknown[]): Promise>; +}; + +export const databaseExportTables = [ + "users", + "auth_sessions", + "app_settings", + "user_preferences", + "organizations", + "tickets", + "projects", + "project_teamspace_entries", + "hidden_user_projects", + "ticket_todos", + "recurring_billings", + "recurring_billing_slots", + "recurring_billing_exceptions", + "sessions", + "ticket_month_closures", + "month_closures", + "ticket_day_closures", + "day_closures", + "ticket_day_billings", + "ticket_day_billing_acknowledgements" +] as const; + +type ColumnInfo = { + columnName: string; + dataType: string; + udtName: string; + ordinalPosition: number; + isNullable: "YES" | "NO"; +}; + +const jsonDataTypes = new Set(["json", "jsonb"]); +const insertBatchSize = 500; + +export function quoteIdentifier(value: string) { + return `"${value.replace(/"/g, '""')}"`; +} + +function quoteSqlString(value: string) { + return `'${value.replace(/'/g, "''")}'`; +} + +function isJsonColumn(column?: Pick) { + return Boolean(column && (jsonDataTypes.has(column.dataType) || jsonDataTypes.has(column.udtName))); +} + +function jsonValueForSql(value: unknown) { + if (value instanceof Date) { + return JSON.stringify(value.toISOString()); + } + + const json = JSON.stringify(value); + return json === undefined ? "null" : json; +} + +export function sqlLiteral(value: unknown, column?: Pick): string { + if (value === null || value === undefined) { + return "NULL"; + } + + if (Buffer.isBuffer(value)) { + return `decode('${value.toString("hex")}', 'hex')`; + } + + if (isJsonColumn(column)) { + return quoteSqlString(jsonValueForSql(value)); + } + + if (value instanceof Date) { + return quoteSqlString(value.toISOString()); + } + + if (typeof value === "boolean") { + return value ? "TRUE" : "FALSE"; + } + + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new Error("Cannot export non-finite number"); + } + + return String(value); + } + + if (typeof value === "bigint") { + return String(value); + } + + if (typeof value === "object") { + return quoteSqlString(jsonValueForSql(value)); + } + + return quoteSqlString(String(value)); +} + +function chunk(items: T[], size: number) { + const chunks: T[][] = []; + + for (let index = 0; index < items.length; index += size) { + chunks.push(items.slice(index, index + size)); + } + + return chunks; +} + +async function tableColumns(client: QueryExecutor, table: string) { + const result = await client.query( + ` + SELECT + column_name AS "columnName", + data_type AS "dataType", + udt_name AS "udtName", + ordinal_position::int AS "ordinalPosition", + is_nullable AS "isNullable" + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = $1 + ORDER BY ordinal_position ASC; + `, + [table] + ); + + return result.rows; +} + +function orderByClause(columns: ColumnInfo[]) { + if (columns.some((column) => column.columnName === "id")) { + return ` ORDER BY ${quoteIdentifier("id")}`; + } + + const orderableColumns = columns.filter((column) => !isJsonColumn(column)).map((column) => quoteIdentifier(column.columnName)); + return orderableColumns.length > 0 ? ` ORDER BY ${orderableColumns.join(", ")}` : ""; +} + +async function sequenceNameForIdColumn(client: QueryExecutor, table: string, columns: ColumnInfo[]) { + if (!columns.some((column) => column.columnName === "id")) { + return null; + } + + const result = await client.query<{ sequenceName: string | null }>( + "SELECT pg_get_serial_sequence($1, 'id') AS \"sequenceName\";", + [`public.${table}`] + ); + + return result.rows[0]?.sequenceName ?? null; +} + +export function sequenceSetvalSql(table: string, sequenceName: string) { + const quotedTable = quoteIdentifier(table); + + return `SELECT setval(${quoteSqlString(sequenceName)}, COALESCE((SELECT MAX("id") FROM ${quotedTable}), 1), COALESCE((SELECT MAX("id") FROM ${quotedTable}), 0) > 0);`; +} + +export function nextSequenceIdAfterRestore(maxId: number | null | undefined) { + return maxId && maxId > 0 ? maxId + 1 : 1; +} + +export async function exportTableSql(client: QueryExecutor, table: string) { + const columns = await tableColumns(client, table); + + if (columns.length === 0) { + return [`-- Tabelle ${table} existiert nicht im aktuellen Schema.`]; + } + + const columnNames = columns.map((column) => column.columnName); + const quotedColumns = columnNames.map(quoteIdentifier); + const rowsResult = await client.query>( + ` + SELECT ${quotedColumns.join(", ")} + FROM ${quoteIdentifier(table)}${orderByClause(columns)}; + ` + ); + + const lines = [`-- ${table}: ${rowsResult.rows.length} Datensatz/Datensätze`, `-- columns ${table}: ${columnNames.join(",")}`]; + + for (const rows of chunk(rowsResult.rows, insertBatchSize)) { + if (rows.length === 0) { + continue; + } + + lines.push( + [ + `INSERT INTO ${quoteIdentifier(table)} (${quotedColumns.join(", ")}) VALUES`, + rows + .map((row) => ` (${columns.map((column) => sqlLiteral(row[column.columnName], column)).join(", ")})`) + .join(",\n") + ";" + ].join("\n") + ); + } + + const sequenceName = await sequenceNameForIdColumn(client, table, columns); + + if (sequenceName) { + lines.push(sequenceSetvalSql(table, sequenceName)); + } + + return lines; +} + +export async function currentTableColumns(client: QueryExecutor) { + const result = await client.query<{ table_name: string; column_name: string }>( + ` + SELECT table_name, column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ANY($1::text[]) + ORDER BY table_name, ordinal_position ASC; + `, + [[...databaseExportTables]] + ); + const columns = new Map(); + + for (const row of result.rows) { + columns.set(row.table_name, [...(columns.get(row.table_name) ?? []), row.column_name]); + } + + return columns; +} + +export function restoreSqlText(sql: string) { + const trimmed = sql.replace(/^\uFEFF/, "").trim(); + + if (!trimmed.includes("TicketTracker Datenbankexport")) { + throw new Error("Nur TicketTracker-SQL-Backups können eingespielt werden"); + } + + return trimmed + .replace(/^\s*BEGIN\s*;\s*$/gim, "") + .replace(/^\s*SET\s+LOCAL\s+(?:lock_timeout|statement_timeout)\s*=\s*'[^']*'\s*;\s*$/gim, "") + .replace(/^\s*COMMIT\s*;\s*$/gim, "") + .replace(/TRUNCATE\s+TABLE[\s\S]*?;\s*/i, "") + .trim(); +} + +function parseDumpColumnList(value: string) { + const quoted = [...value.matchAll(/"((?:[^"]|"")+)"/g)].map((match) => match[1].replace(/""/g, '"')); + + if (quoted.length > 0) { + return quoted; + } + + return value + .split(",") + .map((column) => column.trim().replace(/^"|"$/g, "")) + .filter(Boolean); +} + +export type RestoreValidationResult = { + valid: boolean; + checkedTables: number; + issues: string[]; +}; + +export async function validateTicketTrackerBackup(client: QueryExecutor, sql: string): Promise { + const issues: string[] = []; + let body = ""; + + try { + body = restoreSqlText(sql); + } catch (error) { + return { + valid: false, + checkedTables: 0, + issues: [error instanceof Error ? error.message : "Backup-Datei konnte nicht gelesen werden"] + }; + } + + const dumpColumns = new Map>(); + + for (const match of sql.matchAll(/^--\s*columns\s+([a-z_][a-z0-9_]*):\s*(.+)$/gim)) { + dumpColumns.set(match[1], new Set(parseDumpColumnList(match[2]))); + } + + for (const match of body.matchAll(/INSERT\s+INTO\s+"?([a-z_][a-z0-9_]*)"?\s*\(([^)]+)\)\s*VALUES/gi)) { + const table = match[1]; + const columns = dumpColumns.get(table) ?? new Set(); + + for (const column of parseDumpColumnList(match[2])) { + columns.add(column); + } + + dumpColumns.set(table, columns); + } + + for (const table of dumpColumns.keys()) { + if (!(databaseExportTables as readonly string[]).includes(table)) { + issues.push(`Dump enthält unbekannte Tabelle "${table}".`); + } + } + + const dbColumns = await currentTableColumns(client); + + for (const table of databaseExportTables) { + const currentColumns = dbColumns.get(table) ?? []; + const backupColumns = dumpColumns.get(table); + + if (currentColumns.length === 0) { + issues.push(`Aktuelle Datenbank enthält Tabelle "${table}" nicht.`); + continue; + } + + if (!backupColumns) { + issues.push(`Dump enthält keine Strukturinformationen für Tabelle "${table}". Bitte ein aktuelles TicketTracker-Backup verwenden.`); + continue; + } + + const missingInDump = currentColumns.filter((column) => !backupColumns.has(column)); + const extraInDump = [...backupColumns].filter((column) => !currentColumns.includes(column)); + + if (missingInDump.length > 0) { + issues.push(`Dump passt nicht zu Tabelle "${table}": Feld(er) fehlen: ${missingInDump.join(", ")}.`); + } + + if (extraInDump.length > 0) { + issues.push(`Dump passt nicht zu Tabelle "${table}": unbekannte Feld(er): ${extraInDump.join(", ")}.`); + } + } + + return { + valid: issues.length === 0, + checkedTables: databaseExportTables.length, + issues + }; +} + +export async function exportDatabaseSql(client: QueryExecutor) { + const lines = [ + "-- TicketTracker Datenbankexport", + `-- Erstellt: ${new Date().toISOString()}`, + "--", + "-- Restore:", + "-- 1. Frische PostgreSQL-Datenbank erstellen.", + "-- 2. TicketTracker-Migrationen ausführen.", + "-- 3. TicketTracker-Backend stoppen.", + "-- 4. Diese Datei einspielen:", + "-- psql -v ON_ERROR_STOP=1 -d DATABASE -f backup.sql", + "-- 5. TicketTracker-Backend wieder starten.", + "--", + "-- Der Restore läuft atomar in einer Transaktion.", + "-- ACHTUNG: TRUNCATE benötigt exklusive PostgreSQL-Locks auf den betroffenen Tabellen.", + "-- Für den Restore sollte das TicketTracker-Backend gestoppt werden.", + "", + "BEGIN;", + "SET LOCAL lock_timeout = '10s';", + "SET LOCAL statement_timeout = '15min';", + `TRUNCATE TABLE ${[...databaseExportTables].reverse().map(quoteIdentifier).join(", ")} RESTART IDENTITY CASCADE;` + ]; + + for (const table of databaseExportTables) { + lines.push("", ...(await exportTableSql(client, table))); + } + + lines.push("", "COMMIT;", ""); + return lines.join("\n"); +} diff --git a/backend/src/index.ts b/backend/src/index.ts index d24350f..094b4ae 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -8,6 +8,13 @@ import type pg from "pg"; import { migrate } from "./migrations.js"; import { pool, query, withTransaction } from "./db.js"; import { clearAuthSession, createAuthSession, currentUser, hashPassword, requireAdmin, requireAuth, requireUser, verifyPassword } from "./auth.js"; +import { + databaseExportTables as backupTables, + exportDatabaseSql, + quoteIdentifier as quoteBackupIdentifier, + restoreSqlText as backupRestoreSqlText, + validateTicketTrackerBackup as validateBackupSql +} from "./databaseExport.js"; import { badRequest, parseDay, @@ -33,11 +40,78 @@ app.use(cors()); app.use(express.text({ type: ["text/plain", "text/sql"], limit: requestBodyLimit })); app.use(express.json({ limit: requestBodyLimit })); +type RestoreDebugState = { + id: string; + startedAt: string; + finishedAt: string | null; + phase: string; + ok: boolean | null; + error: string | null; + sqlBytes: number; + bodyBytes: number | null; + checkedTables: number | null; + issueCount: number | null; + adminCount: number | null; +}; + +let lastRestoreDebugState: RestoreDebugState | null = null; + +function logDebug(event: string, details: Record = {}) { + console.info(`[tickettracker:${event}] ${JSON.stringify(details)}`); +} + +function updateRestoreDebug(id: string, update: Partial) { + if (!lastRestoreDebugState || lastRestoreDebugState.id !== id) { + return; + } + + lastRestoreDebugState = { + ...lastRestoreDebugState, + ...update + }; + logDebug("restore", { + id, + phase: lastRestoreDebugState.phase, + ok: lastRestoreDebugState.ok, + sqlBytes: lastRestoreDebugState.sqlBytes, + bodyBytes: lastRestoreDebugState.bodyBytes, + checkedTables: lastRestoreDebugState.checkedTables, + issueCount: lastRestoreDebugState.issueCount, + adminCount: lastRestoreDebugState.adminCount, + error: lastRestoreDebugState.error + }); +} + app.get("/api/health", async (_req, res) => { await query("SELECT 1"); res.json({ ok: true }); }); +app.get("/api/debug/status", async (_req, res) => { + const startedAt = Date.now(); + const tableCounts: Record = {}; + const adminResult = await query<{ count: string }>("SELECT COUNT(*) AS count FROM users WHERE role = 'admin';"); + + for (const table of backupTables) { + const countResult = await query<{ count: string }>(`SELECT COUNT(*) AS count FROM ${quoteBackupIdentifier(table)};`); + tableCounts[table] = Number(countResult.rows[0]?.count ?? 0); + } + + res.json({ + ok: true, + timestamp: new Date().toISOString(), + uptimeSeconds: Math.round(process.uptime()), + database: { + ok: true, + adminCount: Number(adminResult.rows[0]?.count ?? 0), + tableCounts + }, + restore: lastRestoreDebugState, + requestBodyLimit, + durationMs: Date.now() - startedAt + }); +}); + type PeriodConfig = { type: "month" | "day"; tablePrefix: "month" | "day"; @@ -3416,7 +3490,7 @@ app.post("/api/setup/restore/validate", async (req, res) => { } const sql = restoreSqlFromRequestBody(req.body); - res.json(await validateTicketTrackerBackup(sql)); + res.json(await validateBackupSql({ query }, sql)); }); app.post("/api/auth/login", async (req, res) => { @@ -3914,265 +3988,100 @@ app.post("/api/admin/zammad/organizations/sync", requireAdmin, async (req, res) }); }); -const databaseExportTables = [ - "users", - "auth_sessions", - "app_settings", - "user_preferences", - "organizations", - "tickets", - "projects", - "project_teamspace_entries", - "ticket_todos", - "recurring_billings", - "recurring_billing_slots", - "recurring_billing_exceptions", - "sessions", - "ticket_month_closures", - "month_closures", - "ticket_day_closures", - "day_closures", - "ticket_day_billings", - "ticket_day_billing_acknowledgements" -] as const; - -type RestoreValidationResult = { - valid: boolean; - checkedTables: number; - issues: string[]; -}; - -function quoteIdentifier(value: string) { - return `"${value.replace(/"/g, '""')}"`; -} - -function quoteLiteral(value: unknown) { - if (value === null || value === undefined) { - return "NULL"; - } - - if (value instanceof Date) { - return `'${value.toISOString().replace(/'/g, "''")}'`; - } - - if (Buffer.isBuffer(value)) { - return `decode('${value.toString("hex")}', 'hex')`; - } - - return `'${String(value).replace(/'/g, "''")}'`; -} - -async function exportTableSql(table: string) { - const columnsResult = await query<{ column_name: string }>( - ` - SELECT column_name - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = $1 - ORDER BY ordinal_position ASC; - `, - [table] - ); - const columns = columnsResult.rows.map((row) => row.column_name); - - if (columns.length === 0) { - return [`-- Tabelle ${table} existiert nicht im aktuellen Schema.`]; - } - - const rowsResult = await query>( - ` - SELECT ${columns.map(quoteIdentifier).join(", ")} - FROM ${quoteIdentifier(table)} - ORDER BY ${columns.map(quoteIdentifier).join(", ")}; - ` - ); - - const lines = [`-- ${table}: ${rowsResult.rows.length} Datensatz/Datensätze`, `-- columns ${table}: ${columns.join(",")}`]; - - for (const row of rowsResult.rows) { - lines.push( - `INSERT INTO ${quoteIdentifier(table)} (${columns.map(quoteIdentifier).join(", ")}) VALUES (${columns.map((column) => quoteLiteral(row[column])).join(", ")});` - ); - } - - if (columns.includes("id")) { - lines.push( - `SELECT setval(pg_get_serial_sequence(${quoteLiteral(table)}, 'id'), COALESCE((SELECT MAX("id") FROM ${quoteIdentifier(table)}), 1), COALESCE((SELECT MAX("id") FROM ${quoteIdentifier(table)}), 0) > 0);` - ); - } - - return lines; -} - -function restoreSqlText(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(); -} - -function parseDumpColumnList(value: string) { - const quoted = [...value.matchAll(/"((?:[^"]|"")+)"/g)].map((match) => match[1].replace(/""/g, '"')); - - if (quoted.length > 0) { - return quoted; - } - - return value - .split(",") - .map((column) => column.trim().replace(/^"|"$/g, "")) - .filter(Boolean); -} - -async function currentTableColumns() { - const result = await query<{ table_name: string; column_name: string }>( - ` - SELECT table_name, column_name - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = ANY($1::text[]) - ORDER BY table_name, ordinal_position ASC; - `, - [[...databaseExportTables]] - ); - const columns = new Map(); - - for (const row of result.rows) { - columns.set(row.table_name, [...(columns.get(row.table_name) ?? []), row.column_name]); - } - - return columns; -} - -async function validateTicketTrackerBackup(sql: string): Promise { - const issues: string[] = []; - let body = ""; - - try { - body = restoreSqlText(sql); - } catch (error) { - return { - valid: false, - checkedTables: 0, - issues: [error instanceof Error ? error.message : "Backup-Datei konnte nicht gelesen werden"] - }; - } - - const dumpColumns = new Map>(); - - for (const match of sql.matchAll(/^--\s*columns\s+([a-z_][a-z0-9_]*):\s*(.+)$/gim)) { - dumpColumns.set(match[1], new Set(parseDumpColumnList(match[2]))); - } - - for (const match of body.matchAll(/INSERT\s+INTO\s+"?([a-z_][a-z0-9_]*)"?\s*\(([^)]+)\)\s*VALUES/gi)) { - const table = match[1]; - const columns = dumpColumns.get(table) ?? new Set(); - - for (const column of parseDumpColumnList(match[2])) { - columns.add(column); - } - - dumpColumns.set(table, columns); - } - - for (const table of dumpColumns.keys()) { - if (!(databaseExportTables as readonly string[]).includes(table)) { - issues.push(`Dump enthält unbekannte Tabelle "${table}".`); - } - } - - const dbColumns = await currentTableColumns(); - - for (const table of databaseExportTables) { - const currentColumns = dbColumns.get(table) ?? []; - const backupColumns = dumpColumns.get(table); - - if (currentColumns.length === 0) { - issues.push(`Aktuelle Datenbank enthält Tabelle "${table}" nicht.`); - continue; - } - - if (!backupColumns) { - issues.push(`Dump enthält keine Strukturinformationen für Tabelle "${table}". Bitte ein aktuelles TicketTracker-Backup verwenden.`); - continue; - } - - const missingInDump = currentColumns.filter((column) => !backupColumns.has(column)); - const extraInDump = [...backupColumns].filter((column) => !currentColumns.includes(column)); - - if (missingInDump.length > 0) { - issues.push(`Dump passt nicht zu Tabelle "${table}": Feld(er) fehlen: ${missingInDump.join(", ")}.`); - } - - if (extraInDump.length > 0) { - issues.push(`Dump passt nicht zu Tabelle "${table}": unbekannte Feld(er): ${extraInDump.join(", ")}.`); - } - } - - return { - valid: issues.length === 0, - checkedTables: databaseExportTables.length, - issues - }; -} - async function restoreTicketTrackerBackup(sql: string) { - const validation = await validateTicketTrackerBackup(sql); + const restoreId = randomUUID(); + lastRestoreDebugState = { + id: restoreId, + startedAt: new Date().toISOString(), + finishedAt: null, + phase: "started", + ok: null, + error: null, + sqlBytes: Buffer.byteLength(sql, "utf8"), + bodyBytes: null, + checkedTables: null, + issueCount: null, + adminCount: null + }; + updateRestoreDebug(restoreId, { phase: "validating" }); + + const validation = await validateBackupSql({ query }, sql); + updateRestoreDebug(restoreId, { + checkedTables: validation.checkedTables, + issueCount: validation.issues.length + }); if (!validation.valid) { + updateRestoreDebug(restoreId, { + phase: "validation-failed", + ok: false, + finishedAt: new Date().toISOString(), + error: validation.issues[0] ?? "Restore-Datei passt nicht zur aktuellen Datenbankstruktur" + }); throw badRequest(`Restore-Datei passt nicht zur aktuellen Datenbankstruktur: ${validation.issues[0]}`); } - const body = restoreSqlText(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"); - } + updateRestoreDebug(restoreId, { phase: "preparing-sql" }); + const body = backupRestoreSqlText(sql); + updateRestoreDebug(restoreId, { + phase: "transaction-start", + bodyBytes: Buffer.byteLength(body, "utf8") }); + try { + await withTransaction(async (client) => { + updateRestoreDebug(restoreId, { phase: "lock-timeouts" }); + await client.query("SET LOCAL lock_timeout = '10s';"); + await client.query("SET LOCAL statement_timeout = '15min';"); + updateRestoreDebug(restoreId, { phase: "truncate" }); + await client.query(`TRUNCATE TABLE ${[...backupTables].reverse().map(quoteBackupIdentifier).join(", ")} RESTART IDENTITY CASCADE;`); + + if (body) { + updateRestoreDebug(restoreId, { phase: "import-body" }); + await client.query(body); + } + + updateRestoreDebug(restoreId, { phase: "verify-admin" }); + const adminResult = await client.query<{ count: string }>("SELECT COUNT(*) AS count FROM users WHERE role = 'admin';"); + const adminCount = Number(adminResult.rows[0]?.count ?? 0); + updateRestoreDebug(restoreId, { adminCount }); + + if (adminCount !== 1) { + throw badRequest("Restore muss genau einen Admin-Benutzer enthalten"); + } + }); + } catch (error) { + updateRestoreDebug(restoreId, { + ok: false, + finishedAt: new Date().toISOString(), + error: error instanceof Error ? error.message : "Unbekannter Restore-Fehler" + }); + throw error; + } + + updateRestoreDebug(restoreId, { + phase: "finished", + ok: true, + finishedAt: new Date().toISOString() + }); return { ok: true }; } app.get("/api/admin/export/database", requireAdmin, async (_req, res) => { - const lines = [ - "-- TicketTracker Datenbankexport", - `-- Erstellt: ${new Date().toISOString()}`, - "-- Restore: frische TicketTracker-Datenbank migrieren lassen, danach diese Datei mit psql einspielen.", - "BEGIN;", - `TRUNCATE TABLE ${[...databaseExportTables].reverse().map(quoteIdentifier).join(", ")} RESTART IDENTITY CASCADE;` - ]; - - for (const table of databaseExportTables) { - lines.push("", ...(await exportTableSql(table))); - } - - lines.push("", "COMMIT;", ""); + const sql = await withTransaction(async (client) => { + await client.query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY;"); + return exportDatabaseSql(client); + }); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); res.setHeader("Content-Type", "application/sql; charset=utf-8"); res.setHeader("Content-Disposition", `attachment; filename="tickettracker-backup-${timestamp}.sql"`); - res.send(lines.join("\n")); + res.send(sql); }); app.post("/api/admin/restore/database/validate", requireAdmin, async (req, res) => { const sql = restoreSqlFromRequestBody(req.body); - res.json(await validateTicketTrackerBackup(sql)); + res.json(await validateBackupSql({ query }, sql)); }); app.post("/api/admin/restore/database", requireAdmin, async (req, res) => { @@ -7812,10 +7721,25 @@ app.use((error: Error & { status?: number; statusCode?: number; type?: string }, }); async function start() { + logDebug("startup", { + phase: "migrate-start", + port, + serveFrontend, + requestBodyLimit + }); await migrate(); + const adminResult = await query<{ count: string }>("SELECT COUNT(*) AS count FROM users WHERE role = 'admin';"); + logDebug("startup", { + phase: "migrate-finished", + adminCount: Number(adminResult.rows[0]?.count ?? 0) + }); const server = app.listen(port, () => { console.log(`TicketTracker listening on port ${port}`); + logDebug("startup", { + phase: "listening", + port + }); }); const shutdown = async () => { diff --git a/backend/test/databaseExport.test.ts b/backend/test/databaseExport.test.ts new file mode 100644 index 0000000..b7778ba --- /dev/null +++ b/backend/test/databaseExport.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { nextSequenceIdAfterRestore, restoreSqlText, sequenceSetvalSql, sqlLiteral, type QueryExecutor } from "../src/databaseExport.ts"; +import { exportTableSql } from "../src/databaseExport.ts"; + +const jsonbColumn = { dataType: "jsonb", udtName: "jsonb" }; + +test("sqlLiteral serializes primitive values", () => { + assert.equal(sqlLiteral(null), "NULL"); + assert.equal(sqlLiteral(undefined), "NULL"); + assert.equal(sqlLiteral("normaler Text"), "'normaler Text'"); + assert.equal(sqlLiteral("O'Brien"), "'O''Brien'"); + assert.equal(sqlLiteral("erste Zeile\nzweite Zeile"), "'erste Zeile\nzweite Zeile'"); + assert.equal(sqlLiteral("Grüße"), "'Grüße'"); + assert.equal(sqlLiteral(true), "TRUE"); + assert.equal(sqlLiteral(false), "FALSE"); + assert.equal(sqlLiteral(0), "0"); + assert.equal(sqlLiteral(42), "42"); + assert.equal(sqlLiteral(-10), "-10"); + assert.equal(sqlLiteral(1.25), "1.25"); +}); + +test("sqlLiteral serializes dates, buffers, objects and arrays", () => { + assert.equal(sqlLiteral(new Date("2026-09-11T10:15:30.000Z")), "'2026-09-11T10:15:30.000Z'"); + assert.equal(sqlLiteral(Buffer.from("abc")), "decode('616263', 'hex')"); + assert.equal(sqlLiteral({}), "'{}'"); + assert.equal(sqlLiteral([]), "'[]'"); + assert.equal(sqlLiteral({ foo: "bar" }), `'{"foo":"bar"}'`); + assert.equal(sqlLiteral({ foo: "O'Brien" }), `'{"foo":"O''Brien"}'`); + assert.equal(sqlLiteral({ nested: { enabled: true }, items: [{ id: 1 }] }), `'{"nested":{"enabled":true},"items":[{"id":1}]}'`); +}); + +test("sqlLiteral serializes JSON columns as valid JSON values", () => { + assert.equal(sqlLiteral({ foo: "bar", enabled: true }, jsonbColumn), `'{"foo":"bar","enabled":true}'`); + assert.equal(sqlLiteral("plain json string", jsonbColumn), `'"plain json string"'`); + assert.equal(sqlLiteral(true, jsonbColumn), "'true'"); +}); + +test("generated table export never contains implicit object stringification", async () => { + const client: QueryExecutor = { + async query>(text: string): Promise<{ rows: T[]; rowCount: number | null }> { + if (text.includes("information_schema.columns")) { + return { + rowCount: 3, + rows: [ + { columnName: "id", dataType: "bigint", udtName: "int8", ordinalPosition: 1, isNullable: "NO" }, + { columnName: "value", dataType: "jsonb", udtName: "jsonb", ordinalPosition: 2, isNullable: "NO" }, + { columnName: "label", dataType: "text", udtName: "text", ordinalPosition: 3, isNullable: "YES" } + ] as T[] + }; + } + + if (text.includes("pg_get_serial_sequence")) { + return { rowCount: 1, rows: [{ sequenceName: "public.example_id_seq" }] as T[] }; + } + + return { + rowCount: 1, + rows: [{ id: 4, value: { foo: "bar" }, label: "O'Brien" }] as T[] + }; + } + }; + + const sql = (await exportTableSql(client, "example")).join("\n"); + assert.doesNotMatch(sql, /\[object Object\]/); + assert.match(sql, /'\{"foo":"bar"\}'/); +}); + +test("sequence helper advances after restored max id", () => { + assert.equal(nextSequenceIdAfterRestore(4), 5); + assert.equal(nextSequenceIdAfterRestore(null), 1); + assert.match(sequenceSetvalSql("users", "public.users_id_seq"), /SELECT setval\('public\.users_id_seq'/); + assert.match(sequenceSetvalSql("users", "public.users_id_seq"), /MAX\("id"\) FROM "users"/); +}); + +test("restoreSqlText removes dump transaction wrapper after export header", () => { + const body = restoreSqlText(` +-- TicketTracker Datenbankexport +-- Erstellt: 2026-09-14T10:00:00.000Z + +BEGIN; +SET LOCAL lock_timeout = '10s'; +SET LOCAL statement_timeout = '15min'; +TRUNCATE TABLE "users", "auth_sessions" RESTART IDENTITY CASCADE; + +INSERT INTO "users" ("id", "username") VALUES + (1, 'admin'); + +COMMIT; +`); + + assert.doesNotMatch(body, /^\s*BEGIN\s*;/im); + assert.doesNotMatch(body, /^\s*COMMIT\s*;/im); + assert.doesNotMatch(body, /^\s*SET\s+LOCAL/im); + assert.doesNotMatch(body, /TRUNCATE\s+TABLE/i); + assert.match(body, /INSERT INTO "users"/); +}); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 9a47e00..53ce125 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -159,6 +159,10 @@ async function request(url: string, options: ApiRequestInit = {}): Promise return response.json() as Promise; } +async function requestRestoreEndpoint(path: string, options: ApiRequestInit) { + return request(path, options); +} + export function login(payload: { username: string; password: string }) { return request<{ user: AuthUser }>("/api/auth/login", { method: "POST", @@ -225,7 +229,7 @@ export function createSetupAdmin(payload: { username: string; displayName: strin } export function restoreSetupBackup(sql: string) { - return request<{ ok: boolean }>("/api/setup/restore", { + return requestRestoreEndpoint<{ ok: boolean }>("/api/setup/restore", { method: "POST", timeoutMs: 10 * 60 * 1000, headers: { "Content-Type": "text/plain; charset=utf-8" }, @@ -234,9 +238,9 @@ export function restoreSetupBackup(sql: string) { } export function validateSetupBackup(sql: string) { - return request("/api/setup/restore/validate", { + return requestRestoreEndpoint("/api/setup/restore/validate", { method: "POST", - timeoutMs: 10 * 60 * 1000, + timeoutMs: 60_000, headers: { "Content-Type": "text/plain; charset=utf-8" }, body: sql }); @@ -293,7 +297,7 @@ export async function downloadDatabaseExport() { } export function restoreDatabaseBackup(sql: string) { - return request<{ ok: boolean }>("/api/admin/restore/database", { + return requestRestoreEndpoint<{ ok: boolean }>("/api/admin/restore/database", { method: "POST", timeoutMs: 10 * 60 * 1000, headers: { "Content-Type": "text/plain; charset=utf-8" }, @@ -302,9 +306,9 @@ export function restoreDatabaseBackup(sql: string) { } export function validateDatabaseBackup(sql: string) { - return request("/api/admin/restore/database/validate", { + return requestRestoreEndpoint("/api/admin/restore/database/validate", { method: "POST", - timeoutMs: 10 * 60 * 1000, + timeoutMs: 60_000, headers: { "Content-Type": "text/plain; charset=utf-8" }, body: sql }); diff --git a/scripts/install-local-dependencies.sh b/scripts/install-local-dependencies.sh old mode 100755 new mode 100644 index 0a1cd9a..50826d9 --- a/scripts/install-local-dependencies.sh +++ b/scripts/install-local-dependencies.sh @@ -3,10 +3,22 @@ set -euo pipefail NODE_MAJOR_REQUIRED=22 NODESOURCE_SETUP_URL="https://deb.nodesource.com/setup_${NODE_MAJOR_REQUIRED}.x" +MIN_DISK_MB=2048 +MIN_MEMORY_MB=768 SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +if [[ "${1:-}" != "--run" ]]; then + echo "Dieses Setup-Script macht ohne explizites --run nichts." + echo + echo "So startest du es bewusst:" + echo " bash scripts/install-local-dependencies.sh --run" + echo + echo "Es installiert nur Webapp-Abhaengigkeiten, keine DB und kein Docker." + exit 0 +fi + if [[ ! -f "${REPO_ROOT}/package.json" ]]; then echo "Repo-Root nicht gefunden. Erwartet: ${REPO_ROOT}/package.json" exit 1 @@ -46,6 +58,33 @@ node_major() { node -p 'Number(process.versions.node.split(".")[0])' 2>/dev/null || echo 0 } +available_memory_mb() { + awk '/MemAvailable:/ { print int($2 / 1024) }' /proc/meminfo 2>/dev/null || echo 0 +} + +available_disk_mb() { + df -Pm "${REPO_ROOT}" | awk 'NR == 2 { print $4 }' +} + +if [[ "${SKIP_RESOURCE_CHECK:-false}" != "true" ]]; then + AVAILABLE_DISK_MB="$(available_disk_mb)" + AVAILABLE_MEMORY_MB="$(available_memory_mb)" + + if [[ "${AVAILABLE_DISK_MB}" -lt "${MIN_DISK_MB}" ]]; then + echo "Zu wenig freier Speicherplatz fuer npm-Abhaengigkeiten." + echo "Frei: ${AVAILABLE_DISK_MB} MB, benoetigt empfohlen: mindestens ${MIN_DISK_MB} MB." + echo "Falls du das bewusst trotzdem starten willst: SKIP_RESOURCE_CHECK=true bash scripts/install-local-dependencies.sh --run" + exit 1 + fi + + if [[ "${AVAILABLE_MEMORY_MB}" -gt 0 && "${AVAILABLE_MEMORY_MB}" -lt "${MIN_MEMORY_MB}" ]]; then + echo "Zu wenig freier Arbeitsspeicher fuer eine stabile npm-Installation." + echo "Frei: ${AVAILABLE_MEMORY_MB} MB, benoetigt empfohlen: mindestens ${MIN_MEMORY_MB} MB." + echo "Falls Swap vorhanden ist und du das bewusst trotzdem starten willst: SKIP_RESOURCE_CHECK=true bash scripts/install-local-dependencies.sh --run" + exit 1 + fi +fi + echo "Installiere Basiswerkzeuge fuer die lokale Webapp..." run_apt update run_apt install -y ca-certificates curl gnupg git bash build-essential