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
@@ -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",
|
||||
];
|
||||
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 });
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user