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:
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user