feat(editorial): validate publications and preserve partial event updates
CI / check (push) Successful in 57s
CI / deploy (push) Successful in 18s
CI / publish-container (push) Successful in 1m20s

This commit is contained in:
Simo committed 2026-09-11 00:36:55 +02:00
1 parent 76f0420d64
commit db4acbb46e
14 files changed
+613 -16

No files matched your search

+17 -1
View File
@@ -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",
};
+100
View File
@@ -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="java&#x73;cript: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");
});
});
+116
View File
@@ -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(/&colon;/gi, ":")
.replace(/&amp;/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;
}
+25 -1
View File
@@ -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 });
});
+14 -2
View File
@@ -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(),