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:
parent
32177b33f5
commit
3175bd9172
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<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" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
<div className="mt-10">
|
||||
<ImportExportSection importAction={importSiteAction} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
19
src/app/api/admin/export/route.ts
Normal file
19
src/app/api/admin/export/route.ts
Normal 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"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
65
src/components/admin/ImportExportSection.tsx
Normal file
65
src/components/admin/ImportExportSection.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
367
src/lib/services/import-export.ts
Normal file
367
src/lib/services/import-export.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
173
tests/integration/import-export.test.ts
Normal file
173
tests/integration/import-export.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue