291 lines
8.6 KiB
TypeScript
291 lines
8.6 KiB
TypeScript
"use server";
|
|
|
|
import { and, eq } from "drizzle-orm";
|
|
import { z } from "zod";
|
|
import {
|
|
createPeopleMutationInvocation,
|
|
type PeopleMutationOperation,
|
|
peopleMutationService,
|
|
} from "@/features/housekeeping/domains/people/services/mutations";
|
|
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
|
import { hashPassword } from "@/lib/auth/password";
|
|
import { db, User, UsersBadges, UsersCurrency, UsersSettings } from "@/lib/db";
|
|
import { PERMS } from "@/lib/permissions";
|
|
import { adminAction } from "@/lib/safe-action";
|
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
|
import { logAudit } from "@/lib/services/audit";
|
|
import { rcon } from "@/lib/services/rcon";
|
|
import { notify } from "@/lib/services/webhook";
|
|
import {
|
|
banUserSchema,
|
|
createUserSchema,
|
|
giveBadgeSchema,
|
|
updateUserSchema,
|
|
} from "@/lib/validators/user";
|
|
|
|
const DEFAULT_LOOK =
|
|
"hr-115-42.hd-195-19.ch-3030-82.lg-275-1408.fa-1201.ca-1804-64";
|
|
|
|
function isDuplicateKey(err: unknown): boolean {
|
|
if (!err || typeof err !== "object") return false;
|
|
const error = err as { code?: string | number; errno?: number };
|
|
return (
|
|
error.code === "P2002" ||
|
|
error.code === "ER_DUP_ENTRY" ||
|
|
error.errno === 1062
|
|
);
|
|
}
|
|
|
|
function duplicateField(err: unknown): "username" | "mail" | null {
|
|
if (!isDuplicateKey(err)) return null;
|
|
const error = err as { message?: string; meta?: { target?: string[] } };
|
|
const target = error.meta?.target ?? [];
|
|
if (target.includes("username")) return "username";
|
|
if (target.includes("mail")) return "mail";
|
|
const message = error.message ?? "";
|
|
if (message.includes("username")) return "username";
|
|
if (message.includes("mail")) return "mail";
|
|
return null;
|
|
}
|
|
|
|
export const createUser = adminAction(
|
|
{ permission: PERMS.USERS_EDIT, schema: createUserSchema },
|
|
async (ctx) => {
|
|
const { username, mail, password, rank, motto } = ctx.data;
|
|
if (rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
|
|
throw new ActionError("Cannot assign rank equal or higher than your own");
|
|
}
|
|
const hashedPassword = await hashPassword(password);
|
|
const now = Math.floor(Date.now() / 1000);
|
|
try {
|
|
const user = await db.transaction(async (tx) => {
|
|
const [result] = await tx.insert(User).values({
|
|
username,
|
|
mail,
|
|
password: hashedPassword,
|
|
rank,
|
|
motto: motto || "I'm new here!",
|
|
look: DEFAULT_LOOK,
|
|
credits: 5000,
|
|
pixels: 5000,
|
|
accountCreated: now,
|
|
ipRegister: "0.0.0.0",
|
|
ipCurrent: "0.0.0.0",
|
|
});
|
|
const id = Number(result.insertId);
|
|
await tx.insert(UsersSettings).values({ userId: id });
|
|
await tx.insert(UsersCurrency).values([
|
|
{ userId: id, type: 0, amount: 5000 },
|
|
{ userId: id, type: 5, amount: 5000 },
|
|
]);
|
|
return { id, username };
|
|
});
|
|
void logAudit({
|
|
userId: ctx.session.user.id,
|
|
action: "user_create",
|
|
target: "User",
|
|
targetId: user.id,
|
|
after: { username, mail, rank },
|
|
});
|
|
void notify({
|
|
action: "user_edit",
|
|
actor: ctx.session.user.username,
|
|
target: username,
|
|
targetId: user.id,
|
|
details: "Account created by admin",
|
|
});
|
|
return actionOk(user);
|
|
} catch (error) {
|
|
const field = duplicateField(error);
|
|
if (field === "username") throw new ActionError("Username already taken");
|
|
if (field === "mail") throw new ActionError("Email already registered");
|
|
if (isDuplicateKey(error))
|
|
throw new ActionError("Username or email already in use");
|
|
throw error;
|
|
}
|
|
},
|
|
);
|
|
|
|
const legacyMessages: Partial<Record<PeopleMutationOperation, string>> = {
|
|
"user.alert": "Failed to send alert. Is the emulator running?",
|
|
"user.disconnect": "Failed to disconnect. Is the emulator running?",
|
|
"user.mute": "Failed to mute. Is the emulator running?",
|
|
"user.unmute": "Failed to unmute. Is the emulator running?",
|
|
"user.send-currency": "Failed to send credits. Is the emulator running?",
|
|
};
|
|
|
|
async function executeLegacy(
|
|
ctx: { session: { user: { id: number; username: string; rank: number } } },
|
|
operation: PeopleMutationOperation,
|
|
input: unknown,
|
|
) {
|
|
const result = await peopleMutationService.execute(
|
|
createPeopleMutationInvocation(ctx.session.user, createCorrelationId()),
|
|
operation,
|
|
input,
|
|
);
|
|
if (!result.ok) {
|
|
if (result.error.code === "NOT_FOUND")
|
|
throw new ActionError("User not found");
|
|
if (result.error.code === "FORBIDDEN") {
|
|
throw new ActionError("Cannot modify user with equal or higher rank");
|
|
}
|
|
throw new ActionError(legacyMessages[operation] ?? "User action failed");
|
|
}
|
|
return result.data;
|
|
}
|
|
|
|
const updateUserInput = updateUserSchema.extend({
|
|
id: z.coerce.number().int().positive(),
|
|
});
|
|
|
|
export const updateUser = adminAction(
|
|
{ permission: PERMS.USERS_EDIT, schema: updateUserInput },
|
|
async (ctx) => {
|
|
const { id: userId, ...fields } = ctx.data;
|
|
await executeLegacy(ctx, "user.update", {
|
|
userId,
|
|
fields,
|
|
});
|
|
return actionOk();
|
|
},
|
|
);
|
|
|
|
export const banUser = adminAction(
|
|
{ permission: PERMS.USERS_BAN, schema: banUserSchema },
|
|
async (ctx) => {
|
|
await executeLegacy(ctx, "user.ban", ctx.data);
|
|
return actionOk();
|
|
},
|
|
);
|
|
|
|
const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
|
|
|
|
export const unbanUser = adminAction(
|
|
{ permission: PERMS.USERS_BAN, schema: userIdSchema },
|
|
async (ctx) => {
|
|
await executeLegacy(ctx, "user.unban", ctx.data);
|
|
return actionOk();
|
|
},
|
|
);
|
|
|
|
export const resetPassword = adminAction(
|
|
{ permission: PERMS.USERS_RESET_PASSWORD, schema: userIdSchema },
|
|
async (ctx) => {
|
|
const snapshot = await executeLegacy(ctx, "user.reset-password", ctx.data);
|
|
return actionOk({
|
|
newPassword: String(snapshot.output?.newPassword ?? ""),
|
|
});
|
|
},
|
|
);
|
|
|
|
export const disconnectUser = adminAction(
|
|
{ permission: PERMS.USERS_EDIT, schema: userIdSchema },
|
|
async (ctx) => {
|
|
await executeLegacy(ctx, "user.disconnect", ctx.data);
|
|
return actionOk();
|
|
},
|
|
);
|
|
|
|
const alertUserSchema = userIdSchema.extend({
|
|
message: z.string().min(1).max(500),
|
|
});
|
|
export const alertUser = adminAction(
|
|
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
|
|
async (ctx) => {
|
|
await executeLegacy(ctx, "user.alert", ctx.data);
|
|
return actionOk();
|
|
},
|
|
);
|
|
|
|
const muteSchema = userIdSchema.extend({
|
|
duration: z.coerce.number().int().min(0).default(0),
|
|
});
|
|
export const muteUser = adminAction(
|
|
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
|
|
async (ctx) => {
|
|
await executeLegacy(ctx, "user.mute", ctx.data);
|
|
return actionOk();
|
|
},
|
|
);
|
|
|
|
export const unmuteUser = adminAction(
|
|
{ permission: PERMS.USERS_EDIT, schema: userIdSchema },
|
|
async (ctx) => {
|
|
await executeLegacy(ctx, "user.unmute", ctx.data);
|
|
return actionOk();
|
|
},
|
|
);
|
|
|
|
const sendCreditsSchema = userIdSchema.extend({
|
|
amount: z.coerce.number().int().min(1).max(1_000_000),
|
|
});
|
|
export const sendCredits = adminAction(
|
|
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
|
|
async (ctx) => {
|
|
await executeLegacy(ctx, "user.send-currency", ctx.data);
|
|
return actionOk();
|
|
},
|
|
);
|
|
|
|
async function guardRank(targetUserId: number, sessionRank: number) {
|
|
const [target] = await db
|
|
.select({ username: User.username, rank: User.rank, mail: User.mail })
|
|
.from(User)
|
|
.where(eq(User.id, targetUserId))
|
|
.limit(1);
|
|
if (!target) throw new ActionError("User not found");
|
|
if (target.rank >= sessionRank && sessionRank < 7) {
|
|
throw new ActionError("Cannot modify user with equal or higher rank");
|
|
}
|
|
return target;
|
|
}
|
|
|
|
export const giveBadge = adminAction(
|
|
{ permission: PERMS.USERS_EDIT, schema: giveBadgeSchema },
|
|
async (ctx) => {
|
|
const { userId, badgeCode } = ctx.data;
|
|
await guardRank(userId, ctx.session.user.rank);
|
|
const [existing] = await db
|
|
.select({ id: UsersBadges.id })
|
|
.from(UsersBadges)
|
|
.where(
|
|
and(
|
|
eq(UsersBadges.userId, userId),
|
|
eq(UsersBadges.badgeCode, badgeCode),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (existing) throw new ActionError("Badge already assigned");
|
|
await db.insert(UsersBadges).values({ userId, badgeCode });
|
|
await rcon.giveBadge(userId, badgeCode);
|
|
return actionOk();
|
|
},
|
|
);
|
|
|
|
const removeBadgeSchema = z.object({
|
|
userId: z.coerce.number().int().positive(),
|
|
badgeCode: z.string().min(1),
|
|
});
|
|
export const removeBadge = adminAction(
|
|
{ permission: PERMS.USERS_EDIT, schema: removeBadgeSchema },
|
|
async (ctx) => {
|
|
const { userId, badgeCode } = ctx.data;
|
|
await guardRank(userId, ctx.session.user.rank);
|
|
const [existing] = await db
|
|
.select({ id: UsersBadges.id })
|
|
.from(UsersBadges)
|
|
.where(
|
|
and(
|
|
eq(UsersBadges.userId, userId),
|
|
eq(UsersBadges.badgeCode, badgeCode),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!existing) throw new ActionError("Badge not found");
|
|
await db.delete(UsersBadges).where(eq(UsersBadges.id, existing.id));
|
|
await rcon.removeBadge(userId, badgeCode);
|
|
return actionOk();
|
|
},
|
|
);
|