From 3175bd9172cc534e50580f14c0ef5a5c360d6b35 Mon Sep 17 00:00:00 2001 From: matt Date: Sat, 4 Jul 2026 20:53:38 -0400 Subject: [PATCH] Add JSON import/export backup on the settings page Export downloads a versioned JSON snapshot of all posts, pages, tags, navigation, and settings from GET /api/admin/export. Cross-references are keyed by slug rather than database id, so a backup restores cleanly into any database. Import (a new settings-page section) validates the file and atomically replaces all content in one transaction, sanitizing bodies at the trust boundary; users, sessions, and uploaded files are untouched. Server-action body limit raised so backups fit in the import upload. Co-Authored-By: Claude Fable 5 --- next.config.ts | 6 + src/actions/settings.ts | 34 ++ src/app/admin/(panel)/settings/page.tsx | 6 +- src/app/api/admin/export/route.ts | 19 + src/components/admin/ImportExportSection.tsx | 65 ++++ src/lib/services/import-export.ts | 367 +++++++++++++++++++ tests/integration/import-export.test.ts | 173 +++++++++ 7 files changed, 669 insertions(+), 1 deletion(-) create mode 100644 src/app/api/admin/export/route.ts create mode 100644 src/components/admin/ImportExportSection.tsx create mode 100644 src/lib/services/import-export.ts create mode 100644 tests/integration/import-export.test.ts diff --git a/next.config.ts b/next.config.ts index 2e88fdc..211bca9 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,6 +4,12 @@ const nextConfig: NextConfig = { // Pin the workspace root so stray lockfiles in parent directories // don't confuse Turbopack's project detection. turbopack: { root: __dirname }, + experimental: { + // Site-import uploads carry a whole backup in one action request; + // the default 1 MB cap is far too small. Imports themselves are + // capped at 20 MB in importSiteAction. + serverActions: { bodySizeLimit: "25mb" }, + }, }; export default nextConfig; diff --git a/src/actions/settings.ts b/src/actions/settings.ts index 88940d8..e8db3eb 100644 --- a/src/actions/settings.ts +++ b/src/actions/settings.ts @@ -7,6 +7,7 @@ import { pages, tags } from "@/db/schema"; import { requireAdmin } from "@/lib/auth/dal"; import type { FormState } from "@/lib/forms"; import { zodErrorToFormState } from "@/lib/forms"; +import { importSiteExport, parseSiteExportJson } from "@/lib/services/import-export"; import { type NavItemInput, saveSettings } from "@/lib/services/settings"; import { parseNavItemsJson, settingsFormSchema } from "@/lib/validation"; @@ -102,3 +103,36 @@ export async function updateSettingsAction( revalidatePath("/", "layout"); return { status: "success" }; } + +// Keep under next.config.ts serverActions.bodySizeLimit (with multipart overhead). +const MAX_IMPORT_BYTES = 20 * 1024 * 1024; + +export async function importSiteAction( + _prev: FormState, + formData: FormData, +): Promise { + await requireAdmin(); + + const file = formData.get("file"); + if (!(file instanceof File) || file.size === 0) { + return { formError: "Choose an export file (.json) to import." }; + } + if (file.size > MAX_IMPORT_BYTES) { + return { formError: "That file is too large to import (20 MB max)." }; + } + + const result = parseSiteExportJson(await file.text()); + if ("error" in result) return { formError: result.error }; + + try { + await importSiteExport(result.data); + } catch (error) { + console.error("importSiteAction failed", error); + return { + formError: "Something went wrong while importing. The database was not changed.", + }; + } + + revalidatePath("/", "layout"); + return { status: "success" }; +} diff --git a/src/app/admin/(panel)/settings/page.tsx b/src/app/admin/(panel)/settings/page.tsx index b37c7c6..200c901 100644 --- a/src/app/admin/(panel)/settings/page.tsx +++ b/src/app/admin/(panel)/settings/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; -import { updateSettingsAction } from "@/actions/settings"; +import { importSiteAction, updateSettingsAction } from "@/actions/settings"; +import { ImportExportSection } from "@/components/admin/ImportExportSection"; import { SettingsForm } from "@/components/admin/SettingsForm"; import { requireAdmin } from "@/lib/auth/dal"; import { listPublishedPages } from "@/lib/services/pages"; @@ -27,6 +28,9 @@ export default async function AdminSettingsPage() { publishedPages={publishedPages} action={updateSettingsAction} /> +
+ +
); } diff --git a/src/app/api/admin/export/route.ts b/src/app/api/admin/export/route.ts new file mode 100644 index 0000000..2bc2cfa --- /dev/null +++ b/src/app/api/admin/export/route.ts @@ -0,0 +1,19 @@ +import { requireAdmin } from "@/lib/auth/dal"; +import { buildSiteExport } from "@/lib/services/import-export"; + +/** + * Full-site backup download. A plain browser navigation (link on the + * settings page), so signed-out visitors are redirected to the login page + * by requireAdmin rather than shown a JSON error. + */ +export async function GET(): Promise { + await requireAdmin(); + const data = await buildSiteExport(); + const date = data.exportedAt.toISOString().slice(0, 10); + return new Response(JSON.stringify(data, null, 2), { + headers: { + "Content-Type": "application/json", + "Content-Disposition": `attachment; filename="yap-blog-export-${date}.json"`, + }, + }); +} diff --git a/src/components/admin/ImportExportSection.tsx b/src/components/admin/ImportExportSection.tsx new file mode 100644 index 0000000..1d328d5 --- /dev/null +++ b/src/components/admin/ImportExportSection.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useActionState, useId } from "react"; +import { ConfirmButton } from "@/components/admin/ConfirmButton"; +import { Flash, FormErrorBanner } from "@/components/admin/Flash"; +import { buttonVariants, Input, Label } from "@/components/ui"; +import { type FormState, initialFormState } from "@/lib/forms"; + +type Props = { + importAction: (prev: FormState, formData: FormData) => Promise; +}; + +export function ImportExportSection({ importAction }: Props) { + const [state, formAction] = useActionState(importAction, initialFormState); + const ids = useId(); + + return ( +
+

+ Backup +

+ +
+

Export

+

+ Download all posts, pages, tags, navigation, and settings as a single JSON file. + Uploaded images are served from the uploads folder and are not included. +

+ {/* Plain (not LinkButton) so client-side prefetch never hits the download. */} + + Download export (JSON) + +
+ +
+ {state.status === "success" && ( + Import complete. All content and settings were replaced. + )} + {state.formError} + +
+

Import

+

+ Restore a previously exported file. This{" "} + replaces every post, page, tag, navigation + item, and all settings{" "} + with the file's contents. Accounts and uploaded images are kept. +

+ + +
+ + Import and replace everything + +
+
+ ); +} diff --git a/src/lib/services/import-export.ts b/src/lib/services/import-export.ts new file mode 100644 index 0000000..fdf0a59 --- /dev/null +++ b/src/lib/services/import-export.ts @@ -0,0 +1,367 @@ +import { asc, eq } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "@/db"; +import { fontEnum, navItems, pages, postTags, posts, settings, tags, themeEnum } from "@/db/schema"; +import { sanitizeHtml } from "@/lib/html"; +import { isValidLinkUrl } from "@/lib/validation"; +import { getSettings } from "./settings"; + +/** + * Site backup format. Cross-references use slugs instead of database ids so + * a file exported from one database imports cleanly into another (identity + * columns are regenerated on import). + */ +export const SITE_EXPORT_FORMAT = "yap-blog-export"; +export const SITE_EXPORT_VERSION = 1; + +const slugValue = z.string().trim().min(1).max(120); +const statusValue = z.enum(["draft", "published"]); +const bodyValue = z.string().max(500_000); +const timestampValue = z.coerce.date(); + +export const siteExportSchema = z + .object({ + format: z.literal(SITE_EXPORT_FORMAT), + version: z.literal(SITE_EXPORT_VERSION), + exportedAt: timestampValue, + settings: z.object({ + siteTitle: z.string().trim().min(1).max(120), + headerText: z.string().trim().max(300), + footerText: z.string().trim().max(500), + postsPerPage: z.number().int().min(1).max(50), + excerptWords: z.number().int().min(5).max(200), + homeMode: z.enum(["posts", "tag", "page"]), + homeTagSlug: slugValue.nullable(), + homePageSlug: slugValue.nullable(), + theme: z.enum(themeEnum.enumValues), + font: z.enum(fontEnum.enumValues), + }), + navItems: z + .array( + z.object({ + label: z.string().trim().min(1).max(80), + url: z.string().trim().max(2000).nullable(), + pageSlug: slugValue.nullable(), + }), + ) + .max(20), + tags: z + .array(z.object({ name: z.string().trim().min(1).max(80), slug: slugValue })) + .max(5_000), + pages: z + .array( + z.object({ + title: z.string().trim().min(1).max(200), + slug: slugValue, + body: bodyValue, + status: statusValue, + createdAt: timestampValue, + updatedAt: timestampValue, + }), + ) + .max(10_000), + posts: z + .array( + z.object({ + title: z.string().trim().min(1).max(200), + slug: slugValue, + body: bodyValue, + authorName: z.string().trim().min(1).max(120), + featuredImageUrl: z.string().trim().max(2000).nullable(), + featuredImageAlt: z.string().trim().max(300).nullable(), + status: statusValue, + createdAt: timestampValue, + updatedAt: timestampValue, + publishedAt: timestampValue.nullable(), + tagSlugs: z.array(slugValue).max(50), + }), + ) + .max(50_000), + }) + .superRefine((data, ctx) => { + const duplicate = (values: string[]): string | undefined => { + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) return value; + seen.add(value); + } + return undefined; + }; + + const dupTag = duplicate(data.tags.map((t) => t.slug)); + if (dupTag) ctx.addIssue({ code: "custom", message: `Duplicate tag slug “${dupTag}”.` }); + const dupTagName = duplicate(data.tags.map((t) => t.name)); + if (dupTagName) { + ctx.addIssue({ code: "custom", message: `Duplicate tag name “${dupTagName}”.` }); + } + const dupPage = duplicate(data.pages.map((p) => p.slug)); + if (dupPage) ctx.addIssue({ code: "custom", message: `Duplicate page slug “${dupPage}”.` }); + const dupPost = duplicate(data.posts.map((p) => p.slug)); + if (dupPost) ctx.addIssue({ code: "custom", message: `Duplicate post slug “${dupPost}”.` }); + + const tagSlugs = new Set(data.tags.map((t) => t.slug)); + const pageSlugs = new Set(data.pages.map((p) => p.slug)); + + for (const post of data.posts) { + for (const slug of post.tagSlugs) { + if (!tagSlugs.has(slug)) { + ctx.addIssue({ + code: "custom", + message: `Post “${post.slug}” references unknown tag “${slug}”.`, + }); + } + } + } + + for (const item of data.navItems) { + const hasUrl = item.url !== null && item.url !== ""; + const hasPage = item.pageSlug !== null; + if (hasUrl === hasPage) { + ctx.addIssue({ + code: "custom", + message: `Navigation item “${item.label}” must link to either a page or a URL.`, + }); + } else if (hasUrl && !isValidLinkUrl(item.url as string)) { + ctx.addIssue({ + code: "custom", + message: `Navigation item “${item.label}” has an invalid URL.`, + }); + } else if (hasPage && !pageSlugs.has(item.pageSlug as string)) { + ctx.addIssue({ + code: "custom", + message: `Navigation item “${item.label}” references unknown page “${item.pageSlug}”.`, + }); + } + } + + if (data.settings.homeMode === "tag") { + if (!data.settings.homeTagSlug || !tagSlugs.has(data.settings.homeTagSlug)) { + ctx.addIssue({ + code: "custom", + message: "Settings use a home tag that is not in the export.", + }); + } + } + if (data.settings.homeMode === "page") { + if (!data.settings.homePageSlug || !pageSlugs.has(data.settings.homePageSlug)) { + ctx.addIssue({ + code: "custom", + message: "Settings use a home page that is not in the export.", + }); + } + } + }); + +export type SiteExport = z.infer; + +export function parseSiteExportJson( + json: string, +): { data: SiteExport } | { error: string } { + let raw: unknown; + try { + raw = JSON.parse(json); + } catch { + return { error: "That file is not valid JSON." }; + } + const parsed = siteExportSchema.safeParse(raw); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const path = issue?.path.length ? ` (at ${issue.path.join(".")})` : ""; + return { + error: `That file is not a valid site export: ${issue?.message ?? "unknown error"}${path}`, + }; + } + return { data: parsed.data }; +} + +/** Snapshot of everything the admin can edit: settings, nav, tags, pages, posts. */ +export async function buildSiteExport(): Promise { + const [settingsRow, navRows, tagRows, pageRows, postRows, postTagRows] = await Promise.all([ + getSettings(), + db + .select({ item: navItems, pageSlug: pages.slug }) + .from(navItems) + .leftJoin(pages, eq(pages.id, navItems.pageId)) + .orderBy(asc(navItems.sortOrder), asc(navItems.id)), + db.select().from(tags).orderBy(asc(tags.slug)), + db.select().from(pages).orderBy(asc(pages.id)), + db.select().from(posts).orderBy(asc(posts.id)), + db + .select({ postId: postTags.postId, tagSlug: tags.slug }) + .from(postTags) + .innerJoin(tags, eq(tags.id, postTags.tagId)) + .orderBy(asc(tags.slug)), + ]); + + const tagSlugsByPost = new Map(); + for (const row of postTagRows) { + const list = tagSlugsByPost.get(row.postId) ?? []; + list.push(row.tagSlug); + tagSlugsByPost.set(row.postId, list); + } + + return { + format: SITE_EXPORT_FORMAT, + version: SITE_EXPORT_VERSION, + exportedAt: new Date(), + settings: { + siteTitle: settingsRow.siteTitle, + headerText: settingsRow.headerText, + footerText: settingsRow.footerText, + postsPerPage: settingsRow.postsPerPage, + excerptWords: settingsRow.excerptWords, + homeMode: settingsRow.homeMode, + homeTagSlug: tagRows.find((t) => t.id === settingsRow.homeTagId)?.slug ?? null, + homePageSlug: pageRows.find((p) => p.id === settingsRow.homePageId)?.slug ?? null, + theme: settingsRow.theme, + font: settingsRow.font, + }, + navItems: navRows.map(({ item, pageSlug }) => ({ + label: item.label, + url: item.url, + pageSlug, + })), + tags: tagRows.map((t) => ({ name: t.name, slug: t.slug })), + pages: pageRows.map((p) => ({ + title: p.title, + slug: p.slug, + body: p.body, + status: p.status, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + })), + posts: postRows.map((p) => ({ + title: p.title, + slug: p.slug, + body: p.body, + authorName: p.authorName, + featuredImageUrl: p.featuredImageUrl, + featuredImageAlt: p.featuredImageAlt, + status: p.status, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + publishedAt: p.publishedAt, + tagSlugs: tagSlugsByPost.get(p.id) ?? [], + })), + }; +} + +/** Keeps multi-row inserts well under Postgres's 65535-parameter limit. */ +function chunk(items: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +} + +/** + * Restores a backup by REPLACING all content: posts, tags, pages, navigation, + * and settings. Users and sessions are untouched. Runs in one transaction, so + * a failed import leaves the database exactly as it was. + */ +export async function importSiteExport(data: SiteExport): Promise { + await db.transaction(async (tx) => { + // Deleting posts/pages/tags cascades post_tags and page nav items away; + // the old settings row's home references become NULL via ON DELETE SET NULL. + await tx.delete(navItems); + await tx.delete(posts); + await tx.delete(pages); + await tx.delete(tags); + + const tagIdBySlug = new Map(); + for (const batch of chunk(data.tags, 1000)) { + const inserted = await tx + .insert(tags) + .values(batch.map((t) => ({ name: t.name, slug: t.slug }))) + .returning({ id: tags.id, slug: tags.slug }); + for (const row of inserted) tagIdBySlug.set(row.slug, row.id); + } + + const pageIdBySlug = new Map(); + for (const batch of chunk(data.pages, 1000)) { + const inserted = await tx + .insert(pages) + .values( + batch.map((p) => ({ + title: p.title, + slug: p.slug, + // Bodies come from an uploaded file, so sanitize at this trust + // boundary just like the editor actions do. + body: sanitizeHtml(p.body), + status: p.status, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + })), + ) + .returning({ id: pages.id, slug: pages.slug }); + for (const row of inserted) pageIdBySlug.set(row.slug, row.id); + } + + const links: Array<{ postId: number; tagId: number }> = []; + for (const batch of chunk(data.posts, 1000)) { + const inserted = await tx + .insert(posts) + .values( + batch.map((p) => ({ + title: p.title, + slug: p.slug, + body: sanitizeHtml(p.body), + authorName: p.authorName, + featuredImageUrl: p.featuredImageUrl, + featuredImageAlt: p.featuredImageAlt, + status: p.status, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + publishedAt: p.publishedAt, + })), + ) + .returning({ id: posts.id, slug: posts.slug }); + const idBySlug = new Map(inserted.map((row) => [row.slug, row.id])); + for (const post of batch) { + const postId = idBySlug.get(post.slug); + if (postId === undefined) continue; + for (const tagSlug of post.tagSlugs) { + const tagId = tagIdBySlug.get(tagSlug); + if (tagId !== undefined) links.push({ postId, tagId }); + } + } + } + for (const batch of chunk(links, 5000)) { + await tx.insert(postTags).values(batch); + } + + if (data.navItems.length > 0) { + await tx.insert(navItems).values( + data.navItems.map((item, index) => ({ + label: item.label, + url: item.pageSlug !== null ? null : item.url, + pageId: item.pageSlug !== null ? (pageIdBySlug.get(item.pageSlug) ?? null) : null, + sortOrder: index, + })), + ); + } + + const s = data.settings; + const settingsValues = { + siteTitle: s.siteTitle, + headerText: s.headerText, + footerText: s.footerText, + postsPerPage: s.postsPerPage, + excerptWords: s.excerptWords, + homeMode: s.homeMode, + homeTagId: + s.homeMode === "tag" && s.homeTagSlug !== null + ? (tagIdBySlug.get(s.homeTagSlug) ?? null) + : null, + homePageId: + s.homeMode === "page" && s.homePageSlug !== null + ? (pageIdBySlug.get(s.homePageSlug) ?? null) + : null, + theme: s.theme, + font: s.font, + }; + await tx + .insert(settings) + .values({ id: 1, ...settingsValues }) + .onConflictDoUpdate({ target: settings.id, set: settingsValues }); + }); +} diff --git a/tests/integration/import-export.test.ts b/tests/integration/import-export.test.ts new file mode 100644 index 0000000..5ca40fd --- /dev/null +++ b/tests/integration/import-export.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { db } from "@/db"; +import { navItems, posts, tags } from "@/db/schema"; +import { + buildSiteExport, + importSiteExport, + parseSiteExportJson, +} from "@/lib/services/import-export"; +import { createPage } from "@/lib/services/pages"; +import { createPost, getPublishedPostBySlug, type PostInput } from "@/lib/services/posts"; +import { getSettings, listNavItems, saveSettings } from "@/lib/services/settings"; +import { resetDb } from "../helpers/db"; + +const input = (overrides: Partial = {}): PostInput => ({ + title: "A post", + slug: "", + body: "

body

", + authorName: "Tester", + featuredImageUrl: null, + featuredImageAlt: null, + status: "published", + tagIds: [], + newTagNames: [], + ...overrides, +}); + +beforeEach(resetDb); + +/** Simulates writing the export to disk and reading it back. */ +async function exportViaJson() { + const parsed = parseSiteExportJson(JSON.stringify(await buildSiteExport())); + if ("error" in parsed) throw new Error(parsed.error); + return parsed.data; +} + +describe("export/import round trip", () => { + it("restores posts, tags, pages, nav, and settings from a JSON export", async () => { + const published = await createPost( + input({ title: "Keep me", newTagNames: ["Alpha", "Beta"] }), + ); + await createPost(input({ title: "Draft one", status: "draft" })); + const about = await createPage({ + title: "About", + slug: "", + body: "

hi

", + status: "published", + }); + await saveSettings( + { + siteTitle: "Round Trip", + headerText: "hdr", + footerText: "ftr", + postsPerPage: 7, + excerptWords: 25, + homeMode: "page", + homeTagId: null, + homePageId: about.id, + theme: "nord", + font: "lora", + }, + [ + { label: "About", url: null, pageId: about.id }, + { label: "Search", url: "https://example.com", pageId: null }, + ], + ); + + const snapshot = await exportViaJson(); + + // Mutate everything, then restore. + await db.delete(navItems); + await db.delete(posts); + await db.delete(tags); + await createPost(input({ title: "Impostor" })); + await importSiteExport(snapshot); + + const restored = await getPublishedPostBySlug(published.slug); + expect(restored).not.toBeNull(); + expect(restored?.title).toBe("Keep me"); + expect(restored?.tags.map((t) => t.name).sort()).toEqual(["Alpha", "Beta"]); + // Timestamps survive (public ordering depends on publishedAt). + expect(restored?.publishedAt?.getTime()).toBe(published.publishedAt?.getTime()); + expect(await getPublishedPostBySlug("impostor")).toBeNull(); + + const settings = await getSettings(); + expect(settings.siteTitle).toBe("Round Trip"); + expect(settings.postsPerPage).toBe(7); + expect(settings.homeMode).toBe("page"); + expect(settings.theme).toBe("nord"); + expect(settings.font).toBe("lora"); + + // home page and nav point at the re-created page row, not the old id. + const nav = await listNavItems(); + expect(nav).toHaveLength(2); + expect(nav[0].label).toBe("About"); + expect(nav[0].pageId).toBe(settings.homePageId); + expect(nav[1].url).toBe("https://example.com"); + }); + + it("keeps draft posts and unpublished state intact", async () => { + await createPost(input({ title: "Draft", status: "draft" })); + const snapshot = await exportViaJson(); + await importSiteExport(snapshot); + const [row] = await db.select().from(posts); + expect(row.status).toBe("draft"); + expect(row.publishedAt).toBeNull(); + }); + + it("sanitizes imported bodies", async () => { + const snapshot = await exportViaJson(); + snapshot.posts.push({ + title: "Sneaky", + slug: "sneaky", + body: '

ok

', + authorName: "Mallory", + featuredImageUrl: null, + featuredImageAlt: null, + status: "published", + createdAt: new Date(), + updatedAt: new Date(), + publishedAt: new Date(), + tagSlugs: [], + }); + await importSiteExport(snapshot); + const post = await getPublishedPostBySlug("sneaky"); + expect(post?.body).toContain("

ok

"); + expect(post?.body).not.toContain("