478 lines
13 KiB
TypeScript
478 lines
13 KiB
TypeScript
import "server-only";
|
|
|
|
import { eq } from "drizzle-orm";
|
|
import { Ban, type Db, SupportTickets } from "@/lib/db";
|
|
import type { AuditEntry, HousekeepingAuditWriter } from "@/lib/services/audit";
|
|
import type {
|
|
ModerationAction,
|
|
ModerationActionTransport,
|
|
} from "@/lib/services/moderation";
|
|
import type {
|
|
HousekeepingCapabilityContext,
|
|
HousekeepingErrorCode,
|
|
HousekeepingPartialCompletion,
|
|
} from "../../../foundation/contracts";
|
|
|
|
export const PEOPLE_MODERATION_MUTATION_OPERATIONS = [
|
|
"cfh.sanction",
|
|
"ban.create",
|
|
"ban.lift",
|
|
"moderation.action",
|
|
] as const;
|
|
|
|
export type PeopleModerationMutationOperation =
|
|
(typeof PEOPLE_MODERATION_MUTATION_OPERATIONS)[number];
|
|
|
|
export interface PeopleModerationMutationContext {
|
|
readonly capability: HousekeepingCapabilityContext;
|
|
readonly correlationId: string;
|
|
readonly legacy: boolean;
|
|
}
|
|
|
|
export interface PeopleModerationMutationSnapshot {
|
|
readonly before: Readonly<Record<string, unknown>> | null;
|
|
readonly after: Readonly<Record<string, unknown>> | null;
|
|
readonly output?: Readonly<Record<string, unknown>>;
|
|
readonly completion?: HousekeepingPartialCompletion;
|
|
}
|
|
|
|
type AuditOutcome = "intent" | "success" | "failure" | "partial";
|
|
type ModerationTransport = ModerationActionTransport & {
|
|
disconnectUser(userId: number, username?: string): Promise<boolean>;
|
|
};
|
|
|
|
export interface PeopleModerationMutationDependencies {
|
|
readonly db: Db;
|
|
readonly writeAudit: HousekeepingAuditWriter["write"];
|
|
readonly auditEntry: (
|
|
context: PeopleModerationMutationContext,
|
|
operation: PeopleModerationMutationOperation,
|
|
target: string,
|
|
targetId: number | undefined,
|
|
snapshot: PeopleModerationMutationSnapshot,
|
|
outcome: AuditOutcome,
|
|
) => AuditEntry;
|
|
readonly failure: (code: HousekeepingErrorCode, messageKey: string) => Error;
|
|
readonly record: (input: unknown) => Record<string, unknown>;
|
|
readonly positiveInteger: (value: unknown) => number;
|
|
readonly nonNegativeInteger: (value: unknown) => number;
|
|
readonly normalizedText: (
|
|
value: unknown,
|
|
max: number,
|
|
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,
|
|
input: ModerationAction,
|
|
) => Promise<boolean>;
|
|
readonly logStaffActivity: (input: {
|
|
staffId: number;
|
|
action: string;
|
|
description: string;
|
|
targetType?: string;
|
|
targetId?: number;
|
|
}) => Promise<void>;
|
|
readonly runExternalWithAudit: (
|
|
context: PeopleModerationMutationContext,
|
|
operation: PeopleModerationMutationOperation,
|
|
target: string,
|
|
targetId: number | undefined,
|
|
snapshot: PeopleModerationMutationSnapshot,
|
|
execute: () => Promise<void>,
|
|
confirmedFailureSnapshot: PeopleModerationMutationSnapshot,
|
|
) => Promise<PeopleModerationMutationSnapshot>;
|
|
readonly finalizeExternalWithAudit: (
|
|
context: PeopleModerationMutationContext,
|
|
operation: PeopleModerationMutationOperation,
|
|
target: string,
|
|
targetId: number | undefined,
|
|
snapshot: PeopleModerationMutationSnapshot,
|
|
execute: () => Promise<void>,
|
|
mutationCommitted?: boolean,
|
|
confirmedFailureSnapshot?: PeopleModerationMutationSnapshot,
|
|
) => Promise<PeopleModerationMutationSnapshot>;
|
|
}
|
|
|
|
export interface PeopleModerationMutationExecutor {
|
|
execute(
|
|
operation: PeopleModerationMutationOperation,
|
|
input: unknown,
|
|
context: PeopleModerationMutationContext,
|
|
): Promise<PeopleModerationMutationSnapshot>;
|
|
}
|
|
|
|
export function isPeopleModerationMutationOperation(
|
|
operation: string,
|
|
): operation is PeopleModerationMutationOperation {
|
|
return (PEOPLE_MODERATION_MUTATION_OPERATIONS as readonly string[]).includes(
|
|
operation,
|
|
);
|
|
}
|
|
|
|
export function createPeopleModerationMutationExecutor(
|
|
dependencies: PeopleModerationMutationDependencies,
|
|
): PeopleModerationMutationExecutor {
|
|
const {
|
|
db,
|
|
writeAudit,
|
|
auditEntry,
|
|
failure,
|
|
record,
|
|
positiveInteger,
|
|
nonNegativeInteger,
|
|
normalizedText,
|
|
requireRcon,
|
|
loadTarget,
|
|
runWithLockedTargetAuthority,
|
|
transport,
|
|
executeModerationAction,
|
|
logStaffActivity,
|
|
runExternalWithAudit,
|
|
finalizeExternalWithAudit,
|
|
} = dependencies;
|
|
|
|
function moderationAction(data: Record<string, unknown>): ModerationAction {
|
|
const action = normalizedText(data.action, 32);
|
|
if (action === "kick" || action === "unmute") {
|
|
return { action, userId: positiveInteger(data.userId) };
|
|
}
|
|
if (action === "mute") {
|
|
const duration = nonNegativeInteger(data.duration);
|
|
if (duration > 525_600) {
|
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
|
}
|
|
return { action, userId: positiveInteger(data.userId), duration };
|
|
}
|
|
if (action === "alert") {
|
|
return {
|
|
action,
|
|
userId: positiveInteger(data.userId),
|
|
message: normalizedText(data.message, 500),
|
|
};
|
|
}
|
|
if (action === "room-kick") {
|
|
return { action, roomId: positiveInteger(data.roomId) };
|
|
}
|
|
if (action === "broadcast") {
|
|
const type = normalizedText(data.type, 16);
|
|
if (type !== "hotel" && type !== "staff") {
|
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
|
}
|
|
return {
|
|
action,
|
|
type,
|
|
message: normalizedText(data.message, 500),
|
|
};
|
|
}
|
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
|
}
|
|
|
|
function actionTarget(input: ModerationAction): {
|
|
target: string;
|
|
targetId: number | undefined;
|
|
} {
|
|
if ("userId" in input) return { target: "User", targetId: input.userId };
|
|
if ("roomId" in input) return { target: "Room", targetId: input.roomId };
|
|
return { target: "broadcast", targetId: undefined };
|
|
}
|
|
|
|
async function deliverModerationAction(
|
|
input: ModerationAction,
|
|
context: PeopleModerationMutationContext,
|
|
): Promise<void> {
|
|
const delivered = await executeModerationAction(transport, input);
|
|
if (!context.legacy) await requireRcon(delivered);
|
|
}
|
|
|
|
async function executeModerationMutation(
|
|
input: unknown,
|
|
context: PeopleModerationMutationContext,
|
|
): Promise<PeopleModerationMutationSnapshot> {
|
|
const action = moderationAction(record(input));
|
|
const target = actionTarget(action);
|
|
const snapshot = {
|
|
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",
|
|
target.target,
|
|
target.targetId,
|
|
snapshot,
|
|
() => deliverModerationAction(action, context),
|
|
{ before: null, after: null },
|
|
);
|
|
}
|
|
|
|
async function executeCfhSanction(
|
|
input: unknown,
|
|
context: PeopleModerationMutationContext,
|
|
): 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,
|
|
});
|
|
const reason = normalizedText(data.reason, 500);
|
|
let snapshot!: PeopleModerationMutationSnapshot;
|
|
await db.transaction(async (tx) => {
|
|
const [ticket] = await tx
|
|
.select({
|
|
id: SupportTickets.id,
|
|
state: SupportTickets.state,
|
|
modId: SupportTickets.modId,
|
|
reportedId: SupportTickets.reportedId,
|
|
})
|
|
.from(SupportTickets)
|
|
.where(eq(SupportTickets.id, ticketId))
|
|
.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,
|
|
state: ticket.state,
|
|
moderatorId: ticket.modId,
|
|
},
|
|
after: {
|
|
id: ticket.id,
|
|
state: 2,
|
|
moderatorId: context.capability.actor.id,
|
|
sanction: action.action,
|
|
reason,
|
|
},
|
|
};
|
|
await tx
|
|
.update(SupportTickets)
|
|
.set({ state: 2, modId: context.capability.actor.id })
|
|
.where(eq(SupportTickets.id, ticketId));
|
|
await writeAudit(
|
|
auditEntry(
|
|
context,
|
|
"cfh.sanction",
|
|
"support_tickets",
|
|
ticketId,
|
|
snapshot,
|
|
"intent",
|
|
),
|
|
tx,
|
|
);
|
|
});
|
|
return finalizeExternalWithAudit(
|
|
context,
|
|
"cfh.sanction",
|
|
"support_tickets",
|
|
ticketId,
|
|
snapshot,
|
|
() => {
|
|
if (!("userId" in action)) {
|
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
|
}
|
|
return runWithLockedTargetAuthority(action.userId, context, () =>
|
|
deliverModerationAction(action, context),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
async function executeBanMutation(
|
|
operation: Extract<PeopleModerationMutationOperation, `ban.${string}`>,
|
|
input: unknown,
|
|
context: PeopleModerationMutationContext,
|
|
): Promise<PeopleModerationMutationSnapshot> {
|
|
const data = record(input);
|
|
if (operation === "ban.create") {
|
|
const userId = positiveInteger(data.userId);
|
|
const hours = nonNegativeInteger(data.hours);
|
|
const type = normalizedText(data.type, 16);
|
|
const reason = normalizedText(data.reason, 500);
|
|
if (
|
|
hours > 876_000 ||
|
|
!["account", "ip", "machine", "super"].includes(type)
|
|
) {
|
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
|
}
|
|
const now = Math.floor(Date.now() / 1_000);
|
|
const banExpire = hours > 0 ? now + hours * 3_600 : 0;
|
|
let username: string | null = null;
|
|
let banId = 0;
|
|
let snapshot!: PeopleModerationMutationSnapshot;
|
|
await db.transaction(async (tx) => {
|
|
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: needsIp ? user.ipCurrent : "",
|
|
machineId: needsMachine ? user.machineId : "",
|
|
userStaffId: context.capability.actor.id,
|
|
timestamp: now,
|
|
banExpire,
|
|
banReason: reason,
|
|
type: type as "account" | "ip" | "machine" | "super",
|
|
cfhTopic: -1,
|
|
});
|
|
banId = positiveInteger(result.insertId);
|
|
snapshot = {
|
|
before: null,
|
|
after: {
|
|
id: banId,
|
|
userId,
|
|
type,
|
|
reason,
|
|
expiresAt: banExpire,
|
|
},
|
|
};
|
|
await writeAudit(
|
|
auditEntry(context, operation, "Ban", banId, snapshot, "intent"),
|
|
tx,
|
|
);
|
|
});
|
|
return finalizeExternalWithAudit(
|
|
context,
|
|
operation,
|
|
"Ban",
|
|
banId,
|
|
snapshot,
|
|
() =>
|
|
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);
|
|
}),
|
|
);
|
|
}
|
|
|
|
const id = positiveInteger(data.id);
|
|
let snapshot!: PeopleModerationMutationSnapshot;
|
|
await db.transaction(async (tx) => {
|
|
const [existing] = await tx
|
|
.select({
|
|
id: Ban.id,
|
|
userId: Ban.userId,
|
|
reason: Ban.banReason,
|
|
type: Ban.type,
|
|
})
|
|
.from(Ban)
|
|
.where(eq(Ban.id, id))
|
|
.limit(1);
|
|
await tx.delete(Ban).where(eq(Ban.id, id));
|
|
snapshot = { before: existing ?? null, after: null };
|
|
await writeAudit(
|
|
auditEntry(context, operation, "Ban", id, snapshot, "intent"),
|
|
tx,
|
|
);
|
|
});
|
|
return finalizeExternalWithAudit(
|
|
context,
|
|
operation,
|
|
"Ban",
|
|
id,
|
|
snapshot,
|
|
() =>
|
|
logStaffActivity({
|
|
staffId: context.capability.actor.id,
|
|
action: "ban_lift",
|
|
description: `Lifted ban #${id}`,
|
|
}),
|
|
);
|
|
}
|
|
|
|
return {
|
|
execute(operation, input, context) {
|
|
if (operation === "moderation.action") {
|
|
return executeModerationMutation(input, context);
|
|
}
|
|
if (operation === "cfh.sanction") {
|
|
return executeCfhSanction(input, context);
|
|
}
|
|
if (operation === "ban.create" || operation === "ban.lift") {
|
|
return executeBanMutation(operation, input, context);
|
|
}
|
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
|
},
|
|
};
|
|
}
|