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

@@ -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 };
}