diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 034251a2..d404a554 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -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 diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 00000000..60b9f1a8 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -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(/.+/); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index c1a50205..00000000 --- a/eslint.config.mjs +++ /dev/null @@ -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", - }, - }, -); diff --git a/package.json b/package.json index 57848347..99a34088 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..c14dd960 --- /dev/null +++ b/playwright.config.ts @@ -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"] } }], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c237c730..dd86480b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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(mysql2@3.24.2(@types/node@26.4.0)) @@ -96,13 +93,13 @@ importers: version: 3.24.2(@types/node@26.4.0) next: specifier: 16.3.4 - version: 16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.4(@playwright/test@1.62.1)(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-auth: specifier: 5.0.0-beta.32 - version: 5.0.0-beta.32(next@16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + version: 5.0.0-beta.32(next@16.3.4(@playwright/test@1.62.1)(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) next-intl: specifier: 4.14.1 - version: 4.14.1(@swc/helpers@0.5.23)(next@16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + version: 4.14.1(@swc/helpers@0.5.23)(next@16.3.4(@playwright/test@1.62.1)(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) 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(tailwindcss@4.3.3) @@ -149,9 +149,6 @@ importers: '@tailwindcss/typography': specifier: 0.5.20 version: 0.5.20(tailwindcss@4.3.3) - '@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/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@radix-ui/primitive@1.1.7': resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} @@ -1782,10 +1784,6 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/dompurify@3.2.0': - 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/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -2243,6 +2241,11 @@ packages: react-dom: optional: true + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: 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 + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + po-parser@2.2.0: resolution: {integrity: sha512-NdTrKgh0oO7+y+RFX8KKhOB438x/j94UGXEFzl/7pHH0JjWSn42iJ9tgmPksIO6n1JKDHmNDcejPJfKspljB9g==} @@ -3920,6 +3933,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@radix-ui/primitive@1.1.7': {} '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.8)': @@ -4287,10 +4304,6 @@ snapshots: '@types/deep-eql@4.0.2': {} - '@types/dompurify@3.2.0': - dependencies: - dompurify: 3.4.14 - '@types/estree@1.0.9': {} '@types/node@26.4.0': @@ -4615,6 +4628,9 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -4970,15 +4986,15 @@ snapshots: dependencies: content-type: 2.1.0 - next-auth@5.0.0-beta.32(next@16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): + next-auth@5.0.0-beta.32(next@16.3.4(@playwright/test@1.62.1)(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): dependencies: '@auth/core': 0.41.3 - next: 16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.4(@playwright/test@1.62.1)(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 next-intl-swc-plugin-extractor@4.14.1: {} - next-intl@4.14.1(@swc/helpers@0.5.23)(next@16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): + next-intl@4.14.1(@swc/helpers@0.5.23)(next@16.3.4(@playwright/test@1.62.1)(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): dependencies: '@eloqnt/config': 0.0.2 '@eloqnt/format-json': 0.0.3 @@ -4988,14 +5004,14 @@ snapshots: '@swc/core': 1.16.1(@swc/helpers@0.5.23) icu-minify: 4.14.1 negotiator: 1.1.0 - next: 16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.4(@playwright/test@1.62.1)(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-intl-swc-plugin-extractor: 4.14.1 react: 19.2.8 use-intl: 4.14.1(react@19.2.8) transitivePeerDependencies: - '@swc/helpers' - next@16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.3.4(@playwright/test@1.62.1)(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): 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/node@26.4.0) transitivePeerDependencies: - '@babel/core' @@ -5137,6 +5154,14 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.2.0 + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + po-parser@2.2.0: {} postal-mime@2.7.5: {} diff --git a/scripts/jobs-worker.ts b/scripts/jobs-worker.ts index 3f45ef50..97b8f377 100644 --- a/scripts/jobs-worker.ts +++ b/scripts/jobs-worker.ts @@ -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 { .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", }); } diff --git a/src/actions/admin-applications.ts b/src/actions/admin-applications.ts index 5b3deb44..f57326d9 100644 --- a/src/actions/admin-applications.ts +++ b/src/actions/admin-applications.ts @@ -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 { - await requirePermission(PERMS.USERS_EDIT); - const id = formPositiveBigInt(formData, "id"); - if (!id) return; - +async function removeApplication(id: bigint): Promise { 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 { + 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 { - 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"); } diff --git a/src/actions/admin-articles.ts b/src/actions/admin-articles.ts index d550e159..b9cbf1d1 100644 --- a/src/actions/admin-articles.ts +++ b/src/actions/admin-articles.ts @@ -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 { const base = slugify(title); let slug = base; @@ -44,13 +62,18 @@ export async function createArticle(formData: FormData): Promise { .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 { 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 { export async function updateArticle(formData: FormData): Promise { 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 { .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 { export async function deleteArticle(formData: FormData): Promise { 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) diff --git a/src/actions/admin-guilds.ts b/src/actions/admin-guilds.ts index 430a7a29..5d4e4288 100644 --- a/src/actions/admin-guilds.ts +++ b/src/actions/admin-guilds.ts @@ -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( + 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 { const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT); @@ -68,22 +92,40 @@ export async function disbandGuild(formData: FormData): Promise { } export async function updateGuild(formData: FormData): Promise { - 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) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 80c30f41..35fcf42b 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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 diff --git a/src/app/api/admin/import/clone/sync-all/route.ts b/src/app/api/admin/import/clone/sync-all/route.ts index 36af6ece..3c80233b 100644 --- a/src/app/api/admin/import/clone/sync-all/route.ts +++ b/src/app/api/admin/import/clone/sync-all/route.ts @@ -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 diff --git a/src/app/api/admin/search/route.ts b/src/app/api/admin/search/route.ts index 6864e0dd..3b6236fe 100644 --- a/src/app/api/admin/search/route.ts +++ b/src/app/api/admin/search/route.ts @@ -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 } + { type: string; items: Array } > = { 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 }); }, ); diff --git a/src/components/admin/search-dialog.tsx b/src/components/admin/search-dialog.tsx index 7e173d07..7d96fcfa 100644 --- a/src/components/admin/search-dialog.tsx +++ b/src/components/admin/search-dialog.tsx @@ -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") { diff --git a/src/components/mobile-nav.tsx b/src/components/mobile-nav.tsx index cccad1eb..e442f506 100644 --- a/src/components/mobile-nav.tsx +++ b/src/components/mobile-nav.tsx @@ -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(null); const menuRef = useRef(null); - const MENU_ID = "mobile-nav-menu"; + const toggleRef = useRef(null); + const prevFocusRef = useRef(null); + const menuId = useId(); const handleEscape = useCallback( (e: KeyboardEvent) => { if (e.key === "Escape" && open) { setOpen(false); - detailsRef.current?.querySelector("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( + '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 (