P1-P3 admin cleanup: rank guard, SSRF guard, count caching, dead code removal, pagination rewrite
CI / check (push) Canceled after 0s
CI / tests-unit (push) Canceled after 0s
CI / tests-integration (push) Canceled after 0s
CI / tests-ui (push) Canceled after 0s
CI / preflight (push) Canceled after 0s
CI / deploy (push) Canceled after 0s
Gitea Runner Clean Test / test-job (push) Canceled after 0s
Gitea Actions Runner Test / test-job (push) Canceled after 0s

- 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>
);
}
@@ -1,238 +0,0 @@
import { and, desc, eq, inArray, or } from "drizzle-orm";
import { isBanActive, unixNow } from "@/lib/bans";
import {
AdminAuditLog,
Ban,
db,
StaffActivities,
SupportTickets,
User,
UsersSettings,
} from "@/lib/db";
import { formatDate } from "@/lib/format-date";
export type SanctionEvent = {
id: string;
at: number;
kind: "ban" | "lift" | "mute" | "unmute" | "cfh" | "staff" | "status";
label: string;
detail: string;
actor?: string;
href?: string;
};
function parseAuditAt(createdAt: string): number {
const asNum = Number(createdAt);
if (Number.isFinite(asNum) && asNum > 1_000_000_000) return asNum;
const ms = Date.parse(createdAt);
return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
}
const AUDIT_ACTIONS = [
"ban",
"unban",
"user_mute",
"user_unmute",
"mod_mute",
"mod_unmute",
"user_ban",
"ban_lift",
] as const;
/** Build a merged sanction timeline for an admin user show tab. */
export async function loadUserSanctions(
userId: number,
): Promise<{ events: SanctionEvent[]; muteUntil: number }> {
const now = unixNow();
const events: SanctionEvent[] = [];
const [bans, audits, staffActs, cfh, settingsRows] = await Promise.all([
db
.select({
id: Ban.id,
banReason: Ban.banReason,
type: Ban.type,
timestamp: Ban.timestamp,
banExpire: Ban.banExpire,
userStaffId: Ban.userStaffId,
})
.from(Ban)
.where(eq(Ban.userId, userId))
.orderBy(desc(Ban.timestamp))
.limit(40)
.catch(() => []),
db
.select({
id: AdminAuditLog.id,
action: AdminAuditLog.action,
details: AdminAuditLog.details,
createdAt: AdminAuditLog.createdAt,
userId: AdminAuditLog.userId,
actorUsername: User.username,
})
.from(AdminAuditLog)
.leftJoin(User, eq(AdminAuditLog.userId, User.id))
.where(
and(
eq(AdminAuditLog.target, "User"),
eq(AdminAuditLog.targetId, userId),
inArray(AdminAuditLog.action, [...AUDIT_ACTIONS]),
),
)
.orderBy(desc(AdminAuditLog.id))
.limit(40)
.catch(() => []),
db
.select({
id: StaffActivities.id,
action: StaffActivities.action,
description: StaffActivities.description,
createdAt: StaffActivities.createdAt,
userId: StaffActivities.userId,
})
.from(StaffActivities)
.where(
and(
eq(StaffActivities.targetType, "user"),
eq(StaffActivities.targetId, BigInt(userId)),
),
)
.orderBy(desc(StaffActivities.id))
.limit(40)
.catch(() => []),
db
.select({
id: SupportTickets.id,
issue: SupportTickets.issue,
state: SupportTickets.state,
timestamp: SupportTickets.timestamp,
senderId: SupportTickets.senderId,
reportedId: SupportTickets.reportedId,
modId: SupportTickets.modId,
})
.from(SupportTickets)
.where(
or(
eq(SupportTickets.reportedId, userId),
eq(SupportTickets.senderId, userId),
),
)
.orderBy(desc(SupportTickets.id))
.limit(20)
.catch(() => []),
db
.select({ muteEndTimestamp: UsersSettings.muteEndTimestamp })
.from(UsersSettings)
.where(eq(UsersSettings.userId, userId))
.limit(1)
.catch(() => []),
]);
const settings = settingsRows[0] ?? null;
const muteUntil = settings?.muteEndTimestamp ?? 0;
if (muteUntil > now) {
events.push({
id: "mute-active",
at: now,
kind: "status",
label: "Mute active",
detail: `Until ${formatDate(new Date(muteUntil * 1000))}`,
});
}
const staffIds = [
...new Set([
...bans.map((b) => b.userStaffId).filter((id) => id > 0),
...staffActs.map((a) => Number(a.userId)).filter((id) => id > 0),
]),
];
const staffUsers =
staffIds.length > 0
? await db
.select({ id: User.id, username: User.username })
.from(User)
.where(inArray(User.id, staffIds))
.catch(() => [])
: [];
const staffName = new Map(staffUsers.map((u) => [u.id, u.username]));
for (const ban of bans) {
const active = isBanActive(ban.banExpire, now);
events.push({
id: `ban-${ban.id}`,
at: ban.timestamp,
kind: "ban",
label: active ? `Ban (${ban.type}) · active` : `Ban (${ban.type})`,
detail: ban.banReason || "—",
actor: staffName.get(ban.userStaffId),
});
}
for (const a of audits) {
const kind: SanctionEvent["kind"] =
a.action.includes("unmute") || a.action === "unban"
? a.action.includes("mute")
? "unmute"
: "lift"
: a.action.includes("mute")
? "mute"
: a.action.includes("ban")
? "ban"
: "staff";
events.push({
id: `audit-${a.id}`,
at: parseAuditAt(a.createdAt),
kind,
label: a.action,
detail: a.details?.slice(0, 200) || "—",
actor: a.actorUsername ?? undefined,
});
}
for (const s of staffActs) {
events.push({
id: `staff-${String(s.id)}`,
at: Math.floor(s.createdAt.getTime() / 1000),
kind: "staff",
label: s.action,
detail: s.description.slice(0, 200),
actor: staffName.get(Number(s.userId)),
});
}
for (const c of cfh) {
const role =
c.reportedId === userId
? "reported"
: c.senderId === userId
? "reporter"
: "related";
events.push({
id: `cfh-${c.id}`,
at: c.timestamp,
kind: "cfh",
label: `CFH #${c.id} (${role}, state ${c.state})`,
detail: c.issue?.slice(0, 200) || "—",
href: `/admin/moderation/cfh/${c.id}`,
});
}
events.sort((a, b) => b.at - a.at || a.id.localeCompare(b.id));
// Deduplicate near-identical ban+audit pairs within 5s
const deduped: SanctionEvent[] = [];
for (const ev of events) {
const prev = deduped[deduped.length - 1];
if (
prev &&
prev.kind === ev.kind &&
Math.abs(prev.at - ev.at) <= 5 &&
prev.label.split(" ")[0] === ev.label.split(" ")[0]
) {
continue;
}
deduped.push(ev);
}
return { events: deduped.slice(0, 80), muteUntil };
}