fix(housekeeping): unify moderation target authority

This commit is contained in:
Simo committed 2026-09-05 11:17:19 +02:00
1 parent 9b91880e33
commit 8a894617e7
12 files changed
+873 -236

No files matched your search

@@ -1,7 +1,7 @@
import "server-only";
import { eq } from "drizzle-orm";
import { Ban, type Db, SupportTickets, User } from "@/lib/db";
import { Ban, type Db, SupportTickets } from "@/lib/db";
import type { AuditEntry, HousekeepingAuditWriter } from "@/lib/services/audit";
import type {
ModerationAction,
@@ -62,6 +62,25 @@ export interface PeopleModerationMutationDependencies {
required?: boolean,
) => string;
readonly requireRcon: (result: boolean) => Promise<void>;
readonly loadTarget: (
userId: number,
context: PeopleModerationMutationContext,
guardHierarchy: boolean,
source?: Pick<Db, "select">,
lock?: boolean,
) => Promise<{
readonly id: number;
readonly username: string;
readonly rank: number;
readonly ipCurrent: string;
readonly machineId: string;
readonly online: string;
}>;
readonly runWithLockedTargetAuthority: (
userId: number,
context: PeopleModerationMutationContext,
execute: () => Promise<void>,
) => Promise<void>;
readonly transport: ModerationTransport;
readonly executeModerationAction: (
transport: ModerationActionTransport,
@@ -124,6 +143,8 @@ export function createPeopleModerationMutationExecutor(
nonNegativeInteger,
normalizedText,
requireRcon,
loadTarget,
runWithLockedTargetAuthority,
transport,
executeModerationAction,
logStaffActivity,
@@ -194,6 +215,35 @@ export function createPeopleModerationMutationExecutor(
before: null,
after: { ...action },
} satisfies PeopleModerationMutationSnapshot;
if ("userId" in action) {
await db.transaction(async (tx) => {
await loadTarget(action.userId, context, true, tx, true);
await writeAudit(
auditEntry(
context,
"moderation.action",
"User",
action.userId,
snapshot,
"intent",
),
tx,
);
});
return finalizeExternalWithAudit(
context,
"moderation.action",
"User",
action.userId,
snapshot,
() =>
runWithLockedTargetAuthority(action.userId, context, () =>
deliverModerationAction(action, context),
),
false,
{ before: null, after: null },
);
}
return runExternalWithAudit(
context,
"moderation.action",
@@ -211,6 +261,10 @@ export function createPeopleModerationMutationExecutor(
): Promise<PeopleModerationMutationSnapshot> {
const data = record(input);
const ticketId = positiveInteger(data.ticketId);
const requestedAction = normalizedText(data.action, 32);
if (!["kick", "mute", "alert"].includes(requestedAction)) {
throw failure("VALIDATION", "errors.housekeeping.validation");
}
const action = moderationAction({
...data,
message: data.message ?? data.reason,
@@ -223,13 +277,21 @@ export function createPeopleModerationMutationExecutor(
id: SupportTickets.id,
state: SupportTickets.state,
modId: SupportTickets.modId,
reportedId: SupportTickets.reportedId,
})
.from(SupportTickets)
.where(eq(SupportTickets.id, ticketId))
.limit(1);
.for("update");
if (!ticket) {
throw failure("NOT_FOUND", "errors.housekeeping.notFound");
}
if (!("userId" in action) || action.userId !== ticket.reportedId) {
throw failure("FORBIDDEN", "errors.housekeeping.forbidden");
}
if (ticket.state !== 0 && ticket.state !== 1) {
throw failure("CONFLICT", "errors.housekeeping.conflict");
}
await loadTarget(ticket.reportedId, context, true, tx, true);
snapshot = {
before: {
id: ticket.id,
@@ -266,7 +328,14 @@ export function createPeopleModerationMutationExecutor(
"support_tickets",
ticketId,
snapshot,
() => deliverModerationAction(action, context),
() => {
if (!("userId" in action)) {
throw failure("VALIDATION", "errors.housekeeping.validation");
}
return runWithLockedTargetAuthority(action.userId, context, () =>
deliverModerationAction(action, context),
);
},
);
}
@@ -293,16 +362,20 @@ export function createPeopleModerationMutationExecutor(
let banId = 0;
let snapshot!: PeopleModerationMutationSnapshot;
await db.transaction(async (tx) => {
const [user] = await tx
.select({ username: User.username })
.from(User)
.where(eq(User.id, userId))
.limit(1);
username = user?.username ?? null;
const user = await loadTarget(userId, context, true, tx, true);
username = user.username;
const needsIp = type === "ip" || type === "super";
const needsMachine = type === "machine" || type === "super";
if (
(needsIp && user.ipCurrent.trim().length === 0) ||
(needsMachine && user.machineId.trim().length === 0)
) {
throw failure("VALIDATION", "errors.housekeeping.validation");
}
const [result] = await tx.insert(Ban).values({
userId,
ip: "",
machineId: "",
ip: needsIp ? user.ipCurrent : "",
machineId: needsMachine ? user.machineId : "",
userStaffId: context.capability.actor.id,
timestamp: now,
banExpire,
@@ -332,22 +405,23 @@ export function createPeopleModerationMutationExecutor(
"Ban",
banId,
snapshot,
async () => {
let delivered = true;
if (username !== null) {
delivered = await transport.disconnectUser(userId, username);
}
await logStaffActivity({
staffId: context.capability.actor.id,
action: "user_ban",
description: `Banned user #${userId} (${type}, ${
hours > 0 ? `${hours}h` : "permanent"
}): ${reason}`,
targetType: "user",
targetId: userId,
});
await requireRcon(delivered);
},
() =>
runWithLockedTargetAuthority(userId, context, async () => {
let delivered = true;
if (username !== null) {
delivered = await transport.disconnectUser(userId, username);
}
await logStaffActivity({
staffId: context.capability.actor.id,
action: "user_ban",
description: `Banned user #${userId} (${type}, ${
hours > 0 ? `${hours}h` : "permanent"
}): ${reason}`,
targetType: "user",
targetId: userId,
});
await requireRcon(delivered);
}),
);
}