Merge feature/import-export: JSON backup import/export
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
85dd16a564
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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" };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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