P1-P3 admin cleanup: rank guard, SSRF guard, count caching, dead code removal, pagination rewrite

- P1: new src/lib/admin/rank-guard.ts with tryGuardRank/guardRank + RankGuardTarget/Result; extracted from users.ts; applied to admin-bans (create/lift + rank redirect) and admin-badges (caps + rank guard + error surfacing); import-url-safety.ts SSRF guard wired into clone save+icon fetch; logAudit await in help-tickets; antiddos mutations SETTINGS_EDIT; markAllAlertsRead NOTIFICATIONS_EDIT; rankError/saveError in all 25 message files; tests rew/new for bans/badges/rank-guard/import-url-safety

- P2: ticket-inbox merged pagination reworked to single UNION ALL SQL with LIMIT/OFFSET (avoids O(offset) materialization); getCachedAdminCount with cacheFiltered option applied to devops/errors/photos/items/guilds/help-tickets/moderation pages; admin-theme catches→?error=1+banner+25 locales; logger in silent catches (rare-values/writeable-boxes); catalog-items-loader CAST→plain equality; count caching helper extended with opt-in cacheFiltered

- P3: removed dead modules is-staff/notice/recent-furni-resync/log-filters plus load-user-sanctions/user-sanction-timeline; removed orphaned CATALOG_PAGE_COLUMNS re-export; removed getFurniIconUrl from studio-furni-icons

Build: lint 0, typecheck 0, 3480 tests pass, i18n:check exit 0, colors exit 0. Circular chunk warning (9452/10535/66463) is webpack code-splitting artifact; build succeeds.
This commit is contained in:
openhands committed 2026-10-12 17:42:19 +02:00
1 parent 57529fd1ea
commit 7b249a7222
71 files changed
+1031 -1078

No files matched your search

+11 -1
View File
@@ -11,12 +11,17 @@ import { Ban, db } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function AdminBans() {
export default async function AdminBans({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.BANS_VIEW, session.user.rank)) {
redirect("/admin");
}
const params = await searchParams;
const t = await getTranslations("pages.admin.bans");
const now = unixNow();
let bans: (typeof Ban.$inferSelect)[] = [];
@@ -37,6 +42,11 @@ export default async function AdminBans() {
return (
<AdminPageShell icon={BanIcon} title={t("title")} subtitle={t("subtitle")}>
<main>
{params.error === "rank" ? (
<p role="alert" className="text-sm text-[var(--admin-error)] mb-4">
{t("rankError")}
</p>
) : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("activeBans")}
+4 -4
View File
@@ -1,10 +1,11 @@
import { count, desc } from "drizzle-orm";
import { desc } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { Card, CardContent } from "@/components/ui/card";
import { db, EmulatorErrors } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getCachedAdminCount } from "@/lib/services/admin-stats";
export default async function EmulatorErrorsPage({
searchParams,
@@ -21,17 +22,16 @@ export default async function EmulatorErrorsPage({
const page = Math.max(1, Number(params.page) || 1);
const perPage = 50;
const [errors, totalRows] = await Promise.all([
const [errors, total] = await Promise.all([
db
.select()
.from(EmulatorErrors)
.orderBy(desc(EmulatorErrors.id))
.limit(perPage)
.offset((page - 1) * perPage),
db.select({ total: count() }).from(EmulatorErrors),
getCachedAdminCount(EmulatorErrors, "emulator_errors"),
]);
const total = Number(totalRows[0]?.total ?? 0);
const lastPage = Math.ceil(total / perPage);
return (
+2 -5
View File
@@ -11,6 +11,7 @@ import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { db, Guilds, GuildsMembers, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getCachedAdminCount } from "@/lib/services/admin-stats";
export default async function AdminGuildsPage({
searchParams,
@@ -50,11 +51,7 @@ export default async function AdminGuildsPage({
)
: undefined;
const [totalRow] = await db
.select({ total: count() })
.from(Guilds)
.where(where);
total = Number(totalRow?.total ?? 0);
total = await getCachedAdminCount(Guilds, "guilds", where);
const pagination = calcPagination(total, parsed.page, parsed.perPage);
rows = await db
.select({
+19 -20
View File
@@ -23,6 +23,7 @@ import {
} from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getCachedAdminCount } from "@/lib/services/admin-stats";
import { type HelpTicketRow, HelpTicketsTable } from "./help-tickets-table";
export default async function AdminHelpTicketsPage({
@@ -90,29 +91,27 @@ export default async function AdminHelpTicketsPage({
: WebsiteHelpCenterTickets.updatedAt;
const orderBy = parsed.order === "asc" ? asc(orderCol) : desc(orderCol);
const [totalRows, openRows, closedRows, queues] = await Promise.all([
db
.select({ total: count() })
.from(WebsiteHelpCenterTickets)
.where(where)
.catch(() => [{ total: 0 }]),
db
.select({ total: count() })
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.open, true))
.catch(() => [{ total: 0 }]),
db
.select({ total: count() })
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.open, false))
.catch(() => [{ total: 0 }]),
const [total, openCount, closedCount, queues] = await Promise.all([
getCachedAdminCount(
WebsiteHelpCenterTickets,
"help_tickets_total",
where,
).catch(() => 0),
getCachedAdminCount(
WebsiteHelpCenterTickets,
"help_tickets_open",
eq(WebsiteHelpCenterTickets.open, true),
{ cacheFiltered: true },
).catch(() => 0),
getCachedAdminCount(
WebsiteHelpCenterTickets,
"help_tickets_closed",
eq(WebsiteHelpCenterTickets.open, false),
{ cacheFiltered: true },
).catch(() => 0),
fetchTicketQueueOpenCounts(),
]);
const total = Number(totalRows[0]?.total ?? 0);
const openCount = Number(openRows[0]?.total ?? 0);
const closedCount = Number(closedRows[0]?.total ?? 0);
const pagination = calcPagination(total, parsed.page, parsed.perPage);
const tickets = await db
+5 -4
View File
@@ -1,10 +1,11 @@
import { and, count, desc, eq, like, or } from "drizzle-orm";
import { and, desc, eq, like, or } from "drizzle-orm";
import { Package } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { fetchAdminList } from "@/lib/admin-list";
import { db, ItemsBase } from "@/lib/db";
import { canAccess, PERMS } from "@/lib/permissions";
import { getCachedAdminCount } from "@/lib/services/admin-stats";
import { type ItemsBaseRow, ItemsTable } from "./items-table";
export default async function ItemsBasePage({
@@ -34,7 +35,7 @@ export default async function ItemsBasePage({
conditions.push(eq(ItemsBase.type, rawParams.filter_type));
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [rows, totals] = await Promise.all([
const [rows, total] = await Promise.all([
db
.select({
id: ItemsBase.id,
@@ -51,7 +52,7 @@ export default async function ItemsBasePage({
.orderBy(desc(ItemsBase.id))
.limit(perPage)
.offset((page - 1) * perPage),
db.select({ total: count() }).from(ItemsBase).where(where),
getCachedAdminCount(ItemsBase, "items_base", where),
]);
return {
rows: rows.map((r) => ({
@@ -64,7 +65,7 @@ export default async function ItemsBasePage({
width: r.width,
length: r.length,
})),
total: totals[0]?.total ?? 0,
total,
};
},
},
+22 -18
View File
@@ -16,6 +16,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
import { AdminAuditLog, Ban, db, SupportTickets, User } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getCachedAdminCount } from "@/lib/services/admin-stats";
const CFH_STATES: Record<number, { label: string; class: string }> = {
0: {
@@ -39,25 +40,31 @@ export default async function ModerationDashboard() {
const minStaffRank = await getMinStaffRank();
const [
openCfhRows,
pickedCfhRows,
todayBansRows,
openCfh,
pickedCfh,
todayBans,
modsOnlineRows,
recentCfh,
recentModActions,
] = await Promise.all([
db
.select({ total: count() })
.from(SupportTickets)
.where(eq(SupportTickets.state, 0)),
db
.select({ total: count() })
.from(SupportTickets)
.where(eq(SupportTickets.state, 1)),
db
.select({ total: count() })
.from(Ban)
.where(gte(Ban.timestamp, todayStart)),
getCachedAdminCount(
SupportTickets,
"moderation_open_cfh",
eq(SupportTickets.state, 0),
{ cacheFiltered: true },
),
getCachedAdminCount(
SupportTickets,
"moderation_picked_cfh",
eq(SupportTickets.state, 1),
{ cacheFiltered: true },
),
getCachedAdminCount(
Ban,
"moderation_today_bans",
gte(Ban.timestamp, todayStart),
{ cacheFiltered: true },
),
db
.select({ total: count() })
.from(User)
@@ -81,9 +88,6 @@ export default async function ModerationDashboard() {
.limit(10),
]);
const openCfh = Number(openCfhRows[0]?.total ?? 0);
const pickedCfh = Number(pickedCfhRows[0]?.total ?? 0);
const todayBans = Number(todayBansRows[0]?.total ?? 0);
const modsOnline = Number(modsOnlineRows[0]?.total ?? 0);
// Fetch usernames for CFH tickets
+5 -6
View File
@@ -1,4 +1,4 @@
import { count, desc, inArray } from "drizzle-orm";
import { desc, inArray } from "drizzle-orm";
import { Camera } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
@@ -11,6 +11,7 @@ import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { CameraWeb, db, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getCachedAdminCount } from "@/lib/services/admin-stats";
export default async function AdminPhotos({
searchParams,
@@ -25,11 +26,9 @@ export default async function AdminPhotos({
const t = await getTranslations("pages.admin.photos");
const parsed = parseListParams(new URLSearchParams(await searchParams));
const [totalRow] = await db
.select({ total: count() })
.from(CameraWeb)
.catch(() => [{ total: 0 }]);
const total = Number(totalRow?.total ?? 0);
const total = await getCachedAdminCount(CameraWeb, "camera_web").catch(
() => 0,
);
const pagination = calcPagination(total, parsed.page, parsed.perPage || 24);
let photos: (typeof CameraWeb.$inferSelect)[] = [];
+9
View File
@@ -323,6 +323,7 @@ export default async function AdminTheme({
theme?: string;
renamed?: string;
deletedTheme?: string;
error?: string;
}>;
}) {
const { session, permissions } = await getAdminContext();
@@ -372,6 +373,14 @@ export default async function AdminTheme({
return (
<main>
{sp.error ? (
<div
role="alert"
className="mb-4 p-3 rounded-lg text-xs text-[var(--admin-error)]"
>
{t("saveError")}
</div>
) : null}
{sp.saved ? (
<div className="mb-4 p-3 rounded-lg theme-status-success text-xs">
{t("saved")}
@@ -1,83 +0,0 @@
import Link from "@/components/link";
import { formatDate } from "@/lib/format-date";
import type { SanctionEvent } from "../_lib/load-user-sanctions";
const KIND_CLASS: Record<SanctionEvent["kind"], string> = {
ban: "bg-[var(--admin-error-subtle)] text-[var(--admin-error)]",
lift: "bg-[var(--admin-success-subtle)] text-[var(--admin-success)]",
mute: "bg-[var(--admin-accent)]/20 text-[var(--admin-accent)]",
unmute: "bg-[var(--admin-success-subtle)] text-[var(--admin-success)]",
cfh: "bg-[var(--admin-accent)]/15 text-[var(--admin-text)]",
staff: "bg-[var(--admin-border)] text-[var(--admin-text-muted)]",
status: "bg-[var(--admin-accent)]/25 text-[var(--admin-accent)]",
};
export function UserSanctionTimeline({
events,
muteUntil,
}: {
events: SanctionEvent[];
muteUntil: number;
}) {
const now = Math.floor(Date.now() / 1000);
const muted = muteUntil > now;
return (
<div className="rounded-lg border bg-card p-4 space-y-4">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold m-0">Sanctions timeline</h3>
{muted ? (
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/20 text-[var(--admin-accent)]">
Muted until {formatDate(new Date(muteUntil * 1000))}
</span>
) : null}
</div>
{events.length === 0 ? (
<p className="text-sm text-muted-foreground m-0">
No bans, mutes, CFH, or staff actions recorded for this user.
</p>
) : (
<ol className="space-y-3 m-0 p-0 list-none">
{events.map((ev) => (
<li
key={ev.id}
className="flex gap-3 border-b border-[var(--admin-border)] pb-3 last:border-0 last:pb-0"
>
<span
className={`shrink-0 mt-0.5 text-[0.65rem] font-bold uppercase tracking-wide px-2 py-0.5 rounded-full ${KIND_CLASS[ev.kind]}`}
>
{ev.kind}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
{ev.href ? (
<Link
href={ev.href}
className="text-sm font-medium hover:underline"
>
{ev.label}
</Link>
) : (
<span className="text-sm font-medium">{ev.label}</span>
)}
<span className="text-xs text-muted-foreground">
{formatDate(new Date(ev.at * 1000))}
</span>
{ev.actor ? (
<span className="text-xs text-muted-foreground">
by {ev.actor}
</span>
) : null}
</div>
<p className="text-xs text-muted-foreground m-0 mt-0.5 break-words">
{ev.detail}
</p>
</div>
</li>
))}
</ol>
)}
</div>
);
}
File diff suppressed because it is too large. Load diff
+14 -3
View File
@@ -1,5 +1,6 @@
import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { assertSafeImportUrl } from "@/lib/import-url-safety";
import { PERMS } from "@/lib/permissions";
import { extractFurniIconPng } from "@/lib/services/clone-icon";
import { getSource } from "@/lib/services/clone-sources";
@@ -38,10 +39,20 @@ export const GET = withAdmin(
if (icon === undefined) {
const source = await getSource(sourceId);
if (!source) return apiError("Source not found", 404);
if (!source.nitroBaseUrl)
return apiError("Source has no nitro base URL", 400);
try {
const res = await fetch(`${source.nitroBaseUrl}/${classname}.nitro`, {
signal: AbortSignal.timeout(15000),
});
await assertSafeImportUrl(source.nitroBaseUrl, "nitroBaseUrl");
} catch (err) {
return apiError((err as Error).message, 400);
}
try {
const res = await fetch(
`${source.nitroBaseUrl.replace(/\/+$/, "")}/${classname}.nitro`,
{
signal: AbortSignal.timeout(15000),
},
);
if (!res.ok) {
iconCache.set(cacheKey, null);
return apiError("Bundle not found", 404);
+14 -10
View File
@@ -1,5 +1,6 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { assertSafeImportUrl } from "@/lib/import-url-safety";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import {
@@ -87,22 +88,25 @@ export const POST = withAdmin(
} = body as Record<string, string>;
const enabledRaw = (body as Record<string, unknown>).enabled;
const isHttps = (u: string) => {
try {
return new URL(u).protocol === "https:";
} catch {
return false;
}
};
if (!name?.trim()) return apiError("name is required", 400);
if (!furnidataUrl?.trim()) return apiError("furnidataUrl is required", 400);
const normalized = (u: string | undefined) =>
(u ?? "").trim().replace(/\/+$/, "");
if (!isHttps(furnidataUrl))
return apiError("furnidataUrl must be a valid https:// URL", 400);
for (const [label, rawUrl] of [
["furnidataUrl", furnidataUrl],
["nitroBaseUrl", nitroBaseUrl],
["iconBaseUrl", iconBaseUrl],
["sourceSwfBaseUrl", sourceSwfBaseUrl],
] as const) {
if (!rawUrl?.trim()) continue;
try {
await assertSafeImportUrl(rawUrl, label);
} catch (err) {
return apiError((err as Error).message, 400);
}
}
const entry = await upsertSource({
id,