feat(backup): replace JSON import/export with SQLite database backup system

Replace the JSON-based import/export with a proper backup system that copies
the SQLite database file directly. Supports manual on-demand backups, periodic
scheduled backups via node-cron, configurable retention, file download, and
full database restore.

- Add backupService with VACUUM INTO for safe DB copies
- Add backupScheduler following healthcheckScheduler pattern
- Add 6 admin API endpoints (create, list, download, restore, delete, schedule)
- Add BackupPanel UI with backup table, confirmation dialogs, schedule config
- Add backup fields to SystemSettings schema
- Remove old ImportExportPanel, exportService, importService, and related code
This commit is contained in:
2026-04-02 23:16:18 +03:00
parent d479726fe3
commit b0439e39c4
24 changed files with 1079 additions and 1183 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
import { t } from 'svelte-i18n';
import type { PageData } from './$types.js';
import SettingsForm from '$lib/components/admin/SettingsForm.svelte';
import ImportExportPanel from '$lib/components/admin/ImportExportPanel.svelte';
import BackupPanel from '$lib/components/admin/BackupPanel.svelte';
import DiscoveryPanel from '$lib/components/admin/DiscoveryPanel.svelte';
let { data }: { data: PageData } = $props();
@@ -25,5 +25,5 @@
<DiscoveryPanel bind:dockerSocketPath bind:traefikApiUrl />
<ImportExportPanel />
<BackupPanel />
</div>
+45
View File
@@ -0,0 +1,45 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { createBackup, listBackups, enforceRetention, getBackupSettings } from '$lib/server/services/backupService.js';
import { success, error } from '$lib/server/utils/response.js';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
/**
* GET /api/admin/backups — List all backups.
*/
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
try {
const backups = listBackups();
const settings = await getBackupSettings();
return json(success({ backups, schedule: settings }));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to list backups';
return json(error(message), { status: 500 });
}
};
/**
* POST /api/admin/backups — Create a new backup.
*/
export const POST: RequestHandler = async (event) => {
const admin = requireAdmin(event);
try {
const backup = await createBackup();
// Enforce retention after creating backup
const settings = await getBackupSettings();
enforceRetention(settings.backupMaxCount);
logAction(admin.id, AuditAction.BACKUP_CREATED, 'backup', backup.filename);
return json(success(backup), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create backup';
return json(error(message), { status: 500 });
}
};
@@ -0,0 +1,29 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { deleteBackup } from '$lib/server/services/backupService.js';
import { success, error } from '$lib/server/utils/response.js';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
/**
* DELETE /api/admin/backups/:filename — Delete a backup.
*/
export const DELETE: RequestHandler = async (event) => {
const admin = requireAdmin(event);
const { filename } = event.params;
try {
const deleted = deleteBackup(filename);
if (!deleted) {
return json(error('Backup not found'), { status: 404 });
}
logAction(admin.id, AuditAction.BACKUP_DELETED, 'backup', filename);
return json(success({ deleted: true }));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete backup';
return json(error(message), { status: 500 });
}
};
@@ -0,0 +1,33 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { getBackupFilePath } from '$lib/server/services/backupService.js';
import { error } from '$lib/server/utils/response.js';
import fs from 'node:fs';
import path from 'node:path';
import { Readable } from 'node:stream';
/**
* GET /api/admin/backups/:filename/download — Download a backup file (streamed).
*/
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
const { filename } = event.params;
const filePath = getBackupFilePath(filename);
if (!filePath) {
return json(error('Backup not found'), { status: 404 });
}
const stats = fs.statSync(filePath);
const stream = fs.createReadStream(filePath);
return new Response(Readable.toWeb(stream) as ReadableStream, {
status: 200,
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${path.basename(filePath)}"`,
'Content-Length': String(stats.size)
}
});
};
@@ -0,0 +1,26 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { restoreBackup } from '$lib/server/services/backupService.js';
import { success, error } from '$lib/server/utils/response.js';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
/**
* POST /api/admin/backups/:filename/restore — Restore the database from a backup.
*/
export const POST: RequestHandler = async (event) => {
const admin = requireAdmin(event);
const { filename } = event.params;
try {
await restoreBackup(filename);
logAction(admin.id, AuditAction.BACKUP_RESTORED, 'backup', filename);
return json(success({ restored: true }));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to restore backup';
return json(error(message), { status: 500 });
}
};
@@ -0,0 +1,65 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { getBackupSettings, updateBackupSettings } from '$lib/server/services/backupService.js';
import { restartBackupScheduler } from '$lib/server/jobs/backupScheduler.js';
import { success, error } from '$lib/server/utils/response.js';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
import { updateBackupScheduleSchema } from '$lib/utils/validators.js';
import cron from 'node-cron';
/**
* GET /api/admin/backups/schedule — Get current backup schedule settings.
*/
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
try {
const settings = await getBackupSettings();
return json(success(settings));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to get backup schedule';
return json(error(message), { status: 500 });
}
};
/**
* PUT /api/admin/backups/schedule — Update backup schedule settings.
*/
export const PUT: RequestHandler = async (event) => {
const admin = requireAdmin(event);
try {
const raw = await event.request.json();
const parsed = updateBackupScheduleSchema.safeParse(raw);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(`Invalid input: ${messages}`), { status: 400 });
}
const body = parsed.data;
// Validate cron expression if provided
if (body.backupCronExpression && !cron.validate(body.backupCronExpression)) {
return json(error('Invalid cron expression'), { status: 400 });
}
const updated = await updateBackupSettings(body);
// Restart the scheduler with new settings
restartBackupScheduler(updated);
logAction(admin.id, AuditAction.SETTINGS_UPDATED, 'backup_schedule', 'singleton', {
backupEnabled: updated.backupEnabled,
backupCronExpression: updated.backupCronExpression,
backupMaxCount: updated.backupMaxCount
});
return json(success(updated));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update backup schedule';
return json(error(message), { status: 500 });
}
};
-33
View File
@@ -1,33 +0,0 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { exportAllData } from '$lib/server/services/exportService.js';
import { error } from '$lib/server/utils/response.js';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
/**
* GET /api/admin/export — Export all data as JSON file download. Admin only.
*/
export const GET: RequestHandler = async (event) => {
const admin = requireAdmin(event);
try {
logAction(admin.id, AuditAction.EXPORT, 'system', 'export');
const data = await exportAllData();
const jsonString = JSON.stringify(data, null, 2);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `web-app-launcher-export-${timestamp}.json`;
return new Response(jsonString, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Content-Disposition': `attachment; filename="${filename}"`
}
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to export data';
return json(error(message), { status: 500 });
}
};
-49
View File
@@ -1,49 +0,0 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { validateImportData, importData } from '$lib/server/services/importService.js';
import type { ImportMode } from '$lib/server/services/importService.js';
import { success, error } from '$lib/server/utils/response.js';
import { logAction } from '$lib/server/services/auditLogService.js';
import { AuditAction } from '$lib/utils/constants.js';
/**
* POST /api/admin/import — Import data from JSON. Admin only.
* Body: { data: ExportData, mode: "skip" | "overwrite" }
*/
export const POST: RequestHandler = async (event) => {
const admin = requireAdmin(event);
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
if (!body || typeof body !== 'object') {
return json(error('Request body must be an object'), { status: 400 });
}
const { data, mode } = body as { data: unknown; mode: unknown };
if (!data) {
return json(error('Missing "data" field in request body'), { status: 400 });
}
const validMode: ImportMode = mode === 'overwrite' ? 'overwrite' : 'skip';
const validation = validateImportData(data);
if (!validation.success) {
return json(error(`Validation failed: ${validation.errors.join('; ')}`), { status: 400 });
}
try {
const result = await importData(validation.data, validMode);
logAction(admin.id, AuditAction.IMPORT, 'system', 'import', { mode: validMode });
return json(success(result));
} catch (err) {
const message = err instanceof Error ? err.message : 'Import failed';
return json(error(message), { status: 500 });
}
};