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

+3
View File
@@ -47,6 +47,9 @@ jobs:
BCRYPT_ROUNDS: 4
run: pnpm test --maxWorkers=1
- name: Knip (unused files/exports)
run: pnpm knip
# ─────────────────────────────────────────────
# Docker build & deploy
# Draait op de host (self-hosted) zodat Docker
+13
View File
@@ -0,0 +1,13 @@
import { expect, test } from "@playwright/test";
test("health endpoint is reachable", async ({ request }) => {
const res = await request.get("/api/health");
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body).toHaveProperty("status");
});
test("homepage renders", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/.+/);
});
-47
View File
@@ -1,47 +0,0 @@
import js from "@eslint/js";
import nextPlugin from "@next/eslint-plugin-next";
import reactHooks from "eslint-plugin-react-hooks";
import security from "eslint-plugin-security";
import unusedImports from "eslint-plugin-unused-imports";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: ["node_modules", ".next", "dist", "build"],
},
js.configs.recommended,
tseslint.configs.recommended,
{
files: ["src/**/*.{ts,tsx}", "pages/**/*.{ts,tsx}", "app/**/*.{ts,tsx}"],
plugins: {
"unused-imports": unusedImports,
security: security,
"react-hooks": reactHooks,
"@next/next": nextPlugin,
},
rules: {
// Laad automatisch alle aanbevolen beveiligings- en framework-regels in
...security.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
...nextPlugin.configs.recommended.rules,
// Zorg dat variabelen die beginnen met een underscore (_req) genegeerd worden
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
"unused-imports/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
"no-console": "warn",
},
},
);
+2 -2
View File
@@ -18,6 +18,7 @@
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
"jobs:worker": "tsx scripts/jobs-worker.ts",
"test": "vitest run",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx scripts/apply-migrations.ts",
@@ -41,7 +42,6 @@
"clsx": "2.1.1",
"cmdk": "1.1.1",
"croner": "10.0.1",
"dompurify": "3.4.14",
"drizzle-orm": "0.45.2",
"hash-wasm": "4.12.0",
"ioredis": "6.0.0",
@@ -72,10 +72,10 @@
},
"devDependencies": {
"@biomejs/biome": "2.5.11",
"@playwright/test": "^1.62.1",
"@tailwindcss/forms": "0.5.11",
"@tailwindcss/postcss": "4.3.3",
"@tailwindcss/typography": "0.5.20",
"@types/dompurify": "^3.2.0",
"@types/node": "26.4.0",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? "github" : "list",
use: { baseURL, trace: "on-first-retry" },
webServer: process.env.PLAYWRIGHT_BASE_URL
? undefined
: {
command: "pnpm dev --port 3000",
url: "http://127.0.0.1:3000/api/health",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});
+47 -22
View File
@@ -52,9 +52,6 @@ importers:
croner:
specifier: 10.0.1
version: 10.0.1
dompurify:
specifier: 3.4.14
version: 3.4.14
drizzle-orm:
specifier: 0.45.2
version: 0.45.2([email protected](@types/[email protected]))
@@ -96,13 +93,13 @@ importers:
version: 3.24.2(@types/[email protected])
next:
specifier: 16.3.4
version: 16.3.4(@types/[email protected])([email protected]([email protected]))([email protected])
version: 16.3.4(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
next-auth:
specifier: 5.0.0-beta.32
version: 5.0.0-beta.32([email protected](@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
version: 5.0.0-beta.32([email protected](@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
next-intl:
specifier: 4.14.1
version: 4.14.1(@swc/[email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
version: 4.14.1(@swc/[email protected])([email protected](@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
otplib:
specifier: 13.5.0
version: 13.5.0
@@ -140,6 +137,9 @@ importers:
'@biomejs/biome':
specifier: 2.5.11
version: 2.5.11
'@playwright/test':
specifier: ^1.62.1
version: 1.62.1
'@tailwindcss/forms':
specifier: 0.5.11
version: 0.5.11([email protected])
@@ -149,9 +149,6 @@ importers:
'@tailwindcss/typography':
specifier: 0.5.20
version: 0.5.20([email protected])
'@types/dompurify':
specifier: ^3.2.0
version: 3.2.0
'@types/node':
specifier: 26.4.0
version: 26.4.0
@@ -1283,6 +1280,11 @@ packages:
'@pinojs/[email protected]':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@playwright/[email protected]':
resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
engines: {node: '>=20'}
hasBin: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==}
@@ -1782,10 +1784,6 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/[email protected]':
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
'@types/[email protected]':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@@ -2243,6 +2241,11 @@ packages:
react-dom:
optional: true
[email protected]:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
[email protected]:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2739,6 +2742,16 @@ packages:
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
engines: {node: '>=20'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
engines: {node: '>=20'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-NdTrKgh0oO7+y+RFX8KKhOB438x/j94UGXEFzl/7pHH0JjWSn42iJ9tgmPksIO6n1JKDHmNDcejPJfKspljB9g==}
@@ -3920,6 +3933,10 @@ snapshots:
'@pinojs/[email protected]': {}
'@playwright/[email protected]':
dependencies:
playwright: 1.62.1
'@radix-ui/[email protected]': {}
'@radix-ui/[email protected](@types/[email protected])([email protected])':
@@ -4287,10 +4304,6 @@ snapshots:
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
dompurify: 3.4.14
'@types/[email protected]': {}
'@types/[email protected]':
@@ -4615,6 +4628,9 @@ snapshots:
react: 19.2.8
react-dom: 19.2.8([email protected])
[email protected]:
optional: true
[email protected]:
optional: true
@@ -4970,15 +4986,15 @@ snapshots:
dependencies:
content-type: 2.1.0
[email protected]([email protected](@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]):
[email protected]([email protected](@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]):
dependencies:
'@auth/core': 0.41.3
next: 16.3.4(@types/[email protected])([email protected]([email protected]))([email protected])
next: 16.3.4(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
react: 19.2.8
[email protected]: {}
[email protected](@swc/[email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]):
[email protected](@swc/[email protected])([email protected](@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]):
dependencies:
'@eloqnt/config': 0.0.2
'@eloqnt/format-json': 0.0.3
@@ -4988,14 +5004,14 @@ snapshots:
'@swc/core': 1.16.1(@swc/[email protected])
icu-minify: 4.14.1
negotiator: 1.1.0
next: 16.3.4(@types/[email protected])([email protected]([email protected]))([email protected])
next: 16.3.4(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
next-intl-swc-plugin-extractor: 4.14.1
react: 19.2.8
use-intl: 4.14.1([email protected])
transitivePeerDependencies:
- '@swc/helpers'
[email protected](@types/[email protected])([email protected]([email protected]))([email protected]):
[email protected](@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]):
dependencies:
'@next/env': 16.3.4
'@swc/helpers': 0.5.23
@@ -5014,6 +5030,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.3.4
'@next/swc-win32-arm64-msvc': 16.3.4
'@next/swc-win32-x64-msvc': 16.3.4
'@playwright/test': 1.62.1
sharp: 0.35.4(@types/[email protected])
transitivePeerDependencies:
- '@babel/core'
@@ -5137,6 +5154,14 @@ snapshots:
sonic-boom: 4.2.1
thread-stream: 4.2.0
[email protected]: {}
[email protected]:
dependencies:
playwright-core: 1.62.1
optionalDependencies:
fsevents: 2.3.2
[email protected]: {}
[email protected]: {}
+11 -9
View File
@@ -1,6 +1,6 @@
import "./load-env";
import { Cron } from "croner";
import { and, lt, or, sql } from "drizzle-orm";
import { and, eq, lt, lte, sql } from "drizzle-orm";
import { env } from "../src/env";
import {
db,
@@ -237,19 +237,21 @@ async function publishScheduledArticles(): Promise<void> {
.set({
status: "published",
publishedAt: now,
updatedAt: now,
})
.where(
and(
sql`${WebsiteArticles.status} = 'scheduled'`,
or(
sql`${WebsiteArticles.publishAt} IS NULL`,
sql`${WebsiteArticles.publishAt} <= ${now}`,
),
eq(WebsiteArticles.status, "scheduled"),
lte(WebsiteArticles.publishAt, now),
),
);
const info = result as unknown as { affectedRows?: number };
if (info.affectedRows && info.affectedRows > 0) {
logger.info(`Published ${info.affectedRows} scheduled article(s)`, {
const info = result as unknown as {
affectedRows?: number;
rowsAffected?: number;
};
const published = info.affectedRows ?? info.rowsAffected ?? 0;
if (published > 0) {
logger.info(`Published ${published} scheduled article(s)`, {
module: "jobs",
});
}
+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)
+4 -1
View File
@@ -13,6 +13,7 @@ import Link from "@/components/link";
import { unixNow } from "@/lib/bans";
import { Ban, db, StaffActivities, User, WebsiteArticles } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { logServerError } from "@/lib/server-log";
type RankCount = { rank: number; count: number };
@@ -37,7 +38,9 @@ export default async function AdminDashboard() {
online = onlineRow[0]?.total ?? 0;
articles = articlesRow[0]?.total ?? 0;
bans = bansRow[0]?.total ?? 0;
} catch {}
} catch (error) {
logServerError("admin.dashboard_stats_failed", error);
}
const dbOk = users >= 0;
const rankGroups = await db
@@ -2,6 +2,7 @@ import { inArray } from "drizzle-orm";
import { withAdmin } from "@/lib/api-handler";
import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
import { logAudit } from "@/lib/services/audit";
import {
cloneSingleFurni,
@@ -50,7 +51,11 @@ export const POST = withAdmin(
const entries = await fetchSourceFurnidata(source.furnidataUrl);
const byClassname = new Map(entries.map((e) => [e.classname, e]));
sourceFurniDataMap.set(source.id, byClassname);
} catch {}
} catch (error) {
logServerError("clone-import.furnidata_prefetch_failed", error, {
source: source.id,
});
}
}
// Collect all missing items using pre-fetched data
+39 -10
View File
@@ -1,4 +1,5 @@
import { eq, like, or } from "drizzle-orm";
import { NextResponse } from "next/server";
import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import {
@@ -11,8 +12,9 @@ import {
WebsiteShopArticles,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { rateLimit } from "@/lib/rate-limit";
type SearchResult = {
export type AdminSearchResult = {
type: string;
id: number | string;
title: string;
@@ -22,16 +24,36 @@ type SearchResult = {
const PER_TYPE = 5;
const MAX_TOTAL = 20;
const MAX_QUERY_LENGTH = 64;
/** Escape LIKE wildcards so user input can't widen the match. */
export function escapeLike(input: string): string {
return input.replace(/[\\%_]/g, (m) => `\\${m}`);
}
export const GET = withAdmin(
{ permission: PERMS.ADMIN_DASHBOARD },
async (request) => {
const q = (request.nextUrl.searchParams.get("q") || "").trim();
async (request, context) => {
const limited = await rateLimit(
`admin-search:${context.session.user.id}`,
30,
60_000,
);
if (!limited.ok) {
return NextResponse.json(
{ ok: false, error: "Too many requests" },
{ status: 429, headers: { "retry-after": String(limited.retryAfter) } },
);
}
const q = (request.nextUrl.searchParams.get("q") || "")
.trim()
.slice(0, MAX_QUERY_LENGTH);
if (q.length < 2) {
return apiOk({ results: [] });
}
const pattern = `%${q}%`;
const pattern = `%${escapeLike(q)}%`;
const idExact = Number.parseInt(q, 10);
const hasId = Number.isFinite(idExact) && String(idExact) === q;
@@ -131,7 +153,7 @@ export const GET = withAdmin(
const groupMap: Record<
string,
{ type: string; items: Array<SearchResult> }
{ type: string; items: Array<AdminSearchResult> }
> = {
users: { type: "users", items: [] },
articles: { type: "articles", items: [] },
@@ -196,7 +218,7 @@ export const GET = withAdmin(
});
}
const results: SearchResult[] = [];
const results: AdminSearchResult[] = [];
const order = [
"users",
"articles",
@@ -205,12 +227,19 @@ export const GET = withAdmin(
"shop",
"rareValues",
];
for (const key of order) {
for (const item of groupMap[key].items) {
results.push(item);
// Round-robin so no single type starves the others when capped.
for (let i = 0; results.length < MAX_TOTAL; i++) {
let added = false;
for (const key of order) {
const item = groupMap[key]?.items[i];
if (item && results.length < MAX_TOTAL) {
results.push(item);
added = true;
}
}
if (!added) break;
}
return apiOk({ results: results.slice(0, MAX_TOTAL) });
return apiOk({ results });
},
);
+14 -9
View File
@@ -4,6 +4,7 @@ import { SearchIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useRef, useState } from "react";
import type { AdminSearchResult } from "@/app/api/admin/search/route";
import {
Command,
CommandEmpty,
@@ -15,13 +16,7 @@ import {
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useDebounce } from "@/hooks/use-debounce";
type SearchResult = {
type: string;
id: number | string;
title: string;
subtitle: string;
url: string;
};
type SearchResult = AdminSearchResult;
type SearchResponse = {
ok: boolean;
@@ -61,16 +56,26 @@ export function SearchDialog() {
const res = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal,
});
if (!res.ok) return;
const data: SearchResponse = await res.json();
if (abortRef.current !== controller) return;
if (data.ok) setResults(data.results);
} catch {}
setLoading(false);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
if (abortRef.current === controller) setResults([]);
} finally {
if (abortRef.current === controller) setLoading(false);
}
}, []);
useEffect(() => {
fetchResults(debouncedQuery);
}, [debouncedQuery, fetchResults]);
useEffect(() => {
return () => abortRef.current?.abort();
}, []);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
+43 -6
View File
@@ -6,6 +6,7 @@ import {
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
@@ -27,13 +28,15 @@ export function MobileNav({
const [open, setOpen] = useState(false);
const detailsRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const MENU_ID = "mobile-nav-menu";
const toggleRef = useRef<HTMLButtonElement>(null);
const prevFocusRef = useRef<HTMLElement | null>(null);
const menuId = useId();
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
setOpen(false);
detailsRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
(prevFocusRef.current ?? toggleRef.current)?.focus();
}
},
[open],
@@ -44,6 +47,38 @@ export function MobileNav({
return () => document.removeEventListener("keydown", handleEscape);
}, [handleEscape]);
// Initial focus, scroll-lock, click-outside close, focus restore.
useEffect(() => {
if (!open) return;
prevFocusRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
menuRef.current
?.querySelector<HTMLElement>(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
)
?.focus();
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
function handlePointerDown(e: PointerEvent) {
if (
menuRef.current &&
!menuRef.current.contains(e.target as Node) &&
!toggleRef.current?.contains(e.target as Node)
) {
setOpen(false);
}
}
document.addEventListener("pointerdown", handlePointerDown);
return () => {
document.body.style.overflow = prevOverflow;
document.removeEventListener("pointerdown", handlePointerDown);
(prevFocusRef.current ?? toggleRef.current)?.focus();
};
}, [open]);
useEffect(() => {
if (!open) return;
const menu = menuRef.current;
@@ -81,6 +116,7 @@ export function MobileNav({
return (
<div ref={detailsRef} className="group relative md:hidden">
<button
ref={toggleRef}
type="button"
onClick={() => setOpen((o) => !o)}
className="list-none cursor-pointer flex items-center justify-center w-11 h-11 rounded-xl shrink-0
@@ -90,8 +126,8 @@ export function MobileNav({
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary-readable,var(--color-primary))] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-navbar)]"
aria-label={open ? closeLabel : menuLabel}
aria-expanded={open}
aria-haspopup="true"
aria-controls={MENU_ID}
aria-haspopup="dialog"
aria-controls={menuId}
>
<motion.svg
className="w-6 h-6"
@@ -122,8 +158,9 @@ export function MobileNav({
<AnimatePresence>
{open && (
<motion.div
id={MENU_ID}
role="menu"
id={menuId}
role="dialog"
aria-modal="true"
aria-label={brandLabel || "Navigation menu"}
className="absolute left-0 top-full mt-2 w-[min(88vw,360px)] z-50 origin-top-left"
style={{ backgroundColor: "transparent" }}
+6 -1
View File
@@ -11,6 +11,7 @@ import { ThemeSwitcher } from "@/components/theme-switcher";
import { db, MessengerFriendrequests, MessengerOffline } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
export async function Navigation({ session }: { session: Session | null }) {
const t = await getTranslations("nav");
@@ -58,7 +59,11 @@ export async function Navigation({ session }: { session: Session | null }) {
]);
unreadMessages = unreadRows[0]?.total ?? 0;
pendingFriendRequests = pendingRows[0]?.total ?? 0;
} catch {}
} catch (error) {
logServerError("navigation.messenger_counts_failed", error, {
userId: id,
});
}
}
return (
+9 -3
View File
@@ -17,6 +17,7 @@ import {
} from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
function Currency({
icon,
@@ -76,7 +77,9 @@ export async function TopHeader({ session }: { session: Session | null }) {
if (c.type === 0) duckets = c.amount;
if (c.type === 5) diamonds = c.amount;
}
} catch {}
} catch (error) {
logServerError("top-header.wallet_failed", error, { userId: id });
}
let showAdmin = false;
let showMod = false;
@@ -111,7 +114,8 @@ export async function TopHeader({ session }: { session: Session | null }) {
.where(eq(User.online, "1"));
return row?.total ?? 0;
});
} catch {
} catch (error) {
logServerError("top-header.online_count_failed", error);
online = 0;
}
@@ -130,7 +134,9 @@ export async function TopHeader({ session }: { session: Session | null }) {
]);
unreadMessages = unreadRows[0]?.total ?? 0;
pendingFriendRequests = pendingRows[0]?.total ?? 0;
} catch {}
} catch (error) {
logServerError("top-header.messenger_counts_failed", error, { userId: id });
}
const friendRequests = await db
.select({ userFromId: MessengerFriendrequests.userFromId })
+1
View File
@@ -45,6 +45,7 @@ describe("deploy job", () => {
it("runs container with production env and volumes", () => {
expect(deployJob).toContain(". /var/www/atom-nexst/.env");
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal shell snippet
expect(deployJob).toContain('"${ENV_ARGS[@]}"');
expect(deployJob).toContain("/var/www/Gamedata:/var/www/Gamedata");
expect(deployJob).toContain("/app/storage");
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { sanitize } from "./sanitize";
describe("sanitize", () => {
it("returns empty string for nullish input", () => {
expect(sanitize(null)).toBe("");
expect(sanitize(undefined)).toBe("");
expect(sanitize("")).toBe("");
});
it("keeps safe formatting tags", () => {
const out = sanitize("<p>Hello <strong>world</strong></p>");
expect(out).toContain("<strong>world</strong>");
});
it("strips event handlers", () => {
const out = sanitize('<img src="x.gif" onerror="alert(1)">');
expect(out).not.toContain("onerror");
});
it("strips javascript: URLs", () => {
const out = sanitize('<a href="javascript:alert(1)">click</a>');
expect(out).not.toContain("javascript:");
});
it("strips svg onload vectors", () => {
const out = sanitize('<svg onload="alert(1)"><circle r="10"/></svg>');
expect(out).not.toContain("onload");
expect(out).not.toContain("<svg");
});
it("strips script tags", () => {
const out = sanitize("<p>hi</p><script>alert(1)</script>");
expect(out).not.toContain("<script");
expect(out).toContain("hi");
});
});
+47 -1
View File
@@ -1,6 +1,52 @@
import DOMPurify from "isomorphic-dompurify";
const ALLOWED_TAGS = [
"a",
"b",
"blockquote",
"br",
"code",
"em",
"h1",
"h2",
"h3",
"h4",
"hr",
"i",
"img",
"li",
"ol",
"p",
"pre",
"strong",
"table",
"tbody",
"td",
"th",
"thead",
"tr",
"u",
"ul",
];
const ALLOWED_ATTR = [
"href",
"src",
"alt",
"title",
"target",
"rel",
"colspan",
"rowspan",
];
export function sanitize(html: string | null | undefined): string {
if (!html) return "";
return DOMPurify.sanitize(html);
return DOMPurify.sanitize(html, {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOW_DATA_ATTR: false,
FORBID_TAGS: ["style", "script", "svg", "math", "form", "input", "button"],
USE_PROFILES: { html: true },
});
}