feat(housekeeping): deliver people account workflows
This commit is contained in:
1 parent
7920d4f46c
commit
e1b31ff773
45 files changed
+4290
-1243
No files matched your search
@@ -0,0 +1,995 @@
|
||||
import "server-only";
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { and, eq, inArray, max, sql } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import { invalidateLoginCache } from "@/lib/auth";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import {
|
||||
Ban,
|
||||
db,
|
||||
GuildForumViews,
|
||||
Guilds,
|
||||
GuildsForumsComments,
|
||||
GuildsForumsThreads,
|
||||
GuildsMembers,
|
||||
Items,
|
||||
Rooms,
|
||||
Sanctions,
|
||||
User,
|
||||
UsersBadges,
|
||||
UsersCurrency,
|
||||
UsersSettings,
|
||||
WebsiteIpBlacklist,
|
||||
WebsiteIpWhitelist,
|
||||
WebsiteSetting,
|
||||
WebsiteStaffApplications,
|
||||
WebsiteTeams,
|
||||
WebsiteWordfilter,
|
||||
} from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { reloadWordFilter } from "@/lib/services/moderation";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
import { satisfiesCapability } from "../../../foundation/capability-context";
|
||||
import {
|
||||
anyCapability,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingErrorCode,
|
||||
type HousekeepingResult,
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
|
||||
export type PeopleMutationOperation =
|
||||
| "user.update"
|
||||
| "user.ban"
|
||||
| "user.unban"
|
||||
| "user.alert"
|
||||
| "user.disconnect"
|
||||
| "user.mute"
|
||||
| "user.unmute"
|
||||
| "user.reset-password"
|
||||
| "user.send-currency"
|
||||
| "user.trade-lock"
|
||||
| "users.bulk-ban"
|
||||
| "users.bulk-unban"
|
||||
| "users.bulk-currency"
|
||||
| "users.bulk-badge"
|
||||
| "guild.disband"
|
||||
| "application.decide"
|
||||
| "team.change"
|
||||
| "ip.action"
|
||||
| "vpn.configure"
|
||||
| "word-filter.update";
|
||||
|
||||
export interface PeopleMutationSnapshot {
|
||||
readonly before: Readonly<Record<string, unknown>> | null;
|
||||
readonly after: Readonly<Record<string, unknown>> | null;
|
||||
readonly output?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface PeopleMutationContext {
|
||||
readonly capability: HousekeepingCapabilityContext;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
export interface PeopleMutationAdapter {
|
||||
execute(
|
||||
operation: PeopleMutationOperation,
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot>;
|
||||
}
|
||||
|
||||
export interface PeopleMutationService {
|
||||
execute(
|
||||
context: PeopleMutationContext,
|
||||
operation: PeopleMutationOperation,
|
||||
input: unknown,
|
||||
): Promise<HousekeepingResult<PeopleMutationSnapshot>>;
|
||||
}
|
||||
|
||||
class PeopleMutationFailure extends Error {
|
||||
constructor(
|
||||
readonly code: HousekeepingErrorCode,
|
||||
readonly messageKey: string,
|
||||
readonly fieldErrors?: Readonly<Record<string, readonly string[]>>,
|
||||
) {
|
||||
super(messageKey);
|
||||
this.name = "PeopleMutationFailure";
|
||||
}
|
||||
}
|
||||
|
||||
function operationCapability(operation: PeopleMutationOperation) {
|
||||
if (operation === "user.ban" || operation === "user.unban") {
|
||||
return anyCapability(PERMS.USERS_BAN);
|
||||
}
|
||||
if (operation === "user.reset-password") {
|
||||
return anyCapability(PERMS.USERS_RESET_PASSWORD);
|
||||
}
|
||||
if (operation === "ip.action" || operation === "vpn.configure") {
|
||||
return anyCapability(PERMS.SETTINGS_EDIT);
|
||||
}
|
||||
if (operation === "word-filter.update") {
|
||||
return anyCapability(PERMS.WORDFILTER_EDIT);
|
||||
}
|
||||
return anyCapability(PERMS.USERS_EDIT);
|
||||
}
|
||||
|
||||
export function createPeopleMutationService(
|
||||
adapter: PeopleMutationAdapter,
|
||||
): PeopleMutationService {
|
||||
return {
|
||||
async execute(context, operation, input) {
|
||||
if (
|
||||
!satisfiesCapability(context.capability, operationCapability(operation))
|
||||
) {
|
||||
return fail(
|
||||
"FORBIDDEN",
|
||||
"errors.housekeeping.forbidden",
|
||||
context.correlationId,
|
||||
);
|
||||
}
|
||||
try {
|
||||
return ok(
|
||||
await adapter.execute(operation, input, context),
|
||||
context.correlationId,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof PeopleMutationFailure) {
|
||||
return fail(
|
||||
error.code,
|
||||
error.messageKey,
|
||||
context.correlationId,
|
||||
error.fieldErrors,
|
||||
);
|
||||
}
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
context.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createLegacyPeopleMutationContext(
|
||||
staff: {
|
||||
readonly id: number;
|
||||
readonly username: string;
|
||||
readonly rank: number;
|
||||
},
|
||||
permission: string,
|
||||
correlationId: string,
|
||||
): PeopleMutationContext {
|
||||
return {
|
||||
correlationId,
|
||||
capability: {
|
||||
actor: { id: staff.id, username: staff.username, rank: staff.rank },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => slug === permission,
|
||||
hasAny: (...slugs) => slugs.includes(permission),
|
||||
hasAll: (...slugs) => slugs.every((slug) => slug === permission),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function record(input: unknown): Record<string, unknown> {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
return input as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizedText(value: unknown, max: number, required = true): string {
|
||||
const text = String(value ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, max);
|
||||
if (required && text.length === 0) {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function userIds(value: unknown): number[] {
|
||||
if (!Array.isArray(value) || value.length === 0 || value.length > 100) {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
return [...new Set(value.map(positiveInteger))];
|
||||
}
|
||||
|
||||
async function requireRcon(result: boolean): Promise<void> {
|
||||
if (!result) {
|
||||
throw new PeopleMutationFailure(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type TargetUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
rank: number;
|
||||
motto: string;
|
||||
credits: number;
|
||||
pixels: number;
|
||||
online: string;
|
||||
};
|
||||
|
||||
async function loadTarget(
|
||||
userId: number,
|
||||
context: PeopleMutationContext,
|
||||
guardHierarchy: boolean,
|
||||
): Promise<TargetUser> {
|
||||
const [target] = await db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
rank: User.rank,
|
||||
motto: User.motto,
|
||||
credits: User.credits,
|
||||
pixels: User.pixels,
|
||||
online: User.online,
|
||||
})
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
if (!target) {
|
||||
throw new PeopleMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
if (
|
||||
guardHierarchy &&
|
||||
target.rank >= context.capability.actor.rank &&
|
||||
context.capability.actor.rank < 7
|
||||
) {
|
||||
throw new PeopleMutationFailure(
|
||||
"FORBIDDEN",
|
||||
"errors.housekeeping.forbidden",
|
||||
);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function targetSnapshot(target: TargetUser) {
|
||||
return {
|
||||
id: target.id,
|
||||
username: target.username,
|
||||
rank: target.rank,
|
||||
motto: target.motto,
|
||||
credits: target.credits,
|
||||
pixels: target.pixels,
|
||||
online: target.online === "1",
|
||||
};
|
||||
}
|
||||
|
||||
async function auditMutation(
|
||||
context: PeopleMutationContext,
|
||||
operation: PeopleMutationOperation,
|
||||
target: string,
|
||||
targetId: number | undefined,
|
||||
snapshot: PeopleMutationSnapshot,
|
||||
): Promise<void> {
|
||||
await logAudit({
|
||||
userId: context.capability.actor.id,
|
||||
action: `people.${operation}`,
|
||||
target,
|
||||
targetId,
|
||||
before: snapshot.before ?? undefined,
|
||||
after: snapshot.after ?? undefined,
|
||||
correlationId: context.correlationId,
|
||||
outcome: "success",
|
||||
domain: "people",
|
||||
});
|
||||
}
|
||||
|
||||
async function executeUserMutation(
|
||||
operation: Extract<PeopleMutationOperation, `user.${string}`>,
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const userId = positiveInteger(data.userId);
|
||||
const guardHierarchy = operation !== "user.alert";
|
||||
const target = await loadTarget(userId, context, guardHierarchy);
|
||||
const before = targetSnapshot(target);
|
||||
let snapshot: PeopleMutationSnapshot;
|
||||
|
||||
if (operation === "user.update") {
|
||||
const fields = record(data.fields);
|
||||
const nextRank = fields.rank;
|
||||
if (
|
||||
nextRank !== undefined &&
|
||||
positiveInteger(nextRank) >= context.capability.actor.rank &&
|
||||
context.capability.actor.rank < 7
|
||||
) {
|
||||
throw new PeopleMutationFailure(
|
||||
"FORBIDDEN",
|
||||
"errors.housekeeping.forbidden",
|
||||
);
|
||||
}
|
||||
const userPatch = Object.fromEntries(
|
||||
["username", "mail", "rank", "motto", "credits", "pixels"].flatMap(
|
||||
(key) => (fields[key] === undefined ? [] : [[key, fields[key]]]),
|
||||
),
|
||||
);
|
||||
if (Object.keys(userPatch).length > 0) {
|
||||
await db.update(User).set(userPatch).where(eq(User.id, userId));
|
||||
}
|
||||
for (const [key, type] of [
|
||||
["duckets", 0],
|
||||
["diamonds", 5],
|
||||
] as const) {
|
||||
if (fields[key] === undefined) continue;
|
||||
const amount = nonNegativeInteger(fields[key]);
|
||||
await db
|
||||
.insert(UsersCurrency)
|
||||
.values({ userId, type, amount })
|
||||
.onDuplicateKeyUpdate({ set: { amount } });
|
||||
}
|
||||
invalidateLoginCache(target.username);
|
||||
snapshot = { before, after: { ...before, ...fields } };
|
||||
await notify({
|
||||
action: "user_edit",
|
||||
actor: context.capability.actor.username,
|
||||
target: target.username,
|
||||
targetId: userId,
|
||||
});
|
||||
} else if (operation === "user.ban") {
|
||||
const reason = normalizedText(data.reason, 500);
|
||||
const duration = nonNegativeInteger(data.duration);
|
||||
const typeValue = normalizedText(data.type || "account", 32);
|
||||
if (
|
||||
typeValue !== "account" &&
|
||||
typeValue !== "ip" &&
|
||||
typeValue !== "machine"
|
||||
) {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
const type: "account" | "ip" | "machine" = typeValue;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const banExpire = duration > 0 ? now + duration * 3600 : 0;
|
||||
await db.insert(Ban).values({
|
||||
userId,
|
||||
userStaffId: context.capability.actor.id,
|
||||
timestamp: now,
|
||||
banExpire,
|
||||
banReason: reason,
|
||||
type,
|
||||
ip: normalizedText(data.ip, 255, false),
|
||||
machineId: "",
|
||||
});
|
||||
await rcon.disconnectUser(userId);
|
||||
snapshot = {
|
||||
before: { ...before, banned: false },
|
||||
after: { ...before, banned: true, banExpire, type, reason },
|
||||
};
|
||||
await notify({
|
||||
action: "ban",
|
||||
actor: context.capability.actor.username,
|
||||
target: target.username,
|
||||
details: reason,
|
||||
});
|
||||
} else if (operation === "user.unban") {
|
||||
await db.delete(Ban).where(eq(Ban.userId, userId));
|
||||
snapshot = {
|
||||
before: { ...before, banned: true },
|
||||
after: { ...before, banned: false },
|
||||
};
|
||||
await notify({
|
||||
action: "unban",
|
||||
actor: context.capability.actor.username,
|
||||
target: target.username,
|
||||
});
|
||||
} else if (operation === "user.alert") {
|
||||
const message = normalizedText(data.message, 500);
|
||||
await requireRcon(await rcon.alertUser(userId, message));
|
||||
snapshot = { before, after: { ...before, alertDelivered: true } };
|
||||
} else if (operation === "user.disconnect") {
|
||||
await requireRcon(await rcon.disconnectUser(userId));
|
||||
snapshot = { before, after: { ...before, online: false } };
|
||||
await notify({
|
||||
action: "disconnect",
|
||||
actor: context.capability.actor.username,
|
||||
target: target.username,
|
||||
});
|
||||
} else if (operation === "user.mute") {
|
||||
const duration = nonNegativeInteger(data.duration);
|
||||
await requireRcon(await rcon.muteUser(userId, duration));
|
||||
snapshot = { before, after: { ...before, muted: true, duration } };
|
||||
} else if (operation === "user.unmute") {
|
||||
await requireRcon(await rcon.unmuteUser(userId));
|
||||
snapshot = { before, after: { ...before, muted: false } };
|
||||
} else if (operation === "user.reset-password") {
|
||||
const newPassword = crypto
|
||||
.randomBytes(12)
|
||||
.toString("base64url")
|
||||
.slice(0, 16);
|
||||
await db
|
||||
.update(User)
|
||||
.set({ password: await hashPassword(newPassword) })
|
||||
.where(eq(User.id, userId));
|
||||
invalidateLoginCache(target.username);
|
||||
snapshot = {
|
||||
before: { ...before, passwordReset: false },
|
||||
after: { ...before, passwordReset: true },
|
||||
output: { newPassword },
|
||||
};
|
||||
await notify({
|
||||
action: "user_edit",
|
||||
actor: context.capability.actor.username,
|
||||
target: target.username,
|
||||
details: "Password reset",
|
||||
});
|
||||
} else if (operation === "user.send-currency") {
|
||||
const amount = positiveInteger(data.amount);
|
||||
await requireRcon(await rcon.giveCredits(userId, amount));
|
||||
snapshot = { before, after: { ...before, creditsSent: amount } };
|
||||
} else {
|
||||
const untilUnix = nonNegativeInteger(data.untilUnix);
|
||||
const locked = untilUnix > 0;
|
||||
await db.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
.select({ id: Sanctions.id })
|
||||
.from(Sanctions)
|
||||
.where(eq(Sanctions.habboId, userId))
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
await tx
|
||||
.update(Sanctions)
|
||||
.set({
|
||||
tradeLockedUntil: untilUnix,
|
||||
...(locked ? { reason: "Trade lock (CMS)" } : {}),
|
||||
})
|
||||
.where(eq(Sanctions.id, existing.id));
|
||||
} else {
|
||||
await tx.insert(Sanctions).values({
|
||||
habboId: userId,
|
||||
tradeLockedUntil: untilUnix,
|
||||
reason: locked ? "Trade lock (CMS)" : "",
|
||||
});
|
||||
}
|
||||
await tx
|
||||
.update(UsersSettings)
|
||||
.set({
|
||||
canTrade: locked ? "0" : "1",
|
||||
...(locked
|
||||
? { tradelockAmount: sql`${UsersSettings.tradelockAmount} + 1` }
|
||||
: {}),
|
||||
})
|
||||
.where(eq(UsersSettings.userId, userId));
|
||||
});
|
||||
await rcon.setTradeLock(userId, locked);
|
||||
await rcon.alertUser(
|
||||
userId,
|
||||
locked
|
||||
? "Trading has been disabled by staff."
|
||||
: "Trading has been re-enabled by staff.",
|
||||
);
|
||||
if (target.online === "1") {
|
||||
await rcon.disconnectUser(userId, target.username);
|
||||
}
|
||||
snapshot = {
|
||||
before: { ...before, tradeLocked: !locked },
|
||||
after: { ...before, tradeLocked: locked, untilUnix },
|
||||
output: { userId, untilUnix },
|
||||
};
|
||||
}
|
||||
|
||||
await auditMutation(context, operation, "User", userId, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function executeBulkMutation(
|
||||
operation: Extract<PeopleMutationOperation, `users.${string}`>,
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const ids = userIds(data.userIds);
|
||||
const failures: Array<{ userId: number; reason: string }> = [];
|
||||
let completed = 0;
|
||||
let mutationReason: string | undefined;
|
||||
|
||||
if (operation === "users.bulk-unban") {
|
||||
const result = await db.delete(Ban).where(inArray(Ban.userId, ids));
|
||||
completed = Number(
|
||||
(result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
|
||||
);
|
||||
} else if (operation === "users.bulk-ban") {
|
||||
const reason = normalizedText(data.reason, 500);
|
||||
mutationReason = reason;
|
||||
const durationSeconds = nonNegativeInteger(data.duration);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
for (const userId of ids) {
|
||||
try {
|
||||
await db.insert(Ban).values({
|
||||
userId,
|
||||
ip: "",
|
||||
machineId: "",
|
||||
userStaffId: context.capability.actor.id,
|
||||
timestamp: now,
|
||||
banExpire: durationSeconds > 0 ? now + durationSeconds : 0,
|
||||
banReason: reason,
|
||||
type: "account",
|
||||
});
|
||||
completed += 1;
|
||||
} catch {
|
||||
failures.push({ userId, reason: "Database error" });
|
||||
}
|
||||
}
|
||||
} else if (operation === "users.bulk-currency") {
|
||||
const amount = positiveInteger(data.amount);
|
||||
const type = normalizedText(data.type, 16) as
|
||||
| "credits"
|
||||
| "pixels"
|
||||
| "points";
|
||||
for (const userId of ids) {
|
||||
try {
|
||||
if (type === "credits") {
|
||||
await db
|
||||
.update(User)
|
||||
.set({ credits: sql`${User.credits} + ${amount}` })
|
||||
.where(eq(User.id, userId));
|
||||
await rcon.giveCredits(userId, amount);
|
||||
} else {
|
||||
const currencyType = type === "pixels" ? 0 : 101;
|
||||
await db
|
||||
.insert(UsersCurrency)
|
||||
.values({ userId, type: currencyType, amount })
|
||||
.onDuplicateKeyUpdate({
|
||||
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
|
||||
});
|
||||
if (type === "pixels") await rcon.giveDuckets(userId, amount);
|
||||
else await rcon.givePointsGotw(userId, amount);
|
||||
}
|
||||
completed += 1;
|
||||
} catch {
|
||||
failures.push({ userId, reason: "Database error" });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const badgeCode = normalizedText(data.badgeCode, 20);
|
||||
for (const userId of ids) {
|
||||
try {
|
||||
const [existing] = await db
|
||||
.select({ id: UsersBadges.id })
|
||||
.from(UsersBadges)
|
||||
.where(
|
||||
and(
|
||||
eq(UsersBadges.userId, userId),
|
||||
eq(UsersBadges.badgeCode, badgeCode),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) {
|
||||
const [aggregate] = await db
|
||||
.select({ maxSlot: max(UsersBadges.slotId) })
|
||||
.from(UsersBadges)
|
||||
.where(eq(UsersBadges.userId, userId));
|
||||
await db.insert(UsersBadges).values({
|
||||
userId,
|
||||
slotId: (aggregate?.maxSlot ?? 0) + 1,
|
||||
badgeCode,
|
||||
});
|
||||
await rcon.giveBadge(userId, badgeCode);
|
||||
}
|
||||
completed += 1;
|
||||
} catch {
|
||||
failures.push({ userId, reason: "Database error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = {
|
||||
before: { userIds: ids },
|
||||
after: {
|
||||
completed,
|
||||
total: ids.length,
|
||||
failedIds: failures,
|
||||
...(mutationReason ? { reason: mutationReason } : {}),
|
||||
},
|
||||
};
|
||||
await auditMutation(context, operation, "User", undefined, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function executeGuildDisband(
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const guildId = positiveInteger(record(input).guildId);
|
||||
const [guild] = await db
|
||||
.select({ id: Guilds.id, name: Guilds.name, userId: Guilds.userId })
|
||||
.from(Guilds)
|
||||
.where(eq(Guilds.id, guildId))
|
||||
.limit(1);
|
||||
if (!guild) {
|
||||
throw new PeopleMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
await db.transaction(async (tx) => {
|
||||
const threads = await tx
|
||||
.select({ id: GuildsForumsThreads.id })
|
||||
.from(GuildsForumsThreads)
|
||||
.where(eq(GuildsForumsThreads.guildId, guildId));
|
||||
const threadIds = threads.map((thread) => thread.id);
|
||||
if (threadIds.length > 0) {
|
||||
await tx
|
||||
.delete(GuildsForumsComments)
|
||||
.where(inArray(GuildsForumsComments.threadId, threadIds));
|
||||
await tx
|
||||
.delete(GuildsForumsThreads)
|
||||
.where(eq(GuildsForumsThreads.guildId, guildId));
|
||||
}
|
||||
await tx
|
||||
.delete(GuildForumViews)
|
||||
.where(eq(GuildForumViews.guildId, guildId));
|
||||
await tx.delete(GuildsMembers).where(eq(GuildsMembers.guildId, guildId));
|
||||
await tx
|
||||
.update(Rooms)
|
||||
.set({ guildId: 0 })
|
||||
.where(eq(Rooms.guildId, guildId));
|
||||
await tx
|
||||
.update(Items)
|
||||
.set({ guildId: 0 })
|
||||
.where(eq(Items.guildId, guildId));
|
||||
await tx.delete(Guilds).where(eq(Guilds.id, guildId));
|
||||
});
|
||||
const snapshot = {
|
||||
before: { id: guild.id, name: guild.name, userId: guild.userId },
|
||||
after: null,
|
||||
};
|
||||
await auditMutation(context, "guild.disband", "Guild", guildId, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function executeApplicationDecision(
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const applicationId = positiveInteger(data.applicationId);
|
||||
if (data.decision !== "dismiss") {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
const [application] = await db
|
||||
.select({
|
||||
id: WebsiteStaffApplications.id,
|
||||
userId: WebsiteStaffApplications.userId,
|
||||
rankId: WebsiteStaffApplications.rankId,
|
||||
content: WebsiteStaffApplications.content,
|
||||
})
|
||||
.from(WebsiteStaffApplications)
|
||||
.where(eq(WebsiteStaffApplications.id, BigInt(applicationId)))
|
||||
.limit(1);
|
||||
if (!application) {
|
||||
throw new PeopleMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
await db
|
||||
.delete(WebsiteStaffApplications)
|
||||
.where(eq(WebsiteStaffApplications.id, BigInt(applicationId)));
|
||||
const snapshot = {
|
||||
before: {
|
||||
id: Number(application.id),
|
||||
userId: application.userId,
|
||||
rankId: application.rankId,
|
||||
content: application.content,
|
||||
},
|
||||
after: null,
|
||||
};
|
||||
await auditMutation(
|
||||
context,
|
||||
"application.decide",
|
||||
"StaffApplication",
|
||||
applicationId,
|
||||
snapshot,
|
||||
);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function executeTeamChange(
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
if (data.action === "delete") {
|
||||
const teamId = positiveInteger(data.teamId);
|
||||
const [team] = await db
|
||||
.select({
|
||||
id: WebsiteTeams.id,
|
||||
rankName: WebsiteTeams.rankName,
|
||||
badge: WebsiteTeams.badge,
|
||||
jobDescription: WebsiteTeams.jobDescription,
|
||||
hiddenRank: WebsiteTeams.hiddenRank,
|
||||
})
|
||||
.from(WebsiteTeams)
|
||||
.where(eq(WebsiteTeams.id, BigInt(teamId)))
|
||||
.limit(1);
|
||||
if (!team) {
|
||||
throw new PeopleMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
await db.delete(WebsiteTeams).where(eq(WebsiteTeams.id, BigInt(teamId)));
|
||||
const snapshot = {
|
||||
before: { ...team, id: Number(team.id) },
|
||||
after: null,
|
||||
};
|
||||
await auditMutation(context, "team.change", "Team", teamId, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const rankName = normalizedText(data.rankName, 255);
|
||||
const badge = normalizedText(data.badge, 255, false);
|
||||
const jobDescription = normalizedText(data.jobDescription, 255, false);
|
||||
const staffColor = normalizedText(data.staffColor || "#327fa8", 255);
|
||||
const hiddenRank = Boolean(data.hiddenRank);
|
||||
const now = new Date();
|
||||
const [result] = await db.insert(WebsiteTeams).values({
|
||||
rankName,
|
||||
badge: badge || null,
|
||||
jobDescription: jobDescription || null,
|
||||
staffColor,
|
||||
hiddenRank,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const teamId = Number(result.insertId);
|
||||
const snapshot = {
|
||||
before: null,
|
||||
after: {
|
||||
teamId,
|
||||
rankName,
|
||||
badge: badge || null,
|
||||
jobDescription: jobDescription || null,
|
||||
staffColor,
|
||||
hiddenRank,
|
||||
},
|
||||
};
|
||||
await auditMutation(context, "team.change", "Team", teamId, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function executeIpAction(
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const action = normalizedText(data.action, 32);
|
||||
const blacklist = action.endsWith("blacklist");
|
||||
const adding = action.startsWith("add-");
|
||||
const table = blacklist ? WebsiteIpBlacklist : WebsiteIpWhitelist;
|
||||
if (adding) {
|
||||
const ipAddress = normalizedText(data.ipAddress, 255);
|
||||
const asn = normalizedText(data.asn, 255, false) || null;
|
||||
const [result] = blacklist
|
||||
? await db
|
||||
.insert(WebsiteIpBlacklist)
|
||||
.values({ ipAddress, asn, blacklistAsn: asn !== null })
|
||||
: await db
|
||||
.insert(WebsiteIpWhitelist)
|
||||
.values({ ipAddress, asn, whitelistAsn: asn !== null });
|
||||
const id = Number(result.insertId);
|
||||
const snapshot = { before: null, after: { id, action, ipAddress, asn } };
|
||||
await auditMutation(context, "ip.action", "IpRule", id, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
const id = positiveInteger(data.id);
|
||||
await db.delete(table).where(eq(table.id, BigInt(id)));
|
||||
const snapshot = { before: { id, action }, after: null };
|
||||
await auditMutation(context, "ip.action", "IpRule", id, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function writeVpnSetting(
|
||||
key: string,
|
||||
value: string,
|
||||
comment: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}
|
||||
|
||||
async function executeVpnConfiguration(
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const enabled = Boolean(data.enabled);
|
||||
const provider = normalizedText(data.provider, 32);
|
||||
const apiKey = normalizedText(data.apiKey, 255, false);
|
||||
const blockMessage = normalizedText(data.blockMessage, 255, false);
|
||||
const [storedEnabled, storedProvider, storedApiKey, storedBlockMessage] =
|
||||
await Promise.all([
|
||||
siteSettings.get("vpn_block_enabled"),
|
||||
siteSettings.get("vpn_provider"),
|
||||
siteSettings.get("vpn_api_key"),
|
||||
siteSettings.get("vpn_block_message"),
|
||||
]);
|
||||
const before = {
|
||||
enabled: storedEnabled === "1",
|
||||
provider: storedProvider ?? "none",
|
||||
apiKeyConfigured: Boolean(storedApiKey),
|
||||
blockMessage: storedBlockMessage ?? "",
|
||||
};
|
||||
await writeVpnSetting(
|
||||
"vpn_block_enabled",
|
||||
enabled ? "1" : "0",
|
||||
"Block registrations from detected VPN/proxy IPs (0=no, 1=yes)",
|
||||
);
|
||||
await writeVpnSetting(
|
||||
"vpn_provider",
|
||||
provider,
|
||||
"VPN/proxy detection provider (none/proxycheck/ipqualityscore)",
|
||||
);
|
||||
await writeVpnSetting(
|
||||
"vpn_api_key",
|
||||
apiKey,
|
||||
"API key for the VPN/proxy detection provider",
|
||||
);
|
||||
await writeVpnSetting(
|
||||
"vpn_block_message",
|
||||
blockMessage,
|
||||
"Message shown to users blocked for using a VPN/proxy",
|
||||
);
|
||||
await siteSettings.reload();
|
||||
const snapshot = {
|
||||
before,
|
||||
after: {
|
||||
enabled,
|
||||
provider,
|
||||
apiKeyConfigured: apiKey.length > 0,
|
||||
blockMessage,
|
||||
},
|
||||
};
|
||||
await auditMutation(
|
||||
context,
|
||||
"vpn.configure",
|
||||
"VpnConfiguration",
|
||||
undefined,
|
||||
snapshot,
|
||||
);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function executeWordFilterUpdate(
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
if (data.action === "add") {
|
||||
const word = normalizedText(data.word, 255);
|
||||
const [result] = (await db
|
||||
.insert(WebsiteWordfilter)
|
||||
.values({ word })) as unknown as [ResultSetHeader];
|
||||
const id = Number(result.insertId);
|
||||
reloadWordFilter();
|
||||
await rcon.updateWordFilter();
|
||||
const snapshot = { before: null, after: { id, word } };
|
||||
await auditMutation(
|
||||
context,
|
||||
"word-filter.update",
|
||||
"WordFilter",
|
||||
id,
|
||||
snapshot,
|
||||
);
|
||||
return snapshot;
|
||||
}
|
||||
const id = positiveInteger(data.id);
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteWordfilter.id, word: WebsiteWordfilter.word })
|
||||
.from(WebsiteWordfilter)
|
||||
.where(eq(WebsiteWordfilter.id, BigInt(id)))
|
||||
.limit(1);
|
||||
if (!existing) {
|
||||
throw new PeopleMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
await db
|
||||
.delete(WebsiteWordfilter)
|
||||
.where(eq(WebsiteWordfilter.id, BigInt(id)));
|
||||
reloadWordFilter();
|
||||
await rcon.updateWordFilter();
|
||||
const snapshot = {
|
||||
before: { id: Number(existing.id), word: existing.word },
|
||||
after: null,
|
||||
};
|
||||
await auditMutation(
|
||||
context,
|
||||
"word-filter.update",
|
||||
"WordFilter",
|
||||
id,
|
||||
snapshot,
|
||||
);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const productionPeopleMutationAdapter: PeopleMutationAdapter = {
|
||||
async execute(operation, input, context) {
|
||||
if (operation.startsWith("user.")) {
|
||||
return executeUserMutation(
|
||||
operation as Extract<PeopleMutationOperation, `user.${string}`>,
|
||||
input,
|
||||
context,
|
||||
);
|
||||
}
|
||||
if (operation.startsWith("users.")) {
|
||||
return executeBulkMutation(
|
||||
operation as Extract<PeopleMutationOperation, `users.${string}`>,
|
||||
input,
|
||||
context,
|
||||
);
|
||||
}
|
||||
if (operation === "guild.disband")
|
||||
return executeGuildDisband(input, context);
|
||||
if (operation === "application.decide") {
|
||||
return executeApplicationDecision(input, context);
|
||||
}
|
||||
if (operation === "team.change") return executeTeamChange(input, context);
|
||||
if (operation === "ip.action") return executeIpAction(input, context);
|
||||
if (operation === "vpn.configure") {
|
||||
return executeVpnConfiguration(input, context);
|
||||
}
|
||||
return executeWordFilterUpdate(input, context);
|
||||
},
|
||||
};
|
||||
|
||||
export const peopleMutationService = createPeopleMutationService(
|
||||
productionPeopleMutationAdapter,
|
||||
);
|
||||
Reference in new issue
Block a user