fix: harden admin actions, search, sanitization and repo hygiene
CI / check (push) Successful in 1m21s
CI / deploy (push) Successful in 1m25s

- 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
This commit is contained in:
openhands committed 2026-09-04 13:04:08 +02:00
1 parent 61769e355b
commit 399c047515
20 files changed
+435 -147

No files matched your search

+32 -16
View File
@@ -2,39 +2,55 @@
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { db, WebsiteStaffApplications } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
import { logStaffActivity } from "@/lib/services/staff-activity";
export async function dismissApplication(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
async function removeApplication(id: bigint): Promise<boolean> {
try {
await db
.delete(WebsiteStaffApplications)
.where(eq(WebsiteStaffApplications.id, id));
} catch {
// already gone / no DB — nothing to do
return true;
} catch (error) {
logServerError("applications.delete_failed", error, { id: String(id) });
return false;
}
}
export async function dismissApplication(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
await removeApplication(id);
await logStaffActivity({
staffId: staff.id,
action: "application_dismiss",
description: `Dismissed staff application #${id}`,
targetType: "staff_application",
targetId: Number(id),
});
revalidatePath("/admin/applications");
}
export async function approveApplication(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT);
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
try {
await db
.delete(WebsiteStaffApplications)
.where(eq(WebsiteStaffApplications.id, id));
} catch {
// already gone / no DB — nothing to do
}
await removeApplication(id);
await logStaffActivity({
staffId: staff.id,
action: "application_approve",
description: `Approved staff application #${id}`,
targetType: "staff_application",
targetId: Number(id),
});
revalidatePath("/admin/applications");
}
+48 -8
View File
@@ -10,10 +10,28 @@ import {
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;
@@ -44,13 +62,18 @@ export async function createArticle(formData: FormData): Promise<void> {
.normalize("NFC")
.trim();
const rawSlug = String(formData.get("slug") ?? "").trim();
const status = String(formData.get("status") ?? "published");
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 = rawPublishAt ? new Date(rawPublishAt) : null;
const publishAt = parsePublishAt(rawPublishAt);
const slug = rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title);
await db.insert(WebsiteArticles).values({
slug,
@@ -61,7 +84,7 @@ export async function createArticle(formData: FormData): Promise<void> {
userId: staff.id,
createdAt: now,
updatedAt: now,
status: status || "published",
status,
publishAt,
publishedAt: status === "published" ? now : null,
});
@@ -83,12 +106,28 @@ export async function createArticle(formData: FormData): Promise<void> {
export async function updateArticle(formData: FormData): Promise<void> {
await requirePermission(PERMS.NEWS_EDIT);
const id = BigInt(String(formData.get("id")));
const id = formPositiveBigInt(formData, "id");
if (!id) redirect("/admin/articles?error=Missing article id");
const rawSlug = String(formData.get("slug") ?? "").trim();
const status = String(formData.get("status") ?? "published");
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 = rawPublishAt ? new Date(rawPublishAt) : null;
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({
@@ -110,7 +149,7 @@ export async function updateArticle(formData: FormData): Promise<void> {
.slice(0, 255),
status,
publishAt,
publishedAt: status === "published" ? new Date() : undefined,
publishedAt,
updatedAt: new Date(),
})
.where(eq(WebsiteArticles.id, id));
@@ -123,7 +162,8 @@ export async function updateArticle(formData: FormData): Promise<void> {
export async function deleteArticle(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.NEWS_EDIT);
const id = BigInt(String(formData.get("id")));
const id = formPositiveBigInt(formData, "id");
if (!id) redirect("/admin/articles?error=Missing article id");
const [article] = await db
.select({ title: WebsiteArticles.title })
.from(WebsiteArticles)
+53 -11
View File
@@ -2,10 +2,7 @@
import { eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import {
requirePermission,
requirePermissionRateLimited,
} from "@/lib/admin/guard";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import {
db,
GuildForumViews,
@@ -19,6 +16,33 @@ import {
import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity";
const GUILD_STATES = [0, 1, 2] as const;
const GUILD_FORUM = ["0", "1"] as const;
const GUILD_FORUM_ACCESS = [
"EVERYONE",
"OWNER",
"ADMIN",
"MEMBER",
"NONE",
] as const;
const GUILD_MOD_ACCESS = ["ADMINS", "OWNER", "MEMBER", "NONE"] as const;
function parseGuildState(raw: unknown): number | null {
const value = Number(raw);
return (GUILD_STATES as readonly number[]).includes(value) ? value : null;
}
function parseEnum<T extends string>(
raw: unknown,
allowed: readonly T[],
fallback: T,
): T {
const value = String(raw ?? fallback).trim();
return (allowed as readonly string[]).includes(value)
? (value as T)
: fallback;
}
/** Disband a guild and clean related membership/forum rows. */
export async function disbandGuild(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
@@ -68,22 +92,40 @@ export async function disbandGuild(formData: FormData): Promise<void> {
}
export async function updateGuild(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
const id = Number(formData.get("id"));
if (!(id > 0)) return;
const name = String(formData.get("name") ?? "")
.trim()
.slice(0, 50);
if (!name) return;
const description = String(formData.get("description") ?? "")
.trim()
.slice(0, 250);
const state = Number(formData.get("state"));
const forum = String(formData.get("forum") ?? "0");
const readForum = String(formData.get("readForum") ?? "EVERYONE");
const postMessages = String(formData.get("postMessages") ?? "EVERYONE");
const postThreads = String(formData.get("postThreads") ?? "EVERYONE");
const modForum = String(formData.get("modForum") ?? "ADMINS");
const state = parseGuildState(formData.get("state"));
if (state === null) return;
const forum = parseEnum(formData.get("forum"), GUILD_FORUM, "0");
const readForum = parseEnum(
formData.get("readForum"),
GUILD_FORUM_ACCESS,
"EVERYONE",
);
const postMessages = parseEnum(
formData.get("postMessages"),
GUILD_FORUM_ACCESS,
"EVERYONE",
);
const postThreads = parseEnum(
formData.get("postThreads"),
GUILD_FORUM_ACCESS,
"EVERYONE",
);
const modForum = parseEnum(
formData.get("modForum"),
GUILD_MOD_ACCESS,
"ADMINS",
);
await db
.update(Guilds)