import { readdirSync } from 'node:fs'; import { statSync } from 'node:fs'; import { join } from 'node:path'; import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { BACKUP_COMMAND_CHANNEL, archiveFileName, backupIdTime, dumpFileName, listSets, remoteBundleId, type BackupCommand, type BackupConnectionTestInput, type BackupConnectionTestResult, type BackupRestoreInput, type BackupSetView, type BackupSetsView, type BackupSettingsInput, type BackupSettingsView, } from '@dorfteich/shared'; import { webdavList } from '@dorfteich/shared/webdav'; import { User } from '@prisma/client'; import { AuditService } from '../audit/audit.service'; import { BackupTargetService } from '../backup/backup-target.service'; import { MaintenanceStateService } from '../backup/maintenance-state.service'; import { AppConfig } from '../config/app-config.service'; import { PrismaService } from '../prisma/prisma.service'; import { InstanceSettingsService } from '../settings/instance-settings.service'; /** * Site-Admin backup management (issue #103): the Nextcloud target * configuration, manual "back up now", the restore picker (local sets from * the read-only backups mount, remote sets via WebDAV), and the restore * trigger. The sidecar does the actual work — commands travel over the * {@link BACKUP_COMMAND_CHANNEL} NOTIFY bus, progress comes back through * `status.json`/`restore-status.json`. */ @Injectable() export class BackupAdminService { constructor( private readonly target: BackupTargetService, private readonly maintenance: MaintenanceStateService, private readonly settings: InstanceSettingsService, private readonly prisma: PrismaService, private readonly audit: AuditService, private readonly config: AppConfig, ) {} settingsView(): Promise { return this.target.settingsView(); } /** * Persists the backup settings. When the Nextcloud side is enabled, the * connection is live-tested first (with the new password if provided, * else the stored one) — like the setup wizard's SMTP step, nothing is * saved on failure. */ async saveSettings(input: BackupSettingsInput, actor: User): Promise { if (input.nextcloud.enabled) { this.assertTargetAllowed(input.nextcloud.baseUrl); const test = await this.target.testConnection({ baseUrl: input.nextcloud.baseUrl, username: input.nextcloud.username, folder: input.nextcloud.folder, password: input.nextcloud.password, }); if (!test.ok) { throw new BadRequestException({ code: 'backup_connection_failed', details: { nextcloud: [test.error ?? 'connection failed'] }, }); } } await this.target.storePassword(input.nextcloud.password ?? ''); await this.settings.set('backup.localRetentionDays', input.localRetentionDays, actor.id); await this.settings.set('backup.remoteRetentionDays', input.remoteRetentionDays, actor.id); await this.settings.set('backup.nextcloud.enabled', input.nextcloud.enabled, actor.id); await this.settings.set('backup.nextcloud.baseUrl', input.nextcloud.baseUrl, actor.id); await this.settings.set('backup.nextcloud.username', input.nextcloud.username, actor.id); await this.settings.set('backup.nextcloud.folder', input.nextcloud.folder, actor.id); await this.settings.set( 'backup.nextcloud.uploadSchedule', input.nextcloud.uploadSchedule, actor.id, ); // settings.set audits each key; one summary entry names the intent. await this.audit.record({ action: 'backup.settings_changed', actorId: actor.id, details: { nextcloudEnabled: input.nextcloud.enabled }, }); return this.settingsView(); } testConnection(input: BackupConnectionTestInput): Promise { // Policy first (issue #192): the "test connection" button must not be // usable as an egress probe towards non-allowlisted hosts. this.assertTargetAllowed(input.baseUrl); return this.target.testConnection(input); } /** * Deploy-level target policy (issue #192, ADR 0026): an empty * `BACKUP_ALLOWED_TARGETS` disables remote targets outright; a host * outside the list is rejected with an admin-visible error. */ private assertTargetAllowed(baseUrl: string): void { if (!this.target.remoteAllowed()) { throw new BadRequestException({ code: 'backup_remote_disabled_by_policy' }); } if (!this.target.targetAllowed(baseUrl)) { throw new BadRequestException({ code: 'backup_target_not_allowed', details: { nextcloud: [`host is not in BACKUP_ALLOWED_TARGETS`] }, }); } } /** Both restore sources for the picker: newest first. */ async sets(): Promise { const local = this.localSets(); const target = await this.target.resolveTarget(); if (!target) return { local, remoteConfigured: false, remote: [] }; const listed = await webdavList(target); if (!listed.ok) { return { local, remoteConfigured: true, remote: [], remoteError: listed.error }; } const remote = listed.value .filter((entry) => !entry.isCollection) .map((entry) => ({ id: remoteBundleId(entry.name), sizeBytes: entry.sizeBytes })) .filter((entry): entry is { id: string; sizeBytes: number | null } => entry.id !== null) .map((entry) => this.setView(entry.id, entry.sizeBytes)) .sort((a, b) => b.backupId.localeCompare(a.backupId)); return { local, remoteConfigured: true, remote }; } /** "Back up now": dump + upload, executed by the sidecar (202-style). */ async requestRun(actor: User): Promise { await this.notify({ kind: 'run', requestedBy: actor.username }); await this.audit.record({ action: 'backup.run_triggered', actorId: actor.id }); } /** * Requests an in-app restore. The type-to-confirm value must repeat the * backup id — the UI enforces it too, this is the server-side backstop * for the most destructive action the instance has. */ async requestRestore(input: BackupRestoreInput, actor: User): Promise { if (input.confirm !== input.backupId) { throw new BadRequestException({ code: 'backup_restore_confirm_mismatch' }); } if (this.maintenance.current()?.state === 'running' && this.maintenance.isActive()) { throw new ConflictException({ code: 'backup_restore_running' }); } if (input.source === 'remote') { const target = await this.target.resolveTarget(); if (!target) throw new BadRequestException({ code: 'backup_remote_not_configured' }); const listed = await webdavList(target); const exists = listed.ok && listed.value.some((entry) => remoteBundleId(entry.name) === input.backupId); if (!exists) throw new NotFoundException({ code: 'backup_set_not_found' }); } else { const complete = this.localSets().some((set) => set.backupId === input.backupId); if (!complete) throw new NotFoundException({ code: 'backup_set_not_found' }); } await this.notify({ kind: 'restore', source: input.source, backupId: input.backupId, requestedBy: actor.username, }); await this.audit.record({ action: 'backup.restore_requested', actorId: actor.id, targetType: 'backup', targetId: input.backupId, details: { source: input.source }, }); } private async notify(command: BackupCommand): Promise { await this.prisma .$executeRaw`SELECT pg_notify(${BACKUP_COMMAND_CHANNEL}, ${JSON.stringify(command)})`; } /** Complete sets on the read-only backups mount, newest first. */ private localSets(): BackupSetView[] { let names: string[]; try { names = readdirSync(this.config.env.BACKUPS_DIR); } catch { return []; } return listSets(names) .filter((set) => set.complete) .map((set) => { let sizeBytes: number | null = 0; for (const file of [dumpFileName(set.id), archiveFileName(set.id)]) { try { sizeBytes = (sizeBytes ?? 0) + statSync(join(this.config.env.BACKUPS_DIR, file)).size; } catch { sizeBytes = null; } } return this.setView(set.id, sizeBytes); }) .sort((a, b) => b.backupId.localeCompare(a.backupId)); } private setView(backupId: string, sizeBytes: number | null): BackupSetView { return { backupId, startedAt: backupIdTime(backupId)?.toISOString() ?? new Date(0).toISOString(), sizeBytes, }; } }