import { asc, eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { db } from "@/db"; import { comments, fontEnum, navItems, pages, postTags, posts, settings, tags, themeEnum, users, } 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"; // v1: posts/pages/tags/nav/settings. v2 adds comments. v3 adds // posts.authorUsername so post ownership survives a restore. Older // files still import (missing fields default to empty/unowned). export const SITE_EXPORT_VERSION = 3; 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.union([z.literal(1), z.literal(2), z.literal(3)]), 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), /** * Owning account's username. Accounts are not part of the export; * on import this is matched against existing usernames and posts * without a match become unowned (admin-managed). */ authorUsername: z.string().trim().min(1).max(120).nullable().default(null), 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), comments: z .array( z.object({ /** Id local to this file (the exporter's DB id); remapped on import. */ id: z.number().int().positive(), postSlug: slugValue, parentId: z.number().int().positive().nullable(), authorName: z.string().trim().min(1).max(120), authorEmail: z.string().trim().min(1).max(254), emailPublic: z.boolean(), body: z.string().min(1).max(5_000), status: z.enum(["pending", "approved"]), createdAt: timestampValue, }), ) .max(100_000) .default([]), }) .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.", }); } } const postSlugs = new Set(data.posts.map((p) => p.slug)); const commentById = new Map(data.comments.map((c) => [c.id, c])); if (commentById.size !== data.comments.length) { ctx.addIssue({ code: "custom", message: "Duplicate comment ids in the export." }); return; // parent-chain checks below assume unique ids } for (const comment of data.comments) { if (!postSlugs.has(comment.postSlug)) { ctx.addIssue({ code: "custom", message: `Comment ${comment.id} references unknown post “${comment.postSlug}”.`, }); continue; } if (comment.parentId === null) continue; const parent = commentById.get(comment.parentId); if (!parent) { ctx.addIssue({ code: "custom", message: `Comment ${comment.id} replies to unknown comment ${comment.parentId}.`, }); } else if (parent.postSlug !== comment.postSlug) { ctx.addIssue({ code: "custom", message: `Comment ${comment.id} replies to a comment on a different post.`, }); } } // Reject parent cycles (impossible via the app, possible in a crafted // file) — the importer's parents-first insertion would never terminate. const commentDepthCache = new Map(); for (const comment of data.comments) { const seen = new Set(); let current: typeof comment | undefined = comment; while (current && current.parentId !== null) { if (commentDepthCache.get(current.id)) break; // known-good chain if (seen.has(current.id)) { ctx.addIssue({ code: "custom", message: `Comment ${comment.id} is part of a reply cycle.`, }); return; } seen.add(current.id); current = commentById.get(current.parentId); } for (const id of seen) commentDepthCache.set(id, true); } }); 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, commentRows] = 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({ post: posts, authorUsername: users.username }) .from(posts) .leftJoin(users, eq(users.id, posts.authorId)) .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)), db .select({ comment: comments, postSlug: posts.slug }) .from(comments) .innerJoin(posts, eq(posts.id, comments.postId)) .orderBy(asc(comments.id)), ]); 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(({ post: p, authorUsername }) => ({ title: p.title, slug: p.slug, body: p.body, authorName: p.authorName, authorUsername, featuredImageUrl: p.featuredImageUrl, featuredImageAlt: p.featuredImageAlt, status: p.status, createdAt: p.createdAt, updatedAt: p.updatedAt, publishedAt: p.publishedAt, tagSlugs: tagSlugsByPost.get(p.id) ?? [], })), comments: commentRows.map(({ comment, postSlug }) => ({ id: comment.id, postSlug, parentId: comment.parentId, authorName: comment.authorName, authorEmail: comment.authorEmail, emailPublic: comment.emailPublic, body: comment.body, status: comment.status, createdAt: comment.createdAt, })), }; } /** 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, comments, 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); } // Accounts survive an import untouched; posts re-attach to them by // username. Unknown usernames leave the post unowned (admin-managed). const exportUsernames = [ ...new Set( data.posts.flatMap((p) => (p.authorUsername !== null ? [p.authorUsername] : [])), ), ]; const userIdByUsername = new Map(); if (exportUsernames.length > 0) { const userRows = await tx .select({ id: users.id, username: users.username }) .from(users) .where(inArray(users.username, exportUsernames)); for (const row of userRows) userIdByUsername.set(row.username, row.id); } const postIdBySlug = new Map(); 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, authorId: p.authorUsername !== null ? (userIdByUsername.get(p.authorUsername) ?? null) : null, 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 }); for (const row of inserted) postIdBySlug.set(row.slug, row.id); for (const post of batch) { const postId = postIdBySlug.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); } // Comments insert parents-first so replies can point at fresh ids; // the schema validation above guarantees the parent graph is acyclic, // so every pass makes progress. Comment bodies are plain text (rendered // escaped), so no HTML sanitizing is needed. const commentIdByLocal = new Map(); let pendingComments = data.comments; while (pendingComments.length > 0) { const ready = pendingComments.filter( (c) => c.parentId === null || commentIdByLocal.has(c.parentId), ); if (ready.length === 0) { // Unreachable after schema validation; guards the loop all the same. throw new Error("Comment import stalled on an unresolvable parent reference."); } for (const batch of chunk(ready, 1000)) { const inserted = await tx .insert(comments) .values( batch.map((c) => ({ postId: postIdBySlug.get(c.postSlug)!, parentId: c.parentId === null ? null : commentIdByLocal.get(c.parentId)!, authorName: c.authorName, authorEmail: c.authorEmail, emailPublic: c.emailPublic, body: c.body, status: c.status, createdAt: c.createdAt, })), ) // Postgres returns multi-row INSERT ... RETURNING rows in values // order, which is what lets us zip local ids to new ids here. .returning({ id: comments.id }); batch.forEach((c, i) => commentIdByLocal.set(c.id, inserted[i].id)); } pendingComments = pendingComments.filter((c) => !commentIdByLocal.has(c.id)); } 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 }); }); }