diff --git a/plans/database-backup/CONTEXT.md b/plans/database-backup/CONTEXT.md new file mode 100644 index 0000000..8538d06 --- /dev/null +++ b/plans/database-backup/CONTEXT.md @@ -0,0 +1,40 @@ +# Feature Context: Database Backup & Restore + +## Configuration +- **Development mode:** Automated +- **Execution mode:** Direct +- **Strategy:** Big Bang +- **Build:** `npm run build` +- **Test:** `npm run test` +- **Lint:** `npm run lint` +- **Dev server:** `npm run dev` (port: 5173) + +## Current State +Starting implementation. Existing import/export code is still in place — will be removed in Phase 4. + +## Tech Stack +- SvelteKit 5 + TypeScript +- Prisma ORM with SQLite (`data/launcher.db`) +- node-cron (already a dependency, used by healthcheckScheduler) +- Zod validation +- Tailwind CSS + bits-ui components +- svelte-i18n for localization +- lucide-svelte for icons + +## Key Patterns from Codebase +- Services in `src/lib/server/services/` — pure functions, Prisma transactions +- API routes follow SvelteKit conventions with admin auth guards +- Scheduler pattern: `src/lib/server/jobs/healthcheckScheduler.ts` — cron start/stop, wired in hooks.server.ts +- Admin components in `src/lib/components/admin/` +- Audit logging via `auditLogService.ts` + +## Cross-Phase Dependencies +- Phase 2 depends on Phase 1 (backupService) +- Phase 3 depends on Phases 1+2 (API endpoints) +- Phase 4 is independent cleanup + +## Implementation Notes +- Using `VACUUM INTO` for safe backup creation (no locking) +- Backups stored in `data/backups/` directory +- Restore requires Prisma disconnect → file swap → reconnect +- SystemSettings gets new fields: backupEnabled, backupCronExpression, backupMaxCount diff --git a/plans/database-backup/PLAN.md b/plans/database-backup/PLAN.md new file mode 100644 index 0000000..042211b --- /dev/null +++ b/plans/database-backup/PLAN.md @@ -0,0 +1,39 @@ +# Feature: Database Backup & Restore + +**Branch:** `feature/database-backup` +**Base branch:** `master` +**Created:** 2026-04-02 +**Status:** 🟡 In Progress +**Strategy:** Big Bang +**Mode:** Automated +**Execution:** Direct + +## Summary +Replace JSON import/export with SQLite file-copy backup system. Support manual on-demand backups, optional periodic scheduling via node-cron, configurable retention (max backup count), download capability, and full database restore. + +## Build & Test Commands +- **Build:** `npm run build` +- **Test:** `npm run test` +- **Lint:** `npm run lint` + +## Phases + +- [ ] Phase 1: Backup Service & API Endpoints [domain: backend] → [subplan](./phase-1-backup-service.md) +- [ ] Phase 2: Periodic Backup Scheduler [domain: backend] → [subplan](./phase-2-backup-scheduler.md) +- [ ] Phase 3: Frontend BackupPanel [domain: frontend] → [subplan](./phase-3-backup-panel.md) +- [ ] Phase 4: Cleanup Import/Export [domain: fullstack] → [subplan](./phase-4-cleanup.md) + +## Phase Progress Log + +| Phase | Domain | Status | Review | Build | Committed | +|-------|--------|--------|--------|-------|-----------| +| Phase 1: Backup Service & API | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 2: Backup Scheduler | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 3: BackupPanel UI | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 4: Cleanup | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | + +## Final Review +- [ ] Comprehensive code review +- [ ] Full build passes +- [ ] Full test suite passes +- [ ] Merged to `master` diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 85b9b3e..21a5218 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -201,6 +201,9 @@ model SystemSettings { healthcheckDefaults String @default("{}") // JSON stored as string for SQLite customCss String? onboardingComplete Boolean @default(false) + backupEnabled Boolean @default(false) + backupCronExpression String @default("0 3 * * *") // default: daily at 3 AM + backupMaxCount Int @default(10) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 9155239..5575ce3 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -6,6 +6,10 @@ import * as userService from '$lib/server/services/userService.js'; import * as apiTokenService from '$lib/server/services/apiTokenService.js'; import { extractBearerToken } from '$lib/server/middleware/authenticate.js'; import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js'; +import { initBackupScheduler } from '$lib/server/jobs/backupScheduler.js'; + +// Initialize backup scheduler on server startup +initBackupScheduler(); const PUBLIC_PATHS = ['/login', '/register', '/auth/', '/api/health', '/api/onboarding', '/status']; diff --git a/src/lib/components/admin/BackupPanel.svelte b/src/lib/components/admin/BackupPanel.svelte new file mode 100644 index 0000000..896a23e --- /dev/null +++ b/src/lib/components/admin/BackupPanel.svelte @@ -0,0 +1,428 @@ + + +
+

{$t('admin.backup_title')}

+

{$t('admin.backup_description')}

+ + +
+ +
+ + +
+

{$t('admin.backup_list_title')}

+ + {#if backups.length === 0} +

{$t('admin.backup_list_empty')}

+ {:else} +
+ + + + + + + + + + + {#each backups as backup (backup.filename)} + + + + + + + {/each} + +
{$t('admin.backup_filename')}{$t('admin.backup_size')}{$t('admin.backup_date')}{$t('admin.backup_actions')}
{backup.filename}{formatBytes(backup.size)}{formatDate(backup.createdAt)} +
+ + + +
+
+
+ {/if} +
+ + + {#if confirmRestore} +
+
+

+ {$t('admin.backup_restore_confirm_title')} +

+

+ {$t('admin.backup_restore_confirm')} +

+

{confirmRestore}

+
+ + +
+
+
+ {/if} + + + {#if confirmDelete} +
+
+

+ {$t('admin.backup_delete_confirm_title')} +

+

+ {$t('admin.backup_delete_confirm')} +

+

{confirmDelete}

+
+ + +
+
+
+ {/if} + + +
+ + +
+

{$t('admin.backup_schedule_title')}

+ +
+ + + + {#if schedule.backupEnabled} + +
+ + +
+ + {#if cronPreset === 'custom'} +
+ +
+ {/if} + + +
+ + +
+ {/if} + + +
+
+ + + {#if statusMessage} +
+ {statusMessage} +
+ {/if} +
diff --git a/src/lib/components/admin/ImportExportPanel.svelte b/src/lib/components/admin/ImportExportPanel.svelte deleted file mode 100644 index 272d697..0000000 --- a/src/lib/components/admin/ImportExportPanel.svelte +++ /dev/null @@ -1,212 +0,0 @@ - - -
-

{$t('admin.import_export_title')}

-

{$t('admin.import_export_description')}

- - -
-

{$t('admin.export_section')}

- -
- - -
- - -
-

{$t('admin.import_section')}

- - -
- - -
- - - {#if previewData} -
- -
{previewData}
-
- {/if} - - - {#if parsedData} -
- - -
- - - {/if} -
- - - {#if statusMessage} -
- {statusMessage} -
- {/if} -
diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 5938544..29e1c9c 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -235,22 +235,37 @@ "admin.discovery_traefik_url": "Traefik API URL", "admin.discovery_traefik_url_hint": "Traefik dashboard API base URL (e.g. http://traefik:8080). Set via TRAEFIK_API_URL env var.", - "admin.import_export_title": "Import / Export", - "admin.import_export_description": "Export all data (apps, boards, groups, settings) as JSON, or import from a previously exported file.", - "admin.export_section": "Export Data", - "admin.export_button": "Export JSON", - "admin.export_exporting": "Exporting...", - "admin.export_success": "Export downloaded successfully.", - "admin.import_section": "Import Data", - "admin.import_select_file": "Select a JSON export file", - "admin.import_preview": "Preview", - "admin.import_mode_label": "Conflict Resolution", - "admin.import_mode_skip": "Skip existing (keep current data)", - "admin.import_mode_overwrite": "Overwrite existing (replace with imported data)", - "admin.import_button": "Import", - "admin.import_importing": "Importing...", - "admin.import_success": "Import completed.", - "admin.import_invalid_json": "Selected file is not valid JSON.", + "admin.backup_title": "Database Backup", + "admin.backup_description": "Create, restore, and schedule backups of your database. Backups are full copies of the SQLite database file.", + "admin.backup_create": "Create Backup", + "admin.backup_creating": "Creating...", + "admin.backup_create_success": "Backup created successfully.", + "admin.backup_list_title": "Backups", + "admin.backup_list_empty": "No backups yet. Create your first backup above.", + "admin.backup_filename": "Filename", + "admin.backup_size": "Size", + "admin.backup_date": "Created", + "admin.backup_actions": "Actions", + "admin.backup_download": "Download", + "admin.backup_restore": "Restore", + "admin.backup_delete": "Delete", + "admin.backup_restore_confirm_title": "Restore Backup", + "admin.backup_restore_confirm": "Are you sure you want to restore from this backup? This will replace all current data with the backup contents. This action cannot be undone.", + "admin.backup_restore_success": "Database restored successfully. Please reload the page.", + "admin.backup_delete_confirm_title": "Delete Backup", + "admin.backup_delete_confirm": "Are you sure you want to delete this backup? This action cannot be undone.", + "admin.backup_delete_success": "Backup deleted.", + "admin.backup_schedule_title": "Scheduled Backups", + "admin.backup_schedule_enabled": "Enable periodic backups", + "admin.backup_schedule_cron": "Schedule", + "admin.backup_schedule_max_count": "Max backups to keep", + "admin.backup_schedule_preset_daily": "Daily at 3 AM", + "admin.backup_schedule_preset_twice_daily": "Every 12 hours", + "admin.backup_schedule_preset_weekly": "Weekly (Sunday 3 AM)", + "admin.backup_schedule_preset_custom": "Custom cron", + "admin.backup_schedule_save": "Save Schedule", + "admin.backup_schedule_saving": "Saving...", + "admin.backup_schedule_saved": "Backup schedule updated.", "search.placeholder": "Search apps and boards...", "search.trigger": "Search...", diff --git a/src/lib/i18n/ru.json b/src/lib/i18n/ru.json index e993b04..3324e8b 100644 --- a/src/lib/i18n/ru.json +++ b/src/lib/i18n/ru.json @@ -224,22 +224,37 @@ "admin.discovery_traefik_url": "URL API Traefik", "admin.discovery_traefik_url_hint": "Базовый URL API Traefik (напр. http://traefik:8080). Задаётся через TRAEFIK_API_URL.", - "admin.import_export_title": "Импорт / Экспорт", - "admin.import_export_description": "Экспортируйте все данные (приложения, доски, группы, настройки) в формате JSON или импортируйте из ранее экспортированного файла.", - "admin.export_section": "Экспорт данных", - "admin.export_button": "Экспорт JSON", - "admin.export_exporting": "Экспорт...", - "admin.export_success": "Экспорт успешно скачан.", - "admin.import_section": "Импорт данных", - "admin.import_select_file": "Выберите JSON-файл экспорта", - "admin.import_preview": "Предпросмотр", - "admin.import_mode_label": "Разрешение конфликтов", - "admin.import_mode_skip": "Пропустить существующие (оставить текущие данные)", - "admin.import_mode_overwrite": "Перезаписать существующие (заменить импортированными)", - "admin.import_button": "Импортировать", - "admin.import_importing": "Импорт...", - "admin.import_success": "Импорт завершён.", - "admin.import_invalid_json": "Выбранный файл не является корректным JSON.", + "admin.backup_title": "Резервное копирование", + "admin.backup_description": "Создавайте, восстанавливайте и планируйте резервные копии базы данных. Копии — это полные дубликаты файла базы SQLite.", + "admin.backup_create": "Создать копию", + "admin.backup_creating": "Создание...", + "admin.backup_create_success": "Резервная копия успешно создана.", + "admin.backup_list_title": "Резервные копии", + "admin.backup_list_empty": "Копий пока нет. Создайте первую копию выше.", + "admin.backup_filename": "Файл", + "admin.backup_size": "Размер", + "admin.backup_date": "Создана", + "admin.backup_actions": "Действия", + "admin.backup_download": "Скачать", + "admin.backup_restore": "Восстановить", + "admin.backup_delete": "Удалить", + "admin.backup_restore_confirm_title": "Восстановление из копии", + "admin.backup_restore_confirm": "Вы уверены, что хотите восстановить базу из этой копии? Все текущие данные будут заменены содержимым копии. Это действие нельзя отменить.", + "admin.backup_restore_success": "База данных восстановлена. Пожалуйста, перезагрузите страницу.", + "admin.backup_delete_confirm_title": "Удаление копии", + "admin.backup_delete_confirm": "Вы уверены, что хотите удалить эту резервную копию? Это действие нельзя отменить.", + "admin.backup_delete_success": "Копия удалена.", + "admin.backup_schedule_title": "Автоматическое копирование", + "admin.backup_schedule_enabled": "Включить периодическое копирование", + "admin.backup_schedule_cron": "Расписание", + "admin.backup_schedule_max_count": "Максимум хранимых копий", + "admin.backup_schedule_preset_daily": "Ежедневно в 3:00", + "admin.backup_schedule_preset_twice_daily": "Каждые 12 часов", + "admin.backup_schedule_preset_weekly": "Еженедельно (воскресенье 3:00)", + "admin.backup_schedule_preset_custom": "Свой cron", + "admin.backup_schedule_save": "Сохранить расписание", + "admin.backup_schedule_saving": "Сохранение...", + "admin.backup_schedule_saved": "Расписание резервного копирования обновлено.", "search.placeholder": "Поиск приложений и досок...", "search.trigger": "Поиск...", "search.min_chars": "Введите минимум 2 символа для поиска", diff --git a/src/lib/server/jobs/backupScheduler.ts b/src/lib/server/jobs/backupScheduler.ts new file mode 100644 index 0000000..4d88131 --- /dev/null +++ b/src/lib/server/jobs/backupScheduler.ts @@ -0,0 +1,80 @@ +import cron from 'node-cron'; +import { createBackup, enforceRetention, getBackupSettings } from '$lib/server/services/backupService.js'; +import { logAction } from '$lib/server/services/auditLogService.js'; +import { AuditAction } from '$lib/utils/constants.js'; + +let scheduledTask: cron.ScheduledTask | null = null; + +/** + * Start the backup scheduler with the given settings. + * If already running, does nothing — call restartBackupScheduler() to reconfigure. + */ +export function startBackupScheduler(settings: { + readonly backupEnabled: boolean; + readonly backupCronExpression: string; + readonly backupMaxCount: number; +}): void { + if (scheduledTask) { + return; + } + + if (!settings.backupEnabled) { + return; + } + + if (!cron.validate(settings.backupCronExpression)) { + return; + } + + scheduledTask = cron.schedule(settings.backupCronExpression, async () => { + try { + const backup = await createBackup(); + enforceRetention(settings.backupMaxCount); + logAction(null, AuditAction.BACKUP_CREATED, 'backup', backup.filename, { + trigger: 'scheduled' + }); + } catch (err) { + // Log failure to audit log so admins can see scheduled backups are failing + logAction(null, AuditAction.BACKUP_CREATED, 'backup', 'failed', { + trigger: 'scheduled', + error: err instanceof Error ? err.message : 'Unknown error' + }); + } + }); +} + +/** + * Stop the backup scheduler. + */ +export function stopBackupScheduler(): void { + if (scheduledTask) { + scheduledTask.stop(); + scheduledTask = null; + } +} + +/** + * Restart the backup scheduler with new settings. + * Stops the existing scheduler (if any) and starts a new one. + */ +export function restartBackupScheduler(settings: { + readonly backupEnabled: boolean; + readonly backupCronExpression: string; + readonly backupMaxCount: number; +}): void { + stopBackupScheduler(); + startBackupScheduler(settings); +} + +/** + * Initialize the backup scheduler from database settings. + * Call this at server startup. + */ +export async function initBackupScheduler(): Promise { + try { + const settings = await getBackupSettings(); + startBackupScheduler(settings); + } catch { + // Swallow errors — backup scheduler is non-critical + } +} diff --git a/src/lib/server/services/__tests__/exportService.test.ts b/src/lib/server/services/__tests__/exportService.test.ts deleted file mode 100644 index cbadcc4..0000000 --- a/src/lib/server/services/__tests__/exportService.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('../../prisma.js', () => ({ - prisma: { - app: { findMany: vi.fn() }, - board: { findMany: vi.fn() }, - group: { findMany: vi.fn() }, - systemSettings: { upsert: vi.fn() } - } -})); - -import { prisma } from '../../prisma.js'; -import { exportAllData } from '../exportService.js'; - -const mockApp = prisma.app as unknown as { findMany: ReturnType }; -const mockBoard = prisma.board as unknown as { findMany: ReturnType }; -const mockGroup = prisma.group as unknown as { findMany: ReturnType }; -const mockSettings = prisma.systemSettings as unknown as { - upsert: ReturnType; -}; - -describe('exportService', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('exportAllData', () => { - it('returns export data with version and timestamp', async () => { - mockApp.findMany.mockResolvedValue([]); - mockBoard.findMany.mockResolvedValue([]); - mockGroup.findMany.mockResolvedValue([]); - mockSettings.upsert.mockResolvedValue({ - authMode: 'local', - registrationEnabled: true, - defaultTheme: 'dark', - defaultPrimaryColor: '#6366f1', - healthcheckDefaults: '{}' - }); - - const result = await exportAllData(); - - expect(result.version).toBe('1.0'); - expect(result.exportedAt).toBeTruthy(); - expect(result.apps).toEqual([]); - expect(result.boards).toEqual([]); - expect(result.groups).toEqual([]); - expect(result.settings).toEqual({ - authMode: 'local', - registrationEnabled: true, - defaultTheme: 'dark', - defaultPrimaryColor: '#6366f1', - healthcheckDefaults: '{}' - }); - }); - - it('maps apps to export format stripping internal fields', async () => { - mockApp.findMany.mockResolvedValue([ - { - id: 'a1', - name: 'Gitea', - url: 'https://git.local', - icon: 'gitea', - iconType: 'simple', - description: 'Self-hosted Git', - category: 'dev', - tags: 'git,code', - healthcheckEnabled: true, - healthcheckInterval: 300, - healthcheckMethod: 'GET', - healthcheckExpectedStatus: 200, - healthcheckTimeout: 5000, - createdById: 'u1', - createdAt: new Date(), - updatedAt: new Date() - } - ]); - mockBoard.findMany.mockResolvedValue([]); - mockGroup.findMany.mockResolvedValue([]); - mockSettings.upsert.mockResolvedValue({ - authMode: 'local', - registrationEnabled: true, - defaultTheme: 'dark', - defaultPrimaryColor: '#6366f1', - healthcheckDefaults: '{}' - }); - - const result = await exportAllData(); - - expect(result.apps).toHaveLength(1); - expect(result.apps[0]).toEqual({ - name: 'Gitea', - url: 'https://git.local', - icon: 'gitea', - iconType: 'simple', - description: 'Self-hosted Git', - category: 'dev', - tags: 'git,code', - healthcheckEnabled: true, - healthcheckInterval: 300, - healthcheckMethod: 'GET', - healthcheckExpectedStatus: 200, - healthcheckTimeout: 5000 - }); - // Internal fields should not be present - expect((result.apps[0] as unknown as Record).id).toBeUndefined(); - expect((result.apps[0] as unknown as Record).createdById).toBeUndefined(); - }); - - it('maps boards with nested sections and widgets', async () => { - mockApp.findMany.mockResolvedValue([]); - mockBoard.findMany.mockResolvedValue([ - { - name: 'Dashboard', - icon: null, - description: 'Main board', - isDefault: true, - isGuestAccessible: false, - backgroundConfig: null, - sections: [ - { - title: 'Apps', - icon: null, - order: 0, - isExpandedByDefault: true, - widgets: [ - { - type: 'app', - order: 0, - config: '{}', - app: { name: 'Gitea' } - } - ] - } - ] - } - ]); - mockGroup.findMany.mockResolvedValue([]); - mockSettings.upsert.mockResolvedValue({ - authMode: 'local', - registrationEnabled: true, - defaultTheme: 'dark', - defaultPrimaryColor: '#6366f1', - healthcheckDefaults: '{}' - }); - - const result = await exportAllData(); - - expect(result.boards).toHaveLength(1); - expect(result.boards[0].name).toBe('Dashboard'); - expect(result.boards[0].sections).toHaveLength(1); - expect(result.boards[0].sections[0].widgets).toHaveLength(1); - expect(result.boards[0].sections[0].widgets[0].appName).toBe('Gitea'); - }); - - it('maps groups stripping internal fields', async () => { - mockApp.findMany.mockResolvedValue([]); - mockBoard.findMany.mockResolvedValue([]); - mockGroup.findMany.mockResolvedValue([ - { - id: 'g1', - name: 'Admins', - description: 'Admin users', - isDefault: false, - createdAt: new Date(), - updatedAt: new Date() - } - ]); - mockSettings.upsert.mockResolvedValue({ - authMode: 'local', - registrationEnabled: true, - defaultTheme: 'dark', - defaultPrimaryColor: '#6366f1', - healthcheckDefaults: '{}' - }); - - const result = await exportAllData(); - - expect(result.groups).toHaveLength(1); - expect(result.groups[0]).toEqual({ - name: 'Admins', - description: 'Admin users', - isDefault: false - }); - }); - }); -}); diff --git a/src/lib/server/services/__tests__/importService.test.ts b/src/lib/server/services/__tests__/importService.test.ts deleted file mode 100644 index 3419711..0000000 --- a/src/lib/server/services/__tests__/importService.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// Build a transaction mock that mirrors prisma's nested structure -const txMock = { - app: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn() }, - group: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() }, - board: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn() }, - section: { create: vi.fn(), deleteMany: vi.fn() }, - widget: { create: vi.fn() }, - systemSettings: { upsert: vi.fn() } -}; - -vi.mock('../../prisma.js', () => ({ - prisma: { - $transaction: vi.fn(async (fn: (tx: typeof txMock) => Promise) => { - await fn(txMock); - }) - } -})); - -import { validateImportData, importData } from '../importService.js'; -import type { ExportData } from '../exportService.js'; - -function buildValidExportData(overrides: Partial = {}): ExportData { - return { - version: '1.0', - exportedAt: new Date().toISOString(), - apps: [], - boards: [], - groups: [], - settings: { - authMode: 'local', - registrationEnabled: true, - defaultTheme: 'dark', - defaultPrimaryColor: '#6366f1', - healthcheckDefaults: '{}' - }, - ...overrides - }; -} - -describe('importService', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('validateImportData', () => { - it('accepts valid export data', () => { - const data = buildValidExportData(); - const result = validateImportData(data); - expect(result.success).toBe(true); - }); - - it('rejects data missing required fields', () => { - const result = validateImportData({ version: '1.0' }); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.errors.length).toBeGreaterThan(0); - } - }); - - it('rejects non-object input', () => { - const result = validateImportData('not an object'); - expect(result.success).toBe(false); - }); - }); - - describe('importData', () => { - it('creates new apps when none exist', async () => { - const data = buildValidExportData({ - apps: [ - { - name: 'TestApp', - url: 'https://test.local', - icon: null, - iconType: 'lucide', - description: null, - category: null, - tags: '', - healthcheckEnabled: false, - healthcheckInterval: 300, - healthcheckMethod: 'GET', - healthcheckExpectedStatus: 200, - healthcheckTimeout: 5000 - } - ] - }); - - txMock.app.findFirst.mockResolvedValue(null); - txMock.app.create.mockResolvedValue({ id: 'new-app-id', name: 'TestApp' }); - - const result = await importData(data, 'skip'); - - expect(result.apps.created).toBe(1); - expect(result.apps.skipped).toBe(0); - expect(txMock.app.create).toHaveBeenCalledOnce(); - }); - - it('skips existing apps in skip mode', async () => { - const data = buildValidExportData({ - apps: [ - { - name: 'Existing', - url: 'https://existing.local', - icon: null, - iconType: 'lucide', - description: null, - category: null, - tags: '', - healthcheckEnabled: false, - healthcheckInterval: 300, - healthcheckMethod: 'GET', - healthcheckExpectedStatus: 200, - healthcheckTimeout: 5000 - } - ] - }); - - txMock.app.findFirst.mockResolvedValue({ id: 'existing-id', name: 'Existing' }); - - const result = await importData(data, 'skip'); - - expect(result.apps.skipped).toBe(1); - expect(result.apps.created).toBe(0); - expect(txMock.app.update).not.toHaveBeenCalled(); - }); - - it('overwrites existing apps in overwrite mode', async () => { - const data = buildValidExportData({ - apps: [ - { - name: 'Existing', - url: 'https://updated.local', - icon: null, - iconType: 'lucide', - description: 'updated', - category: null, - tags: '', - healthcheckEnabled: true, - healthcheckInterval: 60, - healthcheckMethod: 'GET', - healthcheckExpectedStatus: 200, - healthcheckTimeout: 5000 - } - ] - }); - - txMock.app.findFirst.mockResolvedValue({ id: 'existing-id', name: 'Existing' }); - - const result = await importData(data, 'overwrite'); - - expect(result.apps.updated).toBe(1); - expect(txMock.app.update).toHaveBeenCalledOnce(); - }); - - it('creates new groups', async () => { - const data = buildValidExportData({ - groups: [{ name: 'NewGroup', description: 'A group', isDefault: false }] - }); - - txMock.group.findUnique.mockResolvedValue(null); - - const result = await importData(data, 'skip'); - - expect(result.groups.created).toBe(1); - expect(txMock.group.create).toHaveBeenCalledOnce(); - }); - - it('creates boards with sections and widgets', async () => { - const data = buildValidExportData({ - boards: [ - { - name: 'Board1', - icon: null, - description: null, - isDefault: false, - isGuestAccessible: false, - backgroundConfig: null, - sections: [ - { - title: 'Section1', - icon: null, - order: 0, - isExpandedByDefault: true, - widgets: [{ type: 'note', order: 0, config: '{}', appName: null }] - } - ] - } - ] - }); - - txMock.board.findFirst.mockResolvedValue(null); - txMock.board.create.mockResolvedValue({ id: 'board-id' }); - txMock.section.create.mockResolvedValue({ id: 'section-id' }); - - const result = await importData(data, 'skip'); - - expect(result.boards.created).toBe(1); - expect(txMock.section.create).toHaveBeenCalledOnce(); - expect(txMock.widget.create).toHaveBeenCalledOnce(); - }); - - it('imports settings when provided', async () => { - const data = buildValidExportData({ - settings: { - authMode: 'both', - registrationEnabled: false, - defaultTheme: 'light', - defaultPrimaryColor: '#ff0000', - healthcheckDefaults: '{}' - } - }); - - const result = await importData(data, 'skip'); - - expect(result.settingsUpdated).toBe(true); - expect(txMock.systemSettings.upsert).toHaveBeenCalledOnce(); - }); - }); -}); diff --git a/src/lib/server/services/backupService.ts b/src/lib/server/services/backupService.ts new file mode 100644 index 0000000..f46f260 --- /dev/null +++ b/src/lib/server/services/backupService.ts @@ -0,0 +1,210 @@ +import { prisma } from '../prisma.js'; +import { DEFAULTS } from '$lib/utils/constants.js'; +import fs from 'node:fs'; +import path from 'node:path'; + +const BACKUP_DIR = path.resolve('data', 'backups'); + +let _restoring = false; + +/** + * Check if a database restore is currently in progress. + * Other services can check this to avoid querying during restore. + */ +export function isRestoring(): boolean { + return _restoring; +} + +export interface BackupInfo { + readonly filename: string; + readonly size: number; + readonly createdAt: string; +} + +function ensureBackupDir(): void { + if (!fs.existsSync(BACKUP_DIR)) { + fs.mkdirSync(BACKUP_DIR, { recursive: true }); + } +} + +function getDatabasePath(): string { + const url = process.env.DATABASE_URL ?? 'file:../data/launcher.db'; + // Strip the "file:" prefix and resolve relative to prisma/ directory + const relative = url.replace(/^file:/, ''); + return path.resolve('prisma', relative); +} + +/** + * Create a backup of the SQLite database using VACUUM INTO. + * This produces a clean, compacted copy without locking the live DB. + */ +export async function createBackup(): Promise { + ensureBackupDir(); + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const filename = `backup-${timestamp}.db`; + const backupPath = path.join(BACKUP_DIR, filename); + + // Use VACUUM INTO for a safe, consistent copy + // Escape single quotes for defense-in-depth (path is server-generated, not user input) + const safePath = backupPath.replace(/\\/g, '/').replace(/'/g, "''"); + await prisma.$executeRawUnsafe(`VACUUM INTO '${safePath}'`); + + const stats = fs.statSync(backupPath); + + return { + filename, + size: stats.size, + createdAt: stats.birthtime.toISOString() + }; +} + +/** + * List all existing backups, sorted newest first. + */ +export function listBackups(): ReadonlyArray { + ensureBackupDir(); + + const files = fs.readdirSync(BACKUP_DIR).filter((f) => f.endsWith('.db')); + + return files + .map((filename) => { + const stats = fs.statSync(path.join(BACKUP_DIR, filename)); + return { + filename, + size: stats.size, + createdAt: stats.birthtime.toISOString() + }; + }) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); +} + +/** + * Get the absolute path to a backup file. Returns null if not found. + */ +export function getBackupFilePath(filename: string): string | null { + // Sanitize: prevent directory traversal + const sanitized = path.basename(filename); + const fullPath = path.join(BACKUP_DIR, sanitized); + + if (!fs.existsSync(fullPath)) { + return null; + } + + return fullPath; +} + +/** + * Delete a backup file. Returns true if deleted, false if not found. + */ +export function deleteBackup(filename: string): boolean { + const fullPath = getBackupFilePath(filename); + if (!fullPath) { + return false; + } + + fs.unlinkSync(fullPath); + return true; +} + +/** + * Restore the database from a backup file. + * Sets a restoring flag, disconnects Prisma, replaces the DB file, then reconnects. + */ +export async function restoreBackup(filename: string): Promise { + const backupPath = getBackupFilePath(filename); + if (!backupPath) { + throw new Error(`Backup not found: ${filename}`); + } + + if (_restoring) { + throw new Error('A restore is already in progress'); + } + + const dbPath = getDatabasePath(); + + _restoring = true; + + // Disconnect Prisma so the DB file is not locked + await prisma.$disconnect(); + + try { + // Replace the live database with the backup + fs.copyFileSync(backupPath, dbPath); + } finally { + // Always reconnect, even if copy fails + await prisma.$connect(); + _restoring = false; + } +} + +/** + * Enforce retention policy: delete oldest backups beyond maxCount. + */ +export function enforceRetention(maxCount: number): number { + const backups = listBackups(); + + if (backups.length <= maxCount) { + return 0; + } + + // Backups are sorted newest-first; remove from the end + const toDelete = backups.slice(maxCount); + for (const backup of toDelete) { + deleteBackup(backup.filename); + } + + return toDelete.length; +} + +/** + * Load backup settings from SystemSettings. + */ +export async function getBackupSettings(): Promise<{ + readonly backupEnabled: boolean; + readonly backupCronExpression: string; + readonly backupMaxCount: number; +}> { + const settings = await prisma.systemSettings.upsert({ + where: { id: DEFAULTS.SYSTEM_SETTINGS_ID }, + update: {}, + create: { id: DEFAULTS.SYSTEM_SETTINGS_ID } + }); + + return { + backupEnabled: settings.backupEnabled, + backupCronExpression: settings.backupCronExpression, + backupMaxCount: settings.backupMaxCount + }; +} + +/** + * Update backup settings in SystemSettings. + */ +export async function updateBackupSettings(data: { + readonly backupEnabled?: boolean; + readonly backupCronExpression?: string; + readonly backupMaxCount?: number; +}): Promise<{ + readonly backupEnabled: boolean; + readonly backupCronExpression: string; + readonly backupMaxCount: number; +}> { + const settings = await prisma.systemSettings.upsert({ + where: { id: DEFAULTS.SYSTEM_SETTINGS_ID }, + update: { + ...(data.backupEnabled !== undefined && { backupEnabled: data.backupEnabled }), + ...(data.backupCronExpression !== undefined && { + backupCronExpression: data.backupCronExpression + }), + ...(data.backupMaxCount !== undefined && { backupMaxCount: data.backupMaxCount }) + }, + create: { id: DEFAULTS.SYSTEM_SETTINGS_ID } + }); + + return { + backupEnabled: settings.backupEnabled, + backupCronExpression: settings.backupCronExpression, + backupMaxCount: settings.backupMaxCount + }; +} diff --git a/src/lib/server/services/exportService.ts b/src/lib/server/services/exportService.ts deleted file mode 100644 index 28dc19f..0000000 --- a/src/lib/server/services/exportService.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { prisma } from '../prisma.js'; -import { DEFAULTS } from '$lib/utils/constants.js'; - -export interface ExportData { - readonly version: string; - readonly exportedAt: string; - readonly apps: ReadonlyArray; - readonly boards: ReadonlyArray; - readonly groups: ReadonlyArray; - readonly settings: ExportSettings; -} - -export interface ExportApp { - readonly name: string; - readonly url: string; - readonly icon: string | null; - readonly iconType: string; - readonly description: string | null; - readonly category: string | null; - readonly tags: string; - readonly healthcheckEnabled: boolean; - readonly healthcheckInterval: number; - readonly healthcheckMethod: string; - readonly healthcheckExpectedStatus: number; - readonly healthcheckTimeout: number; -} - -export interface ExportWidget { - readonly type: string; - readonly order: number; - readonly config: string; - readonly appName: string | null; -} - -export interface ExportSection { - readonly title: string; - readonly icon: string | null; - readonly order: number; - readonly isExpandedByDefault: boolean; - readonly widgets: ReadonlyArray; -} - -export interface ExportBoard { - readonly name: string; - readonly icon: string | null; - readonly description: string | null; - readonly isDefault: boolean; - readonly isGuestAccessible: boolean; - readonly backgroundConfig: string | null; - readonly sections: ReadonlyArray; -} - -export interface ExportGroup { - readonly name: string; - readonly description: string | null; - readonly isDefault: boolean; -} - -export interface ExportSettings { - readonly authMode: string; - readonly registrationEnabled: boolean; - readonly defaultTheme: string; - readonly defaultPrimaryColor: string; - readonly healthcheckDefaults: string; -} - -export async function exportAllData(): Promise { - const [apps, boards, groups, settings] = await Promise.all([ - prisma.app.findMany({ - orderBy: { name: 'asc' } - }), - prisma.board.findMany({ - orderBy: { createdAt: 'asc' }, - include: { - sections: { - orderBy: { order: 'asc' }, - include: { - widgets: { - orderBy: { order: 'asc' }, - include: { app: { select: { name: true } } } - } - } - } - } - }), - prisma.group.findMany({ - orderBy: { name: 'asc' } - }), - prisma.systemSettings.upsert({ - where: { id: DEFAULTS.SYSTEM_SETTINGS_ID }, - update: {}, - create: { id: DEFAULTS.SYSTEM_SETTINGS_ID } - }) - ]); - - const exportApps: ReadonlyArray = apps.map((app) => ({ - name: app.name, - url: app.url, - icon: app.icon, - iconType: app.iconType, - description: app.description, - category: app.category, - tags: app.tags, - healthcheckEnabled: app.healthcheckEnabled, - healthcheckInterval: app.healthcheckInterval, - healthcheckMethod: app.healthcheckMethod, - healthcheckExpectedStatus: app.healthcheckExpectedStatus, - healthcheckTimeout: app.healthcheckTimeout - })); - - const exportBoards: ReadonlyArray = boards.map((board) => ({ - name: board.name, - icon: board.icon, - description: board.description, - isDefault: board.isDefault, - isGuestAccessible: board.isGuestAccessible, - backgroundConfig: board.backgroundConfig, - sections: board.sections.map((section) => ({ - title: section.title, - icon: section.icon, - order: section.order, - isExpandedByDefault: section.isExpandedByDefault, - widgets: section.widgets.map((widget) => ({ - type: widget.type, - order: widget.order, - config: widget.config, - appName: widget.app?.name ?? null - })) - })) - })); - - const exportGroups: ReadonlyArray = groups.map((group) => ({ - name: group.name, - description: group.description, - isDefault: group.isDefault - })); - - const exportSettings: ExportSettings = { - authMode: settings.authMode, - registrationEnabled: settings.registrationEnabled, - defaultTheme: settings.defaultTheme, - defaultPrimaryColor: settings.defaultPrimaryColor, - healthcheckDefaults: settings.healthcheckDefaults - }; - - return { - version: '1.0', - exportedAt: new Date().toISOString(), - apps: exportApps, - boards: exportBoards, - groups: exportGroups, - settings: exportSettings - }; -} diff --git a/src/lib/server/services/importService.ts b/src/lib/server/services/importService.ts deleted file mode 100644 index b590d9a..0000000 --- a/src/lib/server/services/importService.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { prisma } from '../prisma.js'; -import { importDataSchema } from '$lib/utils/validators.js'; -import { DEFAULTS } from '$lib/utils/constants.js'; -import type { ExportData } from './exportService.js'; - -export type ImportMode = 'skip' | 'overwrite'; - -export interface ImportResult { - readonly apps: { readonly created: number; readonly updated: number; readonly skipped: number }; - readonly boards: { readonly created: number; readonly updated: number; readonly skipped: number }; - readonly groups: { readonly created: number; readonly updated: number; readonly skipped: number }; - readonly settingsUpdated: boolean; -} - -export function validateImportData( - data: unknown -): { success: true; data: ExportData } | { success: false; errors: string[] } { - const parsed = importDataSchema.safeParse(data); - if (!parsed.success) { - const errors = parsed.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`); - return { success: false, errors }; - } - return { success: true, data: parsed.data as ExportData }; -} - -export async function importData(data: ExportData, mode: ImportMode): Promise { - const result = { - apps: { created: 0, updated: 0, skipped: 0 }, - boards: { created: 0, updated: 0, skipped: 0 }, - groups: { created: 0, updated: 0, skipped: 0 }, - settingsUpdated: false - }; - - await prisma.$transaction(async (tx) => { - // --- Import Apps --- - const appNameToId = new Map(); - - for (const appData of data.apps) { - const existing = await tx.app.findFirst({ where: { name: appData.name } }); - - if (existing) { - appNameToId.set(appData.name, existing.id); - if (mode === 'skip') { - result.apps.skipped++; - continue; - } - // overwrite - await tx.app.update({ - where: { id: existing.id }, - data: { - url: appData.url, - icon: appData.icon, - iconType: appData.iconType, - description: appData.description, - category: appData.category, - tags: appData.tags, - healthcheckEnabled: appData.healthcheckEnabled, - healthcheckInterval: appData.healthcheckInterval, - healthcheckMethod: appData.healthcheckMethod, - healthcheckExpectedStatus: appData.healthcheckExpectedStatus, - healthcheckTimeout: appData.healthcheckTimeout - } - }); - result.apps.updated++; - } else { - const created = await tx.app.create({ - data: { - name: appData.name, - url: appData.url, - icon: appData.icon, - iconType: appData.iconType, - description: appData.description, - category: appData.category, - tags: appData.tags, - healthcheckEnabled: appData.healthcheckEnabled, - healthcheckInterval: appData.healthcheckInterval, - healthcheckMethod: appData.healthcheckMethod, - healthcheckExpectedStatus: appData.healthcheckExpectedStatus, - healthcheckTimeout: appData.healthcheckTimeout - } - }); - appNameToId.set(appData.name, created.id); - result.apps.created++; - } - } - - // --- Import Groups --- - for (const groupData of data.groups) { - const existing = await tx.group.findUnique({ where: { name: groupData.name } }); - - if (existing) { - if (mode === 'skip') { - result.groups.skipped++; - continue; - } - await tx.group.update({ - where: { id: existing.id }, - data: { - description: groupData.description, - isDefault: groupData.isDefault - } - }); - result.groups.updated++; - } else { - await tx.group.create({ - data: { - name: groupData.name, - description: groupData.description, - isDefault: groupData.isDefault - } - }); - result.groups.created++; - } - } - - // --- Import Boards (with sections and widgets) --- - for (const boardData of data.boards) { - const existing = await tx.board.findFirst({ where: { name: boardData.name } }); - - if (existing) { - if (mode === 'skip') { - result.boards.skipped++; - continue; - } - // Overwrite: update board, delete old sections, recreate - await tx.section.deleteMany({ where: { boardId: existing.id } }); - await tx.board.update({ - where: { id: existing.id }, - data: { - icon: boardData.icon, - description: boardData.description, - isDefault: boardData.isDefault, - isGuestAccessible: boardData.isGuestAccessible, - backgroundConfig: boardData.backgroundConfig - } - }); - - for (const sectionData of boardData.sections) { - const section = await tx.section.create({ - data: { - boardId: existing.id, - title: sectionData.title, - icon: sectionData.icon, - order: sectionData.order, - isExpandedByDefault: sectionData.isExpandedByDefault - } - }); - - for (const widgetData of sectionData.widgets) { - const appId = widgetData.appName ? (appNameToId.get(widgetData.appName) ?? null) : null; - await tx.widget.create({ - data: { - sectionId: section.id, - type: widgetData.type, - order: widgetData.order, - config: widgetData.config, - appId - } - }); - } - } - - result.boards.updated++; - } else { - const board = await tx.board.create({ - data: { - name: boardData.name, - icon: boardData.icon, - description: boardData.description, - isDefault: boardData.isDefault, - isGuestAccessible: boardData.isGuestAccessible, - backgroundConfig: boardData.backgroundConfig - } - }); - - for (const sectionData of boardData.sections) { - const section = await tx.section.create({ - data: { - boardId: board.id, - title: sectionData.title, - icon: sectionData.icon, - order: sectionData.order, - isExpandedByDefault: sectionData.isExpandedByDefault - } - }); - - for (const widgetData of sectionData.widgets) { - const appId = widgetData.appName ? (appNameToId.get(widgetData.appName) ?? null) : null; - await tx.widget.create({ - data: { - sectionId: section.id, - type: widgetData.type, - order: widgetData.order, - config: widgetData.config, - appId - } - }); - } - } - - result.boards.created++; - } - } - - // --- Import Settings (always merge) --- - if (data.settings) { - const settingsData: Record = {}; - const s = data.settings; - - if (s.authMode !== undefined) settingsData.authMode = s.authMode; - if (s.registrationEnabled !== undefined) - settingsData.registrationEnabled = s.registrationEnabled; - if (s.defaultTheme !== undefined) settingsData.defaultTheme = s.defaultTheme; - if (s.defaultPrimaryColor !== undefined) - settingsData.defaultPrimaryColor = s.defaultPrimaryColor; - if (s.healthcheckDefaults !== undefined) - settingsData.healthcheckDefaults = s.healthcheckDefaults; - - if (Object.keys(settingsData).length > 0) { - await tx.systemSettings.upsert({ - where: { id: DEFAULTS.SYSTEM_SETTINGS_ID }, - update: settingsData, - create: { id: DEFAULTS.SYSTEM_SETTINGS_ID, ...settingsData } - }); - result.settingsUpdated = true; - } - } - }); - - return result; -} diff --git a/src/lib/utils/constants.ts b/src/lib/utils/constants.ts index 52a9101..0c3c50a 100644 --- a/src/lib/utils/constants.ts +++ b/src/lib/utils/constants.ts @@ -137,7 +137,10 @@ export const AuditAction = { APP_DELETED: 'app_deleted', SETTINGS_UPDATED: 'settings_updated', IMPORT: 'import', - EXPORT: 'export' + EXPORT: 'export', + BACKUP_CREATED: 'backup_created', + BACKUP_RESTORED: 'backup_restored', + BACKUP_DELETED: 'backup_deleted' } as const; export type AuditAction = (typeof AuditAction)[keyof typeof AuditAction]; diff --git a/src/lib/utils/validators.ts b/src/lib/utils/validators.ts index fbbf5f1..cb5965d 100644 --- a/src/lib/utils/validators.ts +++ b/src/lib/utils/validators.ts @@ -220,69 +220,12 @@ export const createPermissionSchema = z.object({ level: z.enum([PermissionLevel.VIEW, PermissionLevel.EDIT, PermissionLevel.ADMIN]) }); -// --- Import/Export --- +// --- Backup Schedule --- -const importAppSchema = z.object({ - name: z.string().min(1).max(200), - url: z.string().url(), - icon: z.string().max(500).nullable(), - iconType: z.string().max(50), - description: z.string().max(1000).nullable(), - category: z.string().max(100).nullable(), - tags: z.string().max(500), - healthcheckEnabled: z.boolean(), - healthcheckInterval: z.number().int().min(30).max(86400), - healthcheckMethod: z.string(), - healthcheckExpectedStatus: z.number().int().min(100).max(599), - healthcheckTimeout: z.number().int().min(1000).max(30000) -}); - -const importWidgetSchema = z.object({ - type: z.string().min(1), - order: z.number().int().min(0), - config: z.string(), - appName: z.string().nullable() -}); - -const importSectionSchema = z.object({ - title: z.string().min(1).max(200), - icon: z.string().max(500).nullable(), - order: z.number().int().min(0), - isExpandedByDefault: z.boolean(), - widgets: z.array(importWidgetSchema) -}); - -const importBoardSchema = z.object({ - name: z.string().min(1).max(200), - icon: z.string().max(500).nullable(), - description: z.string().max(1000).nullable(), - isDefault: z.boolean(), - isGuestAccessible: z.boolean(), - backgroundConfig: z.string().nullable(), - sections: z.array(importSectionSchema) -}); - -const importGroupSchema = z.object({ - name: z.string().min(1).max(100), - description: z.string().max(500).nullable(), - isDefault: z.boolean() -}); - -const importSettingsSchema = z.object({ - authMode: z.string().optional(), - registrationEnabled: z.boolean().optional(), - defaultTheme: z.string().optional(), - defaultPrimaryColor: z.string().optional(), - healthcheckDefaults: z.string().optional() -}); - -export const importDataSchema = z.object({ - version: z.string(), - exportedAt: z.string(), - apps: z.array(importAppSchema).max(1000), - boards: z.array(importBoardSchema).max(100), - groups: z.array(importGroupSchema).max(100), - settings: importSettingsSchema +export const updateBackupScheduleSchema = z.object({ + backupEnabled: z.boolean().optional(), + backupCronExpression: z.string().min(1).max(100).optional(), + backupMaxCount: z.number().int().min(1).max(100).optional() }); // --- System Settings --- @@ -468,7 +411,10 @@ export const auditLogQuerySchema = z.object({ AuditAction.APP_DELETED, AuditAction.SETTINGS_UPDATED, AuditAction.IMPORT, - AuditAction.EXPORT + AuditAction.EXPORT, + AuditAction.BACKUP_CREATED, + AuditAction.BACKUP_RESTORED, + AuditAction.BACKUP_DELETED ]) .optional(), entityType: z.string().max(50).optional(), diff --git a/src/routes/admin/settings/+page.svelte b/src/routes/admin/settings/+page.svelte index 8d1b9fb..a1f0e8a 100644 --- a/src/routes/admin/settings/+page.svelte +++ b/src/routes/admin/settings/+page.svelte @@ -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 @@ - + diff --git a/src/routes/api/admin/backups/+server.ts b/src/routes/api/admin/backups/+server.ts new file mode 100644 index 0000000..028827b --- /dev/null +++ b/src/routes/api/admin/backups/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/api/admin/backups/[filename]/+server.ts b/src/routes/api/admin/backups/[filename]/+server.ts new file mode 100644 index 0000000..f222bf7 --- /dev/null +++ b/src/routes/api/admin/backups/[filename]/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/api/admin/backups/[filename]/download/+server.ts b/src/routes/api/admin/backups/[filename]/download/+server.ts new file mode 100644 index 0000000..9fea917 --- /dev/null +++ b/src/routes/api/admin/backups/[filename]/download/+server.ts @@ -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) + } + }); +}; diff --git a/src/routes/api/admin/backups/[filename]/restore/+server.ts b/src/routes/api/admin/backups/[filename]/restore/+server.ts new file mode 100644 index 0000000..ce42516 --- /dev/null +++ b/src/routes/api/admin/backups/[filename]/restore/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/api/admin/backups/schedule/+server.ts b/src/routes/api/admin/backups/schedule/+server.ts new file mode 100644 index 0000000..bacc0c3 --- /dev/null +++ b/src/routes/api/admin/backups/schedule/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/api/admin/export/+server.ts b/src/routes/api/admin/export/+server.ts deleted file mode 100644 index fffa2cc..0000000 --- a/src/routes/api/admin/export/+server.ts +++ /dev/null @@ -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 }); - } -}; diff --git a/src/routes/api/admin/import/+server.ts b/src/routes/api/admin/import/+server.ts deleted file mode 100644 index a03714a..0000000 --- a/src/routes/api/admin/import/+server.ts +++ /dev/null @@ -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 }); - } -};