bunch of shit
This commit is contained in:
parent
3cf2ec38aa
commit
0f577eef4d
22 changed files with 2889 additions and 37 deletions
385
server/api/admin/users/[pid].put.ts
Normal file
385
server/api/admin/users/[pid].put.ts
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
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.spfn.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.spfn.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://admin.spfn.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}`
|
||||
})
|
||||
}
|
||||
})
|
||||
Loading…
Reference in a new issue