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>
536 lines
19 KiB
TypeScript
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 });
|
|
});
|
|
}
|