fix: harden admin actions, search, sanitization and repo hygiene
- 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:
1 parent
61769e355b
commit
399c047515
20 files changed
+435
-147
No files matched your search
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user