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 <noreply@anthropic.com>
This commit is contained in:
matt 2026-07-04 20:53:38 -04:00
parent 32177b33f5
commit 3175bd9172
7 changed files with 669 additions and 1 deletions

View file

@ -4,6 +4,12 @@ const nextConfig: NextConfig = {
// Pin the workspace root so stray lockfiles in parent directories // Pin the workspace root so stray lockfiles in parent directories
// don't confuse Turbopack's project detection. // don't confuse Turbopack's project detection.
turbopack: { root: __dirname }, 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; export default nextConfig;

View file

@ -7,6 +7,7 @@ import { pages, tags } from "@/db/schema";
import { requireAdmin } from "@/lib/auth/dal"; import { requireAdmin } from "@/lib/auth/dal";
import type { FormState } from "@/lib/forms"; import type { FormState } from "@/lib/forms";
import { zodErrorToFormState } 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 { type NavItemInput, saveSettings } from "@/lib/services/settings";
import { parseNavItemsJson, settingsFormSchema } from "@/lib/validation"; import { parseNavItemsJson, settingsFormSchema } from "@/lib/validation";
@ -102,3 +103,36 @@ export async function updateSettingsAction(
revalidatePath("/", "layout"); revalidatePath("/", "layout");
return { status: "success" }; 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<FormState> {
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" };
}

View file

@ -1,5 +1,6 @@
import type { Metadata } from "next"; 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 { SettingsForm } from "@/components/admin/SettingsForm";
import { requireAdmin } from "@/lib/auth/dal"; import { requireAdmin } from "@/lib/auth/dal";
import { listPublishedPages } from "@/lib/services/pages"; import { listPublishedPages } from "@/lib/services/pages";
@ -27,6 +28,9 @@ export default async function AdminSettingsPage() {
publishedPages={publishedPages} publishedPages={publishedPages}
action={updateSettingsAction} action={updateSettingsAction}
/> />
<div className="mt-10">
<ImportExportSection importAction={importSiteAction} />
</div>
</div> </div>
); );
} }

View file

@ -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<Response> {
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"`,
},
});
}

View file

@ -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<FormState>;
};
export function ImportExportSection({ importAction }: Props) {
const [state, formAction] = useActionState(importAction, initialFormState);
const ids = useId();
return (
<section aria-labelledby={`${ids}-backup`} className="max-w-3xl space-y-5 border-t border-edge pt-8">
<h2 id={`${ids}-backup`} className="border-b border-edge pb-2 text-lg font-semibold text-ink-strong">
Backup
</h2>
<div>
<h3 className="text-sm font-medium text-ink-strong">Export</h3>
<p className="mt-1 mb-3 text-sm text-ink-muted">
Download all posts, pages, tags, navigation, and settings as a single JSON file.
Uploaded images are served from the <code>uploads</code> folder and are not included.
</p>
{/* Plain <a> (not LinkButton) so client-side prefetch never hits the download. */}
<a href="/api/admin/export" download className={buttonVariants.secondary}>
Download export (JSON)
</a>
</div>
<form action={formAction} className="space-y-3">
{state.status === "success" && (
<Flash>Import complete. All content and settings were replaced.</Flash>
)}
<FormErrorBanner>{state.formError}</FormErrorBanner>
<div>
<h3 className="text-sm font-medium text-ink-strong">Import</h3>
<p className="mt-1 mb-3 text-sm text-ink-muted">
Restore a previously exported file. This{" "}
<strong className="text-danger">replaces every post, page, tag, navigation
item, and all settings</strong>{" "}
with the file&apos;s contents. Accounts and uploaded images are kept.
</p>
<Label htmlFor={`${ids}-file`}>Export file</Label>
<Input
id={`${ids}-file`}
name="file"
type="file"
accept=".json,application/json"
required
className="max-w-96 cursor-pointer file:mr-3 file:cursor-pointer file:border-0 file:bg-transparent file:p-0 file:text-sm file:font-medium file:text-link"
/>
</div>
<ConfirmButton confirmMessage="Really replace ALL posts, pages, tags, navigation, and settings with this file? This cannot be undone.">
Import and replace everything
</ConfirmButton>
</form>
</section>
);
}

View file

@ -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<string>();
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<typeof siteExportSchema>;
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<SiteExport> {
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<number, string[]>();
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<T>(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<void> {
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<string, number>();
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<string, number>();
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 });
});
}

View file

@ -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> = {}): PostInput => ({
title: "A post",
slug: "",
body: "<p>body</p>",
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: "<p>hi</p>",
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: '<p>ok</p><script>alert("x")</script>',
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("<p>ok</p>");
expect(post?.body).not.toContain("<script>");
});
});
describe("parseSiteExportJson", () => {
it("rejects non-JSON and foreign JSON", () => {
expect(parseSiteExportJson("not json {")).toHaveProperty("error");
expect(parseSiteExportJson('{"hello":"world"}')).toHaveProperty("error");
});
it("rejects posts referencing tags missing from the file", async () => {
const snapshot = await exportViaJson();
snapshot.posts.push({
title: "Orphan",
slug: "orphan",
body: "",
authorName: "A",
featuredImageUrl: null,
featuredImageAlt: null,
status: "draft",
createdAt: new Date(),
updatedAt: new Date(),
publishedAt: null,
tagSlugs: ["missing-tag"],
});
const result = parseSiteExportJson(JSON.stringify(snapshot));
expect(result).toHaveProperty("error");
if ("error" in result) expect(result.error).toContain("missing-tag");
});
it("rejects duplicate slugs", async () => {
const snapshot = await exportViaJson();
snapshot.pages.push(
{ title: "P", slug: "same", body: "", status: "draft", createdAt: new Date(), updatedAt: new Date() },
{ title: "Q", slug: "same", body: "", status: "draft", createdAt: new Date(), updatedAt: new Date() },
);
const result = parseSiteExportJson(JSON.stringify(snapshot));
expect(result).toHaveProperty("error");
});
it("rejects a home tag that is not part of the export", async () => {
const snapshot = await exportViaJson();
snapshot.settings.homeMode = "tag";
snapshot.settings.homeTagSlug = "ghost";
const result = parseSiteExportJson(JSON.stringify(snapshot));
expect(result).toHaveProperty("error");
});
});