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
@@ -47,6 +47,9 @@ jobs:
|
|||||||
BCRYPT_ROUNDS: 4
|
BCRYPT_ROUNDS: 4
|
||||||
run: pnpm test --maxWorkers=1
|
run: pnpm test --maxWorkers=1
|
||||||
|
|
||||||
|
- name: Knip (unused files/exports)
|
||||||
|
run: pnpm knip
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Docker build & deploy
|
# Docker build & deploy
|
||||||
# Draait op de host (self-hosted) zodat Docker
|
# Draait op de host (self-hosted) zodat Docker
|
||||||
|
|||||||
@@ -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(/.+/);
|
||||||
|
});
|
||||||
@@ -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
@@ -18,6 +18,7 @@
|
|||||||
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
|
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
|
||||||
"jobs:worker": "tsx scripts/jobs-worker.ts",
|
"jobs:worker": "tsx scripts/jobs-worker.ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "tsx scripts/apply-migrations.ts",
|
"db:migrate": "tsx scripts/apply-migrations.ts",
|
||||||
@@ -41,7 +42,6 @@
|
|||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"cmdk": "1.1.1",
|
"cmdk": "1.1.1",
|
||||||
"croner": "10.0.1",
|
"croner": "10.0.1",
|
||||||
"dompurify": "3.4.14",
|
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"hash-wasm": "4.12.0",
|
"hash-wasm": "4.12.0",
|
||||||
"ioredis": "6.0.0",
|
"ioredis": "6.0.0",
|
||||||
@@ -72,10 +72,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.11",
|
"@biomejs/biome": "2.5.11",
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tailwindcss/postcss": "4.3.3",
|
"@tailwindcss/postcss": "4.3.3",
|
||||||
"@tailwindcss/typography": "0.5.20",
|
"@tailwindcss/typography": "0.5.20",
|
||||||
"@types/dompurify": "^3.2.0",
|
|
||||||
"@types/node": "26.4.0",
|
"@types/node": "26.4.0",
|
||||||
"@types/react": "19.2.18",
|
"@types/react": "19.2.18",
|
||||||
"@types/react-dom": "19.2.5",
|
"@types/react-dom": "19.2.5",
|
||||||
|
|||||||
@@ -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"] } }],
|
||||||
|
});
|
||||||
Generated
+47
-22
@@ -52,9 +52,6 @@ importers:
|
|||||||
croner:
|
croner:
|
||||||
specifier: 10.0.1
|
specifier: 10.0.1
|
||||||
version: 10.0.1
|
version: 10.0.1
|
||||||
dompurify:
|
|
||||||
specifier: 3.4.14
|
|
||||||
version: 3.4.14
|
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: 0.45.2
|
specifier: 0.45.2
|
||||||
version: 0.45.2([email protected](@types/[email protected]))
|
version: 0.45.2([email protected](@types/[email protected]))
|
||||||
@@ -96,13 +93,13 @@ importers:
|
|||||||
version: 3.24.2(@types/[email protected])
|
version: 3.24.2(@types/[email protected])
|
||||||
next:
|
next:
|
||||||
specifier: 16.3.4
|
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:
|
next-auth:
|
||||||
specifier: 5.0.0-beta.32
|
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:
|
next-intl:
|
||||||
specifier: 4.14.1
|
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:
|
otplib:
|
||||||
specifier: 13.5.0
|
specifier: 13.5.0
|
||||||
version: 13.5.0
|
version: 13.5.0
|
||||||
@@ -140,6 +137,9 @@ importers:
|
|||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: 2.5.11
|
specifier: 2.5.11
|
||||||
version: 2.5.11
|
version: 2.5.11
|
||||||
|
'@playwright/test':
|
||||||
|
specifier: ^1.62.1
|
||||||
|
version: 1.62.1
|
||||||
'@tailwindcss/forms':
|
'@tailwindcss/forms':
|
||||||
specifier: 0.5.11
|
specifier: 0.5.11
|
||||||
version: 0.5.11([email protected])
|
version: 0.5.11([email protected])
|
||||||
@@ -149,9 +149,6 @@ importers:
|
|||||||
'@tailwindcss/typography':
|
'@tailwindcss/typography':
|
||||||
specifier: 0.5.20
|
specifier: 0.5.20
|
||||||
version: 0.5.20([email protected])
|
version: 0.5.20([email protected])
|
||||||
'@types/dompurify':
|
|
||||||
specifier: ^3.2.0
|
|
||||||
version: 3.2.0
|
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 26.4.0
|
specifier: 26.4.0
|
||||||
version: 26.4.0
|
version: 26.4.0
|
||||||
@@ -1283,6 +1280,11 @@ packages:
|
|||||||
'@pinojs/[email protected]':
|
'@pinojs/[email protected]':
|
||||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
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]':
|
'@radix-ui/[email protected]':
|
||||||
resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==}
|
resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==}
|
||||||
|
|
||||||
@@ -1782,10 +1784,6 @@ packages:
|
|||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
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]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||||
|
|
||||||
@@ -2243,6 +2241,11 @@ packages:
|
|||||||
react-dom:
|
react-dom:
|
||||||
optional: true
|
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]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -2739,6 +2742,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
|
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
|
||||||
hasBin: true
|
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]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-NdTrKgh0oO7+y+RFX8KKhOB438x/j94UGXEFzl/7pHH0JjWSn42iJ9tgmPksIO6n1JKDHmNDcejPJfKspljB9g==}
|
resolution: {integrity: sha512-NdTrKgh0oO7+y+RFX8KKhOB438x/j94UGXEFzl/7pHH0JjWSn42iJ9tgmPksIO6n1JKDHmNDcejPJfKspljB9g==}
|
||||||
|
|
||||||
@@ -3920,6 +3933,10 @@ snapshots:
|
|||||||
|
|
||||||
'@pinojs/[email protected]': {}
|
'@pinojs/[email protected]': {}
|
||||||
|
|
||||||
|
'@playwright/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
playwright: 1.62.1
|
||||||
|
|
||||||
'@radix-ui/[email protected]': {}
|
'@radix-ui/[email protected]': {}
|
||||||
|
|
||||||
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
||||||
@@ -4287,10 +4304,6 @@ snapshots:
|
|||||||
|
|
||||||
'@types/[email protected]': {}
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
'@types/[email protected]':
|
|
||||||
dependencies:
|
|
||||||
dompurify: 3.4.14
|
|
||||||
|
|
||||||
'@types/[email protected]': {}
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
@@ -4615,6 +4628,9 @@ snapshots:
|
|||||||
react: 19.2.8
|
react: 19.2.8
|
||||||
react-dom: 19.2.8([email protected])
|
react-dom: 19.2.8([email protected])
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
optional: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4970,15 +4986,15 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
content-type: 2.1.0
|
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:
|
dependencies:
|
||||||
'@auth/core': 0.41.3
|
'@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
|
react: 19.2.8
|
||||||
|
|
||||||
[email protected]: {}
|
[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:
|
dependencies:
|
||||||
'@eloqnt/config': 0.0.2
|
'@eloqnt/config': 0.0.2
|
||||||
'@eloqnt/format-json': 0.0.3
|
'@eloqnt/format-json': 0.0.3
|
||||||
@@ -4988,14 +5004,14 @@ snapshots:
|
|||||||
'@swc/core': 1.16.1(@swc/[email protected])
|
'@swc/core': 1.16.1(@swc/[email protected])
|
||||||
icu-minify: 4.14.1
|
icu-minify: 4.14.1
|
||||||
negotiator: 1.1.0
|
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
|
next-intl-swc-plugin-extractor: 4.14.1
|
||||||
react: 19.2.8
|
react: 19.2.8
|
||||||
use-intl: 4.14.1([email protected])
|
use-intl: 4.14.1([email protected])
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@swc/helpers'
|
- '@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:
|
dependencies:
|
||||||
'@next/env': 16.3.4
|
'@next/env': 16.3.4
|
||||||
'@swc/helpers': 0.5.23
|
'@swc/helpers': 0.5.23
|
||||||
@@ -5014,6 +5030,7 @@ snapshots:
|
|||||||
'@next/swc-linux-x64-musl': 16.3.4
|
'@next/swc-linux-x64-musl': 16.3.4
|
||||||
'@next/swc-win32-arm64-msvc': 16.3.4
|
'@next/swc-win32-arm64-msvc': 16.3.4
|
||||||
'@next/swc-win32-x64-msvc': 16.3.4
|
'@next/swc-win32-x64-msvc': 16.3.4
|
||||||
|
'@playwright/test': 1.62.1
|
||||||
sharp: 0.35.4(@types/[email protected])
|
sharp: 0.35.4(@types/[email protected])
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
@@ -5137,6 +5154,14 @@ snapshots:
|
|||||||
sonic-boom: 4.2.1
|
sonic-boom: 4.2.1
|
||||||
thread-stream: 4.2.0
|
thread-stream: 4.2.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
playwright-core: 1.62.1
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.2
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|||||||
+11
-9
@@ -1,6 +1,6 @@
|
|||||||
import "./load-env";
|
import "./load-env";
|
||||||
import { Cron } from "croner";
|
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 { env } from "../src/env";
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
@@ -237,19 +237,21 @@ async function publishScheduledArticles(): Promise<void> {
|
|||||||
.set({
|
.set({
|
||||||
status: "published",
|
status: "published",
|
||||||
publishedAt: now,
|
publishedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
sql`${WebsiteArticles.status} = 'scheduled'`,
|
eq(WebsiteArticles.status, "scheduled"),
|
||||||
or(
|
lte(WebsiteArticles.publishAt, now),
|
||||||
sql`${WebsiteArticles.publishAt} IS NULL`,
|
|
||||||
sql`${WebsiteArticles.publishAt} <= ${now}`,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const info = result as unknown as { affectedRows?: number };
|
const info = result as unknown as {
|
||||||
if (info.affectedRows && info.affectedRows > 0) {
|
affectedRows?: number;
|
||||||
logger.info(`Published ${info.affectedRows} scheduled article(s)`, {
|
rowsAffected?: number;
|
||||||
|
};
|
||||||
|
const published = info.affectedRows ?? info.rowsAffected ?? 0;
|
||||||
|
if (published > 0) {
|
||||||
|
logger.info(`Published ${published} scheduled article(s)`, {
|
||||||
module: "jobs",
|
module: "jobs",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,39 +2,55 @@
|
|||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
import { db, WebsiteStaffApplications } from "@/lib/db";
|
import { db, WebsiteStaffApplications } from "@/lib/db";
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
import { PERMS } from "@/lib/permissions";
|
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> {
|
async function removeApplication(id: bigint): Promise<boolean> {
|
||||||
await requirePermission(PERMS.USERS_EDIT);
|
|
||||||
const id = formPositiveBigInt(formData, "id");
|
|
||||||
if (!id) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db
|
await db
|
||||||
.delete(WebsiteStaffApplications)
|
.delete(WebsiteStaffApplications)
|
||||||
.where(eq(WebsiteStaffApplications.id, id));
|
.where(eq(WebsiteStaffApplications.id, id));
|
||||||
} catch {
|
return true;
|
||||||
// already gone / no DB — nothing to do
|
} 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");
|
revalidatePath("/admin/applications");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function approveApplication(formData: FormData): Promise<void> {
|
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");
|
const id = formPositiveBigInt(formData, "id");
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
try {
|
await removeApplication(id);
|
||||||
await db
|
await logStaffActivity({
|
||||||
.delete(WebsiteStaffApplications)
|
staffId: staff.id,
|
||||||
.where(eq(WebsiteStaffApplications.id, id));
|
action: "application_approve",
|
||||||
} catch {
|
description: `Approved staff application #${id}`,
|
||||||
// already gone / no DB — nothing to do
|
targetType: "staff_application",
|
||||||
}
|
targetId: Number(id),
|
||||||
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/applications");
|
revalidatePath("/admin/applications");
|
||||||
}
|
}
|
||||||
@@ -10,10 +10,28 @@ import {
|
|||||||
WebsiteArticleReactions,
|
WebsiteArticleReactions,
|
||||||
WebsiteArticles,
|
WebsiteArticles,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
import { slugify } from "@/lib/format";
|
import { slugify } from "@/lib/format";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { notify } from "@/lib/services/webhook";
|
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> {
|
async function uniqueSlug(title: string): Promise<string> {
|
||||||
const base = slugify(title);
|
const base = slugify(title);
|
||||||
let slug = base;
|
let slug = base;
|
||||||
@@ -44,13 +62,18 @@ export async function createArticle(formData: FormData): Promise<void> {
|
|||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim();
|
.trim();
|
||||||
const rawSlug = String(formData.get("slug") ?? "").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();
|
const rawPublishAt = String(formData.get("publishAt") ?? "").trim();
|
||||||
if (!title) return;
|
if (!title) return;
|
||||||
|
if (status === "scheduled" && !parsePublishAt(rawPublishAt)) {
|
||||||
|
redirect(
|
||||||
|
"/admin/articles/new?error=Scheduled articles need a valid publish date.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const publishAt = rawPublishAt ? new Date(rawPublishAt) : null;
|
const publishAt = parsePublishAt(rawPublishAt);
|
||||||
const slug = rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title);
|
const slug = rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title);
|
||||||
await db.insert(WebsiteArticles).values({
|
await db.insert(WebsiteArticles).values({
|
||||||
slug,
|
slug,
|
||||||
@@ -61,7 +84,7 @@ export async function createArticle(formData: FormData): Promise<void> {
|
|||||||
userId: staff.id,
|
userId: staff.id,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
status: status || "published",
|
status,
|
||||||
publishAt,
|
publishAt,
|
||||||
publishedAt: status === "published" ? now : null,
|
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> {
|
export async function updateArticle(formData: FormData): Promise<void> {
|
||||||
await requirePermission(PERMS.NEWS_EDIT);
|
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 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();
|
const rawPublishAt = String(formData.get("publishAt") ?? "").trim();
|
||||||
|
if (status === "scheduled" && !parsePublishAt(rawPublishAt)) {
|
||||||
|
redirect(
|
||||||
|
"/admin/articles?error=Scheduled articles need a valid publish date.",
|
||||||
|
);
|
||||||
|
}
|
||||||
try {
|
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
|
await db
|
||||||
.update(WebsiteArticles)
|
.update(WebsiteArticles)
|
||||||
.set({
|
.set({
|
||||||
@@ -110,7 +149,7 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
|||||||
.slice(0, 255),
|
.slice(0, 255),
|
||||||
status,
|
status,
|
||||||
publishAt,
|
publishAt,
|
||||||
publishedAt: status === "published" ? new Date() : undefined,
|
publishedAt,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(WebsiteArticles.id, id));
|
.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> {
|
export async function deleteArticle(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
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
|
const [article] = await db
|
||||||
.select({ title: WebsiteArticles.title })
|
.select({ title: WebsiteArticles.title })
|
||||||
.from(WebsiteArticles)
|
.from(WebsiteArticles)
|
||||||
|
|||||||
+53
-11
@@ -2,10 +2,7 @@
|
|||||||
|
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import {
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
requirePermission,
|
|
||||||
requirePermissionRateLimited,
|
|
||||||
} from "@/lib/admin/guard";
|
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
GuildForumViews,
|
GuildForumViews,
|
||||||
@@ -19,6 +16,33 @@ import {
|
|||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
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. */
|
/** Disband a guild and clean related membership/forum rows. */
|
||||||
export async function disbandGuild(formData: FormData): Promise<void> {
|
export async function disbandGuild(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
|
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> {
|
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"));
|
const id = Number(formData.get("id"));
|
||||||
if (!(id > 0)) return;
|
if (!(id > 0)) return;
|
||||||
|
|
||||||
const name = String(formData.get("name") ?? "")
|
const name = String(formData.get("name") ?? "")
|
||||||
.trim()
|
.trim()
|
||||||
.slice(0, 50);
|
.slice(0, 50);
|
||||||
|
if (!name) return;
|
||||||
const description = String(formData.get("description") ?? "")
|
const description = String(formData.get("description") ?? "")
|
||||||
.trim()
|
.trim()
|
||||||
.slice(0, 250);
|
.slice(0, 250);
|
||||||
const state = Number(formData.get("state"));
|
const state = parseGuildState(formData.get("state"));
|
||||||
const forum = String(formData.get("forum") ?? "0");
|
if (state === null) return;
|
||||||
const readForum = String(formData.get("readForum") ?? "EVERYONE");
|
const forum = parseEnum(formData.get("forum"), GUILD_FORUM, "0");
|
||||||
const postMessages = String(formData.get("postMessages") ?? "EVERYONE");
|
const readForum = parseEnum(
|
||||||
const postThreads = String(formData.get("postThreads") ?? "EVERYONE");
|
formData.get("readForum"),
|
||||||
const modForum = String(formData.get("modForum") ?? "ADMINS");
|
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
|
await db
|
||||||
.update(Guilds)
|
.update(Guilds)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Link from "@/components/link";
|
|||||||
import { unixNow } from "@/lib/bans";
|
import { unixNow } from "@/lib/bans";
|
||||||
import { Ban, db, StaffActivities, User, WebsiteArticles } from "@/lib/db";
|
import { Ban, db, StaffActivities, User, WebsiteArticles } from "@/lib/db";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
import { logServerError } from "@/lib/server-log";
|
||||||
|
|
||||||
type RankCount = { rank: number; count: number };
|
type RankCount = { rank: number; count: number };
|
||||||
|
|
||||||
@@ -37,7 +38,9 @@ export default async function AdminDashboard() {
|
|||||||
online = onlineRow[0]?.total ?? 0;
|
online = onlineRow[0]?.total ?? 0;
|
||||||
articles = articlesRow[0]?.total ?? 0;
|
articles = articlesRow[0]?.total ?? 0;
|
||||||
bans = bansRow[0]?.total ?? 0;
|
bans = bansRow[0]?.total ?? 0;
|
||||||
} catch {}
|
} catch (error) {
|
||||||
|
logServerError("admin.dashboard_stats_failed", error);
|
||||||
|
}
|
||||||
const dbOk = users >= 0;
|
const dbOk = users >= 0;
|
||||||
|
|
||||||
const rankGroups = await db
|
const rankGroups = await db
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { inArray } from "drizzle-orm";
|
|||||||
import { withAdmin } from "@/lib/api-handler";
|
import { withAdmin } from "@/lib/api-handler";
|
||||||
import { db, ItemsBase } from "@/lib/db";
|
import { db, ItemsBase } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { logServerError } from "@/lib/server-log";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
import {
|
import {
|
||||||
cloneSingleFurni,
|
cloneSingleFurni,
|
||||||
@@ -50,7 +51,11 @@ export const POST = withAdmin(
|
|||||||
const entries = await fetchSourceFurnidata(source.furnidataUrl);
|
const entries = await fetchSourceFurnidata(source.furnidataUrl);
|
||||||
const byClassname = new Map(entries.map((e) => [e.classname, e]));
|
const byClassname = new Map(entries.map((e) => [e.classname, e]));
|
||||||
sourceFurniDataMap.set(source.id, byClassname);
|
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
|
// Collect all missing items using pre-fetched data
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { eq, like, or } from "drizzle-orm";
|
import { eq, like, or } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
import { withAdmin } from "@/lib/api-handler";
|
import { withAdmin } from "@/lib/api-handler";
|
||||||
import { apiOk } from "@/lib/api-response";
|
import { apiOk } from "@/lib/api-response";
|
||||||
import {
|
import {
|
||||||
@@ -11,8 +12,9 @@ import {
|
|||||||
WebsiteShopArticles,
|
WebsiteShopArticles,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
type SearchResult = {
|
export type AdminSearchResult = {
|
||||||
type: string;
|
type: string;
|
||||||
id: number | string;
|
id: number | string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -22,16 +24,36 @@ type SearchResult = {
|
|||||||
|
|
||||||
const PER_TYPE = 5;
|
const PER_TYPE = 5;
|
||||||
const MAX_TOTAL = 20;
|
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(
|
export const GET = withAdmin(
|
||||||
{ permission: PERMS.ADMIN_DASHBOARD },
|
{ permission: PERMS.ADMIN_DASHBOARD },
|
||||||
async (request) => {
|
async (request, context) => {
|
||||||
const q = (request.nextUrl.searchParams.get("q") || "").trim();
|
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) {
|
if (q.length < 2) {
|
||||||
return apiOk({ results: [] });
|
return apiOk({ results: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
const pattern = `%${q}%`;
|
const pattern = `%${escapeLike(q)}%`;
|
||||||
const idExact = Number.parseInt(q, 10);
|
const idExact = Number.parseInt(q, 10);
|
||||||
const hasId = Number.isFinite(idExact) && String(idExact) === q;
|
const hasId = Number.isFinite(idExact) && String(idExact) === q;
|
||||||
|
|
||||||
@@ -131,7 +153,7 @@ export const GET = withAdmin(
|
|||||||
|
|
||||||
const groupMap: Record<
|
const groupMap: Record<
|
||||||
string,
|
string,
|
||||||
{ type: string; items: Array<SearchResult> }
|
{ type: string; items: Array<AdminSearchResult> }
|
||||||
> = {
|
> = {
|
||||||
users: { type: "users", items: [] },
|
users: { type: "users", items: [] },
|
||||||
articles: { type: "articles", items: [] },
|
articles: { type: "articles", items: [] },
|
||||||
@@ -196,7 +218,7 @@ export const GET = withAdmin(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const results: SearchResult[] = [];
|
const results: AdminSearchResult[] = [];
|
||||||
const order = [
|
const order = [
|
||||||
"users",
|
"users",
|
||||||
"articles",
|
"articles",
|
||||||
@@ -205,12 +227,19 @@ export const GET = withAdmin(
|
|||||||
"shop",
|
"shop",
|
||||||
"rareValues",
|
"rareValues",
|
||||||
];
|
];
|
||||||
for (const key of order) {
|
// Round-robin so no single type starves the others when capped.
|
||||||
for (const item of groupMap[key].items) {
|
for (let i = 0; results.length < MAX_TOTAL; i++) {
|
||||||
results.push(item);
|
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 });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -4,6 +4,7 @@ import { SearchIcon } from "lucide-react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { AdminSearchResult } from "@/app/api/admin/search/route";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -15,13 +16,7 @@ import {
|
|||||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
import { useDebounce } from "@/hooks/use-debounce";
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
|
||||||
type SearchResult = {
|
type SearchResult = AdminSearchResult;
|
||||||
type: string;
|
|
||||||
id: number | string;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SearchResponse = {
|
type SearchResponse = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -61,16 +56,26 @@ export function SearchDialog() {
|
|||||||
const res = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, {
|
const res = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
const data: SearchResponse = await res.json();
|
const data: SearchResponse = await res.json();
|
||||||
|
if (abortRef.current !== controller) return;
|
||||||
if (data.ok) setResults(data.results);
|
if (data.ok) setResults(data.results);
|
||||||
} catch {}
|
} catch (error) {
|
||||||
setLoading(false);
|
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||||
|
if (abortRef.current === controller) setResults([]);
|
||||||
|
} finally {
|
||||||
|
if (abortRef.current === controller) setLoading(false);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchResults(debouncedQuery);
|
fetchResults(debouncedQuery);
|
||||||
}, [debouncedQuery, fetchResults]);
|
}, [debouncedQuery, fetchResults]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => abortRef.current?.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useId,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -27,13 +28,15 @@ export function MobileNav({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const detailsRef = useRef<HTMLDivElement>(null);
|
const detailsRef = useRef<HTMLDivElement>(null);
|
||||||
const menuRef = 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(
|
const handleEscape = useCallback(
|
||||||
(e: KeyboardEvent) => {
|
(e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape" && open) {
|
if (e.key === "Escape" && open) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
detailsRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
|
(prevFocusRef.current ?? toggleRef.current)?.focus();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[open],
|
[open],
|
||||||
@@ -44,6 +47,38 @@ export function MobileNav({
|
|||||||
return () => document.removeEventListener("keydown", handleEscape);
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const menu = menuRef.current;
|
const menu = menuRef.current;
|
||||||
@@ -81,6 +116,7 @@ export function MobileNav({
|
|||||||
return (
|
return (
|
||||||
<div ref={detailsRef} className="group relative md:hidden">
|
<div ref={detailsRef} className="group relative md:hidden">
|
||||||
<button
|
<button
|
||||||
|
ref={toggleRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((o) => !o)}
|
onClick={() => setOpen((o) => !o)}
|
||||||
className="list-none cursor-pointer flex items-center justify-center w-11 h-11 rounded-xl shrink-0
|
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)]"
|
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-label={open ? closeLabel : menuLabel}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="true"
|
aria-haspopup="dialog"
|
||||||
aria-controls={MENU_ID}
|
aria-controls={menuId}
|
||||||
>
|
>
|
||||||
<motion.svg
|
<motion.svg
|
||||||
className="w-6 h-6"
|
className="w-6 h-6"
|
||||||
@@ -122,8 +158,9 @@ export function MobileNav({
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && (
|
||||||
<motion.div
|
<motion.div
|
||||||
id={MENU_ID}
|
id={menuId}
|
||||||
role="menu"
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
aria-label={brandLabel || "Navigation menu"}
|
aria-label={brandLabel || "Navigation menu"}
|
||||||
className="absolute left-0 top-full mt-2 w-[min(88vw,360px)] z-50 origin-top-left"
|
className="absolute left-0 top-full mt-2 w-[min(88vw,360px)] z-50 origin-top-left"
|
||||||
style={{ backgroundColor: "transparent" }}
|
style={{ backgroundColor: "transparent" }}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { ThemeSwitcher } from "@/components/theme-switcher";
|
|||||||
import { db, MessengerFriendrequests, MessengerOffline } from "@/lib/db";
|
import { db, MessengerFriendrequests, MessengerOffline } from "@/lib/db";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { logServerError } from "@/lib/server-log";
|
||||||
|
|
||||||
export async function Navigation({ session }: { session: Session | null }) {
|
export async function Navigation({ session }: { session: Session | null }) {
|
||||||
const t = await getTranslations("nav");
|
const t = await getTranslations("nav");
|
||||||
@@ -58,7 +59,11 @@ export async function Navigation({ session }: { session: Session | null }) {
|
|||||||
]);
|
]);
|
||||||
unreadMessages = unreadRows[0]?.total ?? 0;
|
unreadMessages = unreadRows[0]?.total ?? 0;
|
||||||
pendingFriendRequests = pendingRows[0]?.total ?? 0;
|
pendingFriendRequests = pendingRows[0]?.total ?? 0;
|
||||||
} catch {}
|
} catch (error) {
|
||||||
|
logServerError("navigation.messenger_counts_failed", error, {
|
||||||
|
userId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { logServerError } from "@/lib/server-log";
|
||||||
|
|
||||||
function Currency({
|
function Currency({
|
||||||
icon,
|
icon,
|
||||||
@@ -76,7 +77,9 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
|||||||
if (c.type === 0) duckets = c.amount;
|
if (c.type === 0) duckets = c.amount;
|
||||||
if (c.type === 5) diamonds = c.amount;
|
if (c.type === 5) diamonds = c.amount;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch (error) {
|
||||||
|
logServerError("top-header.wallet_failed", error, { userId: id });
|
||||||
|
}
|
||||||
|
|
||||||
let showAdmin = false;
|
let showAdmin = false;
|
||||||
let showMod = false;
|
let showMod = false;
|
||||||
@@ -111,7 +114,8 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
|||||||
.where(eq(User.online, "1"));
|
.where(eq(User.online, "1"));
|
||||||
return row?.total ?? 0;
|
return row?.total ?? 0;
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
logServerError("top-header.online_count_failed", error);
|
||||||
online = 0;
|
online = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +134,9 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
|||||||
]);
|
]);
|
||||||
unreadMessages = unreadRows[0]?.total ?? 0;
|
unreadMessages = unreadRows[0]?.total ?? 0;
|
||||||
pendingFriendRequests = pendingRows[0]?.total ?? 0;
|
pendingFriendRequests = pendingRows[0]?.total ?? 0;
|
||||||
} catch {}
|
} catch (error) {
|
||||||
|
logServerError("top-header.messenger_counts_failed", error, { userId: id });
|
||||||
|
}
|
||||||
|
|
||||||
const friendRequests = await db
|
const friendRequests = await db
|
||||||
.select({ userFromId: MessengerFriendrequests.userFromId })
|
.select({ userFromId: MessengerFriendrequests.userFromId })
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ describe("deploy job", () => {
|
|||||||
|
|
||||||
it("runs container with production env and volumes", () => {
|
it("runs container with production env and volumes", () => {
|
||||||
expect(deployJob).toContain(". /var/www/atom-nexst/.env");
|
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('"${ENV_ARGS[@]}"');
|
||||||
expect(deployJob).toContain("/var/www/Gamedata:/var/www/Gamedata");
|
expect(deployJob).toContain("/var/www/Gamedata:/var/www/Gamedata");
|
||||||
expect(deployJob).toContain("/app/storage");
|
expect(deployJob).toContain("/app/storage");
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,52 @@
|
|||||||
import DOMPurify from "isomorphic-dompurify";
|
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 {
|
export function sanitize(html: string | null | undefined): string {
|
||||||
if (!html) return "";
|
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 },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user