Validate backups before restore
This commit is contained in:
+135
-3
@@ -1224,6 +1224,16 @@ app.post("/api/setup/restore", async (req, res) => {
|
||||
res.status(201).json(result);
|
||||
});
|
||||
|
||||
app.post("/api/setup/restore/validate", 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");
|
||||
res.json(await validateTicketTrackerBackup(sql));
|
||||
});
|
||||
|
||||
app.post("/api/auth/login", async (req, res) => {
|
||||
const username = requireString(req.body.username, "username");
|
||||
const password = requireString(req.body.password, "password");
|
||||
@@ -1671,6 +1681,12 @@ const databaseExportTables = [
|
||||
"ticket_day_billing_acknowledgements"
|
||||
] as const;
|
||||
|
||||
type RestoreValidationResult = {
|
||||
valid: boolean;
|
||||
checkedTables: number;
|
||||
issues: string[];
|
||||
};
|
||||
|
||||
function quoteIdentifier(value: string) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
@@ -1716,7 +1732,7 @@ async function exportTableSql(table: string) {
|
||||
`
|
||||
);
|
||||
|
||||
const lines = [`-- ${table}: ${rowsResult.rows.length} Datensatz/Datensätze`];
|
||||
const lines = [`-- ${table}: ${rowsResult.rows.length} Datensatz/Datensätze`, `-- columns ${table}: ${columns.join(",")}`];
|
||||
|
||||
for (const row of rowsResult.rows) {
|
||||
lines.push(
|
||||
@@ -1733,7 +1749,7 @@ async function exportTableSql(table: string) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
function managedRestoreBody(sql: string) {
|
||||
function restoreSqlText(sql: string) {
|
||||
const trimmed = sql.replace(/^\uFEFF/, "").trim();
|
||||
|
||||
if (!trimmed.includes("TicketTracker Datenbankexport")) {
|
||||
@@ -1747,8 +1763,119 @@ function managedRestoreBody(sql: string) {
|
||||
.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<string, string[]>();
|
||||
|
||||
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<RestoreValidationResult> {
|
||||
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<string, Set<string>>();
|
||||
|
||||
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<string>();
|
||||
|
||||
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 body = managedRestoreBody(sql);
|
||||
const validation = await validateTicketTrackerBackup(sql);
|
||||
|
||||
if (!validation.valid) {
|
||||
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;`);
|
||||
@@ -1788,6 +1915,11 @@ app.get("/api/admin/export/database", requireAdmin, async (_req, res) => {
|
||||
res.send(lines.join("\n"));
|
||||
});
|
||||
|
||||
app.post("/api/admin/restore/database/validate", requireAdmin, async (req, res) => {
|
||||
const sql = requireString(req.body.sql, "sql");
|
||||
res.json(await validateTicketTrackerBackup(sql));
|
||||
});
|
||||
|
||||
app.post("/api/admin/restore/database", requireAdmin, async (req, res) => {
|
||||
const sql = requireString(req.body.sql, "sql");
|
||||
const result = await restoreTicketTrackerBackup(sql);
|
||||
|
||||
Reference in New Issue
Block a user