{"version":3,"file":"backup-Cm1SZw5g.mjs","names":[],"sources":["../src/api/handlers/backup.ts"],"sourcesContent":["/**\n * Backup handlers — portable content backups, on demand and scheduled.\n *\n * A backup is the snapshot format (see `snapshot.ts`) wrapped in a small\n * envelope: all content (including drafts, scheduled, and trashed entries),\n * schema definitions, taxonomies, menus, widgets, revisions, media metadata,\n * and site settings.\n *\n * Deliberately NOT included:\n * - Users, sessions, credentials, API/OAuth tokens (auth data is neither\n *   portable nor safe in a user-downloadable file)\n * - Secrets (`emdash:preview_secret`, plugin config, passkey challenges)\n * - Media binaries (metadata only — the files live in the same bucket the\n *   scheduled archives are written to)\n *\n * For full point-in-time database recovery on Cloudflare, D1 Time Travel\n * covers the last 30 days out of the box; these backups complement it with\n * user-holdable, longer-lived archives.\n */\n\nimport type { Kysely } from \"kysely\";\n\nimport { OptionsRepository } from \"../../database/repositories/options.js\";\nimport type { Database } from \"../../database/types.js\";\nimport type { Storage } from \"../../storage/types.js\";\nimport { VERSION } from \"../../version.js\";\nimport { ErrorCode } from \"../errors.js\";\nimport type { ApiResult } from \"../types.js\";\nimport { generateSnapshot } from \"./snapshot.js\";\n\n// ── Constants ───────────────────────────────────────────────────\n\n/** Storage key prefix for scheduled/manual archives. */\nexport const BACKUP_STORAGE_PREFIX = \"backups/\";\n\n/**\n * Filename prefix within the backups/ folder. Included in the list() prefix\n * so LocalStorage (which matches directory + filename prefix, not flat keys\n * like S3/R2) finds the archives too.\n */\nconst BACKUP_FILE_PREFIX = \"emdash-backup-\";\n\n/** Options key holding the scheduled-backup settings. */\nexport const BACKUP_SETTINGS_KEY = \"emdash:backups\";\n\n/** Options key holding the ISO timestamp of the last scheduled run. */\nconst BACKUP_LAST_RUN_KEY = \"emdash:backups_last_run\";\n\n/** Minimum interval between scheduled backups (23h — daily with cron jitter). */\nconst SCHEDULED_BACKUP_INTERVAL_MS = 23 * 60 * 60 * 1000;\n\n/** Retention bounds for stored archives. */\nexport const BACKUP_RETENTION_MIN = 1;\nexport const BACKUP_RETENTION_MAX = 30;\nconst BACKUP_RETENTION_DEFAULT = 7;\n\n/**\n * Options-table key prefixes included in backups. Site settings plus the\n * site-identity keys (`emdash:site_title`, `emdash:site_tagline`,\n * `emdash:site_url`). Never widen this to a prefix that can match secrets\n * (`emdash:preview_secret`, `plugin:`, `emdash:passkey_pending:`).\n */\nconst BACKUP_OPTION_PREFIXES = [\"site:\", \"emdash:site_\", \"emdash:locale\"];\n\n/**\n * Archive filename shape. Strict allowlist — the download/delete routes\n * interpolate this into a storage key, so it must never contain `/` or `..`.\n * The random suffix makes names unguessable (defense in depth on top of the\n * media route's backups/ deny) and avoids same-second collisions.\n */\nconst ARCHIVE_NAME_PATTERN =\n\t/^emdash-backup-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-[0-9a-f]{8}\\.json$/;\n\nexport function isValidArchiveName(name: string): boolean {\n\treturn ARCHIVE_NAME_PATTERN.test(name);\n}\n\n// ── Types ───────────────────────────────────────────────────────\n\nexport interface BackupSettings {\n\t/** Whether daily scheduled backups to storage are enabled. */\n\tenabled: boolean;\n\t/** How many archives to keep in storage (oldest pruned first). */\n\tretention: number;\n}\n\nexport interface BackupArchive {\n\t/** Filename within the backups/ prefix (no path separators). */\n\tname: string;\n\t/** Size in bytes. */\n\tsize: number;\n\t/** Last-modified timestamp (ISO). */\n\tlastModified: string;\n}\n\nconst DEFAULT_SETTINGS: BackupSettings = {\n\tenabled: false,\n\tretention: BACKUP_RETENTION_DEFAULT,\n};\n\nfunction clampRetention(value: number): number {\n\tif (!Number.isFinite(value)) return BACKUP_RETENTION_DEFAULT;\n\treturn Math.min(BACKUP_RETENTION_MAX, Math.max(BACKUP_RETENTION_MIN, Math.trunc(value)));\n}\n\n// ── Export ──────────────────────────────────────────────────────\n\n/**\n * Generate a full content backup as a JSON string.\n *\n * ponytail: the whole backup is materialized in memory. Fine for the sites\n * EmDash targets today; truly huge databases should use `wrangler d1 export`\n * (documented on the backups docs page). Upgrade path: stream table-by-table.\n */\nexport async function generateBackupJson(db: Kysely<Database>): Promise<string> {\n\tconst snapshot = await generateSnapshot(db, {\n\t\tincludeDrafts: true,\n\t\tincludeTrashed: true,\n\t\toptionPrefixes: BACKUP_OPTION_PREFIXES,\n\t});\n\n\treturn JSON.stringify({\n\t\tformat: \"emdash-backup\",\n\t\tformatVersion: 1,\n\t\temdashVersion: VERSION,\n\t\tgeneratedAt: snapshot.generatedAt,\n\t\tschema: snapshot.schema,\n\t\ttables: snapshot.tables,\n\t});\n}\n\n/** Derive the archive filename for a given date (plus a random suffix). */\nexport function archiveNameForDate(date: Date): string {\n\t// 2026-07-09T08:45:12.345Z → emdash-backup-2026-07-09T08-45-12-1a2b3c4d.json\n\tconst stamp = date.toISOString().slice(0, 19).replaceAll(\":\", \"-\");\n\tconst suffix = crypto.randomUUID().replaceAll(\"-\", \"\").slice(0, 8);\n\treturn `emdash-backup-${stamp}-${suffix}.json`;\n}\n\n// ── Settings ────────────────────────────────────────────────────\n\nexport async function getBackupSettings(db: Kysely<Database>): Promise<BackupSettings> {\n\tconst options = new OptionsRepository(db);\n\tconst stored = await options.get<Partial<BackupSettings>>(BACKUP_SETTINGS_KEY);\n\tif (!stored) return { ...DEFAULT_SETTINGS };\n\treturn {\n\t\tenabled: stored.enabled === true,\n\t\tretention: clampRetention(stored.retention ?? BACKUP_RETENTION_DEFAULT),\n\t};\n}\n\nexport async function updateBackupSettings(\n\tdb: Kysely<Database>,\n\tinput: { enabled: boolean; retention: number },\n): Promise<ApiResult<BackupSettings>> {\n\ttry {\n\t\tconst settings: BackupSettings = {\n\t\t\tenabled: input.enabled,\n\t\t\tretention: clampRetention(input.retention),\n\t\t};\n\t\tconst options = new OptionsRepository(db);\n\t\tawait options.set(BACKUP_SETTINGS_KEY, settings);\n\t\treturn { success: true, data: settings };\n\t} catch (error) {\n\t\tconsole.error(\"[backup] Failed to update settings:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: ErrorCode.BACKUP_SETTINGS_ERROR, message: \"Failed to update backup settings\" },\n\t\t};\n\t}\n}\n\n// ── Archives in storage ─────────────────────────────────────────\n\n/**\n * List stored archives, newest first.\n *\n * ponytail: single unpaginated list. The retention cap (max 30) bounds the\n * archive count, so one page always suffices.\n */\nexport async function listBackupArchives(storage: Storage): Promise<ApiResult<BackupArchive[]>> {\n\ttry {\n\t\tconst result = await storage.list({\n\t\t\tprefix: `${BACKUP_STORAGE_PREFIX}${BACKUP_FILE_PREFIX}`,\n\t\t\tlimit: 100,\n\t\t});\n\t\tconst archives = result.files\n\t\t\t.map((file) => ({\n\t\t\t\tname: file.key.slice(BACKUP_STORAGE_PREFIX.length),\n\t\t\t\tsize: file.size,\n\t\t\t\tlastModified: file.lastModified.toISOString(),\n\t\t\t}))\n\t\t\t.filter((archive) => isValidArchiveName(archive.name))\n\t\t\t.toSorted((a, b) => (a.name < b.name ? 1 : -1));\n\t\treturn { success: true, data: archives };\n\t} catch (error) {\n\t\tconsole.error(\"[backup] Failed to list archives:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: ErrorCode.BACKUP_LIST_ERROR, message: \"Failed to list backup archives\" },\n\t\t};\n\t}\n}\n\n/**\n * Create a backup and store it as an archive, then prune old archives\n * beyond `retention`.\n */\nexport async function runBackupToStorage(\n\tdb: Kysely<Database>,\n\tstorage: Storage,\n\tretention: number,\n): Promise<ApiResult<BackupArchive>> {\n\ttry {\n\t\tconst json = await generateBackupJson(db);\n\t\tconst name = archiveNameForDate(new Date());\n\t\tconst body = new TextEncoder().encode(json);\n\n\t\tawait storage.upload({\n\t\t\tkey: `${BACKUP_STORAGE_PREFIX}${name}`,\n\t\t\tbody,\n\t\t\tcontentType: \"application/json\",\n\t\t});\n\n\t\tawait pruneArchives(storage, clampRetention(retention));\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: { name, size: body.byteLength, lastModified: new Date().toISOString() },\n\t\t};\n\t} catch (error) {\n\t\tconsole.error(\"[backup] Failed to create archive:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: ErrorCode.BACKUP_CREATE_ERROR, message: \"Failed to create backup archive\" },\n\t\t};\n\t}\n}\n\n/** Delete archives beyond the newest `keep`. Failures are logged, not fatal. */\nasync function pruneArchives(storage: Storage, keep: number): Promise<void> {\n\tconst listed = await listBackupArchives(storage);\n\tif (!listed.success) return;\n\n\tfor (const archive of listed.data.slice(keep)) {\n\t\ttry {\n\t\t\tawait storage.delete(`${BACKUP_STORAGE_PREFIX}${archive.name}`);\n\t\t} catch (error) {\n\t\t\tconsole.error(`[backup] Failed to prune archive ${archive.name}:`, error);\n\t\t}\n\t}\n}\n\nexport async function deleteBackupArchive(\n\tstorage: Storage,\n\tname: string,\n): Promise<ApiResult<{ deleted: true }>> {\n\tif (!isValidArchiveName(name)) {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: ErrorCode.VALIDATION_ERROR, message: \"Invalid archive name\" },\n\t\t};\n\t}\n\ttry {\n\t\tawait storage.delete(`${BACKUP_STORAGE_PREFIX}${name}`);\n\t\treturn { success: true, data: { deleted: true } };\n\t} catch (error) {\n\t\tconsole.error(`[backup] Failed to delete archive ${name}:`, error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: ErrorCode.BACKUP_DELETE_ERROR, message: \"Failed to delete backup archive\" },\n\t\t};\n\t}\n}\n\n// ── Scheduled runs ──────────────────────────────────────────────\n\n/**\n * Run a scheduled backup if enabled and due. Called from the maintenance\n * tick alongside scheduled publishing and system cleanup — never from a\n * request. Never throws.\n *\n * ponytail: last-run bookkeeping is a plain read-then-write, so two isolates\n * ticking simultaneously could both back up. Worst case is a duplicate\n * archive that retention prunes; not worth a lock.\n */\nexport async function maybeRunScheduledBackup(\n\tdb: Kysely<Database>,\n\tstorage: Storage | undefined,\n): Promise<void> {\n\ttry {\n\t\tif (!storage) return;\n\n\t\tconst settings = await getBackupSettings(db);\n\t\tif (!settings.enabled) return;\n\n\t\tconst options = new OptionsRepository(db);\n\t\tconst lastRun = await options.get<string>(BACKUP_LAST_RUN_KEY);\n\t\tif (lastRun) {\n\t\t\tconst elapsed = Date.now() - Date.parse(lastRun);\n\t\t\tif (Number.isFinite(elapsed) && elapsed < SCHEDULED_BACKUP_INTERVAL_MS) return;\n\t\t}\n\n\t\tconst result = await runBackupToStorage(db, storage, settings.retention);\n\t\tif (result.success) {\n\t\t\tawait options.set(BACKUP_LAST_RUN_KEY, new Date().toISOString());\n\t\t\tconsole.log(`[backup] Scheduled backup stored: ${result.data.name}`);\n\t\t}\n\t} catch (error) {\n\t\tconsole.error(\"[backup] Scheduled backup failed:\", error);\n\t}\n}\n"],"mappings":"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"}