yap-blog/src/lib/services/import-export.ts
matt 6d84ae1224 Add author accounts with per-tag posting rights
The seeded account is the single admin; it can create author accounts
on the new /admin/users page (username + password) and grant each one
access to specific tags. Authors sign in to a Posts-only panel where
they can write, edit, publish, and unpublish their own posts — every
post must carry at least one granted tag, tags outside the grants are
rejected server-side, and only the admin can create tags or delete
posts (or anything else: pages, comments, settings, and backups stay
admin-only). Admin-only URLs bounce authors to their post list, and
foreign post editors 404.

posts.author_id records ownership; deleting an account keeps its posts
as unowned, admin-managed rows and signs the account out everywhere.
Backups (export v3) store the owner's username per post and re-attach
ownership on import when the account still exists.

Also fixes a latent form bug: a missing newTags field (author forms
don't render it) failed zod validation with an invisible error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 22:10:02 -04:00

536 lines
19 KiB
TypeScript

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<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.",
});
}
}
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<number, boolean>();
for (const comment of data.comments) {
const seen = new Set<number>();
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<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, 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<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(({ 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<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, 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<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);
}
// 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<string, number>();
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<string, number>();
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<number, number>();
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 });
});
}