All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m45s
CD / Build and push images (push) Successful in 3m49s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 5m35s
CI / Import/export fidelity gate (push) Successful in 47s
Off-host backups for every self-hoster, configured entirely in the admin UI — supersedes the host-specific mirror plan behind #84. shared: - webdav.ts (new package entry like token-crypto): minimal WebDAV client with basic auth — PROPFIND (tolerant multistatus parser), MKCOL, PUT (streamed), GET, DELETE; Nextcloud DAV path derived from the plain server URL, explicit DAV bases pass through - backup-status.ts: additive remote-upload status in status.json, the restore-status.json contract (running/succeeded/failed + staleness bound), the backup_command/backup_maintenance NOTIFY channels, and the one-bundle-per-set naming (dorfteich-backup-<id>.tar.gz) - backup-set.ts moved here from apps/backup (api lists local sets) backup sidecar: - reads the backup.* instance settings directly from the database (admin changes apply next run; local retention row overrides the env) and the app password from the secret store - after each successful set: bundle dump + files archive + manifest into ONE self-contained tar.gz, upload via WebDAV per schedule (off/daily/weekly; manual runs always upload), prune remote bundles — never the newest — and record the outcome in status.json; upload failures alert via a new backupUploadFailed mail (de+en) - command listener on backup_command (run / restore) with a serial queue against the nightly timer - restore orchestrator: restore-status.json → maintenance NOTIFY → grace → (remote: download + manifest-verify bundle) → terminate other DB connections → shared perform-restore path (same code as restore.sh) → final status + maintenance exit api: - MaintenanceGuard (global, registered before the setup gate): 503 maintenance_mode while restore-status says running; health endpoints and the new public GET /backup/restore-status stay exempt; a stale running state (crashed sidecar) unblocks after 30 min - MaintenanceStateService watches the file and restarts the api after a successful restore (fresh caches, migrate-on-start for older dumps); main.ts refuses to touch the database while a restore runs — a container restarting mid-restore must not race pg_restore with migrate deploy - worker sweeps (conversion, mail outbox, scheduler) catch transient database failures instead of dying on an unhandled rejection — the restore's connection termination crashed the api in verification - backup admin endpoints under /admin/system/backup: settings (live connection test before save, password write-only into the secret store), nextcloud/test, sets (local via the ro backups mount + remote via WebDAV), run + restore (type-to-confirm backstop, source validation) — commands travel as NOTIFY payloads; audit actions backup.settings_changed/run_triggered/restore_requested - readyz: new warning-level backup_remote check while a target is configured (26 h daily / 170 h weekly bound) collab: - maintenance listener: on enter, persist + close every live session and refuse new connections until exit (failsafe timeout 30 min) — no in-memory document may write pre-restore content back afterwards web: - Admin → System backup section: status card with remote facts and a "Back up now" button, the Nextcloud settings form with test button, and the restore picker (local + remote sets, type-to-confirm) - global maintenance screen: any 503 maintenance_mode flips the SPA to a status page polling the exempt endpoint, reloading when the instance returns Verified end-to-end against a live stack (fresh DB, native api + sidecar, fake WebDAV server): configure → test → manual backup → bundle upload → readyz/sets/status surfaces → remote restore with maintenance gate, marker rollback and api restart; suites: shared 21, backup 9, collab 11, api 58 files green, lint + i18n:check + typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
206 lines
7.7 KiB
TypeScript
206 lines
7.7 KiB
TypeScript
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<BackupSettingsView> {
|
|
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<BackupSettingsView> {
|
|
if (input.nextcloud.enabled) {
|
|
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<BackupConnectionTestResult> {
|
|
return this.target.testConnection(input);
|
|
}
|
|
|
|
/** Both restore sources for the picker: newest first. */
|
|
async sets(): Promise<BackupSetsView> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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,
|
|
};
|
|
}
|
|
}
|