From 20ab90140541e81cea5bf9557096d930d4d5c909 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:24:40 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(backups):=20admin=20backups=20?= =?UTF-8?q?=E2=80=94=20one-click=20download=20and=20scheduled=20archives?= =?UTF-8?q?=20to=20storage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Backups page under admin settings: download a complete content backup (all content including drafts and trash, schema, taxonomies, menus, widgets, media metadata, site settings — never users or secrets), plus optional daily automatic backups written to the site's storage bucket with configurable retention. Scheduled runs piggyback on the existing maintenance tick. New admin-only backups:manage permission. The public media route now denies keys under backups/. Ref: Discussion #142 --- .changeset/admin-backups.md | 7 + docs/astro.config.mjs | 1 + docs/src/content/docs/guides/backups.mdx | 98 ++++++ packages/admin/src/components/Settings.tsx | 7 + .../components/settings/BackupSettings.tsx | 293 ++++++++++++++++ packages/admin/src/lib/api/backups.ts | 67 ++++ packages/admin/src/router.tsx | 9 + packages/auth/src/rbac.ts | 3 + packages/core/src/api/handlers/backup.ts | 301 +++++++++++++++++ packages/core/src/api/handlers/snapshot.ts | 24 +- packages/core/src/astro/integration/routes.ts | 21 ++ .../astro/routes/api/media/file/[...key].ts | 8 + .../api/settings/backups/archives/[name].ts | 77 +++++ .../api/settings/backups/archives/index.ts | 33 ++ .../routes/api/settings/backups/export.ts | 42 +++ .../routes/api/settings/backups/index.ts | 71 ++++ packages/core/src/emdash-runtime.ts | 6 + .../tests/unit/api/backup-handlers.test.ts | 312 ++++++++++++++++++ .../core/tests/unit/api/backup-routes.test.ts | 92 ++++++ 19 files changed, 1469 insertions(+), 3 deletions(-) create mode 100644 .changeset/admin-backups.md create mode 100644 docs/src/content/docs/guides/backups.mdx create mode 100644 packages/admin/src/components/settings/BackupSettings.tsx create mode 100644 packages/admin/src/lib/api/backups.ts create mode 100644 packages/core/src/api/handlers/backup.ts create mode 100644 packages/core/src/astro/routes/api/settings/backups/archives/[name].ts create mode 100644 packages/core/src/astro/routes/api/settings/backups/archives/index.ts create mode 100644 packages/core/src/astro/routes/api/settings/backups/export.ts create mode 100644 packages/core/src/astro/routes/api/settings/backups/index.ts create mode 100644 packages/core/tests/unit/api/backup-handlers.test.ts create mode 100644 packages/core/tests/unit/api/backup-routes.test.ts diff --git a/.changeset/admin-backups.md b/.changeset/admin-backups.md new file mode 100644 index 0000000000..d915176380 --- /dev/null +++ b/.changeset/admin-backups.md @@ -0,0 +1,7 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +"@emdash-cms/auth": patch +--- + +Adds a Backups page to admin settings: download a complete content backup (all content including drafts and trash, schema, taxonomies, menus, widgets, media metadata, and site settings — never user accounts or secrets) with one click, and optionally enable daily automatic backups to the site's storage bucket with configurable retention. A new `backups:manage` permission gates the feature to admins. diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 157d6f18db..c24a65dd3f 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -78,6 +78,7 @@ export default defineConfig({ { label: "AI Tools", slug: "guides/ai-tools" }, { label: "x402 Payments", slug: "guides/x402-payments" }, { label: "Preview Mode", slug: "guides/preview" }, + { label: "Backups", slug: "guides/backups" }, { label: "Internationalization (i18n)", slug: "guides/internationalization", diff --git a/docs/src/content/docs/guides/backups.mdx b/docs/src/content/docs/guides/backups.mdx new file mode 100644 index 0000000000..61e191cecb --- /dev/null +++ b/docs/src/content/docs/guides/backups.mdx @@ -0,0 +1,98 @@ +--- +title: Backups +description: Download site backups, schedule automatic backups to storage, and restore with D1 Time Travel. +--- + +import { Aside, Steps } from "@astrojs/starlight/components"; + +EmDash gives you three layers of protection for your content, from zero-config point-in-time recovery on Cloudflare to downloadable archives you keep yourself. + +## What's in a backup + +A backup contains everything needed to reconstruct your site's content: + +- All content entries, including drafts, scheduled posts, and trashed items +- Collection and field definitions (your content model) +- Taxonomies and term assignments +- Menus, widgets, sections, and SEO settings +- Revisions and media metadata +- Site settings (title, tagline, display preferences) + +Backups deliberately **exclude**: + +- User accounts, sessions, passkeys, and API tokens — auth data is neither portable nor safe in a downloadable file +- Secrets (preview signing secret, plugin configuration) +- Media binaries — the actual files live in your storage bucket (R2, S3, or local); a backup carries their metadata so references stay intact + +Backups are JSON files in the same snapshot format used by EmDash's preview system, versioned with the EmDash release that created them. + +## One-click download + +Under **Settings → Backups** in the admin, the **Download backup** button generates a fresh backup and downloads it as a JSON file. Requires the admin role. + +This is the right tool before risky operations: bulk imports, schema changes, or major upgrades. + +## Automatic backups to storage + +If your site has a storage backend configured (R2 on Cloudflare, S3, or local storage), you can enable daily automatic backups: + + + +1. Open **Settings → Backups** in the admin. + +2. Toggle **Daily automatic backups** on. + +3. Choose how many backups to keep (1–30). Older archives are pruned automatically. + +4. Save. Backups run as part of EmDash's scheduled maintenance — no extra cron setup needed. + + + +Archives are stored under the `backups/` prefix in your bucket as `emdash-backup-.json`. The **Stored Backups** list in the admin lets you download or delete individual archives, and **Back up now** creates one on demand. + + + +## Point-in-time restore with D1 Time Travel + +If your site runs on Cloudflare D1, you already have full database point-in-time recovery — always on, no configuration: + +```bash +# See the current bookmark (do this before risky operations) +npx wrangler d1 time-travel info my-database + +# Restore the database to a specific timestamp +npx wrangler d1 time-travel restore my-database --timestamp=2026-07-08T13:00:00Z +``` + +Time Travel keeps 30 days of history on the paid plan (7 days on free) with minute-level granularity. It restores the **entire database** — content, users, settings — which makes it the right tool for disaster recovery ("the import went wrong, take me back to an hour ago"). + +See the [D1 Time Travel documentation](https://developers.cloudflare.com/d1/reference/time-travel/) for details. + + + +## Offsite database dumps + +For a complete SQL dump of the raw database (including users and auth tables), use Wrangler: + +```bash +npx wrangler d1 export my-database --remote --output=backup.sql +``` + +On Node deployments, the database is a single SQLite file — copy it while the server is stopped, or use `sqlite3 emdash.db ".backup backup.db"` for a consistent online copy. + +## Restoring a backup + +Restoring from a backup JSON is intentionally not exposed as a one-click admin action yet — overwriting a live database deserves more friction than a button. Current options: + +- **Cloudflare:** use D1 Time Travel (above) for point-in-time restore. +- **Full dumps:** import a `wrangler d1 export` dump with `npx wrangler d1 execute my-database --remote --file=backup.sql`. +- **Backup JSON:** the format matches EmDash's snapshot format; a guided CLI restore is planned. Track [Discussion #142](https://github.com/emdash-cms/emdash/discussions/142). diff --git a/packages/admin/src/components/Settings.tsx b/packages/admin/src/components/Settings.tsx index 25aafb1988..ea60d4616a 100644 --- a/packages/admin/src/components/Settings.tsx +++ b/packages/admin/src/components/Settings.tsx @@ -9,6 +9,7 @@ import { GlobeSimple, Key, Envelope, + DownloadSimple, } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; @@ -115,6 +116,12 @@ export function Settings() { title={t`Email`} description={t`View email provider status and send test emails`} /> + } + title={t`Backups`} + description={t`Download backups and schedule automatic backups to storage`} + /> {/* Language */} diff --git a/packages/admin/src/components/settings/BackupSettings.tsx b/packages/admin/src/components/settings/BackupSettings.tsx new file mode 100644 index 0000000000..a75c81287d --- /dev/null +++ b/packages/admin/src/components/settings/BackupSettings.tsx @@ -0,0 +1,293 @@ +/** + * Backup settings page + * + * One-click full backup download, scheduled backups to the site's storage + * bucket with retention, the list of stored archives, and a pointer to + * D1 Time Travel for point-in-time restore on Cloudflare. + */ + +import { Button, Input, LinkButton, Loader, Switch, useKumoToastManager } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; +import { + Archive, + ClockCounterClockwise, + CloudArrowUp, + DownloadSimple, + Trash, + WarningCircle, +} from "@phosphor-icons/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import * as React from "react"; + +import { + backupArchiveUrl, + BACKUP_EXPORT_URL, + createBackupArchive, + deleteBackupArchive, + fetchBackupOverview, + updateBackupSettings, + type BackupArchive, +} from "../../lib/api/backups.js"; +import { ConfirmDialog } from "../ConfirmDialog.js"; +import { getMutationError } from "../DialogError.js"; +import { BackToSettingsLink } from "./BackToSettingsLink.js"; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function BackupSettings() { + const { t } = useLingui(); + const toastManager = useKumoToastManager(); + const queryClient = useQueryClient(); + const [archiveToDelete, setArchiveToDelete] = React.useState(null); + + const { + data: overview, + isLoading, + error: fetchError, + } = useQuery({ + queryKey: ["backup-overview"], + queryFn: fetchBackupOverview, + }); + + // Local form state seeded from the server once loaded + const [enabled, setEnabled] = React.useState(false); + const [retention, setRetention] = React.useState("7"); + const seeded = React.useRef(false); + React.useEffect(() => { + if (overview && !seeded.current) { + seeded.current = true; + setEnabled(overview.settings.enabled); + setRetention(String(overview.settings.retention)); + } + }, [overview]); + + const saveMutation = useMutation({ + mutationFn: () => { + // Clamp to the server's accepted range so out-of-range input saves + // the nearest valid value instead of failing validation. + const parsed = Number.parseInt(retention, 10); + const clamped = Number.isNaN(parsed) ? 7 : Math.min(30, Math.max(1, parsed)); + return updateBackupSettings({ enabled, retention: clamped }); + }, + onSuccess: (settings) => { + setEnabled(settings.enabled); + setRetention(String(settings.retention)); + void queryClient.invalidateQueries({ queryKey: ["backup-overview"] }); + toastManager.add({ title: t`Backup settings saved`, variant: "success", timeout: 4000 }); + }, + onError: (error) => { + toastManager.add({ + title: t`Failed to save backup settings`, + description: getMutationError(error) || t`An error occurred`, + variant: "error", + timeout: 5000, + }); + }, + }); + + const backupNowMutation = useMutation({ + mutationFn: createBackupArchive, + onSuccess: (archive) => { + void queryClient.invalidateQueries({ queryKey: ["backup-overview"] }); + toastManager.add({ + title: t`Backup created: ${archive.name}`, + variant: "success", + timeout: 4000, + }); + }, + onError: (error) => { + toastManager.add({ + title: t`Failed to create backup`, + description: getMutationError(error) || t`An error occurred`, + variant: "error", + timeout: 5000, + }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (name: string) => deleteBackupArchive(name), + onSuccess: () => { + setArchiveToDelete(null); + void queryClient.invalidateQueries({ queryKey: ["backup-overview"] }); + }, + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (fetchError) { + return ( +
+
+ +

{t`Backups`}

+
+
+ + {getMutationError(fetchError) || t`Failed to load backup settings`} +
+
+ ); + } + + const storageAvailable = overview?.storageAvailable ?? false; + const archives = overview?.archives ?? []; + + return ( +
+ {/* Header */} +
+ +

{t`Backups`}

+
+ + {/* One-click download */} +
+
+ +

{t`Download Backup`}

+
+

+ {t`Download a complete backup of your site: all content (including drafts and trash), collections, taxonomies, menus, widgets, media metadata, and site settings. User accounts and secrets are never included.`} +

+ {t`Download backup`} +
+ + {/* Scheduled backups */} +
+
+ +

{t`Automatic Backups`}

+
+ + {storageAvailable ? ( +
+

+ {t`Store a daily backup in your site's storage bucket. Old backups are removed automatically.`} +

+ +
+ setRetention(e.target.value)} + /> +
+
+ + +
+
+ ) : ( +
+ +

+ {t`Automatic backups need a storage backend (R2, S3, or local storage). Configure storage in your EmDash config to enable them.`} +

+
+ )} +
+ + {/* Stored archives */} + {storageAvailable && archives.length > 0 && ( +
+
+ +

{t`Stored Backups`}

+
+
    + {archives.map((archive) => ( +
  • +
    +
    {archive.name}
    +
    + {new Date(archive.lastModified).toLocaleString()} · {formatBytes(archive.size)} +
    +
    +
    + + + + +
    +
  • + ))} +
+
+ )} + + {/* Time Travel hint */} +
+
+ +

{t`Point-in-Time Restore`}

+
+

+ {t`Sites on Cloudflare D1 can additionally restore the database to any minute within the last 30 days using D1 Time Travel — always on, no setup required.`}{" "} + + {t`Learn more`} + +

+
+ + setArchiveToDelete(null)} + title={t`Delete backup?`} + description={t`This permanently deletes ${archiveToDelete?.name ?? ""} from storage.`} + confirmLabel={t`Delete`} + pendingLabel={t`Deleting...`} + isPending={deleteMutation.isPending} + error={deleteMutation.error} + onConfirm={() => { + if (archiveToDelete) deleteMutation.mutate(archiveToDelete.name); + }} + /> +
+ ); +} diff --git a/packages/admin/src/lib/api/backups.ts b/packages/admin/src/lib/api/backups.ts new file mode 100644 index 0000000000..7676d1af50 --- /dev/null +++ b/packages/admin/src/lib/api/backups.ts @@ -0,0 +1,67 @@ +/** + * Backup settings API client functions + */ + +import { i18n } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; + +import { API_BASE, apiFetch, parseApiResponse } from "./client.js"; + +// ============================================================================= +// Types +// ============================================================================= + +export interface BackupSettings { + enabled: boolean; + retention: number; +} + +export interface BackupArchive { + name: string; + size: number; + lastModified: string; +} + +export interface BackupOverview { + settings: BackupSettings; + archives: BackupArchive[]; + storageAvailable: boolean; +} + +// ============================================================================= +// API functions +// ============================================================================= + +export async function fetchBackupOverview(): Promise { + const res = await apiFetch(`${API_BASE}/settings/backups`); + return parseApiResponse(res, i18n._(msg`Failed to fetch backup settings`)); +} + +export async function updateBackupSettings(settings: BackupSettings): Promise { + const res = await apiFetch(`${API_BASE}/settings/backups`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settings), + }); + return parseApiResponse(res, i18n._(msg`Failed to update backup settings`)); +} + +export async function createBackupArchive(): Promise { + const res = await apiFetch(`${API_BASE}/settings/backups/archives`, { method: "POST" }); + return parseApiResponse(res, i18n._(msg`Failed to create backup`)); +} + +export async function deleteBackupArchive(name: string): Promise { + const res = await apiFetch(`${API_BASE}/settings/backups/archives/${encodeURIComponent(name)}`, { + method: "DELETE", + }); + await parseApiResponse<{ deleted: boolean }>(res, i18n._(msg`Failed to delete backup`)); +} + +/** URL for the one-click full backup download (plain GET, session cookie auth). */ +export const BACKUP_EXPORT_URL = `${API_BASE}/settings/backups/export`; + +/** URL for downloading a stored archive. */ +export function backupArchiveUrl(name: string): string { + return `${API_BASE}/settings/backups/archives/${encodeURIComponent(name)}`; +} diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index d0006f7f2c..18a3e77579 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -51,6 +51,7 @@ import { Sections } from "./components/Sections"; import { Settings } from "./components/Settings"; import { AllowedDomainsSettings } from "./components/settings/AllowedDomainsSettings"; import { ApiTokenSettings } from "./components/settings/ApiTokenSettings"; +import { BackupSettings } from "./components/settings/BackupSettings"; import { EmailSettings } from "./components/settings/EmailSettings"; import { GeneralSettings } from "./components/settings/GeneralSettings"; import { SecuritySettings } from "./components/settings/SecuritySettings"; @@ -1436,6 +1437,13 @@ const emailSettingsRoute = createRoute({ component: EmailSettings, }); +// Backup settings route +const backupSettingsRoute = createRoute({ + getParentRoute: () => adminLayoutRoute, + path: "/settings/backups", + component: BackupSettings, +}); + // General settings route const generalSettingsRoute = createRoute({ getParentRoute: () => adminLayoutRoute, @@ -1964,6 +1972,7 @@ const adminRoutes = adminLayoutRoute.addChildren([ allowedDomainsSettingsRoute, apiTokenSettingsRoute, emailSettingsRoute, + backupSettingsRoute, wordpressImportRoute, notFoundRoute, ]); diff --git a/packages/auth/src/rbac.ts b/packages/auth/src/rbac.ts index 0d9333a025..66ceb0f34a 100644 --- a/packages/auth/src/rbac.ts +++ b/packages/auth/src/rbac.ts @@ -86,6 +86,9 @@ export const Permissions = { // Import "import:execute": Role.ADMIN, + // Backups (full content export — admin-only, same tier as settings:manage) + "backups:manage": Role.ADMIN, + // Search "search:read": Role.SUBSCRIBER, "search:manage": Role.ADMIN, diff --git a/packages/core/src/api/handlers/backup.ts b/packages/core/src/api/handlers/backup.ts new file mode 100644 index 0000000000..34e70500fb --- /dev/null +++ b/packages/core/src/api/handlers/backup.ts @@ -0,0 +1,301 @@ +/** + * Backup handlers — portable content backups, on demand and scheduled. + * + * A backup is the snapshot format (see `snapshot.ts`) wrapped in a small + * envelope: all content (including drafts, scheduled, and trashed entries), + * schema definitions, taxonomies, menus, widgets, revisions, media metadata, + * and site settings. + * + * Deliberately NOT included: + * - Users, sessions, credentials, API/OAuth tokens (auth data is neither + * portable nor safe in a user-downloadable file) + * - Secrets (`emdash:preview_secret`, plugin config, passkey challenges) + * - Media binaries (metadata only — the files live in the same bucket the + * scheduled archives are written to) + * + * For full point-in-time database recovery on Cloudflare, D1 Time Travel + * covers the last 30 days out of the box; these backups complement it with + * user-holdable, longer-lived archives. + */ + +import type { Kysely } from "kysely"; + +import { OptionsRepository } from "../../database/repositories/options.js"; +import type { Database } from "../../database/types.js"; +import type { Storage } from "../../storage/types.js"; +import { VERSION } from "../../version.js"; +import type { ApiResult } from "../types.js"; +import { generateSnapshot } from "./snapshot.js"; + +// ── Constants ─────────────────────────────────────────────────── + +/** Storage key prefix for scheduled/manual archives. */ +export const BACKUP_STORAGE_PREFIX = "backups/"; + +/** Options key holding the scheduled-backup settings. */ +export const BACKUP_SETTINGS_KEY = "emdash:backups"; + +/** Options key holding the ISO timestamp of the last scheduled run. */ +const BACKUP_LAST_RUN_KEY = "emdash:backups_last_run"; + +/** Minimum interval between scheduled backups (23h — daily with cron jitter). */ +const SCHEDULED_BACKUP_INTERVAL_MS = 23 * 60 * 60 * 1000; + +/** Retention bounds for stored archives. */ +export const BACKUP_RETENTION_MIN = 1; +export const BACKUP_RETENTION_MAX = 30; +const BACKUP_RETENTION_DEFAULT = 7; + +/** + * Options-table key prefixes included in backups. Site settings plus the + * site-identity keys (`emdash:site_title`, `emdash:site_tagline`, + * `emdash:site_url`). Never widen this to a prefix that can match secrets + * (`emdash:preview_secret`, `plugin:`, `emdash:passkey_pending:`). + */ +const BACKUP_OPTION_PREFIXES = ["site:", "emdash:site_", "emdash:locale"]; + +/** + * Archive filename shape. Strict allowlist — the download/delete routes + * interpolate this into a storage key, so it must never contain `/` or `..`. + * The random suffix makes names unguessable (defense in depth on top of the + * media route's backups/ deny) and avoids same-second collisions. + */ +const ARCHIVE_NAME_PATTERN = + /^emdash-backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-[0-9a-f]{8}\.json$/; + +export function isValidArchiveName(name: string): boolean { + return ARCHIVE_NAME_PATTERN.test(name); +} + +// ── Types ─────────────────────────────────────────────────────── + +export interface BackupSettings { + /** Whether daily scheduled backups to storage are enabled. */ + enabled: boolean; + /** How many archives to keep in storage (oldest pruned first). */ + retention: number; +} + +export interface BackupArchive { + /** Filename within the backups/ prefix (no path separators). */ + name: string; + /** Size in bytes. */ + size: number; + /** Last-modified timestamp (ISO). */ + lastModified: string; +} + +const DEFAULT_SETTINGS: BackupSettings = { + enabled: false, + retention: BACKUP_RETENTION_DEFAULT, +}; + +function clampRetention(value: number): number { + if (!Number.isFinite(value)) return BACKUP_RETENTION_DEFAULT; + return Math.min(BACKUP_RETENTION_MAX, Math.max(BACKUP_RETENTION_MIN, Math.trunc(value))); +} + +// ── Export ────────────────────────────────────────────────────── + +/** + * Generate a full content backup as a JSON string. + * + * ponytail: the whole backup is materialized in memory. Fine for the sites + * EmDash targets today; truly huge databases should use `wrangler d1 export` + * (documented on the backups docs page). Upgrade path: stream table-by-table. + */ +export async function generateBackupJson(db: Kysely): Promise { + const snapshot = await generateSnapshot(db, { + includeDrafts: true, + includeTrashed: true, + optionPrefixes: BACKUP_OPTION_PREFIXES, + }); + + return JSON.stringify({ + format: "emdash-backup", + formatVersion: 1, + emdashVersion: VERSION, + generatedAt: snapshot.generatedAt, + schema: snapshot.schema, + tables: snapshot.tables, + }); +} + +/** Derive the archive filename for a given date (plus a random suffix). */ +export function archiveNameForDate(date: Date): string { + // 2026-07-09T08:45:12.345Z → emdash-backup-2026-07-09T08-45-12-1a2b3c4d.json + const stamp = date.toISOString().slice(0, 19).replaceAll(":", "-"); + const suffix = crypto.randomUUID().replaceAll("-", "").slice(0, 8); + return `emdash-backup-${stamp}-${suffix}.json`; +} + +// ── Settings ──────────────────────────────────────────────────── + +export async function getBackupSettings(db: Kysely): Promise { + const options = new OptionsRepository(db); + const stored = await options.get>(BACKUP_SETTINGS_KEY); + if (!stored) return { ...DEFAULT_SETTINGS }; + return { + enabled: stored.enabled === true, + retention: clampRetention(stored.retention ?? BACKUP_RETENTION_DEFAULT), + }; +} + +export async function updateBackupSettings( + db: Kysely, + input: { enabled: boolean; retention: number }, +): Promise> { + try { + const settings: BackupSettings = { + enabled: input.enabled, + retention: clampRetention(input.retention), + }; + const options = new OptionsRepository(db); + await options.set(BACKUP_SETTINGS_KEY, settings); + return { success: true, data: settings }; + } catch (error) { + console.error("[backup] Failed to update settings:", error); + return { + success: false, + error: { code: "BACKUP_SETTINGS_ERROR", message: "Failed to update backup settings" }, + }; + } +} + +// ── Archives in storage ───────────────────────────────────────── + +/** + * List stored archives, newest first. + * + * ponytail: single unpaginated list. The retention cap (max 30) bounds the + * archive count, so one page always suffices. + */ +export async function listBackupArchives(storage: Storage): Promise> { + try { + const result = await storage.list({ prefix: BACKUP_STORAGE_PREFIX, limit: 100 }); + const archives = result.files + .map((file) => ({ + name: file.key.slice(BACKUP_STORAGE_PREFIX.length), + size: file.size, + lastModified: file.lastModified.toISOString(), + })) + .filter((archive) => isValidArchiveName(archive.name)) + .toSorted((a, b) => (a.name < b.name ? 1 : -1)); + return { success: true, data: archives }; + } catch (error) { + console.error("[backup] Failed to list archives:", error); + return { + success: false, + error: { code: "BACKUP_LIST_ERROR", message: "Failed to list backup archives" }, + }; + } +} + +/** + * Create a backup and store it as an archive, then prune old archives + * beyond `retention`. + */ +export async function runBackupToStorage( + db: Kysely, + storage: Storage, + retention: number, +): Promise> { + try { + const json = await generateBackupJson(db); + const name = archiveNameForDate(new Date()); + const body = new TextEncoder().encode(json); + + await storage.upload({ + key: `${BACKUP_STORAGE_PREFIX}${name}`, + body, + contentType: "application/json", + }); + + await pruneArchives(storage, clampRetention(retention)); + + return { + success: true, + data: { name, size: body.byteLength, lastModified: new Date().toISOString() }, + }; + } catch (error) { + console.error("[backup] Failed to create archive:", error); + return { + success: false, + error: { code: "BACKUP_CREATE_ERROR", message: "Failed to create backup archive" }, + }; + } +} + +/** Delete archives beyond the newest `keep`. Failures are logged, not fatal. */ +async function pruneArchives(storage: Storage, keep: number): Promise { + const listed = await listBackupArchives(storage); + if (!listed.success) return; + + for (const archive of listed.data.slice(keep)) { + try { + await storage.delete(`${BACKUP_STORAGE_PREFIX}${archive.name}`); + } catch (error) { + console.error(`[backup] Failed to prune archive ${archive.name}:`, error); + } + } +} + +export async function deleteBackupArchive( + storage: Storage, + name: string, +): Promise> { + if (!isValidArchiveName(name)) { + return { + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid archive name" }, + }; + } + try { + await storage.delete(`${BACKUP_STORAGE_PREFIX}${name}`); + return { success: true, data: { deleted: true } }; + } catch (error) { + console.error(`[backup] Failed to delete archive ${name}:`, error); + return { + success: false, + error: { code: "BACKUP_DELETE_ERROR", message: "Failed to delete backup archive" }, + }; + } +} + +// ── Scheduled runs ────────────────────────────────────────────── + +/** + * Run a scheduled backup if enabled and due. Called from the maintenance + * tick alongside scheduled publishing and system cleanup — never from a + * request. Never throws. + * + * ponytail: last-run bookkeeping is a plain read-then-write, so two isolates + * ticking simultaneously could both back up. Worst case is a duplicate + * archive that retention prunes; not worth a lock. + */ +export async function maybeRunScheduledBackup( + db: Kysely, + storage: Storage | undefined, +): Promise { + try { + if (!storage) return; + + const settings = await getBackupSettings(db); + if (!settings.enabled) return; + + const options = new OptionsRepository(db); + const lastRun = await options.get(BACKUP_LAST_RUN_KEY); + if (lastRun) { + const elapsed = Date.now() - Date.parse(lastRun); + if (Number.isFinite(elapsed) && elapsed < SCHEDULED_BACKUP_INTERVAL_MS) return; + } + + const result = await runBackupToStorage(db, storage, settings.retention); + if (result.success) { + await options.set(BACKUP_LAST_RUN_KEY, new Date().toISOString()); + console.log(`[backup] Scheduled backup stored: ${result.data.name}`); + } + } catch (error) { + console.error("[backup] Scheduled backup failed:", error); + } +} diff --git a/packages/core/src/api/handlers/snapshot.ts b/packages/core/src/api/handlers/snapshot.ts index fcc054911e..892440c961 100644 --- a/packages/core/src/api/handlers/snapshot.ts +++ b/packages/core/src/api/handlers/snapshot.ts @@ -218,10 +218,19 @@ interface ColumnInfo { } export interface GenerateSnapshotOptions { - /** Include draft and trashed content (default: false) */ + /** Include draft and scheduled content (default: false) */ includeDrafts?: boolean; + /** Include trashed content (deleted_at set). Used by backups (default: false) */ + includeTrashed?: boolean; /** Origin URL for absolutizing local media URLs (e.g. "https://mysite.com") */ origin?: string; + /** + * Allowlist of options-table key prefixes to include (default: + * `SAFE_OPTIONS_PREFIXES`). Callers widening this must never include a + * prefix that matches secrets (`emdash:preview_secret`, `plugin:`, + * `emdash:passkey_pending:`) — the output may be user-downloadable. + */ + optionPrefixes?: string[]; } /** @@ -235,6 +244,8 @@ export async function generateSnapshot( options?: GenerateSnapshotOptions, ): Promise { const includeDrafts = options?.includeDrafts ?? false; + const includeTrashed = options?.includeTrashed ?? false; + const optionPrefixes = options?.optionPrefixes ?? SAFE_OPTIONS_PREFIXES; // Discover all ec_* content tables const tableResult = await sql<{ name: string }>` @@ -280,7 +291,14 @@ export async function generateSnapshot( let rows: Record[]; if (tableName.startsWith("ec_")) { - if (includeDrafts) { + if (includeTrashed) { + // Everything, including trash — full-fidelity backup export + rows = ( + await sql>` + SELECT * FROM ${sql.raw(`"${tableName}"`)} + `.execute(db) + ).rows; + } else if (includeDrafts) { // Include all non-deleted content (published, draft, scheduled) rows = ( await sql>` @@ -307,7 +325,7 @@ export async function generateSnapshot( `.execute(db) ).rows.filter((row) => { const name = typeof row.name === "string" ? row.name : ""; - return SAFE_OPTIONS_PREFIXES.some((prefix) => name.startsWith(prefix)); + return optionPrefixes.some((prefix) => name.startsWith(prefix)); }); } else { rows = ( diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index 7b818cadba..2174640914 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -346,6 +346,27 @@ export function injectCoreRoutes( entrypoint: resolveRoute("api/settings/email.ts"), }); + // Backup routes + injectRoute({ + pattern: "/_emdash/api/settings/backups", + entrypoint: resolveRoute("api/settings/backups/index.ts"), + }); + + injectRoute({ + pattern: "/_emdash/api/settings/backups/export", + entrypoint: resolveRoute("api/settings/backups/export.ts"), + }); + + injectRoute({ + pattern: "/_emdash/api/settings/backups/archives", + entrypoint: resolveRoute("api/settings/backups/archives/index.ts"), + }); + + injectRoute({ + pattern: "/_emdash/api/settings/backups/archives/[name]", + entrypoint: resolveRoute("api/settings/backups/archives/[name].ts"), + }); + // Snapshot route (for DO preview database population) injectRoute({ pattern: "/_emdash/api/snapshot", diff --git a/packages/core/src/astro/routes/api/media/file/[...key].ts b/packages/core/src/astro/routes/api/media/file/[...key].ts index 4dc231de0d..f9b0583c25 100644 --- a/packages/core/src/astro/routes/api/media/file/[...key].ts +++ b/packages/core/src/astro/routes/api/media/file/[...key].ts @@ -36,6 +36,14 @@ export const GET: APIRoute = async ({ params, locals }) => { return apiError("NOT_FOUND", "File not found", 404); } + // Backup archives share the storage bucket but hold the site's full + // content export — they must never be reachable through the public, + // unauthenticated media route. Admins download them via the + // authenticated backups API. + if (key.startsWith("backups/")) { + return apiError("NOT_FOUND", "File not found", 404); + } + if (!emdash?.storage) { return apiError("NOT_CONFIGURED", "Storage not configured", 500); } diff --git a/packages/core/src/astro/routes/api/settings/backups/archives/[name].ts b/packages/core/src/astro/routes/api/settings/backups/archives/[name].ts new file mode 100644 index 0000000000..cb69506eea --- /dev/null +++ b/packages/core/src/astro/routes/api/settings/backups/archives/[name].ts @@ -0,0 +1,77 @@ +/** + * Backup archives — download / delete + * + * GET /_emdash/api/settings/backups/archives/:name — download an archive. + * DELETE /_emdash/api/settings/backups/archives/:name — delete an archive. + * + * `name` is validated against the strict archive-filename pattern before it + * touches a storage key, so path traversal into other prefixes is impossible. + */ + +import type { APIRoute } from "astro"; + +import { requirePerm } from "#api/authorize.js"; +import { apiError, handleError, unwrapResult } from "#api/error.js"; +import { + BACKUP_STORAGE_PREFIX, + deleteBackupArchive, + isValidArchiveName, +} from "#api/handlers/backup.js"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params, locals }) => { + const { emdash, user } = locals; + const name = params.name ?? ""; + + if (!emdash?.db) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + + const denied = requirePerm(user, "backups:manage"); + if (denied) return denied; + + if (!emdash.storage) { + return apiError("STORAGE_NOT_CONFIGURED", "No storage backend is configured", 503); + } + + if (!isValidArchiveName(name)) { + return apiError("VALIDATION_ERROR", "Invalid archive name", 400); + } + + try { + const result = await emdash.storage.download(`${BACKUP_STORAGE_PREFIX}${name}`); + return new Response(result.body, { + status: 200, + headers: { + "Content-Type": "application/json", + "Content-Disposition": `attachment; filename="${name}"`, + "Cache-Control": "private, no-store", + "X-Content-Type-Options": "nosniff", + }, + }); + } catch (error) { + if (error instanceof Error && error.message.toLowerCase().includes("not found")) { + return apiError("NOT_FOUND", "Archive not found", 404); + } + return handleError(error, "Failed to download archive", "BACKUP_DOWNLOAD_ERROR"); + } +}; + +export const DELETE: APIRoute = async ({ params, locals }) => { + const { emdash, user } = locals; + const name = params.name ?? ""; + + if (!emdash?.db) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + + const denied = requirePerm(user, "backups:manage"); + if (denied) return denied; + + if (!emdash.storage) { + return apiError("STORAGE_NOT_CONFIGURED", "No storage backend is configured", 503); + } + + return unwrapResult(await deleteBackupArchive(emdash.storage, name)); +}; diff --git a/packages/core/src/astro/routes/api/settings/backups/archives/index.ts b/packages/core/src/astro/routes/api/settings/backups/archives/index.ts new file mode 100644 index 0000000000..3aaba51b3b --- /dev/null +++ b/packages/core/src/astro/routes/api/settings/backups/archives/index.ts @@ -0,0 +1,33 @@ +/** + * Backup archives — create + * + * POST /_emdash/api/settings/backups/archives — generate a backup and store + * it in the storage backend under `backups/`, pruning archives beyond the + * configured retention. + */ + +import type { APIRoute } from "astro"; + +import { requirePerm } from "#api/authorize.js"; +import { apiError, unwrapResult } from "#api/error.js"; +import { getBackupSettings, runBackupToStorage } from "#api/handlers/backup.js"; + +export const prerender = false; + +export const POST: APIRoute = async ({ locals }) => { + const { emdash, user } = locals; + + if (!emdash?.db) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + + const denied = requirePerm(user, "backups:manage"); + if (denied) return denied; + + if (!emdash.storage) { + return apiError("STORAGE_NOT_CONFIGURED", "No storage backend is configured", 503); + } + + const settings = await getBackupSettings(emdash.db); + return unwrapResult(await runBackupToStorage(emdash.db, emdash.storage, settings.retention), 201); +}; diff --git a/packages/core/src/astro/routes/api/settings/backups/export.ts b/packages/core/src/astro/routes/api/settings/backups/export.ts new file mode 100644 index 0000000000..bd5da98d53 --- /dev/null +++ b/packages/core/src/astro/routes/api/settings/backups/export.ts @@ -0,0 +1,42 @@ +/** + * One-click backup download + * + * GET /_emdash/api/settings/backups/export — generate a fresh backup and + * stream it as a JSON download. Read-only (nothing is stored), so GET is + * appropriate despite the cost of generating the payload. + */ + +import type { APIRoute } from "astro"; + +import { requirePerm } from "#api/authorize.js"; +import { apiError, handleError } from "#api/error.js"; +import { archiveNameForDate, generateBackupJson } from "#api/handlers/backup.js"; + +export const prerender = false; + +export const GET: APIRoute = async ({ locals }) => { + const { emdash, user } = locals; + + if (!emdash?.db) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + + const denied = requirePerm(user, "backups:manage"); + if (denied) return denied; + + try { + const json = await generateBackupJson(emdash.db); + return new Response(json, { + status: 200, + headers: { + "Content-Type": "application/json", + "Content-Disposition": `attachment; filename="${archiveNameForDate(new Date())}"`, + // Session-specific full-content export — never cacheable anywhere. + "Cache-Control": "private, no-store", + "X-Content-Type-Options": "nosniff", + }, + }); + } catch (error) { + return handleError(error, "Failed to generate backup", "BACKUP_EXPORT_ERROR"); + } +}; diff --git a/packages/core/src/astro/routes/api/settings/backups/index.ts b/packages/core/src/astro/routes/api/settings/backups/index.ts new file mode 100644 index 0000000000..7b15b505b0 --- /dev/null +++ b/packages/core/src/astro/routes/api/settings/backups/index.ts @@ -0,0 +1,71 @@ +/** + * Backup settings + archive listing + * + * GET /_emdash/api/settings/backups — scheduled-backup settings, stored + * archives, and whether a storage backend is available. + * PUT /_emdash/api/settings/backups — update scheduled-backup settings. + */ + +import type { APIRoute } from "astro"; +import { z } from "zod"; + +import { requirePerm } from "#api/authorize.js"; +import { apiError, apiSuccess, handleError, unwrapResult } from "#api/error.js"; +import { + BACKUP_RETENTION_MAX, + BACKUP_RETENTION_MIN, + getBackupSettings, + listBackupArchives, + updateBackupSettings, + type BackupArchive, +} from "#api/handlers/backup.js"; +import { isParseError, parseBody } from "#api/parse.js"; + +export const prerender = false; + +export const GET: APIRoute = async ({ locals }) => { + const { emdash, user } = locals; + + if (!emdash?.db) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + + const denied = requirePerm(user, "backups:manage"); + if (denied) return denied; + + try { + const settings = await getBackupSettings(emdash.db); + + let archives: BackupArchive[] = []; + const storageAvailable = !!emdash.storage; + if (emdash.storage) { + const listed = await listBackupArchives(emdash.storage); + if (listed.success) archives = listed.data; + } + + return apiSuccess({ settings, archives, storageAvailable }); + } catch (error) { + return handleError(error, "Failed to load backup settings", "BACKUP_SETTINGS_READ_ERROR"); + } +}; + +const settingsBody = z.object({ + enabled: z.boolean(), + retention: z.number().int().min(BACKUP_RETENTION_MIN).max(BACKUP_RETENTION_MAX), +}); + +export const PUT: APIRoute = async ({ request, locals }) => { + const { emdash, user } = locals; + + if (!emdash?.db) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + + const denied = requirePerm(user, "backups:manage"); + if (denied) return denied; + + const body = await parseBody(request, settingsBody); + if (isParseError(body)) return body; + + return unwrapResult(await updateBackupSettings(emdash.db, body)); +}; diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index c5279699e0..1c6783c0cf 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -121,6 +121,7 @@ function isValidMetadataContribution(c: unknown): c is PageMetadataContribution } import { after } from "./after.js"; +import { maybeRunScheduledBackup } from "./api/handlers/backup.js"; import { loadBundleFromR2 } from "./api/handlers/marketplace.js"; import { runSystemCleanup } from "./cleanup.js"; import { @@ -656,6 +657,9 @@ export class EmDashRuntime { console.error("[cleanup] System cleanup failed:", error); } + // Never throws; no-op unless scheduled backups are enabled and due. + await maybeRunScheduledBackup(this.db, this.storage ?? undefined); + return { published }; } @@ -1541,6 +1545,8 @@ export class EmDashRuntime { // by runSystemCleanup. This catches unexpected errors. console.error("[cleanup] System cleanup failed:", error); } + // Never throws; no-op unless scheduled backups are enabled and due. + await maybeRunScheduledBackup(db, storage ?? undefined); }); // Add cron reschedule callback (merges with existing factory options) diff --git a/packages/core/tests/unit/api/backup-handlers.test.ts b/packages/core/tests/unit/api/backup-handlers.test.ts new file mode 100644 index 0000000000..01e7c0d13d --- /dev/null +++ b/packages/core/tests/unit/api/backup-handlers.test.ts @@ -0,0 +1,312 @@ +/** + * Backup handler tests + * + * Covers: full-fidelity export (drafts + trash included), secrets exclusion, + * archive naming/validation, settings clamping, storage archive lifecycle + * (create, list, prune, delete), and the scheduled-run gate. + */ + +import { sql } from "kysely"; +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + BACKUP_STORAGE_PREFIX, + archiveNameForDate, + deleteBackupArchive, + generateBackupJson, + getBackupSettings, + isValidArchiveName, + listBackupArchives, + maybeRunScheduledBackup, + runBackupToStorage, + updateBackupSettings, +} from "../../../src/api/handlers/backup.js"; +import { OptionsRepository } from "../../../src/database/repositories/options.js"; +import type { Database } from "../../../src/database/types.js"; +import type { DownloadResult, Storage } from "../../../src/storage/types.js"; +import { setupTestDatabaseWithCollections } from "../../utils/test-db.js"; + +// ── Storage fake ──────────────────────────────────────────────── + +interface StoredFile { + key: string; + body: Uint8Array; + contentType: string; + lastModified: Date; +} + +function createFakeStorage(): Storage & { files: Map } { + const files = new Map(); + + return { + files, + async upload(options) { + if (!(options.body instanceof Uint8Array)) { + throw new Error("fake storage only supports Uint8Array bodies"); + } + files.set(options.key, { + key: options.key, + body: options.body, + contentType: options.contentType, + lastModified: new Date(), + }); + return { key: options.key, url: `fake://${options.key}`, size: options.body.byteLength }; + }, + async download(key): Promise { + const file = files.get(key); + if (!file) throw new Error(`not found: ${key}`); + return { + body: new Blob([file.body]).stream(), + contentType: file.contentType, + size: file.body.byteLength, + }; + }, + async delete(key) { + files.delete(key); + }, + async exists(key) { + return files.has(key); + }, + async list(options) { + const prefix = options?.prefix ?? ""; + return { + files: [...files.values()] + .filter((f) => f.key.startsWith(prefix)) + .map((f) => ({ + key: f.key, + size: f.body.byteLength, + lastModified: f.lastModified, + })), + }; + }, + async getSignedUploadUrl() { + throw new Error("not implemented"); + }, + getPublicUrl(key) { + return `fake://${key}`; + }, + }; +} + +// ── Tests ─────────────────────────────────────────────────────── + +describe("backup handlers", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabaseWithCollections(); + }); + + afterEach(async () => { + await db.destroy(); + }); + + describe("generateBackupJson", () => { + it("includes published, draft, and trashed content", async () => { + await sql` + INSERT INTO ec_post (id, slug, status, title, content, created_at, updated_at, version) + VALUES ('pub1', 'published-post', 'published', 'Pub', 'c', datetime('now'), datetime('now'), 1) + `.execute(db); + await sql` + INSERT INTO ec_post (id, slug, status, title, content, created_at, updated_at, version) + VALUES ('draft1', 'draft-post', 'draft', 'Draft', 'c', datetime('now'), datetime('now'), 1) + `.execute(db); + await sql` + INSERT INTO ec_post (id, slug, status, title, content, created_at, updated_at, deleted_at, version) + VALUES ('del1', 'trashed-post', 'published', 'Trashed', 'c', datetime('now'), datetime('now'), datetime('now'), 1) + `.execute(db); + + const backup = JSON.parse(await generateBackupJson(db)); + + expect(backup.format).toBe("emdash-backup"); + expect(backup.formatVersion).toBe(1); + expect(backup.generatedAt).toBeTruthy(); + + const slugs = backup.tables.ec_post.map((r: { slug: string }) => r.slug); + expect(slugs).toContain("published-post"); + expect(slugs).toContain("draft-post"); + expect(slugs).toContain("trashed-post"); + }); + + it("includes site settings but never secrets or auth tables", async () => { + const options = new OptionsRepository(db); + await options.set("site:title", "My Site"); + await options.set("emdash:site_title", "My Site"); + await options.set("emdash:site_tagline", "Tagline"); + await options.set("emdash:preview_secret", "super-secret-value"); + await options.set("plugin:some-plugin:api_key", "plugin-secret"); + await options.set("emdash:passkey_pending:user1", { challenge: "abc" }); + + const backup = JSON.parse(await generateBackupJson(db)); + + const optionNames = (backup.tables.options ?? []).map((r: { name: string }) => r.name); + expect(optionNames).toContain("site:title"); + expect(optionNames).toContain("emdash:site_title"); + expect(optionNames).toContain("emdash:site_tagline"); + expect(optionNames).not.toContain("emdash:preview_secret"); + expect(optionNames).not.toContain("plugin:some-plugin:api_key"); + expect(optionNames).not.toContain("emdash:passkey_pending:user1"); + + // Raw string check: no secret value anywhere in the payload + const raw = await generateBackupJson(db); + expect(raw).not.toContain("super-secret-value"); + expect(raw).not.toContain("plugin-secret"); + + // Auth tables excluded entirely + expect(backup.schema).not.toHaveProperty("users"); + expect(backup.schema).not.toHaveProperty("sessions"); + expect(backup.schema).not.toHaveProperty("credentials"); + expect(backup.schema).not.toHaveProperty("_emdash_api_tokens"); + }); + }); + + describe("archive names", () => { + it("round-trips through validation", () => { + const name = archiveNameForDate(new Date("2026-07-09T08:45:12.345Z")); + expect(name).toMatch(/^emdash-backup-2026-07-09T08-45-12-[0-9a-f]{8}\.json$/); + expect(isValidArchiveName(name)).toBe(true); + }); + + it("generates unique names for the same second", () => { + const date = new Date("2026-07-09T08:45:12.000Z"); + expect(archiveNameForDate(date)).not.toBe(archiveNameForDate(date)); + }); + + it("rejects traversal and foreign names", () => { + expect(isValidArchiveName("../../../etc/passwd")).toBe(false); + expect(isValidArchiveName("emdash-backup-2026-07-09T08-45-12-abcd1234.json/../x")).toBe( + false, + ); + expect(isValidArchiveName("media/photo.jpg")).toBe(false); + expect(isValidArchiveName("")).toBe(false); + }); + }); + + describe("settings", () => { + it("defaults to disabled with retention 7", async () => { + expect(await getBackupSettings(db)).toEqual({ enabled: false, retention: 7 }); + }); + + it("persists and clamps retention", async () => { + const result = await updateBackupSettings(db, { enabled: true, retention: 999 }); + expect(result.success).toBe(true); + expect(await getBackupSettings(db)).toEqual({ enabled: true, retention: 30 }); + + await updateBackupSettings(db, { enabled: true, retention: 0 }); + expect((await getBackupSettings(db)).retention).toBe(1); + }); + }); + + describe("archives in storage", () => { + it("creates, lists, and deletes archives", async () => { + const storage = createFakeStorage(); + + const created = await runBackupToStorage(db, storage, 7); + expect(created.success).toBe(true); + if (!created.success) return; + expect(isValidArchiveName(created.data.name)).toBe(true); + expect(storage.files.has(`${BACKUP_STORAGE_PREFIX}${created.data.name}`)).toBe(true); + + const listed = await listBackupArchives(storage); + expect(listed.success).toBe(true); + if (!listed.success) return; + expect(listed.data).toHaveLength(1); + expect(listed.data[0]?.name).toBe(created.data.name); + + const deleted = await deleteBackupArchive(storage, created.data.name); + expect(deleted.success).toBe(true); + expect(storage.files.size).toBe(0); + }); + + it("rejects deleting invalid names without touching storage", async () => { + const storage = createFakeStorage(); + const result = await deleteBackupArchive(storage, "../evil"); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("VALIDATION_ERROR"); + }); + + it("prunes archives beyond retention, oldest first", async () => { + const storage = createFakeStorage(); + + // Seed three archives with distinct timestamps in their names + for (const stamp of ["2026-07-01T00-00-00", "2026-07-02T00-00-00", "2026-07-03T00-00-00"]) { + const key = `${BACKUP_STORAGE_PREFIX}emdash-backup-${stamp}-abcd1234.json`; + storage.files.set(key, { + key, + body: new Uint8Array([1]), + contentType: "application/json", + lastModified: new Date(), + }); + } + + // Creating a fourth with retention 2 keeps only the 2 newest + const created = await runBackupToStorage(db, storage, 2); + expect(created.success).toBe(true); + if (!created.success) return; + + const listed = await listBackupArchives(storage); + expect(listed.success).toBe(true); + if (!listed.success) return; + expect(listed.data).toHaveLength(2); + expect(listed.data[0]?.name).toBe(created.data.name); + expect(listed.data[1]?.name).toBe("emdash-backup-2026-07-03T00-00-00-abcd1234.json"); + }); + + it("ignores foreign files under the backups prefix when listing", async () => { + const storage = createFakeStorage(); + storage.files.set(`${BACKUP_STORAGE_PREFIX}not-a-backup.txt`, { + key: `${BACKUP_STORAGE_PREFIX}not-a-backup.txt`, + body: new Uint8Array([1]), + contentType: "text/plain", + lastModified: new Date(), + }); + + const listed = await listBackupArchives(storage); + expect(listed.success).toBe(true); + if (!listed.success) return; + expect(listed.data).toHaveLength(0); + }); + }); + + describe("maybeRunScheduledBackup", () => { + it("does nothing when disabled or storage is missing", async () => { + const storage = createFakeStorage(); + + await maybeRunScheduledBackup(db, storage); + expect(storage.files.size).toBe(0); + + await updateBackupSettings(db, { enabled: true, retention: 7 }); + await maybeRunScheduledBackup(db, undefined); + expect(storage.files.size).toBe(0); + }); + + it("runs when enabled and skips within the daily interval", async () => { + const storage = createFakeStorage(); + await updateBackupSettings(db, { enabled: true, retention: 7 }); + + await maybeRunScheduledBackup(db, storage); + expect(storage.files.size).toBe(1); + + // Immediately after, the last-run gate suppresses a second backup + await maybeRunScheduledBackup(db, storage); + expect(storage.files.size).toBe(1); + }); + + it("runs again once the interval has passed", async () => { + const storage = createFakeStorage(); + await updateBackupSettings(db, { enabled: true, retention: 7 }); + + // Simulate a last run 24h ago + const options = new OptionsRepository(db); + await options.set( + "emdash:backups_last_run", + new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + ); + + await maybeRunScheduledBackup(db, storage); + expect(storage.files.size).toBe(1); + }); + }); +}); diff --git a/packages/core/tests/unit/api/backup-routes.test.ts b/packages/core/tests/unit/api/backup-routes.test.ts new file mode 100644 index 0000000000..dc05adb3a3 --- /dev/null +++ b/packages/core/tests/unit/api/backup-routes.test.ts @@ -0,0 +1,92 @@ +/** + * Backup route tests + * + * - Route registration for all four backup endpoints + * - Authorization: every endpoint requires the admin-only backups:manage + * permission + * - The public media file route must never serve keys under backups/ + * (archives contain the site's full content export) + */ + +import { describe, expect, it, vi } from "vitest"; + +import { injectCoreRoutes } from "../../../src/astro/integration/routes.js"; +import { GET as mediaFileGet } from "../../../src/astro/routes/api/media/file/[...key].js"; +import { + DELETE as archiveDelete, + GET as archiveGet, +} from "../../../src/astro/routes/api/settings/backups/archives/[name].js"; +import { POST as archivesPost } from "../../../src/astro/routes/api/settings/backups/archives/index.js"; +import { GET as exportGet } from "../../../src/astro/routes/api/settings/backups/export.js"; +import { + GET as backupsGet, + PUT as backupsPut, +} from "../../../src/astro/routes/api/settings/backups/index.js"; + +// Minimal APIContext stand-in; routes only touch locals/params/request. +// eslint-disable-next-line typescript/no-explicit-any -- test double for APIContext +function ctx(overrides: Record): any { + return { + locals: { emdash: { db: {}, storage: {} }, user: null }, + params: {}, + request: new Request("https://example.com"), + ...overrides, + }; +} + +describe("backup route registration", () => { + it("registers all backup routes", () => { + const injectRoute = vi.fn(); + injectCoreRoutes(injectRoute); + + const patterns = injectRoute.mock.calls.map((call) => (call[0] as { pattern: string }).pattern); + expect(patterns).toContain("/_emdash/api/settings/backups"); + expect(patterns).toContain("/_emdash/api/settings/backups/export"); + expect(patterns).toContain("/_emdash/api/settings/backups/archives"); + expect(patterns).toContain("/_emdash/api/settings/backups/archives/[name]"); + }); +}); + +describe("backup route authorization", () => { + const cases: [string, (c: unknown) => Promise][] = [ + ["GET /settings/backups", (c) => backupsGet(c as never)], + ["PUT /settings/backups", (c) => backupsPut(c as never)], + ["GET /settings/backups/export", (c) => exportGet(c as never)], + ["POST /settings/backups/archives", (c) => archivesPost(c as never)], + ["GET /settings/backups/archives/[name]", (c) => archiveGet(c as never)], + ["DELETE /settings/backups/archives/[name]", (c) => archiveDelete(c as never)], + ]; + + for (const [label, invoke] of cases) { + it(`${label} rejects anonymous requests`, async () => { + const res = await invoke( + ctx({ params: { name: "emdash-backup-2026-07-09T08-45-12-abcd1234.json" } }), + ); + expect(res.status).toBe(401); + }); + + it(`${label} rejects editors (below admin)`, async () => { + const res = await invoke( + ctx({ + locals: { emdash: { db: {}, storage: {} }, user: { id: "u1", role: 40 } }, + params: { name: "emdash-backup-2026-07-09T08-45-12-abcd1234.json" }, + }), + ); + expect(res.status).toBe(403); + }); + } +}); + +describe("media file route denies backup archives", () => { + it("returns 404 for keys under backups/ without touching storage", async () => { + const download = vi.fn(); + const res = await mediaFileGet( + ctx({ + params: { key: "backups/emdash-backup-2026-07-09T08-45-12-abcd1234.json" }, + locals: { emdash: { storage: { download } } }, + }) as never, + ); + expect(res.status).toBe(404); + expect(download).not.toHaveBeenCalled(); + }); +}); From 7358367a7b48a44c4c8ff7124373ef78dff7d0d5 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:38:26 +0200 Subject: [PATCH 2/5] fix(backups): make archive listing work on LocalStorage, doc bucket-exposure caveat LocalStorage.list matches directory + filename prefix rather than flat keys, so include the emdash-backup- filename prefix in the list call. Docs now warn that publicly exposed buckets serve archives by URL. --- demos/simple/emdash-env.d.ts | 166 +++++++++++++++++++++++ docs/src/content/docs/guides/backups.mdx | 9 +- packages/core/src/api/handlers/backup.ts | 12 +- 3 files changed, 185 insertions(+), 2 deletions(-) diff --git a/demos/simple/emdash-env.d.ts b/demos/simple/emdash-env.d.ts index 4c380e8e87..fcfe1243c7 100644 --- a/demos/simple/emdash-env.d.ts +++ b/demos/simple/emdash-env.d.ts @@ -5,12 +5,110 @@ import type { ContentBylineCredit, TaxonomyTerm, PortableTextBlock } from "emdash"; +export interface Betriebssystem { + id: string; + slug: string | null; + status: string; + title: string; + content?: PortableTextBlock[]; + excerpt?: string; + featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record }; + createdAt: Date; + updatedAt: Date; + publishedAt: Date | null; + bylines?: ContentBylineCredit[]; + terms?: Record; +} + +export interface Company { + id: string; + slug: string | null; + status: string; + title: string; + content?: PortableTextBlock[]; + excerpt?: string; + featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record }; + company_icon?: number; + seo_description?: string; + seo_title?: string; + createdAt: Date; + updatedAt: Date; + publishedAt: Date | null; + bylines?: ContentBylineCredit[]; + terms?: Record; +} + +export interface Event { + id: string; + slug: string | null; + status: string; + title: string; + content?: PortableTextBlock[]; + excerpt?: string; + featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record }; + createdAt: Date; + updatedAt: Date; + publishedAt: Date | null; + bylines?: ContentBylineCredit[]; + terms?: Record; +} + export interface Page { id: string; slug: string | null; status: string; title: string; content?: PortableTextBlock[]; + excerpt?: string; + featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record }; + mnky_post_views_count?: number; + mnky_header_overlay?: string; + mnky_page_title?: string; + mnky_pre_content_activation?: string; + mnky_pre_content_responsive_height?: string; + mnky_top_bar?: string; + mnky_custom_page_title_background_gradient?: string; + mnky_custom_page_title_background_image?: string; + mnky_custom_page_title_background_image_switch?: string; + mnky_custom_page_title_paddings?: string; + mnky_custom_page_title_styles?: string; + mnky_custom_page_title_text_color?: string; + thirstydata?: unknown; + seo_description?: string; + seo_title?: string; + createdAt: Date; + updatedAt: Date; + publishedAt: Date | null; + bylines?: ContentBylineCredit[]; + terms?: Record; +} + +export interface Person { + id: string; + slug: string | null; + status: string; + title: string; + content?: PortableTextBlock[]; + excerpt?: string; + featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record }; + seo_description?: string; + seo_title?: string; + createdAt: Date; + updatedAt: Date; + publishedAt: Date | null; + bylines?: ContentBylineCredit[]; + terms?: Record; +} + +export interface Plattform { + id: string; + slug: string | null; + status: string; + title: string; + content?: PortableTextBlock[]; + excerpt?: string; + featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record }; + seo_description?: string; createdAt: Date; updatedAt: Date; publishedAt: Date | null; @@ -26,6 +124,66 @@ export interface Post { featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record }; content?: PortableTextBlock[]; excerpt?: string; + is_sponsored?: string; + coauthor2?: number; + photo_credit?: string; + article_100989_review_count?: string; + article_100989_rating?: string; + recipients?: number; + response_body?: string; + wp_status?: number; + seo_description?: string; + seo_title?: string; + createdAt: Date; + updatedAt: Date; + publishedAt: Date | null; + bylines?: ContentBylineCredit[]; + terms?: Record; +} + +export interface Product { + id: string; + slug: string | null; + status: string; + title: string; + content?: PortableTextBlock[]; + excerpt?: string; + featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record }; + product_icon?: string; + seo_description?: string; + seo_title?: string; + createdAt: Date; + updatedAt: Date; + publishedAt: Date | null; + bylines?: ContentBylineCredit[]; + terms?: Record; +} + +export interface Produktlinie { + id: string; + slug: string | null; + status: string; + title: string; + content?: PortableTextBlock[]; + excerpt?: string; + featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record }; + produktlinie_icon?: number; + seo_description?: string; + createdAt: Date; + updatedAt: Date; + publishedAt: Date | null; + bylines?: ContentBylineCredit[]; + terms?: Record; +} + +export interface Software { + id: string; + slug: string | null; + status: string; + title: string; + content?: PortableTextBlock[]; + excerpt?: string; + featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record }; createdAt: Date; updatedAt: Date; publishedAt: Date | null; @@ -35,7 +193,15 @@ export interface Post { declare module "emdash" { interface EmDashCollections { + betriebssystem: Betriebssystem; + company: Company; + events: Event; pages: Page; + person: Person; + plattform: Plattform; posts: Post; + products: Product; + produktlinie: Produktlinie; + softwares: Software; } } \ No newline at end of file diff --git a/docs/src/content/docs/guides/backups.mdx b/docs/src/content/docs/guides/backups.mdx index 61e191cecb..c361cbdef7 100644 --- a/docs/src/content/docs/guides/backups.mdx +++ b/docs/src/content/docs/guides/backups.mdx @@ -48,7 +48,14 @@ If your site has a storage backend configured (R2 on Cloudflare, S3, or local st -Archives are stored under the `backups/` prefix in your bucket as `emdash-backup-.json`. The **Stored Backups** list in the admin lets you download or delete individual archives, and **Back up now** creates one on demand. +Archives are stored under the `backups/` prefix in your bucket as `emdash-backup--.json`. The **Stored Backups** list in the admin lets you download or delete individual archives, and **Back up now** creates one on demand. + + - +Automatic backups piggyback on the scheduled maintenance tick (the same mechanism that powers +scheduled publishing) — on Cloudflare this is the Worker's cron trigger, on Node the built-in +scheduler. If your deployment has no cron trigger configured, use **Back up now** or the download +button instead. ## Point-in-time restore with D1 Time Travel