move
This commit is contained in:
1 parent
9a08bfbf44
commit
cfb96f03c0
6 files changed
+675
-246
No files matched your search
+164
-240
@@ -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<string, unknown> = {}) {
|
||||
console.info(`[tickettracker:${event}] ${JSON.stringify(details)}`);
|
||||
}
|
||||
|
||||
function updateRestoreDebug(id: string, update: Partial<RestoreDebugState>) {
|
||||
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<string, number> = {};
|
||||
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<Record<string, unknown>>(
|
||||
`
|
||||
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<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 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 () => {
|
||||
|
||||
Reference in new issue
Block a user