- Split approve/dismiss application workflows with distinct audit logs, rate-limited guards and real error logging - Validate article status/date/id input and stop resetting publishedAt on every update - Validate guild updates (state, forum enums, non-empty name) behind rate-limited guard - Fix scheduled-article publishing (ignore NULL dates, set updatedAt, type-safe predicates) - Harden admin search API (LIKE escaping, query cap, per-user rate limit, round-robin result cap) and fix search dialog abort/res.ok/loading races - Lock down HTML sanitizer to an allowlist profile and add XSS tests - Improve mobile nav accessibility (unique id, dialog role, focus management, scroll lock, outside close) - Log swallowed server errors instead of silent catch blocks - Remove dead eslint config, drop unused dompurify deps, restore knip CI step, add Playwright config with smoke spec
193 lines
5.5 KiB
TypeScript
193 lines
5.5 KiB
TypeScript
"use server";
|
|
|
|
import { eq } from "drizzle-orm";
|
|
import { revalidatePath } from "next/cache";
|
|
import { redirect } from "next/navigation";
|
|
import { requirePermission } from "@/lib/admin/guard";
|
|
import {
|
|
db,
|
|
WebsiteArticleComments,
|
|
WebsiteArticleReactions,
|
|
WebsiteArticles,
|
|
} from "@/lib/db";
|
|
import { formPositiveBigInt } from "@/lib/form-data";
|
|
import { slugify } from "@/lib/format";
|
|
import { PERMS } from "@/lib/permissions";
|
|
import { notify } from "@/lib/services/webhook";
|
|
|
|
const ARTICLE_STATUSES = ["published", "scheduled", "draft"] as const;
|
|
type ArticleStatus = (typeof ARTICLE_STATUSES)[number];
|
|
|
|
function parseArticleStatus(raw: unknown): ArticleStatus {
|
|
const value = String(raw ?? "published").trim();
|
|
return (ARTICLE_STATUSES as readonly string[]).includes(value)
|
|
? (value as ArticleStatus)
|
|
: "published";
|
|
}
|
|
|
|
function parsePublishAt(raw: unknown): Date | null {
|
|
const value = String(raw ?? "").trim();
|
|
if (!value) return null;
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) ? null : date;
|
|
}
|
|
|
|
async function uniqueSlug(title: string): Promise<string> {
|
|
const base = slugify(title);
|
|
let slug = base;
|
|
let n = 2;
|
|
for (;;) {
|
|
const [existing] = await db
|
|
.select({ id: WebsiteArticles.id })
|
|
.from(WebsiteArticles)
|
|
.where(eq(WebsiteArticles.slug, slug))
|
|
.limit(1);
|
|
if (!existing) return slug;
|
|
slug = `${base}-${n++}`;
|
|
}
|
|
}
|
|
|
|
export async function createArticle(formData: FormData): Promise<void> {
|
|
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
|
const title = String(formData.get("title") ?? "")
|
|
.normalize("NFC")
|
|
.trim();
|
|
const shortStory = String(formData.get("shortStory") ?? "")
|
|
.normalize("NFC")
|
|
.trim();
|
|
const fullStory = String(formData.get("fullStory") ?? "")
|
|
.normalize("NFC")
|
|
.trim();
|
|
const image = String(formData.get("image") ?? "")
|
|
.normalize("NFC")
|
|
.trim();
|
|
const rawSlug = String(formData.get("slug") ?? "").trim();
|
|
const status = parseArticleStatus(formData.get("status"));
|
|
const rawPublishAt = String(formData.get("publishAt") ?? "").trim();
|
|
if (!title) return;
|
|
if (status === "scheduled" && !parsePublishAt(rawPublishAt)) {
|
|
redirect(
|
|
"/admin/articles/new?error=Scheduled articles need a valid publish date.",
|
|
);
|
|
}
|
|
|
|
try {
|
|
const now = new Date();
|
|
const publishAt = parsePublishAt(rawPublishAt);
|
|
const slug = rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title);
|
|
await db.insert(WebsiteArticles).values({
|
|
slug,
|
|
title: title.slice(0, 255),
|
|
shortStory: shortStory.slice(0, 255),
|
|
fullStory,
|
|
image: image.slice(0, 255),
|
|
userId: staff.id,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
status,
|
|
publishAt,
|
|
publishedAt: status === "published" ? now : null,
|
|
});
|
|
|
|
notify({
|
|
action: "news_publish",
|
|
actor: staff.username,
|
|
target: title,
|
|
details: slug,
|
|
});
|
|
} catch {
|
|
// Database error — re-render unchanged with error.
|
|
redirect(
|
|
"/admin/articles/new?error=Database error while creating article. Please try again.",
|
|
);
|
|
}
|
|
redirect("/admin/articles");
|
|
}
|
|
|
|
export async function updateArticle(formData: FormData): Promise<void> {
|
|
await requirePermission(PERMS.NEWS_EDIT);
|
|
const id = formPositiveBigInt(formData, "id");
|
|
if (!id) redirect("/admin/articles?error=Missing article id");
|
|
const rawSlug = String(formData.get("slug") ?? "").trim();
|
|
const status = parseArticleStatus(formData.get("status"));
|
|
const rawPublishAt = String(formData.get("publishAt") ?? "").trim();
|
|
if (status === "scheduled" && !parsePublishAt(rawPublishAt)) {
|
|
redirect(
|
|
"/admin/articles?error=Scheduled articles need a valid publish date.",
|
|
);
|
|
}
|
|
try {
|
|
const publishAt = parsePublishAt(rawPublishAt);
|
|
const [existing] = await db
|
|
.select({
|
|
status: WebsiteArticles.status,
|
|
publishedAt: WebsiteArticles.publishedAt,
|
|
})
|
|
.from(WebsiteArticles)
|
|
.where(eq(WebsiteArticles.id, id))
|
|
.limit(1);
|
|
const publishedAt =
|
|
status === "published" ? (existing?.publishedAt ?? new Date()) : null;
|
|
await db
|
|
.update(WebsiteArticles)
|
|
.set({
|
|
title: String(formData.get("title") ?? "")
|
|
.normalize("NFC")
|
|
.trim()
|
|
.slice(0, 255),
|
|
...(rawSlug ? { slug: await uniqueSlug(rawSlug) } : {}),
|
|
shortStory: String(formData.get("shortStory") ?? "")
|
|
.normalize("NFC")
|
|
.trim()
|
|
.slice(0, 255),
|
|
fullStory: String(formData.get("fullStory") ?? "")
|
|
.normalize("NFC")
|
|
.trim(),
|
|
image: String(formData.get("image") ?? "")
|
|
.normalize("NFC")
|
|
.trim()
|
|
.slice(0, 255),
|
|
status,
|
|
publishAt,
|
|
publishedAt,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(WebsiteArticles.id, id));
|
|
} catch {
|
|
redirect("/admin/articles?error=Update failed");
|
|
}
|
|
revalidatePath(`/admin/articles/${id}`);
|
|
redirect("/admin/articles");
|
|
}
|
|
|
|
export async function deleteArticle(formData: FormData): Promise<void> {
|
|
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
|
const id = formPositiveBigInt(formData, "id");
|
|
if (!id) redirect("/admin/articles?error=Missing article id");
|
|
const [article] = await db
|
|
.select({ title: WebsiteArticles.title })
|
|
.from(WebsiteArticles)
|
|
.where(eq(WebsiteArticles.id, id))
|
|
.limit(1);
|
|
try {
|
|
await db.transaction(async (tx) => {
|
|
await tx
|
|
.delete(WebsiteArticleReactions)
|
|
.where(eq(WebsiteArticleReactions.articleId, id));
|
|
await tx
|
|
.delete(WebsiteArticleComments)
|
|
.where(eq(WebsiteArticleComments.articleId, id));
|
|
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
|
|
});
|
|
|
|
notify({
|
|
action: "news_delete",
|
|
actor: staff.username,
|
|
target: article?.title ?? String(id),
|
|
});
|
|
} catch {
|
|
redirect("/admin/articles?error=Delete failed");
|
|
}
|
|
redirect("/admin/articles");
|
|
}
|