feat(editorial): validate publications and preserve partial event updates
This commit is contained in:
1 parent
76f0420d64
commit
db4acbb46e
14 files changed
+613
-16
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { publicationIssues } from "./publication-preflight";
|
||||
import type { ActionResult } from "./safe-action-shared";
|
||||
export type ArticleSaveResult = ActionResult<{ redirectTo: string }>;
|
||||
export class ArticleInputError extends Error {
|
||||
@@ -10,7 +11,9 @@ export class ArticleInputError extends Error {
|
||||
| "summaryTooLong"
|
||||
| "imageTooLong"
|
||||
| "invalidStatus"
|
||||
| "publishDateRequired",
|
||||
| "publishDateRequired"
|
||||
| "imageInvalid"
|
||||
| "linksInvalid",
|
||||
) {
|
||||
super(code);
|
||||
}
|
||||
@@ -34,6 +37,17 @@ export function readArticleInput(form: FormData) {
|
||||
const publishAt = status === "scheduled" ? new Date(text("publishAt")) : null;
|
||||
if (publishAt && Number.isNaN(publishAt.getTime()))
|
||||
throw new ArticleInputError("publishDateRequired");
|
||||
if (status !== "draft") {
|
||||
const issue = publicationIssues({
|
||||
kind: "article",
|
||||
image,
|
||||
body: fullStory,
|
||||
status,
|
||||
publishAt,
|
||||
}).find((issue) => issue.severity === "error");
|
||||
if (issue?.code === "imageInvalid" || issue?.code === "linksInvalid")
|
||||
throw new ArticleInputError(issue.code);
|
||||
}
|
||||
return {
|
||||
title,
|
||||
shortStory,
|
||||
@@ -57,6 +71,8 @@ export function articleInputField(
|
||||
titleTooLong: "title",
|
||||
summaryTooLong: "shortStory",
|
||||
imageTooLong: "image",
|
||||
imageInvalid: "image",
|
||||
linksInvalid: "fullStory",
|
||||
invalidStatus: "status",
|
||||
publishDateRequired: "publishAt",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readArticleInput } from "./article-input";
|
||||
import { publicationIssues, safePublicationUrl } from "./publication-preflight";
|
||||
|
||||
describe("publication preflight", () => {
|
||||
it("allows local images and web links but rejects unsafe schemes and credentials", () => {
|
||||
expect(safePublicationUrl("/images/news.png", true)).toBe(true);
|
||||
expect(safePublicationUrl("https://example.com/image.png", true)).toBe(
|
||||
true,
|
||||
);
|
||||
for (const url of [
|
||||
"javascript:alert(1)",
|
||||
"//external.test/image",
|
||||
"https://user:[email protected]/a",
|
||||
"data:text/html,test",
|
||||
"https:\\example.com",
|
||||
]) {
|
||||
expect(safePublicationUrl(url, true)).toBe(false);
|
||||
}
|
||||
});
|
||||
it("checks encoded link schemes without fetching destinations", () => {
|
||||
const issues = publicationIssues({
|
||||
kind: "article",
|
||||
image: "/cover.png",
|
||||
body: '<a href="javascript:alert(1)">bad</a>',
|
||||
});
|
||||
expect(issues).toContainEqual({
|
||||
code: "linksInvalid",
|
||||
severity: "error",
|
||||
field: "fullStory",
|
||||
});
|
||||
expect(
|
||||
publicationIssues({
|
||||
kind: "article",
|
||||
image: "/cover.png",
|
||||
body: '<a href="/help">Help</a><a href="mailto:[email protected]">Mail</a>',
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
it("rejects reversed event dates and distinguishes a past-date warning", () => {
|
||||
expect(
|
||||
publicationIssues({
|
||||
kind: "event",
|
||||
startsAt: "2030-01-02",
|
||||
endsAt: "2030-01-01",
|
||||
}),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: "scheduleInvalid", severity: "error" }),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
publicationIssues(
|
||||
{ kind: "event", startsAt: "2020-01-01" },
|
||||
Date.parse("2021-01-01"),
|
||||
),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: "schedulePast", severity: "warning" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
it("warns for missing image and normalized slug without blocking drafts", () => {
|
||||
const issues = publicationIssues({
|
||||
kind: "article",
|
||||
slug: "Hello World",
|
||||
normalizedSlug: "hello-world",
|
||||
});
|
||||
expect(issues.map((i) => i.code)).toEqual([
|
||||
"imageMissing",
|
||||
"slugNormalized",
|
||||
]);
|
||||
expect(issues.every((i) => i.severity === "warning")).toBe(true);
|
||||
});
|
||||
it("requires a valid scheduled publication date", () => {
|
||||
expect(
|
||||
publicationIssues({
|
||||
kind: "article",
|
||||
status: "scheduled",
|
||||
publishAt: "",
|
||||
}),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: "scheduleInvalid" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
it("enforces image and link checks in server article input but preserves draft saving", () => {
|
||||
const form = new FormData();
|
||||
form.set("title", "News");
|
||||
form.set("image", "javascript:alert(1)");
|
||||
expect(() => readArticleInput(form)).toThrow("imageInvalid");
|
||||
form.set("status", "draft");
|
||||
expect(readArticleInput(form).image).toBe("javascript:alert(1)");
|
||||
form.set("status", "published");
|
||||
form.set("image", "/cover.png");
|
||||
form.set("fullStory", '<a href="javascript:alert(1)">bad</a>');
|
||||
expect(() => readArticleInput(form)).toThrow("linksInvalid");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
export type PublicationIssueCode =
|
||||
| "imageMissing"
|
||||
| "imageInvalid"
|
||||
| "linksInvalid"
|
||||
| "scheduleInvalid"
|
||||
| "schedulePast"
|
||||
| "slugNormalized";
|
||||
export interface PublicationIssue {
|
||||
code: PublicationIssueCode;
|
||||
severity: "error" | "warning";
|
||||
field: string;
|
||||
}
|
||||
export interface PublicationInput {
|
||||
kind: "article" | "event";
|
||||
image?: string | null;
|
||||
body?: string;
|
||||
slug?: string;
|
||||
normalizedSlug?: string;
|
||||
status?: string;
|
||||
publishAt?: Date | string | null;
|
||||
startsAt?: Date | string | null;
|
||||
endsAt?: Date | string | null;
|
||||
}
|
||||
export function safePublicationUrl(value: string, image = false): boolean {
|
||||
const text = value.trim();
|
||||
if (
|
||||
!text ||
|
||||
[...text].some((char) => char.charCodeAt(0) <= 32 || char === "\\")
|
||||
)
|
||||
return false;
|
||||
if (text.startsWith("/") && !text.startsWith("//")) return true;
|
||||
if (!image && text.startsWith("#")) return true;
|
||||
try {
|
||||
const url = new URL(text);
|
||||
return (
|
||||
!url.username &&
|
||||
!url.password &&
|
||||
(url.protocol === "https:" ||
|
||||
url.protocol === "http:" ||
|
||||
(!image && url.protocol === "mailto:"))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function decodeLink(value: string): string {
|
||||
return value
|
||||
.replace(/&#(x[\da-f]+|\d+);?/gi, (_all, code: string) => {
|
||||
const n =
|
||||
code[0].toLowerCase() === "x"
|
||||
? Number.parseInt(code.slice(1), 16)
|
||||
: Number(code);
|
||||
return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : "";
|
||||
})
|
||||
.replace(/:/gi, ":")
|
||||
.replace(/&/gi, "&");
|
||||
}
|
||||
export function publicationIssues(
|
||||
input: PublicationInput,
|
||||
now = Date.now(),
|
||||
): PublicationIssue[] {
|
||||
const issues: PublicationIssue[] = [];
|
||||
if (!input.image?.trim())
|
||||
issues.push({ code: "imageMissing", severity: "warning", field: "image" });
|
||||
else if (!safePublicationUrl(input.image, true))
|
||||
issues.push({ code: "imageInvalid", severity: "error", field: "image" });
|
||||
if (input.kind === "article") {
|
||||
const links = [
|
||||
...(input.body ?? "").matchAll(
|
||||
/\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi,
|
||||
),
|
||||
].map((match) => decodeLink(match[1] ?? match[2] ?? match[3] ?? ""));
|
||||
if (links.some((link) => !safePublicationUrl(link)))
|
||||
issues.push({
|
||||
code: "linksInvalid",
|
||||
severity: "error",
|
||||
field: "fullStory",
|
||||
});
|
||||
if (
|
||||
input.slug &&
|
||||
input.normalizedSlug &&
|
||||
input.slug !== input.normalizedSlug
|
||||
)
|
||||
issues.push({
|
||||
code: "slugNormalized",
|
||||
severity: "warning",
|
||||
field: "slug",
|
||||
});
|
||||
}
|
||||
const date =
|
||||
input.kind === "event"
|
||||
? input.startsAt
|
||||
: input.status === "scheduled"
|
||||
? input.publishAt
|
||||
: null;
|
||||
if (input.kind === "event" || input.status === "scheduled") {
|
||||
const start = date ? new Date(date).getTime() : NaN;
|
||||
const end = input.endsAt ? new Date(input.endsAt).getTime() : null;
|
||||
if (
|
||||
!Number.isFinite(start) ||
|
||||
(end !== null && (!Number.isFinite(end) || end <= start))
|
||||
)
|
||||
issues.push({
|
||||
code: "scheduleInvalid",
|
||||
severity: "error",
|
||||
field: input.kind === "event" ? "startsAt" : "publishAt",
|
||||
});
|
||||
else if (start < now)
|
||||
issues.push({
|
||||
code: "schedulePast",
|
||||
severity: "warning",
|
||||
field: input.kind === "event" ? "startsAt" : "publishAt",
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createEventSchema, eventTypeSchema } from "./event";
|
||||
import { createEventSchema, eventTypeSchema, updateEventSchema } from "./event";
|
||||
|
||||
describe("eventTypeSchema", () => {
|
||||
it("accepts valid type", () => {
|
||||
@@ -98,3 +98,27 @@ describe("createEventSchema", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves omitted status and recurrence on partial event updates", () => {
|
||||
expect(updateEventSchema.parse({ title: "Changed" })).toEqual({
|
||||
title: "Changed",
|
||||
});
|
||||
});
|
||||
it("rejects a null event start instead of converting it to the Unix epoch", () => {
|
||||
expect(
|
||||
createEventSchema.safeParse({
|
||||
title: "Event",
|
||||
description: "Details",
|
||||
typeId: 1,
|
||||
startsAt: null,
|
||||
status: "published",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("distinguishes an omitted image from explicit image removal", () => {
|
||||
expect(updateEventSchema.parse({ title: "Changed" })).not.toHaveProperty(
|
||||
"image",
|
||||
);
|
||||
expect(updateEventSchema.parse({ image: "" })).toEqual({ image: null });
|
||||
});
|
||||
@@ -25,7 +25,11 @@ export const createEventSchema = z.object({
|
||||
emptyToUndefined,
|
||||
z.coerce.number().int().positive().nullable().optional(),
|
||||
),
|
||||
startsAt: z.coerce.date(),
|
||||
startsAt: z.preprocess(
|
||||
(value) =>
|
||||
value == null || typeof value === "boolean" ? undefined : value,
|
||||
z.coerce.date(),
|
||||
),
|
||||
endsAt: z.preprocess(emptyToUndefined, z.coerce.date().nullable().optional()),
|
||||
maxPlayers: z.preprocess(
|
||||
emptyToUndefined,
|
||||
@@ -45,7 +49,15 @@ export const createEventSchema = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
export const updateEventSchema = createEventSchema.partial();
|
||||
// Zod applies defaults inside optional schemas; partial edits must not reset state.
|
||||
export const updateEventSchema = createEventSchema.partial().extend({
|
||||
status: createEventSchema.shape.status.removeDefault().optional(),
|
||||
isRecurring: createEventSchema.shape.isRecurring.removeDefault().optional(),
|
||||
image: z.preprocess(
|
||||
(value) => (value === "" ? null : value),
|
||||
z.string().max(500).nullable().optional(),
|
||||
),
|
||||
});
|
||||
|
||||
export const eventPrizeSchema = z.object({
|
||||
eventId: z.coerce.number().int().positive(),
|
||||
|
||||
Reference in new issue
Block a user