move
This commit is contained in:
1 parent
9a08bfbf44
commit
cfb96f03c0
6 files changed
+675
-246
No files matched your search
@@ -8,6 +8,7 @@
|
|||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
|
"test": "node --import tsx --test test/*.test.ts",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
type QueryResult<T> = {
|
||||||
|
rows: T[];
|
||||||
|
rowCount: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QueryExecutor = {
|
||||||
|
query<T extends Record<string, unknown> = Record<string, unknown>>(text: string, params?: unknown[]): Promise<QueryResult<T>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<ColumnInfo, "dataType" | "udtName">) {
|
||||||
|
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<ColumnInfo, "dataType" | "udtName">): 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<T>(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<ColumnInfo>(
|
||||||
|
`
|
||||||
|
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<Record<string, unknown>>(
|
||||||
|
`
|
||||||
|
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<string, string[]>();
|
||||||
|
|
||||||
|
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<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(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");
|
||||||
|
}
|
||||||
+164
-240
@@ -8,6 +8,13 @@ import type pg from "pg";
|
|||||||
import { migrate } from "./migrations.js";
|
import { migrate } from "./migrations.js";
|
||||||
import { pool, query, withTransaction } from "./db.js";
|
import { pool, query, withTransaction } from "./db.js";
|
||||||
import { clearAuthSession, createAuthSession, currentUser, hashPassword, requireAdmin, requireAuth, requireUser, verifyPassword } from "./auth.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 {
|
import {
|
||||||
badRequest,
|
badRequest,
|
||||||
parseDay,
|
parseDay,
|
||||||
@@ -33,11 +40,78 @@ app.use(cors());
|
|||||||
app.use(express.text({ type: ["text/plain", "text/sql"], limit: requestBodyLimit }));
|
app.use(express.text({ type: ["text/plain", "text/sql"], limit: requestBodyLimit }));
|
||||||
app.use(express.json({ 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) => {
|
app.get("/api/health", async (_req, res) => {
|
||||||
await query("SELECT 1");
|
await query("SELECT 1");
|
||||||
res.json({ ok: true });
|
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 PeriodConfig = {
|
||||||
type: "month" | "day";
|
type: "month" | "day";
|
||||||
tablePrefix: "month" | "day";
|
tablePrefix: "month" | "day";
|
||||||
@@ -3416,7 +3490,7 @@ app.post("/api/setup/restore/validate", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sql = restoreSqlFromRequestBody(req.body);
|
const sql = restoreSqlFromRequestBody(req.body);
|
||||||
res.json(await validateTicketTrackerBackup(sql));
|
res.json(await validateBackupSql({ query }, sql));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/auth/login", async (req, res) => {
|
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) {
|
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) {
|
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]}`);
|
throw badRequest(`Restore-Datei passt nicht zur aktuellen Datenbankstruktur: ${validation.issues[0]}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = restoreSqlText(sql);
|
updateRestoreDebug(restoreId, { phase: "preparing-sql" });
|
||||||
|
const body = backupRestoreSqlText(sql);
|
||||||
await withTransaction(async (client) => {
|
updateRestoreDebug(restoreId, {
|
||||||
await client.query(`TRUNCATE TABLE ${[...databaseExportTables].reverse().map(quoteIdentifier).join(", ")} RESTART IDENTITY CASCADE;`);
|
phase: "transaction-start",
|
||||||
|
bodyBytes: Buffer.byteLength(body, "utf8")
|
||||||
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");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get("/api/admin/export/database", requireAdmin, async (_req, res) => {
|
app.get("/api/admin/export/database", requireAdmin, async (_req, res) => {
|
||||||
const lines = [
|
const sql = await withTransaction(async (client) => {
|
||||||
"-- TicketTracker Datenbankexport",
|
await client.query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY;");
|
||||||
`-- Erstellt: ${new Date().toISOString()}`,
|
return exportDatabaseSql(client);
|
||||||
"-- 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 timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
res.setHeader("Content-Type", "application/sql; charset=utf-8");
|
res.setHeader("Content-Type", "application/sql; charset=utf-8");
|
||||||
res.setHeader("Content-Disposition", `attachment; filename="tickettracker-backup-${timestamp}.sql"`);
|
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) => {
|
app.post("/api/admin/restore/database/validate", requireAdmin, async (req, res) => {
|
||||||
const sql = restoreSqlFromRequestBody(req.body);
|
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) => {
|
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() {
|
async function start() {
|
||||||
|
logDebug("startup", {
|
||||||
|
phase: "migrate-start",
|
||||||
|
port,
|
||||||
|
serveFrontend,
|
||||||
|
requestBodyLimit
|
||||||
|
});
|
||||||
await migrate();
|
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, () => {
|
const server = app.listen(port, () => {
|
||||||
console.log(`TicketTracker listening on port ${port}`);
|
console.log(`TicketTracker listening on port ${port}`);
|
||||||
|
logDebug("startup", {
|
||||||
|
phase: "listening",
|
||||||
|
port
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const shutdown = async () => {
|
const shutdown = async () => {
|
||||||
|
|||||||
@@ -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<T extends Record<string, unknown>>(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"/);
|
||||||
|
});
|
||||||
+10
-6
@@ -159,6 +159,10 @@ async function request<T>(url: string, options: ApiRequestInit = {}): Promise<T>
|
|||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestRestoreEndpoint<T>(path: string, options: ApiRequestInit) {
|
||||||
|
return request<T>(path, options);
|
||||||
|
}
|
||||||
|
|
||||||
export function login(payload: { username: string; password: string }) {
|
export function login(payload: { username: string; password: string }) {
|
||||||
return request<{ user: AuthUser }>("/api/auth/login", {
|
return request<{ user: AuthUser }>("/api/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -225,7 +229,7 @@ export function createSetupAdmin(payload: { username: string; displayName: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function restoreSetupBackup(sql: string) {
|
export function restoreSetupBackup(sql: string) {
|
||||||
return request<{ ok: boolean }>("/api/setup/restore", {
|
return requestRestoreEndpoint<{ ok: boolean }>("/api/setup/restore", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
timeoutMs: 10 * 60 * 1000,
|
timeoutMs: 10 * 60 * 1000,
|
||||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||||
@@ -234,9 +238,9 @@ export function restoreSetupBackup(sql: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function validateSetupBackup(sql: string) {
|
export function validateSetupBackup(sql: string) {
|
||||||
return request<RestoreValidationResult>("/api/setup/restore/validate", {
|
return requestRestoreEndpoint<RestoreValidationResult>("/api/setup/restore/validate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
timeoutMs: 10 * 60 * 1000,
|
timeoutMs: 60_000,
|
||||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||||
body: sql
|
body: sql
|
||||||
});
|
});
|
||||||
@@ -293,7 +297,7 @@ export async function downloadDatabaseExport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function restoreDatabaseBackup(sql: string) {
|
export function restoreDatabaseBackup(sql: string) {
|
||||||
return request<{ ok: boolean }>("/api/admin/restore/database", {
|
return requestRestoreEndpoint<{ ok: boolean }>("/api/admin/restore/database", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
timeoutMs: 10 * 60 * 1000,
|
timeoutMs: 10 * 60 * 1000,
|
||||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||||
@@ -302,9 +306,9 @@ export function restoreDatabaseBackup(sql: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function validateDatabaseBackup(sql: string) {
|
export function validateDatabaseBackup(sql: string) {
|
||||||
return request<RestoreValidationResult>("/api/admin/restore/database/validate", {
|
return requestRestoreEndpoint<RestoreValidationResult>("/api/admin/restore/database/validate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
timeoutMs: 10 * 60 * 1000,
|
timeoutMs: 60_000,
|
||||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||||
body: sql
|
body: sql
|
||||||
});
|
});
|
||||||
|
|||||||
Executable → Regular
+39
@@ -3,10 +3,22 @@ set -euo pipefail
|
|||||||
|
|
||||||
NODE_MAJOR_REQUIRED=22
|
NODE_MAJOR_REQUIRED=22
|
||||||
NODESOURCE_SETUP_URL="https://deb.nodesource.com/setup_${NODE_MAJOR_REQUIRED}.x"
|
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)"
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && 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
|
if [[ ! -f "${REPO_ROOT}/package.json" ]]; then
|
||||||
echo "Repo-Root nicht gefunden. Erwartet: ${REPO_ROOT}/package.json"
|
echo "Repo-Root nicht gefunden. Erwartet: ${REPO_ROOT}/package.json"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -46,6 +58,33 @@ node_major() {
|
|||||||
node -p 'Number(process.versions.node.split(".")[0])' 2>/dev/null || echo 0
|
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..."
|
echo "Installiere Basiswerkzeuge fuer die lokale Webapp..."
|
||||||
run_apt update
|
run_apt update
|
||||||
run_apt install -y ca-certificates curl gnupg git bash build-essential
|
run_apt install -y ca-certificates curl gnupg git bash build-essential
|
||||||
|
|||||||
Reference in new issue
Block a user