feat(bots/discord): improve admin commands

- The reload command now properly reloads configuration changes by skipping the configuration cache
- The eval command now sends a file if the output is too long
- The eval command now restricts access to the bot token by removing it and sandboxing the input code execution
This commit is contained in:
PalmDevs
2024-09-25 06:30:51 +07:00
parent e0e40237fa
commit 0346741188
3 changed files with 79 additions and 20 deletions

View File

@@ -1,8 +1,12 @@
import { unlinkSync, writeFileSync } from 'fs'
import { join } from 'path'
import { inspect } from 'util' import { inspect } from 'util'
import { runInNewContext } from 'vm'
import { ApplicationCommandOptionType } from 'discord.js' import { ApplicationCommandOptionType } from 'discord.js'
import { AdminCommand } from '$/classes/Command' import { AdminCommand } from '$/classes/Command'
import { createSuccessEmbed } from '$/utils/discord/embeds' import { createSuccessEmbed } from '$/utils/discord/embeds'
import { parseDuration } from '$/utils/duration'
export default new AdminCommand({ export default new AdminCommand({
name: 'eval', name: 'eval',
@@ -18,26 +22,73 @@ export default new AdminCommand({
type: ApplicationCommandOptionType.Boolean, type: ApplicationCommandOptionType.Boolean,
required: false, required: false,
}, },
['inspect-depth']: {
description: 'How many times to recurse while formatting the object (default: 1)',
type: ApplicationCommandOptionType.Integer,
required: false,
},
timeout: {
description: 'Timeout for the evaluation (default: 10s)',
type: ApplicationCommandOptionType.String,
required: false,
},
}, },
async execute(context, trigger, { code, 'show-hidden': showHidden }) { async execute(context, trigger, { code, 'show-hidden': showHidden, timeout, ['inspect-depth']: inspectDepth }) {
// So it doesn't show up as unused, and we can use it in `code` const currentToken = context.discord.client.token
context const currentEnvToken = process.env['DISCORD_TOKEN']
context.discord.client.token = null
process.env['DISCORD_TOKEN'] = undefined
// This allows developers to access and modify the context object to apply changes
// to the bot while the bot is running, minus malicious actors getting the token to perform malicious actions
const output = await runInNewContext(
code,
{
...globalThis,
context,
},
{
timeout: parseDuration(timeout ?? '10s'),
filename: 'eval',
displayErrors: true,
},
)
context.discord.client.token = currentToken
process.env['DISCORD_TOKEN'] = currentEnvToken
const inspectedOutput = inspect(output, {
depth: inspectDepth ?? 1,
showHidden,
getters: showHidden,
numericSeparator: true,
showProxy: showHidden,
})
const embed = createSuccessEmbed('Evaluate', `\`\`\`js\n${code}\`\`\``)
const files: string[] = []
const filepath = join(Bun.main, '..', `output-eval-${Date.now()}.js`)
if (inspectedOutput.length > 1000) {
writeFileSync(filepath, inspectedOutput)
files.push(filepath)
embed.addFields({
name: 'Result',
value: '```js\n// (output too long, file uploaded)```',
})
} else
embed.addFields({
name: 'Result',
value: `\`\`\`js\n${inspectedOutput}\`\`\``,
})
await trigger.reply({ await trigger.reply({
ephemeral: true, ephemeral: true,
embeds: [ embeds: [embed],
createSuccessEmbed('Evaluate', `\`\`\`js\n${code}\`\`\``).addFields({ files,
name: 'Result',
// biome-ignore lint/security/noGlobalEval: This is fine as it's an admin command
value: `\`\`\`js\n${inspect(await eval(code), {
depth: 1,
showHidden,
getters: true,
numericSeparator: true,
showProxy: true,
})}\`\`\``,
}),
],
}) })
if (files.length) unlinkSync(filepath)
}, },
}) })

View File

@@ -11,7 +11,10 @@ export default new AdminCommand({
description: 'The type of exception to throw', description: 'The type of exception to throw',
type: ApplicationCommandOptionType.String, type: ApplicationCommandOptionType.String,
required: true, required: true,
choices: Object.keys(CommandErrorType).map(k => ({ name: k, value: k })), choices: [
{ name: 'Process', value: 'Process' },
...Object.keys(CommandErrorType).map(k => ({ name: k, value: k })),
],
}, },
}, },
async execute(_, __, { type }) { async execute(_, __, { type }) {

View File

@@ -1,5 +1,5 @@
import { dirname, join } from 'path'
import { AdminCommand } from '$/classes/Command' import { AdminCommand } from '$/classes/Command'
import { join, dirname } from 'path'
import type { Config } from 'config.schema' import type { Config } from 'config.schema'
@@ -8,7 +8,12 @@ export default new AdminCommand({
description: 'Reload configuration', description: 'Reload configuration',
async execute(context, trigger) { async execute(context, trigger) {
const { api, logger, discord } = context const { api, logger, discord } = context
context.config = ((await import(join(dirname(Bun.main), '..', 'config.js'))) as { default: Config }).default logger.info(`Reload triggered by ${context.executor.tag} (${context.executor.id})`)
// Apparently the query strings only work with non-Windows "URLs", otherwise it'd just infinitely hang
const path = `${Bun.pathToFileURL(join(dirname(Bun.main), '..', 'config.js')).toString()}?cache=${Date.now()}`
logger.debug(`Reloading configuration from: ${path}`)
context.config = ((await import(path)) as { default: Config }).default
if ('deferReply' in trigger) await trigger.deferReply({ ephemeral: true }) if ('deferReply' in trigger) await trigger.deferReply({ ephemeral: true })
@@ -32,6 +37,6 @@ export default new AdminCommand({
await discord.client.login(process.env['DISCORD_TOKEN']) await discord.client.login(process.env['DISCORD_TOKEN'])
// @ts-expect-error: TypeScript dum // @ts-expect-error: TypeScript dum
await trigger[('deferReply' in trigger ? 'editReply' : 'reply')]({ content: 'Reloaded configuration' }) await trigger['deferReply' in trigger ? 'editReply' : 'reply']({ content: 'Reloaded configuration' })
}, },
}) })