import { queryNnas } from '../../../utils/nnasDb' import { query as queryAuthDb } from '../../../utils/db' import { WebhookClient, EmbedBuilder } from 'discord.js' const FIELD_LABELS: Record = { 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 = { 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 = { '-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}` }) } })