436 lines
13 KiB
TypeScript
436 lines
13 KiB
TypeScript
'use server'
|
|
|
|
import crypto from 'node:crypto'
|
|
import { hash } from 'bcryptjs'
|
|
import { z } from 'zod'
|
|
import { Prisma } from '@/generated/prisma/client'
|
|
import { PERMS } from '@/lib/permissions'
|
|
import { prisma } from '@/lib/prisma'
|
|
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'
|
|
|
|
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 hash(password, 12)
|
|
const now = Math.floor(Date.now() / 1000)
|
|
|
|
try {
|
|
const user = await prisma.$transaction(async (tx) => {
|
|
const created = await tx.user.create({
|
|
data: {
|
|
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',
|
|
},
|
|
})
|
|
|
|
await tx.usersSettings.create({ data: { userId: created.id } })
|
|
await tx.usersCurrency.createMany({
|
|
data: [
|
|
{ userId: created.id, type: 0, amount: 5000 },
|
|
{ userId: created.id, type: 5, amount: 5000 },
|
|
],
|
|
})
|
|
|
|
return created
|
|
})
|
|
|
|
logAudit({
|
|
userId: ctx.session.user.id,
|
|
action: 'user_create',
|
|
target: 'User',
|
|
targetId: user.id,
|
|
after: { username, mail, rank },
|
|
})
|
|
|
|
notify({
|
|
action: 'user_edit',
|
|
actor: ctx.session.user.username,
|
|
target: username,
|
|
targetId: user.id,
|
|
details: 'Account created by admin',
|
|
})
|
|
|
|
return actionOk({ id: user.id, username: user.username })
|
|
} catch (err) {
|
|
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
|
|
const target = (err.meta?.target as string[]) ?? []
|
|
if (target.includes('username')) throw new ActionError('Username already taken')
|
|
if (target.includes('mail')) throw new ActionError('Email already registered')
|
|
throw new ActionError('Username or email already in use')
|
|
}
|
|
throw err
|
|
}
|
|
},
|
|
)
|
|
|
|
const updateUserInput = updateUserSchema.extend({
|
|
id: z.coerce.number().int().positive(),
|
|
})
|
|
|
|
export const updateUser = adminAction(
|
|
{ permission: PERMS.USERS_EDIT, schema: updateUserInput },
|
|
async (ctx) => {
|
|
const { id, diamonds, duckets, ...userData } = ctx.data
|
|
|
|
const targetUser = await guardRank(id, ctx.session.user.rank)
|
|
|
|
if (
|
|
userData.rank !== undefined &&
|
|
userData.rank >= ctx.session.user.rank &&
|
|
ctx.session.user.rank < 7
|
|
) {
|
|
throw new ActionError('Cannot assign rank equal or higher than your own')
|
|
}
|
|
|
|
await prisma.user.update({ where: { id }, data: userData })
|
|
|
|
if (diamonds !== undefined) {
|
|
await prisma.usersCurrency.upsert({
|
|
where: { userId_type: { userId: id, type: 5 } },
|
|
update: { amount: diamonds },
|
|
create: { userId: id, type: 5, amount: diamonds },
|
|
})
|
|
}
|
|
if (duckets !== undefined) {
|
|
await prisma.usersCurrency.upsert({
|
|
where: { userId_type: { userId: id, type: 0 } },
|
|
update: { amount: duckets },
|
|
create: { userId: id, type: 0, amount: duckets },
|
|
})
|
|
}
|
|
|
|
logAudit({
|
|
userId: ctx.session.user.id,
|
|
action: 'user_edit',
|
|
target: 'User',
|
|
targetId: id,
|
|
before: { username: targetUser.username, mail: targetUser.mail, rank: targetUser.rank },
|
|
after: userData,
|
|
})
|
|
|
|
notify({
|
|
action: 'user_edit',
|
|
actor: ctx.session.user.username,
|
|
target: targetUser.username,
|
|
targetId: id,
|
|
})
|
|
|
|
return actionOk()
|
|
},
|
|
)
|
|
|
|
const banInput = banUserSchema.extend({})
|
|
|
|
export const banUser = adminAction(
|
|
{ permission: PERMS.USERS_BAN, schema: banInput },
|
|
async (ctx) => {
|
|
const { userId, reason, duration, type, ip } = ctx.data
|
|
|
|
const targetUser = await guardRank(userId, ctx.session.user.rank)
|
|
|
|
const now = Math.floor(Date.now() / 1000)
|
|
const banExpire = duration > 0 ? now + duration * 3600 : 0
|
|
|
|
await prisma.ban.create({
|
|
data: {
|
|
userId,
|
|
userStaffId: ctx.session.user.id,
|
|
timestamp: now,
|
|
banExpire,
|
|
banReason: reason,
|
|
type: type || 'account',
|
|
ip: ip || '',
|
|
machineId: '',
|
|
},
|
|
})
|
|
|
|
await rcon.disconnectUser(userId)
|
|
|
|
logAudit({
|
|
userId: ctx.session.user.id,
|
|
action: 'ban',
|
|
target: 'User',
|
|
targetId: userId,
|
|
after: { reason, type, duration },
|
|
})
|
|
|
|
notify({
|
|
action: 'ban',
|
|
actor: ctx.session.user.username,
|
|
target: targetUser.username,
|
|
details: reason,
|
|
})
|
|
|
|
return actionOk()
|
|
},
|
|
)
|
|
|
|
const unbanInput = z.object({ userId: z.coerce.number().int().positive() })
|
|
|
|
export const unbanUser = adminAction(
|
|
{ permission: PERMS.USERS_BAN, schema: unbanInput },
|
|
async (ctx) => {
|
|
const { userId } = ctx.data
|
|
|
|
const targetUser = await guardRank(userId, ctx.session.user.rank)
|
|
|
|
await prisma.ban.deleteMany({ where: { userId } })
|
|
|
|
logAudit({
|
|
userId: ctx.session.user.id,
|
|
action: 'unban',
|
|
target: 'User',
|
|
targetId: userId,
|
|
})
|
|
|
|
notify({
|
|
action: 'unban',
|
|
actor: ctx.session.user.username,
|
|
target: targetUser.username,
|
|
})
|
|
|
|
return actionOk()
|
|
},
|
|
)
|
|
|
|
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 prisma.usersBadges.findFirst({ where: { userId, badgeCode } })
|
|
if (existing) throw new ActionError('Badge already assigned')
|
|
|
|
await prisma.usersBadges.create({ data: { userId, badgeCode } })
|
|
await rcon.giveBadge(userId, badgeCode)
|
|
|
|
return actionOk()
|
|
},
|
|
)
|
|
|
|
// ── Remove Badge ────────────────────────────────────────────────────
|
|
|
|
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 prisma.usersBadges.findFirst({ where: { userId, badgeCode } })
|
|
if (!existing) throw new ActionError('Badge not found')
|
|
|
|
await prisma.usersBadges.delete({ where: { id: existing.id } })
|
|
await rcon.removeBadge(userId, badgeCode)
|
|
|
|
return actionOk()
|
|
},
|
|
)
|
|
|
|
// ── Rank guard helper ───────────────────────────────────────────────
|
|
|
|
async function guardRank(targetUserId: number, sessionRank: number) {
|
|
const target = await prisma.user.findUnique({
|
|
where: { id: targetUserId },
|
|
select: { username: true, rank: true, mail: true },
|
|
})
|
|
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
|
|
}
|
|
|
|
// ── Reset Password ──────────────────────────────────────────────────
|
|
|
|
const resetPasswordSchema = z.object({
|
|
userId: z.coerce.number().int().positive(),
|
|
})
|
|
|
|
export const resetPassword = adminAction(
|
|
{ permission: PERMS.USERS_RESET_PASSWORD, schema: resetPasswordSchema },
|
|
async (ctx) => {
|
|
const target = await guardRank(ctx.data.userId, ctx.session.user.rank)
|
|
|
|
const newPassword = crypto.randomBytes(12).toString('base64url').slice(0, 16)
|
|
const hashed = await hash(newPassword, 10)
|
|
|
|
await prisma.user.update({
|
|
where: { id: ctx.data.userId },
|
|
data: { password: hashed },
|
|
})
|
|
|
|
logAudit({
|
|
userId: ctx.session.user.id,
|
|
action: 'reset_password',
|
|
target: 'User',
|
|
targetId: ctx.data.userId,
|
|
})
|
|
|
|
notify({
|
|
action: 'user_edit',
|
|
actor: ctx.session.user.username,
|
|
target: target.username,
|
|
details: 'Password reset',
|
|
})
|
|
|
|
return actionOk({ newPassword })
|
|
},
|
|
)
|
|
|
|
// ── Disconnect User ─────────────────────────────────────────────────
|
|
|
|
const disconnectSchema = z.object({
|
|
userId: z.coerce.number().int().positive(),
|
|
})
|
|
|
|
export const disconnectUser = adminAction(
|
|
{ permission: PERMS.USERS_EDIT, schema: disconnectSchema },
|
|
async (ctx) => {
|
|
const target = await guardRank(ctx.data.userId, ctx.session.user.rank)
|
|
const success = await rcon.disconnectUser(ctx.data.userId)
|
|
if (!success) throw new ActionError('Failed to disconnect. Is the emulator running?')
|
|
|
|
logAudit({
|
|
userId: ctx.session.user.id,
|
|
action: 'user_disconnect',
|
|
target: 'User',
|
|
targetId: ctx.data.userId,
|
|
})
|
|
|
|
notify({
|
|
action: 'disconnect',
|
|
actor: ctx.session.user.username,
|
|
target: target.username,
|
|
})
|
|
|
|
return actionOk()
|
|
},
|
|
)
|
|
|
|
// ── Alert User (in-game message) ────────────────────────────────────
|
|
|
|
const alertUserSchema = z.object({
|
|
userId: z.coerce.number().int().positive(),
|
|
message: z.string().min(1).max(500),
|
|
})
|
|
|
|
export const alertUser = adminAction(
|
|
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
|
|
async (ctx) => {
|
|
const success = await rcon.alertUser(ctx.data.userId, ctx.data.message)
|
|
if (!success) throw new ActionError('Failed to send alert. Is the emulator running?')
|
|
return actionOk()
|
|
},
|
|
)
|
|
|
|
// ── Mute User ───────────────────────────────────────────────────────
|
|
|
|
const muteSchema = z.object({
|
|
userId: z.coerce.number().int().positive(),
|
|
duration: z.coerce.number().int().min(0).default(0),
|
|
})
|
|
|
|
export const muteUser = adminAction(
|
|
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
|
|
async (ctx) => {
|
|
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank)
|
|
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration)
|
|
if (!success) throw new ActionError('Failed to mute. Is the emulator running?')
|
|
|
|
logAudit({
|
|
userId: ctx.session.user.id,
|
|
action: 'user_mute',
|
|
target: 'User',
|
|
targetId: ctx.data.userId,
|
|
after: { duration: ctx.data.duration },
|
|
})
|
|
|
|
return actionOk()
|
|
},
|
|
)
|
|
|
|
// ── Unmute User ─────────────────────────────────────────────────────
|
|
|
|
const unmuteSchema = z.object({
|
|
userId: z.coerce.number().int().positive(),
|
|
})
|
|
|
|
export const unmuteUser = adminAction(
|
|
{ permission: PERMS.USERS_EDIT, schema: unmuteSchema },
|
|
async (ctx) => {
|
|
await guardRank(ctx.data.userId, ctx.session.user.rank)
|
|
const success = await rcon.unmuteUser(ctx.data.userId)
|
|
if (!success) throw new ActionError('Failed to unmute. Is the emulator running?')
|
|
|
|
logAudit({
|
|
userId: ctx.session.user.id,
|
|
action: 'user_unmute',
|
|
target: 'User',
|
|
targetId: ctx.data.userId,
|
|
})
|
|
|
|
return actionOk()
|
|
},
|
|
)
|
|
|
|
// ── Send Credits via RCON ───────────────────────────────────────────
|
|
|
|
const sendCreditsSchema = z.object({
|
|
userId: z.coerce.number().int().positive(),
|
|
amount: z.coerce.number().int().min(1).max(1000000),
|
|
})
|
|
|
|
export const sendCredits = adminAction(
|
|
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
|
|
async (ctx) => {
|
|
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank)
|
|
const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount)
|
|
if (!success) throw new ActionError('Failed to send credits. Is the emulator running?')
|
|
|
|
logAudit({
|
|
userId: ctx.session.user.id,
|
|
action: 'user_send_credits',
|
|
target: 'User',
|
|
targetId: ctx.data.userId,
|
|
after: { amount: ctx.data.amount },
|
|
})
|
|
|
|
return actionOk()
|
|
},
|
|
)
|