diff --git a/src/commands/ban.ts b/src/commands/ban.ts index 82b6d98..b81ed34 100644 --- a/src/commands/ban.ts +++ b/src/commands/ban.ts @@ -1,6 +1,6 @@ -import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, GuildMember, InteractionContextType, MessageMentions, PermissionFlagsBits, SlashCommandBuilder, time, TimestampStyles } from 'discord.js'; +import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, Guild, GuildMember, InteractionContextType, MessageMentions, PermissionFlagsBits, SlashCommandBuilder, time, TimestampStyles, User } from 'discord.js'; import { Database } from '../shared/Database'; -import { deferInteraction } from '../utils'; +import { AltData, comprehensiveAltLookupFromDiscord, deferInteraction } from '../utils'; const mentionRegex = new RegExp(MessageMentions.UsersPattern); @@ -50,6 +50,12 @@ export default { .setRequired(false) .setMinValue(0) .setMaxValue(7) + ) + .addBooleanOption(option => + option + .setName('full-ban') + .setDescription('Whether or not to prevent the user from joining on known alts (and ban all existing alts).') + .setRequired(false) ), handler: async function (client: Client, interaction: ChatInputCommandInteraction) { await deferInteraction(interaction); @@ -74,6 +80,8 @@ export default { const deleteMessageDays = (interaction.options.getNumber('delete-message-days') ?? 0) * 86400; + const fullBan = interaction.options.getBoolean('full-ban') ?? false; + let banMember: GuildMember | null = null; try { @@ -94,11 +102,11 @@ export default { const expiresAt = new Date(Date.now() + duration); - if (duration > 0) await Database.putBan(idToUse, expiresAt); + await Database.putBan(idToUse, duration > 0 ? expiresAt : null, fullBan); try { await interaction.guild.bans.create(idToUse, { - reason: (reason + ` Banned by ${interaction.user.username} (${interaction.user.id})${duration > 0 ? `. Expires at: ${time(expiresAt, TimestampStyles.ShortDateTime)}` : ''}`).trim(), + reason: (reason + ` ${fullBan ? 'Full banned' : 'Banned'} by ${interaction.user.username} (${interaction.user.id})${duration > 0 ? `. Expires at: ${time(expiresAt, TimestampStyles.ShortDateTime)}` : ''}`).trim(), deleteMessageSeconds: deleteMessageDays }); } catch (e) { @@ -106,6 +114,31 @@ export default { return await interaction.editReply("Error banning user (couldn't ban)."); } - await interaction.editReply(`<@${idToUse}> (${idToUse}) has been banned.`); + if (fullBan) { + const alts = await comprehensiveAltLookupFromDiscord(idToUse, interaction.guild); + + await banAllAlts([alts], interaction.guild, interaction.user, fullBan, reason, deleteMessageDays, duration, expiresAt); + } + + await interaction.editReply(`<@${idToUse}> (${idToUse}) has been ${fullBan ? 'full banned' : 'banned'}.`); } -}; \ No newline at end of file +}; + +async function banAllAlts(altData: AltData[], guild: Guild, moderator: User, fullBan: boolean, reason: string, deleteMessageDays: number, duration: number, expiresAt: Date) { + for (const data of altData) { + if (data.type == 'discord') { + try { + if (!data.banned) { + await guild!.bans.create(data.thisId as string, { + reason: (reason + ` ${fullBan ? 'Full banned' : 'Banned'} by ${moderator.username} (${moderator.id})${duration > 0 ? `. Expires at: ${time(expiresAt, TimestampStyles.ShortDateTime)}` : ''}`).trim(), + deleteMessageSeconds: deleteMessageDays + }); + } + } catch (e) { + console.error(e); + } + } + + await banAllAlts(data.alts, guild, moderator, fullBan, reason, deleteMessageDays, duration, expiresAt); + } +} \ No newline at end of file diff --git a/src/shared/Database.ts b/src/shared/Database.ts index e57d933..0a0c578 100644 --- a/src/shared/Database.ts +++ b/src/shared/Database.ts @@ -79,7 +79,9 @@ const DB_SCHEMA = ` CREATE TABLE IF NOT EXISTS bans ( id INTEGER PRIMARY KEY, user_id TEXT, - expires_at datetime + expires INTEGER, + expires_at datetime, + full_ban INTEGER ); CREATE TABLE IF NOT EXISTS github_user_mapping ( @@ -371,16 +373,20 @@ export class Database { // -- START BANS -- - static async putBan(userId: string, expiresAt: Date) { - await Database.db.run('INSERT INTO bans(user_id, expires_at) VALUES (?, ?)', userId, expiresAt); + static async putBan(userId: string, expiresAt: Date | null, fullBan = false) { + await Database.db.run('INSERT INTO bans(user_id, expires, expires_at, full_ban) VALUES (?, ?, ?, ?)', userId, expiresAt != null ? 1 : 0, expiresAt, fullBan); + } + + static async getBan(userId: string): Promise { + return await Database.db.get('SELECT * from bans WHERE user_id = ? ORDER BY id DESC', userId); } static async getExpiredBans(date: Date): Promise { - return await Database.db.all('SELECT * from bans WHERE expires_at <= ?', date); + return await Database.db.all('SELECT * from bans WHERE expires = 1 AND expires_at <= ?', date); } static async pruneExpiredBans(date: Date) { - await Database.db.all('DELETE from bans WHERE expires_at <= ?', date); + await Database.db.all('DELETE from bans WHERE expires = 1 AND expires_at <= ?', date); } static async removeBan(userId: string) { diff --git a/src/types/database-types.d.ts b/src/types/database-types.d.ts index 80986f6..48ff513 100644 --- a/src/types/database-types.d.ts +++ b/src/types/database-types.d.ts @@ -51,7 +51,9 @@ export type Note = { export type Ban = { id: number user_id: string + expires: 0 | 1 expires_at: string + full_ban: 0 | 1 } export type GithubUserMapping = { diff --git a/src/utils/alt-utils.ts b/src/utils/alt-utils.ts index 4196405..7b9c14b 100644 --- a/src/utils/alt-utils.ts +++ b/src/utils/alt-utils.ts @@ -3,7 +3,7 @@ import { Database } from '../shared/Database'; import { userIsBanned } from './e621-utils'; import { config } from '../config'; -type AltData = { +export type AltData = { type: 'e621' | 'discord' thisId: number | string banned: boolean @@ -52,15 +52,15 @@ export async function getDiscordAlts(e621Id: number, guild: Guild, depth = 1, ig return content; } -export async function comprehensiveAltLookupFromDiscord(discordId: string, guild: Guild): Promise { +export async function comprehensiveAltLookupFromDiscord(discordId: string, guild: Guild | null): Promise { return getE621AltData(discordId, guild); } -export async function comprehensiveAltLookupFromE621(e621Id: number, guild: Guild): Promise { +export async function comprehensiveAltLookupFromE621(e621Id: number, guild: Guild | null): Promise { return getDiscordAltData(e621Id, guild); } -async function getE621AltData(discordId: string, guild: Guild, depth = 1, ignore: number[] = []): Promise { +async function getE621AltData(discordId: string, guild: Guild | null, depth = 1, ignore: number[] = []): Promise { const e621UserIds = await Database.getE621Ids(discordId); const toIgnore = ignore.concat(e621UserIds); @@ -69,7 +69,7 @@ async function getE621AltData(discordId: string, guild: Guild, depth = 1, ignore // It's either this or fetch all the bans and sift through them for every discord alt. try { - banned = !!(await guild.bans.fetch(discordId)); + banned = guild ? !!(await guild.bans.fetch(discordId)) : false; } catch (e) { } const data: AltData = { type: 'discord', thisId: discordId, banned, alts: [] }; @@ -83,7 +83,7 @@ async function getE621AltData(discordId: string, guild: Guild, depth = 1, ignore return data; } -async function getDiscordAltData(e621Id: number, guild: Guild, depth = 1, ignore: number[] = []): Promise { +async function getDiscordAltData(e621Id: number, guild: Guild | null, depth = 1, ignore: number[] = []): Promise { const discordIds = await Database.getDiscordIds(e621Id); const data: AltData = { type: 'e621', thisId: e621Id, banned: await userIsBanned(e621Id), alts: [] }; diff --git a/src/webserver/index.ts b/src/webserver/index.ts index 84072b2..0b62c5c 100644 --- a/src/webserver/index.ts +++ b/src/webserver/index.ts @@ -11,6 +11,7 @@ import { Client } from 'discord.js'; import bodyParser from 'body-parser'; import { fixPings } from '../utils/github-user-utils'; import { logDebug } from '../utils/debug-utils'; +import { AltData, comprehensiveAltLookupFromE621 } from '../utils'; declare module 'express-session' { interface SessionData { @@ -33,33 +34,51 @@ const oauth = new DiscordOAuth2({ credentials: Buffer.from(`${config.DISCORD_CLIENT_ID!}:${config.DISCORD_CLIENT_SECRET!}`).toString('base64') }); -async function joinGuild(code: string, userId: string, username: string) { - if (Number.isNaN(userId)) return false; - if (!username) return false; +const enum JoinResponse { + Success = 1, + Error = 2, + Banned = 3 +}; - const id = Number(userId); +async function joinGuild(code: string, userId: string, username: string): Promise { + let tokenResponse; + try { + if (Number.isNaN(userId)) return JoinResponse.Error; + if (!username) return JoinResponse.Error; - const tokenResponse = await oauth.tokenRequest({ - code, - scope: 'identify guilds.join', - grantType: 'authorization_code' - }); + const id = Number(userId); - const user = await oauth.getUser(tokenResponse.access_token); + tokenResponse = await oauth.tokenRequest({ + code, + scope: 'identify guilds.join', + grantType: 'authorization_code' + }); - await Database.putUser(id, user); + const user = await oauth.getUser(tokenResponse.access_token); - await oauth.addMember({ - accessToken: tokenResponse.access_token, - botToken: config.DISCORD_TOKEN!, - guildId: config.DISCORD_GUILD_ID!, - userId: user.id, - nickname: username - }); + await Database.putUser(id, user); - await oauth.revokeToken(tokenResponse.access_token); + const alts = await comprehensiveAltLookupFromE621(id, null); - return true; + if (await checkAltsForFullBans([alts])) return JoinResponse.Banned; + + await oauth.addMember({ + accessToken: tokenResponse.access_token, + botToken: config.DISCORD_TOKEN!, + guildId: config.DISCORD_GUILD_ID!, + userId: user.id, + nickname: username + }); + } catch (e: any) { + if (e.code == 40007) return JoinResponse.Banned; + console.error(`Error joining user (${userId}) to discord:`); + console.error(e); + return JoinResponse.Error; + } finally { + if (tokenResponse) await oauth.revokeToken(tokenResponse.access_token); + } + + return JoinResponse.Success; } async function handleInitial(req: Request, res: Response): Promise { @@ -124,9 +143,12 @@ async function handleCallback(req: Request, res: Response): Promise { }); try { - if (!await joinGuild(code, userId, username)) { + const response = await joinGuild(code, userId, username); + if (response == JoinResponse.Error) { console.error(`Error joining user: ${username} (${userId})`); return sendInteralServerError(res, 'Unable to join user to guild.'); + } else if (response == JoinResponse.Banned) { + return sendForbidden(res, 'User is banned.'); } } catch (e) { console.error(e); @@ -211,6 +233,23 @@ async function handleGithubRelease(client: Client, req: Request, res: Response): logDebug('Github webhook processed'); } +async function checkAltsForFullBans(altData: AltData[]): Promise { + for (const data of altData) { + if (data.type == 'discord') { + try { + const banData = await Database.getBan(data.thisId as string); + if (banData?.full_ban) return true; + } catch (e) { + console.error(e); + } + } + + if (await checkAltsForFullBans(data.alts)) return true; + } + + return false; +} + export function initializeWebserver(client: Client) { const app = express();