yap-blog/src/lib/validation.ts
matt 35fb33c5a7 Add granular author permissions, admin-tag persistence, own-password change
Each author account now carries five grantable permissions, editable on
the Users page: publish posts, unpublish posts, delete posts (all three
scoped to the author's own posts), create tags, and approve comments
(scoped to comments on the author's posts, without delete). A bare
author writes and edits their own drafts only. Permission checks gate
the status TRANSITION, so editing an already-published post never
requires the publish permission, and the editor's status dropdown only
offers what the account may do. Tags an author creates are granted to
them automatically, and "creating" an existing off-grant tag is
refused (it would be a self-grant loophole). Existing author accounts
keep publish+unpublish via migration backfill.

Tags the admin attaches outside an author's grants now survive the
author's edits: the form shows them checked-and-locked and the server
re-attaches them on every save.

Every account can change its own password on the new /admin/account
page (current password required); the username in the admin header
links there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 12:58:19 -04:00

202 lines
6.8 KiB
TypeScript

import { z } from "zod";
import { fontEnum, themeEnum } from "@/db/schema";
/** http(s) URL or a site-relative path starting with a single "/". */
export function isValidLinkUrl(value: string): boolean {
if (value.startsWith("/")) return !value.startsWith("//");
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
const slugInput = z
.string()
.trim()
.max(120, "Slug must be at most 120 characters.")
.default("");
const statusInput = z.enum(["draft", "published"], {
message: "Status must be draft or published.",
});
export const postFormSchema = z.object({
title: z.string().trim().min(1, "Title is required.").max(200, "Title is too long."),
slug: slugInput,
authorName: z
.string()
.trim()
.min(1, "Author name is required.")
.max(120, "Author name is too long."),
body: z.string().max(500_000, "Body is too long."),
featuredImageUrl: z
.string()
.trim()
.max(2000, "Image URL is too long.")
.default("")
.refine((v) => v === "" || isValidLinkUrl(v), {
message: "Enter an http(s) URL or a site-relative path.",
}),
featuredImageAlt: z.string().trim().max(300, "Alt text is too long.").default(""),
status: statusInput,
tagIds: z.array(z.coerce.number().int().positive()).max(50).default([]),
newTags: z.string().trim().max(500, "New tag list is too long.").default(""),
});
export type PostFormData = z.infer<typeof postFormSchema>;
export const pageFormSchema = z.object({
title: z.string().trim().min(1, "Title is required.").max(200, "Title is too long."),
slug: slugInput,
body: z.string().max(500_000, "Body is too long."),
status: statusInput,
});
export type PageFormData = z.infer<typeof pageFormSchema>;
export const commentFormSchema = z.object({
postId: z.coerce.number().int().positive(),
parentId: z.preprocess(
(v) => (v === "" || v === null || v === undefined ? null : v),
z.coerce.number().int().positive().nullable(),
),
authorName: z.string().trim().min(1, "Name is required.").max(120, "Name is too long."),
authorEmail: z
.string()
.trim()
.min(1, "Email is required.")
.max(254, "Email is too long.")
.pipe(z.email("Enter a valid email address.")),
// Checkbox: present ("on") when ticked, absent otherwise.
emailPublic: z.preprocess((v) => v === "on" || v === "true" || v === true, z.boolean()),
body: z
.string()
.trim()
.min(1, "Comment cannot be empty.")
.max(5000, "Comments are limited to 5000 characters."),
});
export type CommentFormData = z.infer<typeof commentFormSchema>;
export const loginFormSchema = z.object({
username: z.string().trim().min(1, "Username is required.").max(120),
password: z.string().min(1, "Password is required.").max(200),
});
const usernameInput = z
.string()
.trim()
.min(1, "Username is required.")
.max(120, "Username is too long.")
.regex(
/^[a-zA-Z0-9._-]+$/,
"Usernames may only contain letters, numbers, dots, dashes, and underscores.",
);
const newPasswordInput = z
.string()
.min(8, "Password must be at least 8 characters.")
.max(200, "Password is too long.");
/** Checkbox: present ("on") when ticked, absent otherwise. */
const checkboxInput = z.preprocess((v) => v === "on" || v === "true" || v === true, z.boolean());
const permissionsInput = z.object({
canCreateTags: checkboxInput,
canPublishPosts: checkboxInput,
canUnpublishPosts: checkboxInput,
canDeletePosts: checkboxInput,
canApproveComments: checkboxInput,
});
export const createUserFormSchema = z.object({
username: usernameInput,
password: newPasswordInput,
tagIds: z.array(z.coerce.number().int().positive()).max(200).default([]),
permissions: permissionsInput,
});
export const updateUserFormSchema = z.object({
// Blank means "keep the current password".
password: z.preprocess(
(v) => (v === "" || v === null ? null : v),
newPasswordInput.nullable(),
),
tagIds: z.array(z.coerce.number().int().positive()).max(200).default([]),
permissions: permissionsInput,
});
export const changePasswordFormSchema = z.object({
currentPassword: z.string().min(1, "Enter your current password.").max(200),
newPassword: newPasswordInput,
});
export const navItemSchema = z
.object({
label: z.string().trim().min(1, "Every navigation item needs a label.").max(80),
url: z.string().trim().max(2000).nullable(),
pageId: z.number().int().positive().nullable(),
})
.superRefine((item, ctx) => {
const hasUrl = item.url !== null && item.url !== "";
const hasPage = item.pageId !== null;
if (hasUrl === hasPage) {
ctx.addIssue({
code: "custom",
message: `${item.label}” must link to either a page or a URL.`,
});
} else if (hasUrl && !isValidLinkUrl(item.url as string)) {
ctx.addIssue({
code: "custom",
message: `${item.label}” needs an http(s) URL or a path starting with “/”.`,
});
}
});
const optionalId = z.preprocess(
(v) => (v === "" || v === null || v === undefined ? undefined : v),
z.coerce.number().int().positive().optional(),
);
export const settingsFormSchema = z.object({
siteTitle: z.string().trim().min(1, "Site title is required.").max(120),
headerText: z.string().trim().max(300, "Header text is too long.").default(""),
footerText: z.string().trim().max(500, "Footer text is too long.").default(""),
postsPerPage: z.coerce
.number({ message: "Posts per page must be a number." })
.int("Posts per page must be a whole number.")
.min(1, "Posts per page must be at least 1.")
.max(50, "Posts per page must be at most 50."),
excerptWords: z.coerce
.number({ message: "Excerpt word limit must be a number." })
.int("Excerpt word limit must be a whole number.")
.min(5, "Excerpt word limit must be at least 5.")
.max(200, "Excerpt word limit must be at most 200."),
homeMode: z.enum(["posts", "tag", "page"]),
homeTagId: optionalId,
homePageId: optionalId,
theme: z.enum(themeEnum.enumValues, {
message: "Choose one of the available themes.",
}),
font: z.enum(fontEnum.enumValues, {
message: "Choose one of the available fonts.",
}),
navItemsJson: z.string().max(50_000).default("[]"),
});
export type SettingsFormData = z.infer<typeof settingsFormSchema>;
export function parseNavItemsJson(
json: string,
): { items: Array<z.infer<typeof navItemSchema>> } | { error: string } {
let raw: unknown;
try {
raw = JSON.parse(json);
} catch {
return { error: "Navigation items could not be read. Reload and try again." };
}
const parsed = z.array(navItemSchema).max(20, "At most 20 navigation items.").safeParse(raw);
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? "Navigation items are invalid." };
}
return { items: parsed.data };
}