fix: harden admin actions, search, sanitization and repo hygiene
CI / check (push) Successful in 1m21s
CI / deploy (push) Successful in 1m25s

- Split approve/dismiss application workflows with distinct audit logs,
  rate-limited guards and real error logging
- Validate article status/date/id input and stop resetting publishedAt
  on every update
- Validate guild updates (state, forum enums, non-empty name) behind
  rate-limited guard
- Fix scheduled-article publishing (ignore NULL dates, set updatedAt,
  type-safe predicates)
- Harden admin search API (LIKE escaping, query cap, per-user
  rate limit, round-robin result cap) and fix search dialog
  abort/res.ok/loading races
- Lock down HTML sanitizer to an allowlist profile and add XSS tests
- Improve mobile nav accessibility (unique id, dialog role, focus
  management, scroll lock, outside close)
- Log swallowed server errors instead of silent catch blocks
- Remove dead eslint config, drop unused dompurify deps, restore knip
  CI step, add Playwright config with smoke spec
This commit is contained in:
openhands committed 2026-09-04 13:04:08 +02:00
1 parent 61769e355b
commit 399c047515
20 files changed
+435 -147

No files matched your search

@@ -2,6 +2,7 @@ import { inArray } from "drizzle-orm";
import { withAdmin } from "@/lib/api-handler";
import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
import { logAudit } from "@/lib/services/audit";
import {
cloneSingleFurni,
@@ -50,7 +51,11 @@ export const POST = withAdmin(
const entries = await fetchSourceFurnidata(source.furnidataUrl);
const byClassname = new Map(entries.map((e) => [e.classname, e]));
sourceFurniDataMap.set(source.id, byClassname);
} catch {}
} catch (error) {
logServerError("clone-import.furnidata_prefetch_failed", error, {
source: source.id,
});
}
}
// Collect all missing items using pre-fetched data
+39 -10
View File
@@ -1,4 +1,5 @@
import { eq, like, or } from "drizzle-orm";
import { NextResponse } from "next/server";
import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import {
@@ -11,8 +12,9 @@ import {
WebsiteShopArticles,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { rateLimit } from "@/lib/rate-limit";
type SearchResult = {
export type AdminSearchResult = {
type: string;
id: number | string;
title: string;
@@ -22,16 +24,36 @@ type SearchResult = {
const PER_TYPE = 5;
const MAX_TOTAL = 20;
const MAX_QUERY_LENGTH = 64;
/** Escape LIKE wildcards so user input can't widen the match. */
export function escapeLike(input: string): string {
return input.replace(/[\\%_]/g, (m) => `\\${m}`);
}
export const GET = withAdmin(
{ permission: PERMS.ADMIN_DASHBOARD },
async (request) => {
const q = (request.nextUrl.searchParams.get("q") || "").trim();
async (request, context) => {
const limited = await rateLimit(
`admin-search:${context.session.user.id}`,
30,
60_000,
);
if (!limited.ok) {
return NextResponse.json(
{ ok: false, error: "Too many requests" },
{ status: 429, headers: { "retry-after": String(limited.retryAfter) } },
);
}
const q = (request.nextUrl.searchParams.get("q") || "")
.trim()
.slice(0, MAX_QUERY_LENGTH);
if (q.length < 2) {
return apiOk({ results: [] });
}
const pattern = `%${q}%`;
const pattern = `%${escapeLike(q)}%`;
const idExact = Number.parseInt(q, 10);
const hasId = Number.isFinite(idExact) && String(idExact) === q;
@@ -131,7 +153,7 @@ export const GET = withAdmin(
const groupMap: Record<
string,
{ type: string; items: Array<SearchResult> }
{ type: string; items: Array<AdminSearchResult> }
> = {
users: { type: "users", items: [] },
articles: { type: "articles", items: [] },
@@ -196,7 +218,7 @@ export const GET = withAdmin(
});
}
const results: SearchResult[] = [];
const results: AdminSearchResult[] = [];
const order = [
"users",
"articles",
@@ -205,12 +227,19 @@ export const GET = withAdmin(
"shop",
"rareValues",
];
for (const key of order) {
for (const item of groupMap[key].items) {
results.push(item);
// Round-robin so no single type starves the others when capped.
for (let i = 0; results.length < MAX_TOTAL; i++) {
let added = false;
for (const key of order) {
const item = groupMap[key]?.items[i];
if (item && results.length < MAX_TOTAL) {
results.push(item);
added = true;
}
}
if (!added) break;
}
return apiOk({ results: results.slice(0, MAX_TOTAL) });
return apiOk({ results });
},
);