fix: backup creation and restore reliability (#527)

This commit is contained in:
santiagosayshey
2026-05-05 15:58:38 +09:30
committed by GitHub
parent 1824246956
commit ce72f41bfc
21 changed files with 1132 additions and 220 deletions

4
deno.lock generated
View File

@@ -1503,7 +1503,7 @@
"npm:@deno/vite-plugin@^2.0.2",
"npm:@jsr/db__sqlite@0.12",
"npm:@playwright/test@^1.59.1",
"npm:@sveltejs/kit@^2.58.0",
"npm:@sveltejs/kit@^2.59.0",
"npm:@sveltejs/vite-plugin-svelte@7",
"npm:@tailwindcss/vite@^4.2.4",
"npm:@types/better-sqlite3@^7.6.13",
@@ -1521,7 +1521,7 @@
"npm:prettier-plugin-svelte@^3.5.1",
"npm:prettier-plugin-tailwindcss@0.8",
"npm:prettier@3.8.3",
"npm:simple-icons@^16.18.0",
"npm:simple-icons@^16.18.1",
"npm:svelte-check@^4.4.7",
"npm:svelte@^5.55.5",
"npm:tailwindcss@^4.1.13",

View File

@@ -52,6 +52,21 @@ backup_file:
get:
operationId: downloadBackup
summary: Download Backup
description: |
Download a backup archive. The local file on disk is full-fidelity, but
the downloaded copy is sanitized on the fly so it is safer to share.
The following are removed from the downloaded archive:
- Arr instances (URLs, API keys, sync configs, drift state, rename and
cleanup history)
- Notification services (webhook URLs, tokens, history)
- User accounts and active sessions
- Personal access tokens for linked databases
- AI and TMDB API keys
The local archive on the server is not modified. Restoring the
downloaded file on a different host will require re-adding the removed
items.
tags:
- Backups
parameters:
@@ -63,7 +78,7 @@ backup_file:
type: string
responses:
'200':
description: Backup file download
description: Sanitized backup file download
headers:
Content-Disposition:
schema:

View File

@@ -368,11 +368,21 @@ Secrets are stripped at two levels:
- **Frontend responses** - server-side load functions replace sensitive fields
with boolean flags (`hasApiKey`, `hasPat`). Webhook URLs are omitted from
notification config. Password hashes never leave the server.
- **Backup downloads** - the DB copy inside the archive has all secrets nulled
(`arr_instances.api_key`, `database_instances.personal_access_token`,
`auth_settings.api_key`, `ai_settings.api_key`, `tmdb_settings.api_key`),
notification configs cleared, and auth tables (`users`, `sessions`,
`login_attempts`) emptied. The production database is never touched.
- **Backup downloads** - local backup archives on disk are full-fidelity; the
filesystem is the documented trust boundary, so a backup file sitting next
to the live database does not need to be sanitized. The download endpoint
(`GET /api/v1/backups/{filename}`) instead sanitizes the archive on the fly
before streaming the response. Sanitization deletes whole rows from
`arr_instances` (cascading through arr-side sync, drift, rename, cleanup,
and upgrade tables) and `notification_services` (cascading through
history), and empties `users`, `sessions`, and `login_attempts`. Personal
access tokens on `database_instances`, AI keys on `ai_settings`, and TMDB
keys on `tmdb_settings` are nulled but the rows are kept (PCD repos remain
linked, schedules and other settings preserved). `auth_settings.api_key`
is left in place because it is a bcrypt hash of a high-entropy random key,
computationally infeasible to brute-force from the hash alone. The local
archive on disk and the production database are never modified. See
`src/lib/server/utils/backup/sanitize.ts` for the exact policy.
### XSS via Markdown / {@html}

View File

@@ -15,6 +15,7 @@ import { logSettings } from '$logger/settings.ts';
import { logger } from '$logger/logger.ts';
import { db } from '$db/db.ts';
import { runMigrations } from '$db/migrations.ts';
import { applyPendingRestore } from '$utils/backup/applyPending.ts';
import { initializeJobs } from '$jobs/init.ts';
import { recoverInterruptedSyncs } from '$lib/server/sync/utils.ts';
import { pcdManager } from '$pcd/core/manager.ts';
@@ -31,6 +32,11 @@ if (!isReload) {
// Initialize configuration on server startup
await config.init();
// Apply any pending restore before opening the DB. This is the only
// safe window: directories exist (config.init mkdir'd them) but no
// SQLite handle is open yet, so we can swap files freely.
await applyPendingRestore();
// Initialize database
await db.initialize();

20
src/lib/api/v1.d.ts vendored
View File

@@ -250,7 +250,23 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Download Backup */
/**
* Download Backup
* @description Download a backup archive. The local file on disk is full-fidelity, but
* the downloaded copy is sanitized on the fly so it is safer to share.
* The following are removed from the downloaded archive:
*
* - Arr instances (URLs, API keys, sync configs, drift state, rename and
* cleanup history)
* - Notification services (webhook URLs, tokens, history)
* - User accounts and active sessions
* - Personal access tokens for linked databases
* - AI and TMDB API keys
*
* The local archive on the server is not modified. Restoring the
* downloaded file on a different host will require re-adding the removed
* items.
*/
get: operations['downloadBackup'];
put?: never;
post?: never;
@@ -1522,7 +1538,7 @@ export interface operations {
};
requestBody?: never;
responses: {
/** @description Backup file download */
/** @description Sanitized backup file download */
200: {
headers: {
/** @example attachment; filename="backup-2026-03-15-100005.tar.gz" */

View File

@@ -1822,6 +1822,7 @@
"get": {
"operationId": "downloadBackup",
"summary": "Download Backup",
"description": "Download a backup archive. The local file on disk is full-fidelity, but\nthe downloaded copy is sanitized on the fly so it is safer to share.\nThe following are removed from the downloaded archive:\n\n- Arr instances (URLs, API keys, sync configs, drift state, rename and\n cleanup history)\n- Notification services (webhook URLs, tokens, history)\n- User accounts and active sessions\n- Personal access tokens for linked databases\n- AI and TMDB API keys\n\nThe local archive on the server is not modified. Restoring the\ndownloaded file on a different host will require re-adding the removed\nitems.\n",
"tags": ["Backups"],
"parameters": [
{
@@ -1836,7 +1837,7 @@
],
"responses": {
"200": {
"description": "Backup file download",
"description": "Sanitized backup file download",
"headers": {
"Content-Disposition": {
"schema": {

View File

@@ -1,9 +1,21 @@
/**
* Core backup creation logic
* Separated from job definition to avoid database/config dependencies for testing
* Core backup creation logic.
*
* Local backups are full-fidelity: secrets are kept in the on-disk archive
* because it sits inside the same filesystem trust boundary as the source DB.
* Sanitization happens at egress (download endpoint), not at creation.
*
* The SQLite app database is copied via the online backup API rather than a
* raw filesystem `cp`, so the archive contains a single consistent `.db`
* with no `-wal`/`-shm` sidecars. Other contents of the source directory
* (notably `databases/` for cloned PCD repos) are copied as-is.
*/
import { Database } from '@jsr/db__sqlite';
import { db } from '$db/db.ts';
import { migrationRunner } from '$db/migrations.ts';
import { logger } from '$logger/logger.ts';
import { build } from '$lib/shared/build.ts';
export interface CreateBackupResult {
success: boolean;
@@ -12,31 +24,6 @@ export interface CreateBackupResult {
error?: string;
}
/**
* SQL statements to strip secrets from a backup database copy.
* The production database is never modified — these run against a temp copy only.
*/
const SANITIZE_SQL = [
"UPDATE arr_instances SET api_key = ''",
'UPDATE database_instances SET personal_access_token = NULL',
'UPDATE auth_settings SET api_key = NULL',
"UPDATE ai_settings SET api_key = ''",
"UPDATE tmdb_settings SET api_key = ''",
"UPDATE notification_services SET config = '{}'",
'DELETE FROM users',
'DELETE FROM sessions',
'DELETE FROM login_attempts'
];
/**
* Core backup logic - creates a tar.gz archive of a directory
* Pure function that only depends on Deno APIs
*
* @param sourceDir Directory to backup (will backup this entire directory)
* @param backupDir Directory where backup file will be saved
* @param timestamp Optional timestamp for backup filename (defaults to current time)
* @returns Backup result with filename and size or error
*/
export async function createBackup(
sourceDir: string,
backupDir: string,
@@ -78,7 +65,8 @@ export async function createBackup(
};
}
// Copy source to a temp directory and sanitize the DB copy
// Copy source to a temp directory; the DB inside it gets replaced
// with a clean snapshot below.
tmpDir = await Deno.makeTempDir({ prefix: 'profilarr-backup-' });
const tmpDataDir = `${tmpDir}/data`;
@@ -96,32 +84,74 @@ export async function createBackup(
};
}
// Sanitize the database copy (skip if no DB in source)
// Replace the raw-copied DB (and its sidecars) with a consistent
// snapshot via SQLite's online backup API. The cp above may have
// captured a mid-write `-wal`/`-shm`; a snapshot of committed state
// is the only safe way to copy a live WAL database. Skipped when
// the source has no `profilarr.db` (unit tests with fake source dirs).
const dbPath = `${tmpDataDir}/profilarr.db`;
const walPath = `${tmpDataDir}/profilarr.db-wal`;
const shmPath = `${tmpDataDir}/profilarr.db-shm`;
try {
const dbStat = await Deno.stat(dbPath);
if (dbStat.isFile) {
const db = new Database(dbPath);
await Deno.remove(dbPath);
for (const sidecar of [walPath, shmPath]) {
try {
await Deno.remove(sidecar);
} catch (err) {
if (!(err instanceof Deno.errors.NotFound)) throw err;
}
}
const dest = new Database(dbPath);
try {
for (const sql of SANITIZE_SQL) {
db.exec(sql); // nosemgrep: profilarr.sql.exec-with-variable — SANITIZE_SQL is a hardcoded constant
db.getDatabase().backup(dest);
dest.exec('PRAGMA wal_checkpoint(TRUNCATE)');
dest.exec('PRAGMA journal_mode = DELETE');
// Verify the snapshot. The backup API copies pages as-is,
// so a corrupt source produces a corrupt archive. Surfacing
// it here saves diagnosing mystery errors after restore.
const rows = dest.prepare('PRAGMA integrity_check').all() as Array<{
integrity_check: string;
}>;
const ok = rows.length === 1 && rows[0].integrity_check === 'ok';
if (!ok) {
await logger.warn('Backup source has integrity issues; archive contains them as-is', {
source: 'createBackup',
meta: { issues: rows.map((r) => r.integrity_check).slice(0, 10) }
});
}
} finally {
db.close();
dest.close();
}
// Write INFO.json metadata. Read by the boot-time apply step
// for diagnostics and by the download endpoint for sanitized
// flag handling. Schema version comes from the `migrations`
// table (Profilarr's tracking mechanism), not PRAGMA user_version.
const info = {
appVersion: build.version,
appChannel: build.channel,
schemaVersion: migrationRunner.getCurrentVersion(),
createdAt: now.toISOString(),
sanitized: false
};
await Deno.writeTextFile(`${tmpDataDir}/INFO.json`, JSON.stringify(info, null, 2));
}
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
// No database to sanitize — that's fine
// No database in source; nothing to snapshot.
} else {
return {
success: false,
error: `Failed to sanitize backup database: ${error instanceof Error ? error.message : String(error)}`
error: `Failed to snapshot backup database: ${error instanceof Error ? error.message : String(error)}`
};
}
}
// Create tar.gz archive from the sanitized temp copy
// Create tar.gz archive from the temp copy
const command = new Deno.Command('tar', {
args: ['-czf', backupPath, '-C', tmpDir, 'data'],
stdout: 'piped',

View File

@@ -0,0 +1,150 @@
/**
* Boot-time restore application.
*
* The restore form action stages a pending restore by writing a sentinel
* file at `${base}/.restore-pending` containing the absolute path of the
* archive to apply. This module's `applyPendingRestore()` runs early in
* the boot sequence, after `config.init()` (so directories exist) but
* before `db.initialize()` (so no SQLite handle is open), and applies
* the staged archive in-place.
*
* Why boot-time, not in-process: SQLite holds memory-mapped state for
* `-wal`/`-shm` while the DB is open. Overwriting `profilarr.db` mid-flight
* leaves the running process reading inconsistent pages. Doing the swap
* during the boot window where no connection exists is the only correct
* pattern (matches Sonarr's `DatabaseRestorationService`).
*
* Failure model: extraction crashes leave the live `data/` untouched
* because we extract into a separate `.restoring/` staging dir before any
* destructive step. Crashes after the wipe but before the swap leave the
* sentinel in place so the next boot retries cleanly. Unexpected errors
* are logged loudly and rethrown so boot fails fast rather than continuing
* into `db.initialize()` against a broken `data/`.
*/
import { config } from '$config';
import { logger } from '$logger/logger.ts';
const SENTINEL_NAME = '.restore-pending';
const STAGING_NAME = '.restoring';
interface ArchiveInfo {
appVersion?: string;
appChannel?: string;
schemaVersion?: number | null;
createdAt?: string;
sanitized?: boolean;
}
async function removeIfExists(path: string, opts?: { recursive?: boolean }): Promise<void> {
try {
await Deno.remove(path, opts);
} catch (err) {
if (!(err instanceof Deno.errors.NotFound)) throw err;
}
}
async function readArchiveInfo(stagingDataDir: string): Promise<ArchiveInfo> {
try {
const raw = await Deno.readTextFile(`${stagingDataDir}/INFO.json`);
return JSON.parse(raw) as ArchiveInfo;
} catch {
// Older archives don't have INFO.json. Not fatal.
return {};
}
}
export async function applyPendingRestore(): Promise<void> {
const sentinelPath = `${config.paths.base}/${SENTINEL_NAME}`;
const stagingPath = `${config.paths.base}/${STAGING_NAME}`;
const dataDir = config.paths.data;
let archivePath: string;
try {
archivePath = (await Deno.readTextFile(sentinelPath)).trim();
} catch (err) {
if (err instanceof Deno.errors.NotFound) return;
throw err;
}
// Verify archive still exists. If it's gone, drop the sentinel and
// continue boot with the existing data, destroying nothing.
try {
await Deno.stat(archivePath);
} catch {
await logger.error('Pending restore archive missing; aborting and clearing sentinel', {
source: 'applyPendingRestore',
meta: { archivePath }
});
await removeIfExists(sentinelPath);
return;
}
// Wipe any leftover staging dir from a previously crashed apply.
await removeIfExists(stagingPath, { recursive: true });
await Deno.mkdir(stagingPath, { recursive: true });
// Extract into the staging dir. Live `data/` untouched at this point.
const tar = new Deno.Command('tar', {
args: ['-xzf', archivePath, '-C', stagingPath],
stdout: 'piped',
stderr: 'piped'
});
const tarResult = await tar.output();
if (tarResult.code !== 0) {
const stderr = new TextDecoder().decode(tarResult.stderr).trim();
await logger.error('Pending restore extraction failed; live data left untouched', {
source: 'applyPendingRestore',
meta: { archivePath, stderr, exitCode: tarResult.code }
});
await removeIfExists(stagingPath, { recursive: true });
// Leave the sentinel in place: the operator may want to fix the
// archive and retry. Failing fast prevents boot against unknown state.
throw new Error(`Restore extraction failed (exit ${tarResult.code}): ${stderr}`);
}
const stagingDataDir = `${stagingPath}/data`;
// Sanity-check the archive shape before destroying anything.
try {
const stat = await Deno.stat(`${stagingDataDir}/profilarr.db`);
if (!stat.isFile) throw new Error('profilarr.db is not a file');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await logger.error('Staged archive missing profilarr.db; aborting', {
source: 'applyPendingRestore',
meta: { archivePath, error: message }
});
await removeIfExists(stagingPath, { recursive: true });
throw new Error(`Restore archive does not contain data/profilarr.db: ${message}`);
}
const info = await readArchiveInfo(stagingDataDir);
// Wipe live data dir contents we own. Anything else (e.g. a file an
// operator manually placed) is left alone.
await removeIfExists(`${dataDir}/profilarr.db`);
await removeIfExists(`${dataDir}/profilarr.db-wal`);
await removeIfExists(`${dataDir}/profilarr.db-shm`);
await removeIfExists(`${dataDir}/databases`, { recursive: true });
await removeIfExists(`${dataDir}/INFO.json`);
// Move staged contents into place. Per-entry rename is atomic on the
// same filesystem (which staging and data both are, both under base).
for await (const entry of Deno.readDir(stagingDataDir)) {
const src = `${stagingDataDir}/${entry.name}`;
const dst = `${dataDir}/${entry.name}`;
await removeIfExists(dst, { recursive: true });
await Deno.rename(src, dst);
}
// Cleanup. Sentinel last so a crash anywhere above leaves it in place
// for the next boot to retry.
await removeIfExists(stagingPath, { recursive: true });
await Deno.remove(sentinelPath);
await logger.info('Pending restore applied', {
source: 'applyPendingRestore',
meta: { archivePath, info }
});
}

View File

@@ -0,0 +1,155 @@
/**
* Backup sanitization for the download endpoint.
*
* Local backup archives are full-fidelity (secrets included) because they sit
* inside the same filesystem trust boundary as the source database. The moment
* a backup leaves the host though (download, share, attach to a bug report)
* the trust boundary ends. To make the download path safe to share, we strip
* sensitive data on the fly when the file is requested.
*
* The strategy is "nuke whole rows" rather than "blank specific fields":
*
* - DELETE FROM arr_instances cascades through every arr-side table (sync
* configs, drift, rename, cleanup, upgrades, runs). The user loses URLs
* (which can be internal hostnames), api keys, and the entire sync graph.
* - DELETE FROM notification_services cascades to history. Webhook URLs and
* service names (which can be revealing) go with them.
* - users / sessions / login_attempts removed; usernames could be real
* emails for OIDC users.
* - database_instances PATs are nulled but the rows stay (PCD repos remain
* linked to the user post-restore).
* - ai_settings / tmdb_settings api keys nulled (rows stay).
* - auth_settings.api_key NOT touched; it's bcrypt-hashed of a high-entropy
* random key, so the hash is computationally infeasible to brute-force.
*
* This is more aggressive than a strict per-field strip but it's fail-safe:
* any new sensitive column added later to arr_instances or
* notification_services is automatically protected by virtue of the row
* being deleted.
*/
import { Database } from '@jsr/db__sqlite';
/**
* SQL applied to a backup database copy at download time. Order matters
* only insofar as cascades fire from the parents.
*/
export const SANITIZE_SQL = [
'DELETE FROM arr_instances',
'DELETE FROM notification_services',
'DELETE FROM users',
'DELETE FROM sessions',
'DELETE FROM login_attempts',
'UPDATE database_instances SET personal_access_token = NULL',
"UPDATE ai_settings SET api_key = ''",
"UPDATE tmdb_settings SET api_key = ''"
];
/**
* Categories of data stripped, surfaced to the UI so the user knows exactly
* what they're about to share. Kept in sync with `SANITIZE_SQL` above.
*/
export const SANITIZED_CATEGORIES = [
'Arr instances (URLs, API keys, sync configs, drift state, rename and cleanup history)',
'Notification services (webhook URLs, tokens, history)',
'User accounts and active sessions',
'Personal access tokens for linked databases',
'AI and TMDB API keys'
];
/**
* Apply the sanitize SQL to an open SQLite database handle.
* Caller is responsible for opening and closing the handle.
*/
export function applySanitize(db: Database): void {
for (const sql of SANITIZE_SQL) {
db.exec(sql); // nosemgrep: profilarr.sql.exec-with-variable - SANITIZE_SQL is a hardcoded constant
}
}
interface ArchiveInfo {
appVersion?: string;
appChannel?: string;
schemaVersion?: number | null;
createdAt?: string;
sanitized?: boolean;
}
/**
* Produce a sanitized copy of a local backup archive. Extracts the archive
* into a temp directory, runs SANITIZE_SQL against the embedded DB, flips
* INFO.json's `sanitized` flag to true, re-tars, and returns the bytes.
*
* The local archive on disk is not modified.
*/
export async function buildSanitizedArchive(archivePath: string): Promise<Uint8Array> {
const tmpDir = await Deno.makeTempDir({ prefix: 'profilarr-backup-egress-' });
try {
// Extract.
const extract = new Deno.Command('tar', {
args: ['-xzf', archivePath, '-C', tmpDir],
stdout: 'piped',
stderr: 'piped'
});
const extractResult = await extract.output();
if (extractResult.code !== 0) {
const stderr = new TextDecoder().decode(extractResult.stderr).trim();
throw new Error(`Failed to extract archive: ${stderr}`);
}
const dataDir = `${tmpDir}/data`;
const dbPath = `${dataDir}/profilarr.db`;
// Sanitize the DB copy in place.
const dbExists = await Deno.stat(dbPath).then(
() => true,
(err) => {
if (err instanceof Deno.errors.NotFound) return false;
throw err;
}
);
if (dbExists) {
const dest = new Database(dbPath);
try {
applySanitize(dest);
dest.exec('PRAGMA wal_checkpoint(TRUNCATE)');
dest.exec('PRAGMA journal_mode = DELETE');
} finally {
dest.close();
}
}
// Flip INFO.json's sanitized flag.
const infoPath = `${dataDir}/INFO.json`;
try {
const raw = await Deno.readTextFile(infoPath);
const info = JSON.parse(raw) as ArchiveInfo;
info.sanitized = true;
await Deno.writeTextFile(infoPath, JSON.stringify(info, null, 2));
} catch (err) {
if (!(err instanceof Deno.errors.NotFound)) throw err;
// Older archives may not have INFO.json. Not fatal.
}
// Re-tar the (now sanitized) data dir.
const archive = `${tmpDir}/sanitized.tar.gz`;
const tar = new Deno.Command('tar', {
args: ['-czf', archive, '-C', tmpDir, 'data'],
stdout: 'piped',
stderr: 'piped'
});
const tarResult = await tar.output();
if (tarResult.code !== 0) {
const stderr = new TextDecoder().decode(tarResult.stderr).trim();
throw new Error(`Failed to re-archive after sanitize: ${stderr}`);
}
return await Deno.readFile(archive);
} finally {
try {
await Deno.remove(tmpDir, { recursive: true });
} catch {
// Best-effort cleanup.
}
}
}

View File

@@ -6,6 +6,17 @@ import { config } from '$lib/server/utils/config/config.ts';
import { build } from '$lib/shared/build.ts';
import { inbox } from '$announcements/index.ts';
async function readPendingRestore(): Promise<{ filename: string } | null> {
try {
const path = (await Deno.readTextFile(`${config.paths.base}/.restore-pending`)).trim();
const filename = path.split('/').pop() || path;
return { filename };
} catch (err) {
if (err instanceof Deno.errors.NotFound) return null;
throw err;
}
}
export const load: LayoutServerLoad = async () => {
const arrInstances = arrInstancesQueries.getAll().map((i) => ({
id: i.id,
@@ -24,6 +35,7 @@ export const load: LayoutServerLoad = async () => {
arrInstances,
databases,
parserAvailable: await isParserHealthy(),
unreadAnnouncements: inbox.getUnreadCount()
unreadAnnouncements: inbox.getUnreadCount(),
restorePending: await readPendingRestore()
};
};

View File

@@ -17,6 +17,10 @@
import { dev } from '$app/environment';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { enhance } from '$app/forms';
import { alertStore } from '$alerts/store';
import { AlertTriangle, X } from 'lucide-svelte';
import Button from '$ui/button/Button.svelte';
export let data;
@@ -75,5 +79,47 @@
? ''
: `pt-16 pb-16 md:pt-0 md:pb-0 ${$sidebarCollapsed ? 'md:pl-14' : 'md:pl-80'}`} transition-[padding-left] duration-200 ease-in-out"
>
{#if data.restorePending && !isAuthPage}
<div
class="flex h-16 items-center gap-3 border-b border-neutral-200 bg-neutral-50 px-4 text-sm text-neutral-700 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-200"
role="status"
>
<AlertTriangle class="h-5 w-5 shrink-0 text-amber-500 dark:text-amber-400" />
<div class="min-w-0 flex-1 truncate">
<strong class="font-semibold">Restore pending:</strong>
<span class="font-mono">{data.restorePending.filename}</span>
<span class="hidden md:inline">
will be applied on the next restart. Any changes you make now will be lost.
</span>
</div>
<form
class="shrink-0"
method="POST"
action="/settings/backups?/cancelRestore"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success') {
alertStore.add('success', 'Pending restore cancelled');
} else if (result.type === 'failure' && result.data) {
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to cancel'
);
}
await update();
};
}}
>
<Button
type="submit"
size="sm"
text="Cancel restore"
icon={X}
iconColor="text-red-600 dark:text-red-400"
hideTextOnMobile
/>
</form>
</div>
{/if}
<slot />
</main>

View File

@@ -2,14 +2,20 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import type { components } from '$api/v1.d.ts';
import { config } from '$config';
import { logger } from '$logger/logger.ts';
import { isValidBackupFilename, resolveBackupPath } from '$utils/backup/validation.ts';
import { buildSanitizedArchive } from '$utils/backup/sanitize.ts';
type ErrorResponse = components['schemas']['ErrorResponse'];
/**
* GET /api/v1/backups/{filename}
*
* Download a backup file.
* Download a backup. The local archive on disk is full-fidelity, but the
* downloaded copy is sanitized on the fly before being streamed back: arr
* instances and notification services are deleted (cascading through their
* sync/history tables), users/sessions are wiped, and other secret-bearing
* fields are nulled. See `$utils/backup/sanitize.ts` for the exact policy.
*/
export const GET: RequestHandler = async ({ params }) => {
const { filename } = params;
@@ -32,8 +38,20 @@ export const GET: RequestHandler = async ({ params }) => {
return json(error, { status: 404 });
}
const file = await Deno.readFile(backupPath);
return new Response(file, {
let bytes: Uint8Array;
try {
bytes = await buildSanitizedArchive(backupPath);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await logger.error('Failed to sanitize backup for download', {
source: 'api/v1/backups/download',
meta: { filename, error: message }
});
const error: ErrorResponse = { error: 'Failed to prepare backup for download' };
return json(error, { status: 500 });
}
return new Response(bytes as BlobPart, {
headers: {
'Content-Type': 'application/gzip',
'Content-Disposition': `attachment; filename="${filename}"`

View File

@@ -5,15 +5,20 @@ import { logger } from '$logger/logger.ts';
import { enqueueJob } from '$lib/server/jobs/queueService.ts';
import { buildJobDisplayName } from '$lib/server/jobs/display.ts';
import { listBackups } from '$utils/backup/list.ts';
import { isValidBackupFilename, resolveBackupPath } from '$utils/backup/validation.ts';
import { SANITIZED_CATEGORIES } from '$utils/backup/sanitize.ts';
export const load = async () => {
const backups = await listBackups(config.paths.backups);
return { backups };
return { backups, sanitizedCategories: SANITIZED_CATEGORIES };
};
// These actions stay as form actions (session-only, not in the public API):
// - cleanupBackups: convenience trigger, already automated via job queue
// - restoreBackup: destructive (overwrites data dir, requires restart)
// - restoreBackup: stages a pending restore; the actual swap happens at
// next boot via applyPendingRestore() before the DB is opened.
// - cancelRestore: clears a staged restore. The banner in the root layout
// posts here from any page, so removing the sentinel is the only effect.
//
// All other backup operations use /api/v1/backups/* endpoints.
export const actions: Actions = {
@@ -48,54 +53,62 @@ export const actions: Actions = {
const formData = await request.formData();
const filename = formData.get('filename') as string;
if (!filename || !filename.startsWith('backup-') || !filename.endsWith('.tar.gz')) {
if (!filename || !isValidBackupFilename(filename)) {
return fail(400, { error: 'Invalid filename' });
}
const backupPath = `${config.paths.backups}/${filename}`;
const backupPath = resolveBackupPath(filename, config.paths.backups);
if (!backupPath) {
return fail(400, { error: 'Invalid filename' });
}
try {
// Verify backup exists
await Deno.stat(backupPath);
await logger.warn(`Restoring from backup: ${filename}`, {
// Stage a pending restore. The sentinel sits at the base level
// (outside `data/`) so a backup taken while a restore is staged
// won't carry the sentinel into its archive. The actual swap
// happens at next boot via applyPendingRestore().
const sentinelPath = `${config.paths.base}/.restore-pending`;
await Deno.writeTextFile(sentinelPath, backupPath);
await logger.warn(`Restore staged: ${filename}`, {
source: 'settings/backups',
meta: { filename }
});
// Extract backup to base directory (will overwrite data directory)
const command = new Deno.Command('tar', {
args: ['-xzf', backupPath, '-C', config.paths.base],
stdout: 'piped',
stderr: 'piped'
});
const { code, stderr } = await command.output();
if (code !== 0) {
const errorMessage = new TextDecoder().decode(stderr);
await logger.error('Backup restoration failed', {
source: 'settings/backups',
meta: { filename, error: errorMessage, exitCode: code }
});
return fail(500, { error: `Restore failed: ${errorMessage}` });
}
await logger.info(`Successfully restored from backup: ${filename}`, {
source: 'settings/backups',
meta: { filename }
meta: { filename, archivePath: backupPath }
});
return {
success: true,
message: 'Backup restored successfully. Please restart the application.'
restartRequired: true,
message: 'Backup staged. Restart Profilarr to apply.'
};
} catch (err) {
await logger.error(`Failed to restore backup: ${filename}`, {
await logger.error(`Failed to stage restore: ${filename}`, {
source: 'settings/backups',
meta: { filename, error: err }
});
return fail(500, { error: 'Failed to restore backup' });
return fail(500, { error: 'Failed to stage restore' });
}
},
cancelRestore: async () => {
const sentinelPath = `${config.paths.base}/.restore-pending`;
try {
await Deno.remove(sentinelPath);
await logger.info('Pending restore cancelled', {
source: 'settings/backups'
});
return { success: true, message: 'Pending restore cancelled.' };
} catch (err) {
if (err instanceof Deno.errors.NotFound) {
return { success: true, message: 'No pending restore to cancel.' };
}
await logger.error('Failed to cancel pending restore', {
source: 'settings/backups',
meta: { error: err }
});
return fail(500, { error: 'Failed to cancel pending restore' });
}
}
};

View File

@@ -37,6 +37,7 @@
// Modal state
let showDeleteModal = false;
let showRestoreModal = false;
let showDownloadModal = false;
let selectedBackup: string | null = null;
let restoreFormRef: HTMLFormElement | null = null;
@@ -44,8 +45,22 @@
let fileInput: HTMLInputElement;
let cleanupFormRef: HTMLFormElement;
function downloadBackup(filename: string) {
window.location.href = `/api/v1/backups/${filename}`;
function openDownloadModal(filename: string) {
selectedBackup = filename;
showDownloadModal = true;
}
function confirmDownload() {
if (selectedBackup) {
window.location.href = `/api/v1/backups/${selectedBackup}`;
}
showDownloadModal = false;
selectedBackup = null;
}
function cancelDownload() {
showDownloadModal = false;
selectedBackup = null;
}
function triggerFileUpload() {
@@ -252,7 +267,7 @@
icon={Download}
size="xs"
tooltip="Download"
on:click={() => downloadBackup(row.filename)}
on:click={() => openDownloadModal(row.filename)}
/>
<form
@@ -263,13 +278,13 @@
if (result.type === 'failure' && result.data) {
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to restore backup'
(result.data as { error?: string }).error || 'Failed to stage restore'
);
} else if (result.type === 'success') {
alertStore.add(
'success',
'Backup restored successfully. Please restart the application.'
);
const message =
(result.data as { message?: string } | undefined)?.message ||
'Backup staged. Restart Profilarr to apply.';
alertStore.add('success', message);
}
await update();
};
@@ -318,7 +333,7 @@
<Modal
open={showRestoreModal}
header="Restore Backup"
bodyMessage="Restoring this backup will replace all current data with the data from the backup. This action cannot be undone. You will need to restart the application after restoring.{selectedBackup
bodyMessage="This will stage the backup to be applied on the next restart. When Profilarr restarts, all current data will be replaced with the contents of this backup. The replacement cannot be undone after restart.{selectedBackup
? `\n\nBackup: ${selectedBackup}`
: ''}"
confirmText="Restore Backup"
@@ -326,3 +341,33 @@
on:confirm={confirmRestore}
on:cancel={cancelRestore}
/>
<!-- Download Confirmation Modal -->
<Modal
open={showDownloadModal}
header="Download Backup"
confirmText="Download"
cancelText="Cancel"
on:confirm={confirmDownload}
on:cancel={cancelDownload}
>
<div slot="body" class="space-y-3 text-sm text-neutral-600 dark:text-neutral-400">
<p>
The local copy on this server is full-fidelity. The downloaded file is sanitized so it's safer
to share. The following will be removed before download:
</p>
<ul class="list-disc space-y-1 pl-5">
{#each data.sanitizedCategories as category}
<li>{category}</li>
{/each}
</ul>
<p>
Restoring the downloaded file on a different host will require re-adding the removed items.
Linked databases (PCD repos), AI/TMDB settings rows, and your backup schedule are preserved
(with their secret values blanked).
</p>
{#if selectedBackup}
<p class="font-mono text-xs">Backup: {selectedBackup}</p>
{/if}
</div>
</Modal>

View File

@@ -1,38 +1,28 @@
/**
* Integration tests: Backup secret stripping
* Integration tests: Backup download sanitization
*
* When a user downloads a backup, the included profilarr.db should NOT
* contain any secrets. This prevents credential leakage if a backup
* file is shared, stored insecurely, or downloaded by a compromised session.
* Local on-disk backup archives are full-fidelity (secrets included). The
* filesystem is the documented trust boundary, so an archive sitting next
* to the live database does not need stripping. The download endpoint
* (GET /api/v1/backups/{filename}) sanitizes on the fly before streaming
* the response, so any backup that leaves the host has secrets removed.
*
* The test seeds known secrets into the live database, creates a backup via
* POST /api/v1/backups, polls the job until complete, downloads and extracts
* the tar.gz, and inspects the database copy inside.
* Sanitize policy (see src/lib/server/utils/backup/sanitize.ts):
* - DELETE arr_instances (cascades through arr-side sync, drift, rename,
* cleanup, upgrade tables)
* - DELETE notification_services (cascades to history)
* - DELETE users, sessions, login_attempts
* - NULL database_instances.personal_access_token (rows preserved)
* - Empty ai_settings.api_key, tmdb_settings.api_key
* - auth_settings.api_key intentionally untouched (bcrypt hash of a
* high-entropy random key, computationally safe to share)
*
* Secrets that must be stripped:
* - arr_instances.api_key (Radarr/Sonarr API keys)
* - database_instances.personal_access_token (GitHub PATs)
* - auth_settings.api_key (bcrypt-hashed Profilarr API key)
* - ai_settings.api_key (OpenAI/Anthropic keys)
* - tmdb_settings.api_key (TMDB key)
* - notification_services.config (JSON with webhook URLs)
* - users (password hashes)
* - sessions (active session tokens)
* - login_attempts (IP addresses)
*
* Tests:
* 1. Backup DB does not contain arr API keys
* 2. Backup DB does not contain database PATs
* 3. Backup DB does not contain Profilarr API key
* 4. Backup DB does not contain AI API key
* 5. Backup DB does not contain TMDB API key
* 6. Backup DB does not contain notification webhook URLs
* 7. Backup DB does not contain user password hashes
* 8. Backup DB does not contain sessions
* 9. Backup DB does not contain login attempts
* The test seeds known secrets, creates a backup via the v1 API, downloads
* it, and inspects the downloaded copy. It also verifies the on-disk
* archive is bytewise unchanged by the download.
*/
import { assertEquals } from '@std/assert';
import { assert, assertEquals, assertNotEquals } from '@std/assert';
import { TestClient } from '$test-harness/client.ts';
import { startServer, stopServer, getDbPath } from '$test-harness/server.ts';
import { createUserDirect, login } from '$test-harness/setup.ts';
@@ -42,6 +32,7 @@ import { hash } from '@felix/bcrypt';
const PORT = 7017;
const ORIGIN = `http://localhost:${PORT}`;
const BACKUPS_DIR = `./dist/integration-${PORT}/backups`;
// Known secrets seeded into the live DB
const ARR_API_KEY = 'sonarr-backup-test-key-abc123';
@@ -52,7 +43,11 @@ const PROFILARR_API_KEY = 'profilarr-backup-test-key-jkl345';
const WEBHOOK_URL = 'https://discord.com/api/webhooks/backup-test-id/backup-test-token';
let backupDbPath: string;
let infoPath: string;
let extractDir: string;
let onDiskBytesBefore: Uint8Array;
let onDiskBytesAfter: Uint8Array;
let liveProfilarrApiKeyHash: string;
async function seedSecrets(dbPath: string) {
const db = openDb(dbPath);
@@ -104,17 +99,26 @@ async function seedSecrets(dbPath: string) {
}
}
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
/**
* Trigger backup via the v1 API, poll the job until complete, download and
* extract the archive. Returns the path to the extracted profilarr.db.
* Trigger backup via the v1 API, poll the job until complete, capture the
* on-disk bytes, download (which sanitizes on the fly), capture the on-disk
* bytes again to verify they're unchanged, then extract the downloaded copy.
*/
async function downloadAndExtractBackup(client: TestClient): Promise<string> {
// Trigger backup creation via API
async function downloadAndExtractBackup(
client: TestClient
): Promise<{ dbPath: string; infoPath: string }> {
const createRes = await client.post('/api/v1/backups', {});
assertEquals(createRes.status, 202, 'Backup creation should return 202');
const { jobId } = await createRes.json();
// Poll job status until complete
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 1000));
const jobRes = await client.get(`/api/v1/jobs/${jobId}`);
@@ -123,7 +127,6 @@ async function downloadAndExtractBackup(client: TestClient): Promise<string> {
if (job.status === 'failure') throw new Error(`Backup job failed: ${job.result?.error}`);
}
// Get the backup filename from the list
const listRes = await client.get('/api/v1/backups');
assertEquals(listRes.status, 200, 'Backup list should return 200');
const backups = await listRes.json();
@@ -131,18 +134,24 @@ async function downloadAndExtractBackup(client: TestClient): Promise<string> {
throw new Error('No backup files found after job completed');
}
const backupFilename = backups[0].filename;
const onDiskArchivePath = `${BACKUPS_DIR}/${backupFilename}`;
// Download the backup
// Capture bytes BEFORE download.
onDiskBytesBefore = await Deno.readFile(onDiskArchivePath);
// Download triggers sanitize-on-egress.
const res = await client.get(`/api/v1/backups/${backupFilename}`);
assertEquals(res.status, 200, 'Backup download should return 200');
// Write to a temp file
// Capture bytes AFTER download. Should match exactly: the on-disk source
// is never mutated.
onDiskBytesAfter = await Deno.readFile(onDiskArchivePath);
const tmpDir = await Deno.makeTempDir({ prefix: 'profilarr-backup-test-' });
const tarPath = `${tmpDir}/backup.tar.gz`;
const data = new Uint8Array(await res.arrayBuffer());
await Deno.writeFile(tarPath, data);
// Extract
extractDir = `${tmpDir}/extracted`;
await Deno.mkdir(extractDir, { recursive: true });
const extract = new Deno.Command('tar', {
@@ -155,10 +164,9 @@ async function downloadAndExtractBackup(client: TestClient): Promise<string> {
throw new Error('Failed to extract backup tar.gz');
}
// Find profilarr.db in extracted contents
const dbPath = `${extractDir}/data/profilarr.db`;
await Deno.stat(dbPath);
return dbPath;
return { dbPath, infoPath: `${extractDir}/data/INFO.json` };
}
setup(async () => {
@@ -166,141 +174,177 @@ setup(async () => {
await createUserDirect(getDbPath(PORT), 'admin', 'password123');
await seedSecrets(getDbPath(PORT));
// Login and download the backup once — all tests inspect the same extracted DB
// Capture the live bcrypt hash so we can later assert it survives the
// sanitize unchanged.
const live = openDb(getDbPath(PORT));
try {
const row = live.prepare('SELECT api_key FROM auth_settings WHERE id = 1').get() as {
api_key: string;
};
liveProfilarrApiKeyHash = row.api_key;
} finally {
live.close();
}
const client = new TestClient(ORIGIN);
await login(client, 'admin', 'password123', ORIGIN);
backupDbPath = await downloadAndExtractBackup(client);
const paths = await downloadAndExtractBackup(client);
backupDbPath = paths.dbPath;
infoPath = paths.infoPath;
});
teardown(async () => {
await stopServer(PORT);
if (extractDir) {
try {
// extractDir is inside a temp dir — remove the parent
const tmpDir = extractDir.replace('/extracted', '');
await Deno.remove(tmpDir, { recursive: true });
} catch {
// Cleanup is best-effort
// best effort
}
}
});
test('backup DB does not contain arr API keys', () => {
// ─── Row deletion ────────────────────────────────────────────────────────────
test('downloaded archive: arr_instances rows are deleted', () => {
const db = openDb(backupDbPath);
try {
const rows = db.prepare('SELECT api_key FROM arr_instances').all() as { api_key: string }[];
for (const row of rows) {
assertEquals(row.api_key === ARR_API_KEY, false, 'Backup DB contains plaintext arr API key');
}
const row = db.prepare('SELECT COUNT(*) as count FROM arr_instances').get() as {
count: number;
};
assertEquals(row.count, 0, 'arr_instances should be empty in downloaded archive');
} finally {
db.close();
}
});
test('backup DB does not contain database PATs', () => {
test('downloaded archive: notification_services rows are deleted', () => {
const db = openDb(backupDbPath);
try {
const row = db.prepare('SELECT COUNT(*) as count FROM notification_services').get() as {
count: number;
};
assertEquals(row.count, 0, 'notification_services should be empty in downloaded archive');
} finally {
db.close();
}
});
test('downloaded archive: users are deleted', () => {
const db = openDb(backupDbPath);
try {
const rows = db.prepare('SELECT * FROM users').all();
assertEquals(rows.length, 0);
} finally {
db.close();
}
});
test('downloaded archive: sessions are deleted', () => {
const db = openDb(backupDbPath);
try {
const rows = db.prepare('SELECT * FROM sessions').all();
assertEquals(rows.length, 0);
} finally {
db.close();
}
});
test('downloaded archive: login_attempts are deleted', () => {
const db = openDb(backupDbPath);
try {
const rows = db.prepare('SELECT * FROM login_attempts').all();
assertEquals(rows.length, 0);
} finally {
db.close();
}
});
// ─── Field blanking ──────────────────────────────────────────────────────────
test('downloaded archive: database_instances rows preserved with PAT nulled', () => {
const db = openDb(backupDbPath);
try {
const rows = db.prepare('SELECT personal_access_token FROM database_instances').all() as {
personal_access_token: string | null;
}[];
assert(
rows.length > 0,
'database_instances rows should be preserved (PCD repos remain linked)'
);
for (const row of rows) {
assertEquals(
row.personal_access_token === DB_PAT,
false,
'Backup DB contains plaintext database PAT'
);
assertEquals(row.personal_access_token, null, 'PAT should be NULL in downloaded archive');
}
} finally {
db.close();
}
});
test('backup DB does not contain Profilarr API key', () => {
const db = openDb(backupDbPath);
try {
const row = db.prepare('SELECT api_key FROM auth_settings WHERE id = 1').get() as {
api_key: string | null;
};
assertEquals(row.api_key, null, 'Backup DB contains Profilarr API key hash');
} finally {
db.close();
}
});
test('backup DB does not contain AI API key', () => {
test('downloaded archive: AI api_key is blanked', () => {
const db = openDb(backupDbPath);
try {
const row = db.prepare('SELECT api_key FROM ai_settings WHERE id = 1').get() as
| {
api_key: string | null;
}
| { api_key: string }
| undefined;
if (row) {
assertEquals(row.api_key === AI_API_KEY, false, 'Backup DB contains plaintext AI API key');
assertNotEquals(row.api_key, AI_API_KEY, 'AI api_key plaintext should be removed');
}
} finally {
db.close();
}
});
test('backup DB does not contain TMDB API key', () => {
test('downloaded archive: TMDB api_key is blanked', () => {
const db = openDb(backupDbPath);
try {
const row = db.prepare('SELECT api_key FROM tmdb_settings WHERE id = 1').get() as {
api_key: string;
};
assertEquals(row.api_key === TMDB_API_KEY, false, 'Backup DB contains plaintext TMDB API key');
assertNotEquals(row.api_key, TMDB_API_KEY, 'TMDB api_key plaintext should be removed');
} finally {
db.close();
}
});
test('backup DB does not contain notification webhook URLs', () => {
// ─── Intentionally preserved ─────────────────────────────────────────────────
test('downloaded archive: auth_settings.api_key bcrypt hash is preserved', () => {
const db = openDb(backupDbPath);
try {
const rows = db.prepare('SELECT config FROM notification_services').all() as {
config: string;
}[];
for (const row of rows) {
assertEquals(
row.config.includes(WEBHOOK_URL),
false,
'Backup DB contains webhook URL in notification config'
);
}
const row = db.prepare('SELECT api_key FROM auth_settings WHERE id = 1').get() as {
api_key: string | null;
};
assert(row.api_key !== null, 'auth_settings.api_key should retain its bcrypt hash');
assertEquals(
row.api_key,
liveProfilarrApiKeyHash,
'hash in archive should match the live DB value'
);
assertNotEquals(
row.api_key,
PROFILARR_API_KEY,
'hash should never equal plaintext (sanity check)'
);
} finally {
db.close();
}
});
test('backup DB does not contain user password hashes', () => {
const db = openDb(backupDbPath);
try {
const rows = db.prepare('SELECT * FROM users').all();
assertEquals(rows.length, 0, 'Backup DB still contains user records');
} finally {
db.close();
}
// ─── INFO.json + on-disk integrity ───────────────────────────────────────────
test('downloaded archive: INFO.json has sanitized=true', async () => {
const raw = await Deno.readTextFile(infoPath);
const info = JSON.parse(raw) as { sanitized?: boolean };
assertEquals(info.sanitized, true, 'INFO.json.sanitized should be flipped to true on download');
});
test('backup DB does not contain sessions', () => {
const db = openDb(backupDbPath);
try {
const rows = db.prepare('SELECT * FROM sessions').all();
assertEquals(rows.length, 0, 'Backup DB still contains session records');
} finally {
db.close();
}
});
test('backup DB does not contain login attempts', () => {
const db = openDb(backupDbPath);
try {
const rows = db.prepare('SELECT * FROM login_attempts').all();
assertEquals(rows.length, 0, 'Backup DB still contains login attempt records');
} finally {
db.close();
}
test('local on-disk archive is bytewise unchanged after download', () => {
assertEquals(
bytesEqual(onDiskBytesBefore, onDiskBytesAfter),
true,
'Download must not modify the source archive on disk'
);
});
await run();

View File

@@ -19,14 +19,21 @@ let client: TestClient;
let unauthClient: TestClient;
let apiKeyClient: TestClient;
/** Create a dummy backup file for testing. */
/**
* Create a dummy backup file for testing.
*
* The download endpoint extracts the archive and re-tars `${tmp}/data` after
* sanitizing, so dummies must contain a `data/` directory at the archive root
* for the download path to succeed. The tests below only check status and
* headers, so we don't need a real DB inside.
*/
async function createDummyBackup(filename: string, content: string = 'dummy'): Promise<void> {
// Create a real (tiny) tar.gz so download tests get valid gzip content
const tmpDir = await Deno.makeTempDir();
await Deno.writeTextFile(`${tmpDir}/data.txt`, content);
await Deno.mkdir(`${tmpDir}/data`);
await Deno.writeTextFile(`${tmpDir}/data/data.txt`, content);
const cmd = new Deno.Command('tar', {
args: ['-czf', `${BACKUPS_DIR}/${filename}`, '-C', tmpDir, 'data.txt'],
args: ['-czf', `${BACKUPS_DIR}/${filename}`, '-C', tmpDir, 'data'],
stdout: 'null',
stderr: 'null'
});

View File

@@ -53,16 +53,19 @@ function seedData(dbPath: string) {
}
/**
* Create a backup on server A via the v1 API, poll until complete,
* download it, and return the raw tar.gz bytes.
* Create a backup on server A via the v1 API, poll until complete, and
* return the on-disk archive bytes.
*
* Reads from the filesystem rather than via GET /api/v1/backups/{filename}
* because the download endpoint sanitizes on the fly (deletes arr instances
* etc.) and this test wants to verify a full-fidelity restore. Reading from
* disk simulates an operator copying the file directly off the host.
*/
async function createAndDownloadBackup(client: TestClient): Promise<Uint8Array> {
// Trigger
async function createAndReadBackup(client: TestClient, port: number): Promise<Uint8Array> {
const createRes = await client.post('/api/v1/backups', {});
assertEquals(createRes.status, 202, 'Backup creation should return 202');
const { jobId } = await createRes.json();
// Poll job until complete
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 1000));
const jobRes = await client.get(`/api/v1/jobs/${jobId}`);
@@ -71,15 +74,11 @@ async function createAndDownloadBackup(client: TestClient): Promise<Uint8Array>
if (job.status === 'failure') throw new Error(`Backup job failed: ${job.result?.error}`);
}
// Get filename and download
const listRes = await client.get('/api/v1/backups');
const backups = await listRes.json();
if (backups.length === 0) throw new Error('No backup files after job completed');
const res = await client.get(`/api/v1/backups/${backups[0].filename}`);
assertEquals(res.status, 200, 'Backup download should return 200');
return new Uint8Array(await res.arrayBuffer());
return await Deno.readFile(`./dist/integration-${port}/backups/${backups[0].filename}`);
}
/**
@@ -116,7 +115,7 @@ setup(async () => {
seedData(getDbPath(PORT_A));
const archiveBytes = await createAndDownloadBackup(clientA);
const archiveBytes = await createAndReadBackup(clientA, PORT_A);
await stopServer(PORT_A);
// ── Server B: restore from backup ────────────────────────────────────

View File

@@ -0,0 +1,120 @@
/**
* Integration tests for the restore + cancelRestore form actions on
* `/settings/backups`. Coverage:
*
* - restoreBackup writes `${base}/.restore-pending` containing the resolved
* archive path.
* - cancelRestore removes the sentinel.
* - cancelRestore is idempotent when the sentinel is already absent.
*
* The actions don't require a real backup archive to be a valid SQLite DB;
* they only stat the file before staging. A small dummy tar.gz next to the
* other backups is enough.
*/
import { assert, assertEquals } from '@std/assert';
import { TestClient } from '$test-harness/client.ts';
import { startServer, stopServer } from '$test-harness/server.ts';
import { createUser, login } from '$test-harness/setup.ts';
import { setup, teardown, test, run } from '$test-harness/runner.ts';
const PORT = 7040;
const ORIGIN = `http://localhost:${PORT}`;
const BASE = `./dist/integration-${PORT}`;
const BACKUPS_DIR = `${BASE}/backups`;
const SENTINEL_PATH = `${BASE}/.restore-pending`;
const BACKUP_FILENAME = 'backup-2099-01-01-000000.tar.gz';
let client: TestClient;
async function exists(path: string): Promise<boolean> {
try {
await Deno.stat(path);
return true;
} catch (err) {
if (err instanceof Deno.errors.NotFound) return false;
throw err;
}
}
async function createDummyBackup(filename: string): Promise<void> {
const tmpDir = await Deno.makeTempDir();
await Deno.mkdir(`${tmpDir}/data`);
await Deno.writeTextFile(`${tmpDir}/data/data.txt`, 'dummy');
const tar = new Deno.Command('tar', {
args: ['-czf', `${BACKUPS_DIR}/${filename}`, '-C', tmpDir, 'data'],
stdout: 'null',
stderr: 'null'
});
await tar.output();
await Deno.remove(tmpDir, { recursive: true });
}
setup(async () => {
await startServer(PORT, { AUTH: 'on', ORIGIN }, 'preview');
client = new TestClient(ORIGIN);
await createUser(client, 'admin', 'password123', ORIGIN);
await login(client, 'admin', 'password123', ORIGIN);
await createDummyBackup(BACKUP_FILENAME);
});
teardown(async () => {
await stopServer(PORT);
});
test('restoreBackup writes a sentinel pointing at the resolved archive path', async () => {
// Make sure we start clean.
if (await exists(SENTINEL_PATH)) await Deno.remove(SENTINEL_PATH);
const res = await client.postForm(
'/settings/backups?/restoreBackup',
{ filename: BACKUP_FILENAME },
{ headers: { Origin: ORIGIN } }
);
// SvelteKit form actions reply with 200/303 depending on Accept negotiation.
// We don't depend on a specific status; we depend on the side effect.
assert(res.status < 400, `Expected non-error status, got ${res.status}`);
await res.body?.cancel();
assertEquals(await exists(SENTINEL_PATH), true, 'sentinel should be written');
const contents = (await Deno.readTextFile(SENTINEL_PATH)).trim();
assert(
contents.endsWith(`/backups/${BACKUP_FILENAME}`),
`sentinel should point at the archive (got ${contents})`
);
});
test('cancelRestore removes the sentinel', async () => {
// Pre-condition: a sentinel exists from the previous test.
if (!(await exists(SENTINEL_PATH))) {
await Deno.writeTextFile(SENTINEL_PATH, `${BACKUPS_DIR}/${BACKUP_FILENAME}`);
}
const res = await client.postForm(
'/settings/backups?/cancelRestore',
{},
{ headers: { Origin: ORIGIN } }
);
assert(res.status < 400, `Expected non-error status, got ${res.status}`);
await res.body?.cancel();
assertEquals(await exists(SENTINEL_PATH), false, 'sentinel should be removed');
});
test('cancelRestore is idempotent when no sentinel exists', async () => {
// Make sure no sentinel.
if (await exists(SENTINEL_PATH)) await Deno.remove(SENTINEL_PATH);
const res = await client.postForm(
'/settings/backups?/cancelRestore',
{},
{ headers: { Origin: ORIGIN } }
);
assert(res.status < 400, `Expected non-error status, got ${res.status}`);
await res.body?.cancel();
assertEquals(await exists(SENTINEL_PATH), false);
});
await run();

View File

@@ -10,7 +10,7 @@ import { startServer, stopServer, getDbPath } from '$test-harness/server.ts';
import { TestClient } from '$test-harness/client.ts';
import { createUser, login, setApiKey } from '$test-harness/setup.ts';
const PORT = 7034;
const PORT = 7037;
const ORIGIN = `http://localhost:${PORT}`;
const API_KEY = 'test-api-key-backups-settings-123';

View File

@@ -267,7 +267,7 @@ async function runIntegration(target?: string): Promise<number> {
// Determine which suites to run
const suitesToRun = suite
? [suite]
: ['auth', 'api', 'conflicts', 'notifications', 'announcements'];
: ['auth', 'api', 'conflicts', 'notifications', 'announcements', 'backups'];
// Docker is needed when running auth specs (all or specific ones that need it)
const runningAuthSpecs = suitesToRun.includes('auth');

View File

@@ -0,0 +1,225 @@
/**
* Unit tests for `applyPendingRestore()` from
* `src/lib/server/utils/backup/applyPending.ts`.
*
* The function is pure filesystem work (extract a tar.gz, swap files, remove
* a sentinel). No DB schema or running server is needed. We isolate state
* per test by pointing `config.setBasePath` at a per-test temp dir.
*
* Branches covered:
* - no sentinel -> noop, no throw
* - sentinel + missing archive -> sentinel removed, live data preserved
* - sentinel + corrupt archive -> throws, sentinel preserved, staging cleaned,
* live data preserved
* - sentinel + archive without `data/profilarr.db` -> throws, sentinel preserved,
* staging cleaned, live data preserved
* - happy path -> sidecars wiped, archive applied, sentinel + staging removed
* - pre-existing `.restoring/` from a crashed previous apply is wiped
* before extract
*/
import { BaseTest, type TestContext } from '../base/BaseTest.ts';
import { assertEquals, assertRejects } from '@std/assert';
import { applyPendingRestore } from '../../../src/lib/server/utils/backup/applyPending.ts';
import { config } from '../../../src/lib/server/utils/config/config.ts';
const SENTINEL_NAME = '.restore-pending';
const STAGING_NAME = '.restoring';
/**
* Build a valid tar.gz at `archivePath` containing `data/profilarr.db` and
* `data/INFO.json`. The "DB" is an arbitrary byte string; applyPendingRestore
* doesn't open it, only stat-checks it as a file.
*/
async function createValidArchive(
workDir: string,
archivePath: string,
dbContents: string
): Promise<void> {
const stage = `${workDir}/_stage`;
await Deno.mkdir(`${stage}/data`, { recursive: true });
await Deno.writeTextFile(`${stage}/data/profilarr.db`, dbContents);
await Deno.writeTextFile(
`${stage}/data/INFO.json`,
JSON.stringify({ appVersion: 'test', schemaVersion: 0, sanitized: false })
);
const tar = new Deno.Command('tar', {
args: ['-czf', archivePath, '-C', stage, 'data'],
stdout: 'piped',
stderr: 'piped'
});
const { code } = await tar.output();
if (code !== 0) throw new Error(`createValidArchive: tar failed with exit ${code}`);
await Deno.remove(stage, { recursive: true });
}
/** Build a tar.gz containing only `data/scratch.txt` (no profilarr.db). */
async function createArchiveWithoutDb(workDir: string, archivePath: string): Promise<void> {
const stage = `${workDir}/_stage`;
await Deno.mkdir(`${stage}/data`, { recursive: true });
await Deno.writeTextFile(`${stage}/data/scratch.txt`, 'no db here');
const tar = new Deno.Command('tar', {
args: ['-czf', archivePath, '-C', stage, 'data'],
stdout: 'piped',
stderr: 'piped'
});
const { code } = await tar.output();
if (code !== 0) throw new Error(`createArchiveWithoutDb: tar failed with exit ${code}`);
await Deno.remove(stage, { recursive: true });
}
async function exists(path: string): Promise<boolean> {
try {
await Deno.stat(path);
return true;
} catch (err) {
if (err instanceof Deno.errors.NotFound) return false;
throw err;
}
}
async function readText(path: string): Promise<string> {
return await Deno.readTextFile(path);
}
class ApplyPendingTest extends BaseTest {
protected override async beforeEach(ctx: TestContext): Promise<void> {
// Point config at the per-test temp dir, then create the data/
// subdirectory the same way config.init() would.
config.setBasePath(ctx.tempDir);
await Deno.mkdir(`${ctx.tempDir}/data`, { recursive: true });
}
testNoSentinel(): void {
this.test('no sentinel: returns without error and does not touch data', async (ctx) => {
await Deno.writeTextFile(`${ctx.tempDir}/data/profilarr.db`, 'live');
await applyPendingRestore();
assertEquals(await readText(`${ctx.tempDir}/data/profilarr.db`), 'live');
assertEquals(await exists(`${ctx.tempDir}/${SENTINEL_NAME}`), false);
assertEquals(await exists(`${ctx.tempDir}/${STAGING_NAME}`), false);
});
}
testMissingArchive(): void {
this.test('sentinel pointing at missing archive: clears sentinel, leaves data', async (ctx) => {
await Deno.writeTextFile(`${ctx.tempDir}/data/profilarr.db`, 'live');
await Deno.writeTextFile(
`${ctx.tempDir}/${SENTINEL_NAME}`,
`${ctx.tempDir}/does-not-exist.tar.gz`
);
await applyPendingRestore();
assertEquals(await exists(`${ctx.tempDir}/${SENTINEL_NAME}`), false);
assertEquals(await readText(`${ctx.tempDir}/data/profilarr.db`), 'live');
});
}
testCorruptArchive(): void {
this.test(
'sentinel + corrupt archive: throws, sentinel preserved, data preserved',
async (ctx) => {
await Deno.writeTextFile(`${ctx.tempDir}/data/profilarr.db`, 'live');
const archivePath = `${ctx.tempDir}/junk.tar.gz`;
await Deno.writeFile(archivePath, new Uint8Array([0xff, 0xff, 0xff, 0xff, 0xff]));
await Deno.writeTextFile(`${ctx.tempDir}/${SENTINEL_NAME}`, archivePath);
await assertRejects(() => applyPendingRestore(), Error, 'Restore extraction failed');
assertEquals(await exists(`${ctx.tempDir}/${SENTINEL_NAME}`), true);
assertEquals(await exists(`${ctx.tempDir}/${STAGING_NAME}`), false);
assertEquals(await readText(`${ctx.tempDir}/data/profilarr.db`), 'live');
}
);
}
testArchiveMissingDb(): void {
this.test('sentinel + archive without profilarr.db: throws, data preserved', async (ctx) => {
await Deno.writeTextFile(`${ctx.tempDir}/data/profilarr.db`, 'live');
const archivePath = `${ctx.tempDir}/no-db.tar.gz`;
await createArchiveWithoutDb(ctx.tempDir, archivePath);
await Deno.writeTextFile(`${ctx.tempDir}/${SENTINEL_NAME}`, archivePath);
await assertRejects(() => applyPendingRestore(), Error, 'does not contain data/profilarr.db');
assertEquals(await exists(`${ctx.tempDir}/${SENTINEL_NAME}`), true);
assertEquals(await exists(`${ctx.tempDir}/${STAGING_NAME}`), false);
assertEquals(await readText(`${ctx.tempDir}/data/profilarr.db`), 'live');
});
}
testHappyPath(): void {
this.test(
'happy path: sidecars wiped, archive applied, sentinel and staging removed',
async (ctx) => {
// Live data: stale DB plus sidecars plus a databases/ subtree.
await Deno.writeTextFile(`${ctx.tempDir}/data/profilarr.db`, 'live-OLD');
await Deno.writeTextFile(`${ctx.tempDir}/data/profilarr.db-wal`, 'stale-wal');
await Deno.writeTextFile(`${ctx.tempDir}/data/profilarr.db-shm`, 'stale-shm');
await Deno.mkdir(`${ctx.tempDir}/data/databases`);
await Deno.writeTextFile(`${ctx.tempDir}/data/databases/old.txt`, 'gone');
const archivePath = `${ctx.tempDir}/backup.tar.gz`;
await createValidArchive(ctx.tempDir, archivePath, 'restored-NEW');
await Deno.writeTextFile(`${ctx.tempDir}/${SENTINEL_NAME}`, archivePath);
await applyPendingRestore();
// New DB content moved into place.
assertEquals(await readText(`${ctx.tempDir}/data/profilarr.db`), 'restored-NEW');
// Sidecars wiped (archive doesn't include them, and the live ones are removed pre-swap).
assertEquals(await exists(`${ctx.tempDir}/data/profilarr.db-wal`), false);
assertEquals(await exists(`${ctx.tempDir}/data/profilarr.db-shm`), false);
// Old databases/ subtree replaced.
assertEquals(await exists(`${ctx.tempDir}/data/databases/old.txt`), false);
// INFO.json restored.
assertEquals(await exists(`${ctx.tempDir}/data/INFO.json`), true);
// Sentinel removed last; staging dir cleaned up.
assertEquals(await exists(`${ctx.tempDir}/${SENTINEL_NAME}`), false);
assertEquals(await exists(`${ctx.tempDir}/${STAGING_NAME}`), false);
}
);
}
testLeftoverStagingWiped(): void {
this.test(
'pre-existing .restoring/ from a crashed apply is wiped before extract',
async (ctx) => {
await Deno.writeTextFile(`${ctx.tempDir}/data/profilarr.db`, 'live-OLD');
// Simulate a crashed previous apply: a stale .restoring/ with junk in it.
await Deno.mkdir(`${ctx.tempDir}/${STAGING_NAME}/data`, { recursive: true });
await Deno.writeTextFile(`${ctx.tempDir}/${STAGING_NAME}/junk.txt`, 'leftover');
const archivePath = `${ctx.tempDir}/backup.tar.gz`;
await createValidArchive(ctx.tempDir, archivePath, 'restored-NEW');
await Deno.writeTextFile(`${ctx.tempDir}/${SENTINEL_NAME}`, archivePath);
await applyPendingRestore();
// New content applied, leftover gone, staging dir removed entirely.
assertEquals(await readText(`${ctx.tempDir}/data/profilarr.db`), 'restored-NEW');
assertEquals(await exists(`${ctx.tempDir}/${STAGING_NAME}`), false);
assertEquals(await exists(`${ctx.tempDir}/${SENTINEL_NAME}`), false);
}
);
}
runTests(): void {
this.testNoSentinel();
this.testMissingArchive();
this.testCorruptArchive();
this.testArchiveMissingDb();
this.testHappyPath();
this.testLeftoverStagingWiped();
}
}
const test = new ApplyPendingTest();
await test.runTests();