import { createReadStream, createWriteStream } from 'node:fs'; import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { remoteBundleId, remoteBundleName, type BackupEnv, type BackupRemoteStatus, } from '@dorfteich/shared'; import { webdavCheck, webdavDelete, webdavGet, webdavList, webdavPut, type WebDavTarget, } from '@dorfteich/shared/webdav'; import { createArchiveOfFiles, extractArchiveTo } from './archive.js'; import { archiveFileName, backupIdTime, dumpFileName } from './backup-set.js'; import { sendUploadFailureMail } from './mail.js'; import { NEXTCLOUD_PASSWORD_SECRET_KEY, type BackupDbSettings } from './settings.js'; /** * Off-host half of a backup run (issue #103): bundle a complete local set * into ONE self-contained archive, upload it to the admin-configured * Nextcloud folder via WebDAV, and prune expired remote bundles — never the * newest one, mirroring the local guarantee (#83). */ export interface RemoteLogger { info(details: object, message: string): void; warn(details: object, message: string): void; error(details: object, message: string): void; } /** * The effective WebDAV target, or null when the feature is off or not fully * configured. The app password comes from the wizard-written secret store * (never the database); base URL and username from instance settings. */ export function resolveRemoteTarget( settings: BackupDbSettings, secrets: Record, ): WebDavTarget | null { const { enabled, baseUrl, username, folder } = settings.nextcloud; const password = secrets[NEXTCLOUD_PASSWORD_SECRET_KEY] ?? ''; if (!enabled || !baseUrl || !username || !password) return null; return { baseUrl, username, password, folder }; } /** * Whether a scheduled run should upload: `off` never (manual trigger only), * `daily` after every successful set, `weekly` when the last remote copy is * at least ~a week old (half a day of slack so a slightly early nightly run * does not skip its week). */ export function uploadDue( schedule: BackupDbSettings['nextcloud']['uploadSchedule'], lastSuccessfulUploadAt: string | null, now: Date, ): boolean { if (schedule === 'off') return false; if (schedule === 'daily') return true; if (!lastSuccessfulUploadAt) return true; const ageMs = now.getTime() - new Date(lastSuccessfulUploadAt).getTime(); return ageMs >= 6.5 * 24 * 60 * 60 * 1000; } /** Contents of the bundle's manifest.json — the self-description a rebuild * after total loss relies on (documented in the restore runbook). */ export interface BundleManifest { schemaVersion: 1; backupId: string; createdAt: string; files: string[]; } /** * Packs one complete local set into `dorfteich-backup-.tar.gz` next to * the set (staged as `.partial`, like every other artifact). Returns the * bundle path; the caller uploads and then deletes it — bundles are derived * data and would double the volume's footprint. */ export async function buildBundle( backupsDir: string, backupId: string, now: Date, ): Promise<{ path: string; sizeBytes: number }> { const files = [dumpFileName(backupId), archiveFileName(backupId)]; const manifest: BundleManifest = { schemaVersion: 1, backupId, createdAt: now.toISOString(), files, }; const manifestName = 'manifest.json'; await writeFile(join(backupsDir, manifestName), JSON.stringify(manifest, null, 2) + '\n'); const bundlePath = join(backupsDir, remoteBundleName(backupId)); await createArchiveOfFiles(`${bundlePath}.partial`, backupsDir, [...files, manifestName]); await rename(`${bundlePath}.partial`, bundlePath); await rm(join(backupsDir, manifestName), { force: true }); return { path: bundlePath, sizeBytes: (await stat(bundlePath)).size }; } /** * Uploads the bundle for `backupId` and prunes expired remote bundles. * Returns the new remote status; an upload failure alerts by mail (the * local run stays succeeded — the admin card and readyz surface the gap). */ export async function uploadSet(deps: { env: BackupEnv; backupsDir: string; target: WebDavTarget; remoteRetentionDays: number; backupId: string; previous: BackupRemoteStatus | undefined; now(): Date; log: RemoteLogger; }): Promise { const { backupId, target } = deps; const previousSuccess = deps.previous?.lastSuccessfulUpload ?? null; let bundle: { path: string; sizeBytes: number } | null = null; try { const check = await webdavCheck(target); if (!check.ok) throw new Error(check.error); bundle = await buildBundle(deps.backupsDir, backupId, deps.now()); const put = await webdavPut( target, remoteBundleName(backupId), Readable.toWeb(createReadStream(bundle.path)) as ReadableStream, { contentLength: bundle.sizeBytes }, ); if (!put.ok) throw new Error(put.error); await pruneRemote(target, deps.remoteRetentionDays, deps.now(), deps.log); const finishedAt = deps.now().toISOString(); deps.log.info({ backupId, sizeBytes: bundle.sizeBytes }, 'remote upload succeeded'); return { lastUpload: { backupId, finishedAt, outcome: 'succeeded', sizeBytes: bundle.sizeBytes }, lastSuccessfulUpload: { backupId, finishedAt, sizeBytes: bundle.sizeBytes }, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); deps.log.error({ backupId, error: message }, 'remote upload failed'); try { const sent = await sendUploadFailureMail(deps.env, { backupId, error: message, lastSuccessAt: previousSuccess?.finishedAt ?? null, }); if (!sent) deps.log.warn({ backupId }, 'no BACKUP_MAIL_TO configured, upload alert not sent'); } catch (mailError) { deps.log.error({ backupId, error: String(mailError) }, 'upload alert could not be sent'); } return { lastUpload: { backupId, finishedAt: deps.now().toISOString(), outcome: 'failed', error: message, }, lastSuccessfulUpload: previousSuccess, }; } finally { if (bundle) await rm(bundle.path, { force: true }); await rm(join(deps.backupsDir, 'manifest.json'), { force: true }); } } /** * Deletes remote bundles older than the retention window — never the newest * one, even when expired (same guarantee as the local prune, #83). Foreign * files in the folder are never touched. Prune failures are logged, not * thrown: the upload itself succeeded and must count. */ export async function pruneRemote( target: WebDavTarget, retentionDays: number, now: Date, log: RemoteLogger, ): Promise { const listed = await webdavList(target); if (!listed.ok) { log.warn({ error: listed.error }, 'remote prune skipped: listing failed'); return; } const bundles = listed.value .filter((entry) => !entry.isCollection) .map((entry) => ({ name: entry.name, id: remoteBundleId(entry.name) })) .filter((entry): entry is { name: string; id: string } => entry.id !== null) .sort((a, b) => a.id.localeCompare(b.id)); const cutoff = now.getTime() - retentionDays * 24 * 60 * 60 * 1000; const newest = bundles.at(-1); for (const bundle of bundles) { if (bundle === newest) continue; const time = backupIdTime(bundle.id); if (!time || time.getTime() >= cutoff) continue; const deleted = await webdavDelete(target, bundle.name); if (deleted.ok) log.info({ name: bundle.name }, 'pruned expired remote bundle'); else log.warn({ name: bundle.name, error: deleted.error }, 'remote prune of bundle failed'); } } /** * Downloads a remote bundle and unpacks its set artifacts into the backups * directory (the in-app restore path, and documented for operators in the * runbook). Verifies the manifest matches the requested id. */ export async function fetchRemoteSet(deps: { backupsDir: string; target: WebDavTarget; backupId: string; log: RemoteLogger; }): Promise { const name = remoteBundleName(deps.backupId); const response = await webdavGet(deps.target, name); if (!response.ok) throw new Error(response.error); if (!response.value.body) throw new Error(`download ${name}: empty response body`); const downloadPath = join(deps.backupsDir, `${name}.download`); const scratchDir = join(deps.backupsDir, `.restore-${deps.backupId}`); try { await pipeline( Readable.fromWeb(response.value.body as import('node:stream/web').ReadableStream), createWriteStream(downloadPath), ); await mkdir(scratchDir, { recursive: true }); await extractArchiveTo(downloadPath, scratchDir); const manifest = JSON.parse( await readFile(join(scratchDir, 'manifest.json'), 'utf8'), ) as BundleManifest; if (manifest.backupId !== deps.backupId) { throw new Error( `bundle manifest mismatch: requested ${deps.backupId}, bundle contains ${manifest.backupId}`, ); } for (const file of [dumpFileName(deps.backupId), archiveFileName(deps.backupId)]) { await rename(join(scratchDir, file), join(deps.backupsDir, file)); } deps.log.info({ backupId: deps.backupId }, 'remote set downloaded and unpacked'); } finally { await rm(downloadPath, { force: true }); await rm(scratchDir, { recursive: true, force: true }); } }