mod-portal-v2/server/api/admin/users/[pid].put.ts
2026-08-09 21:57:29 +02:00

385 lines
No EOL
11 KiB
TypeScript

import { queryNnas } from '../../../utils/nnasDb'
import { query as queryAuthDb } from '../../../utils/db'
import { WebhookClient, EmbedBuilder } from 'discord.js'
const FIELD_LABELS: Record<string, string> = {
account_level: 'access level',
country: 'Country',
language: 'Language',
timezone: 'Timezone',
gender: 'Gender',
email: 'Email',
marketing_allowed: 'Marketing Allowed',
off_device_allowed: 'Off-Device Allowed'
}
const FIELD_ROLES: Record<string, string> = {
email: 'can-edit-email',
country: 'can-edit-country',
language: 'can-edit-language',
timezone: 'can-edit-timezone',
gender: 'can-edit-gender',
account_level: 'can-edit-account_level',
marketing_allowed: 'can-edit-marketing_allowed',
off_device_allowed: 'can-edit-off_device_allowed'
}
const ACCOUNT_LEVEL_NAMES: Record<number, string> = {
'-3': 'Omey Banned (-3)',
'-2': 'Inf Eula Banned (-2)',
'-1': 'Banned (-1)',
'0': 'Member (0)',
'1': 'Tester (1)',
'2': 'Moderator (2)',
'3': 'Admin / Developer (3)',
'4': 'Manager (4)'
}
const countryNames = new Intl.DisplayNames(['en'], { type: 'region' })
const languageNames = new Intl.DisplayNames(['en'], { type: 'language' })
function decodeJwtPayload(token: string) {
try {
const base64Url = token.split('.')[1]
if (!base64Url) return null
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
const jsonPayload = Buffer.from(base64, 'base64').toString('utf-8')
return JSON.parse(jsonPayload)
} catch {
return null
}
}
function formatValue(field: string, val: any): string {
if (val === null || val === undefined || val === '') return 'N/A'
if (field === 'account_level') {
return ACCOUNT_LEVEL_NAMES[Number(val)] || `Level ${val}`
}
if (field === 'country') {
try {
return countryNames.of(String(val).toUpperCase()) || String(val)
} catch {
return String(val)
}
}
if (field === 'language') {
try {
return languageNames.of(String(val).toLowerCase()) || String(val)
} catch {
return String(val)
}
}
if (typeof val === 'boolean') {
return val ? 'True' : 'False'
}
return String(val)
}
async function sendDiscordAuditLog({
webhookUrl,
editorSfid,
editorPid,
targetPid,
targetName,
auditLogId,
panelBaseUrl,
changes,
changeReason,
modNote
}: {
webhookUrl: string
editorSfid: string
editorPid: string
targetPid: string
targetName: string
auditLogId: string
panelBaseUrl: string
changes: Array<{ field: string; oldVal: any; newVal: any }>
changeReason?: string
modNote?: string
}) {
if (!webhookUrl || changes.length === 0) return
try {
const webhookClient = new WebhookClient({ url: webhookUrl })
const editorMiiUrl = editorPid
? `https://mii.spbr.net/${editorPid}/main.png`
: 'https://git.spbr.net/spacebar/mod-portal-v2/raw/branch/main/public/img_unknown_MiiIcon.png'
const targetMiiUrl = targetPid
? `https://mii.spbr.net/${targetPid}/main.png`
: 'https://git.spbr.net/spacebar/mod-portal-v2/raw/branch/main/public/img_unknown_MiiIcon.png'
const fields = changes.map((c, index) => {
const paddedIndex = String(index + 1).padStart(2, '0')
const label = FIELD_LABELS[c.field] || c.field
const formattedOld = formatValue(c.field, c.oldVal)
const formattedNew = formatValue(c.field, c.newVal)
return {
name: `\`${paddedIndex}\` - Updated ${label}`,
value: `> Was: \`${formattedOld}\`\nNow: \`${formattedNew}\``,
inline: true
}
})
if (changeReason) {
fields.push({
name: 'Reason for Change',
value: `> ${changeReason}`,
inline: false
})
}
if (modNote) {
fields.push({
name: 'Mod Note',
value: `> ${modNote}`,
inline: false
})
}
let safeOrigin = panelBaseUrl
if (!safeOrigin || safeOrigin === 'http://' || safeOrigin === 'https://') {
safeOrigin = 'https://mod.spbr.net'
} else if (!safeOrigin.startsWith('http://') && !safeOrigin.startsWith('https://')) {
safeOrigin = `http://${safeOrigin}`
}
const logUrl = new URL(`/audit/log/${auditLogId}/`, safeOrigin).href
const embed = new EmbedBuilder()
.setAuthor({
name: editorSfid,
iconURL: editorMiiUrl
})
.setTitle(`${editorSfid} updated ${targetName}`)
.setThumbnail(targetMiiUrl)
.setDescription(`[:link: View log on panel](${logUrl})`)
.addFields(fields)
.setFooter({
text: 'Spacebar Network',
iconURL: 'https://git.spbr.net/spacebar/website/raw/branch/master/public/spbr-coloured.png'
})
.setTimestamp()
await webhookClient.send({
embeds: [embed]
})
} catch (err) {
console.error('Failed to dispatch Discord Audit Log via discord.js:', err)
}
}
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const discordWebhookUrl = config.discordWebhookUrl || process.env.DISCORD_WEBHOOK_URL
const panelBaseUrl = config.public?.siteUrl || process.env.SITE_URL
const pid = getRouterParam(event, 'pid')
if (!pid) {
throw createError({
statusCode: 400,
statusMessage: 'PID parameter is required.'
})
}
const authHeader = getHeader(event, 'authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized: Missing or invalid Authorization token.'
})
}
const token = authHeader.split(' ')[1]
const decoded = decodeJwtPayload(token)
if (!decoded) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized: Token payload invalid.'
})
}
let editorSfid = decoded?.preferred_username || decoded?.name || 'Unknown Admin'
let editorPid = decoded?.pid || null
const keycloakId = decoded?.sub
const editorEmail = decoded?.email
try {
const editorRes = await queryAuthDb(
`SELECT sfid, pid FROM users WHERE keycloak_id = $1 OR email = $2 LIMIT 1`,
[keycloakId, editorEmail]
)
if (editorRes.rows.length > 0) {
editorSfid = editorRes.rows[0].sfid || editorSfid
editorPid = editorRes.rows[0].pid || editorPid
}
} catch (err: any) {
console.warn('Could not fetch editor author details from auth DB:', err.message)
}
const realmRoles = decoded?.realm_access?.roles || []
const clientRoles = Object.values(decoded?.resource_access || {})
.flatMap((client: any) => client.roles || [])
const userRoles = [...realmRoles, ...clientRoles].map(r => String(r).toLowerCase())
const body = await readBody(event)
let existingUser: any = null
try {
const existingRes = await queryNnas(`SELECT * FROM users WHERE pid = $1 LIMIT 1`, [pid])
if (existingRes.rows.length === 0) {
throw createError({ statusCode: 404, statusMessage: 'User not found.' })
}
existingUser = existingRes.rows[0]
} catch (err: any) {
if (err.statusCode === 404) throw err
console.warn('Could not fetch target user record from NNAS DB:', err.message)
}
const updates: string[] = []
const values: any[] = []
const auditChanges: Array<{ field: string; oldVal: any; newVal: any }> = []
let paramIndex = 1
const changeReason = body.change_reason ? String(body.change_reason).trim() : ''
const modNote = body.mod_note ? String(body.mod_note).trim() : ''
let newlyAppliedBanLevel: number | null = null
for (const [field, requiredRole] of Object.entries(FIELD_ROLES)) {
if (body[field] !== undefined) {
const hasPermission = userRoles.includes(requiredRole.toLowerCase())
if (!hasPermission) {
throw createError({
statusCode: 403,
statusMessage: `Forbidden: You do not have permission to edit the '${field}' field.`
})
}
const oldVal = existingUser ? existingUser[field] : null
const newVal = body[field]
if (String(oldVal) !== String(newVal)) {
const isTransitionToBanned = Number(oldVal) >= 0 && Number(newVal) < 0
if (field === 'account_level' && isTransitionToBanned) {
if (!changeReason) {
throw createError({
statusCode: 400,
statusMessage: 'A violation reason is required when banning an account.'
})
}
newlyAppliedBanLevel = Number(newVal)
}
updates.push(`${field} = $${paramIndex}`)
values.push(newVal)
paramIndex++
auditChanges.push({ field, oldVal, newVal })
}
}
}
if (updates.length === 0) {
return existingUser || { message: 'No fields were modified.' }
}
updates.push(`updated = NOW()`)
values.push(pid)
try {
const queryStr = `
UPDATE users
SET ${updates.join(', ')}
WHERE pid = $${paramIndex}
RETURNING *
`
const result = await queryNnas(queryStr, values)
if (result.rows.length === 0) {
throw createError({ statusCode: 404, statusMessage: 'User not found or update failed.' })
}
const updatedUser = result.rows[0]
const targetName = updatedUser.username || updatedUser.sfid || `User ${pid}`
const auditLogId = String(Date.now())
try {
await queryAuthDb(
`INSERT INTO audit_logs (log_id, editor_sfid, editor_pid, target_name, target_pid, changes)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
auditLogId,
editorSfid,
editorPid,
targetName,
Number(pid),
JSON.stringify({
changes: auditChanges,
reason: changeReason || null,
mod_note: modNote || null
})
]
)
} catch (err: any) {
console.error('Failed to save audit log to DB:', err.message)
}
if (newlyAppliedBanLevel !== null) {
try {
await queryAuthDb(
`INSERT INTO ban_logs (log_id, target_pid, target_name, editor_sfid, editor_pid, ban_level, reason, mod_note)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
auditLogId,
Number(pid),
targetName,
editorSfid,
editorPid,
newlyAppliedBanLevel,
changeReason,
modNote || null
]
)
} catch (err: any) {
console.error('Failed to save ban log to DB:', err.message)
}
}
if (discordWebhookUrl) {
sendDiscordAuditLog({
webhookUrl: discordWebhookUrl,
editorSfid,
editorPid,
targetPid: pid,
targetName,
auditLogId,
panelBaseUrl,
changes: auditChanges,
changeReason,
modNote
})
}
return updatedUser
} catch (error: any) {
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.statusMessage || `Database update error: ${error.message}`
})
}
})