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