diff --git a/.env.example b/.env.example index f7793ed23..909bba5c1 100644 --- a/.env.example +++ b/.env.example @@ -27,8 +27,8 @@ VITE_STATIC_ASSETS_URL=https://sendou-assets.nyc3.cdn.digitaloceanspaces.com TWITCH_CLIENT_ID= TWITCH_CLIENT_SECRET= -// Discord webhook new user reports are posted to (skipped when unset) -USER_REPORT_DISCORD_WEBHOOK_URL= +// Discord webhook mod events (user reports, bans) are posted to (skipped when unset) +MOD_DISCORD_WEBHOOK_URL= SKALOP_SYSTEM_MESSAGE_URL=http://localhost:5900/system SKALOP_TOKEN=secret diff --git a/app/features/admin/actions/admin.server.ts b/app/features/admin/actions/admin.server.ts index be285ae14..70bb5a74e 100644 --- a/app/features/admin/actions/admin.server.ts +++ b/app/features/admin/actions/admin.server.ts @@ -8,12 +8,17 @@ import * as UserRepository from "~/features/user-page/UserRepository.server"; import { requireRole } from "~/modules/permissions/guards.server"; import { errorToast, + notFoundIfFalsy, parseRequestPayload, successToast, } from "~/utils/remix.server"; import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql"; import { assertUnreachable } from "~/utils/types"; import { _action, actualNumber, friendCode } from "~/utils/zod"; +import { + sendUserBannedWebhook, + sendUserUnbannedWebhook, +} from "../core/discord-webhook.server"; import { plusTiersFromVotingAndLeaderboard } from "../core/plus-tier.server"; export const action = async ({ request }: ActionFunctionArgs) => { @@ -124,21 +129,37 @@ export const action = async ({ request }: ActionFunctionArgs) => { case "BAN_USER": { requireRole("STAFF"); + const bannedUser = notFoundIfFalsy( + await UserRepository.findLeanById(data.user), + ); + const banExpiresAt = data.duration ? new Date(data.duration) : null; + await AdminRepository.banUser({ bannedReason: data.reason ?? null, userId: data.user, - banned: data.duration ? new Date(data.duration) : 1, + banned: banExpiresAt ?? 1, bannedByUserId: user.id, }); await refreshBannedCache(); + sendUserBannedWebhook({ + bannedUser, + bannedBy: user, + reason: data.reason ?? null, + expiresAt: banExpiresAt, + }); + message = "User banned"; break; } case "UNBAN_USER": { requireRole("STAFF"); + const unbannedUser = notFoundIfFalsy( + await UserRepository.findLeanById(data.user), + ); + await AdminRepository.unbanUser({ userId: data.user, unbannedByUserId: user.id, @@ -146,6 +167,11 @@ export const action = async ({ request }: ActionFunctionArgs) => { await refreshBannedCache(); + sendUserUnbannedWebhook({ + unbannedUser, + unbannedBy: user, + }); + message = "User unbanned"; break; } diff --git a/app/features/admin/core/discord-webhook.server.ts b/app/features/admin/core/discord-webhook.server.ts new file mode 100644 index 000000000..b1ecaeaf2 --- /dev/null +++ b/app/features/admin/core/discord-webhook.server.ts @@ -0,0 +1,70 @@ +import { + sendModDiscordWebhook, + truncateEmbedValue, + userAdminPageLink, + userPageLink, + type WebhookUser, +} from "~/modules/discord-webhook.server"; + +/** + * Posts a rich embed about a user getting banned to the mod channel Discord webhook. + * Fire-and-forget (see `sendModDiscordWebhook`). + */ +export function sendUserBannedWebhook(args: { + bannedUser: WebhookUser; + bannedBy: WebhookUser; + reason: string | null; + /** When the ban ends, null when the ban has no end date */ + expiresAt: Date | null; +}) { + sendModDiscordWebhook({ + title: "User banned", + fields: [ + { + name: "Banned user", + value: userAdminPageLink(args.bannedUser), + }, + { + name: "Banned by", + value: userPageLink(args.bannedBy), + }, + { + name: "Expires", + value: args.expiresAt + ? `` + : "No end date", + }, + ...(args.reason + ? [ + { + name: "Reason", + value: truncateEmbedValue(args.reason), + }, + ] + : []), + ], + }); +} + +/** + * Posts a rich embed about a user getting unbanned to the mod channel Discord webhook. + * Fire-and-forget (see `sendModDiscordWebhook`). + */ +export function sendUserUnbannedWebhook(args: { + unbannedUser: WebhookUser; + unbannedBy: WebhookUser; +}) { + sendModDiscordWebhook({ + title: "User unbanned", + fields: [ + { + name: "Unbanned user", + value: userAdminPageLink(args.unbannedUser), + }, + { + name: "Unbanned by", + value: userPageLink(args.unbannedBy), + }, + ], + }); +} diff --git a/app/features/user-report/core/discord-webhook.server.ts b/app/features/user-report/core/discord-webhook.server.ts index d003db9bc..cacc3f5d6 100644 --- a/app/features/user-report/core/discord-webhook.server.ts +++ b/app/features/user-report/core/discord-webhook.server.ts @@ -1,93 +1,58 @@ import type { UserReportCategory } from "~/db/tables"; -import { logger } from "~/utils/logger"; -import { SENDOU_INK_BASE_URL, sendouQMatchPage, userPage } from "~/utils/urls"; +import { + sendModDiscordWebhook, + truncateEmbedValue, + userAdminPageLink, + userPageLink, + type WebhookUser, +} from "~/modules/discord-webhook.server"; +import { SENDOU_INK_BASE_URL, sendouQMatchPage } from "~/utils/urls"; import { USER_REPORT_CATEGORY_LABELS } from "../user-report-constants"; -const EMBED_DESCRIPTION_MAX_LENGTH = 1000; - /** * Posts a rich embed about a new/updated user report to the mod channel Discord webhook. - * Fire-and-forget: meant to be called without awaiting, never throws, skipped with a log - * line when `USER_REPORT_DISCORD_WEBHOOK_URL` is unset (e.g. in development). + * Fire-and-forget (see `sendModDiscordWebhook`). */ export function sendUserReportWebhook(args: { - reportedUser: { id: number; username: string }; - reporter: { username: string; discordId: string; customUrl: string | null }; + reportedUser: WebhookUser; + reporter: WebhookUser; category: UserReportCategory; description: string; matchId: number | null; isUpdate: boolean; reportCounts: { lastMonth: number; lastYear: number }; }) { - const webhookUrl = process.env.USER_REPORT_DISCORD_WEBHOOK_URL; - if (!webhookUrl) { - logger.info( - "USER_REPORT_DISCORD_WEBHOOK_URL not set, skipping user report webhook", - ); - return; - } - - const reportedUserAdminUrl = `${SENDOU_INK_BASE_URL}/u/${args.reportedUser.id}/admin`; - const reporterUrl = `${SENDOU_INK_BASE_URL}${userPage(args.reporter)}`; - - const body = { - embeds: [ + sendModDiscordWebhook({ + title: args.isUpdate ? "User report updated" : "New user report", + fields: [ { - title: args.isUpdate ? "User report updated" : "New user report", - fields: [ - { - name: "Reported user", - value: `[${args.reportedUser.username}](${reportedUserAdminUrl})`, - }, - { - name: "Reporter", - value: `[${args.reporter.username}](${reporterUrl})`, - }, - { - name: "Category", - value: USER_REPORT_CATEGORY_LABELS[args.category], - }, - { - name: "Description", - value: truncate(args.description), - }, - ...(args.matchId !== null - ? [ - { - name: "SendouQ match", - value: `[#${args.matchId}](${SENDOU_INK_BASE_URL}${sendouQMatchPage(args.matchId)})`, - }, - ] - : []), - { - name: "Reports against this user", - value: `Last month: ${args.reportCounts.lastMonth} • Last year: ${args.reportCounts.lastYear}`, - }, - ], - timestamp: new Date().toISOString(), + name: "Reported user", + value: userAdminPageLink(args.reportedUser), + }, + { + name: "Reporter", + value: userPageLink(args.reporter), + }, + { + name: "Category", + value: USER_REPORT_CATEGORY_LABELS[args.category], + }, + { + name: "Description", + value: truncateEmbedValue(args.description), + }, + ...(args.matchId !== null + ? [ + { + name: "SendouQ match", + value: `[#${args.matchId}](${SENDOU_INK_BASE_URL}${sendouQMatchPage(args.matchId)})`, + }, + ] + : []), + { + name: "Reports against this user", + value: `Last month: ${args.reportCounts.lastMonth} • Last year: ${args.reportCounts.lastYear}`, }, ], - }; - - fetch(webhookUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }) - .then((response) => { - if (!response.ok) { - logger.error( - `User report webhook responded with status ${response.status}`, - ); - } - }) - .catch((error) => { - logger.error("Failed to send user report webhook", error); - }); -} - -function truncate(description: string) { - if (description.length <= EMBED_DESCRIPTION_MAX_LENGTH) return description; - - return `${description.slice(0, EMBED_DESCRIPTION_MAX_LENGTH)}…`; + }); } diff --git a/app/modules/discord-webhook.server.ts b/app/modules/discord-webhook.server.ts new file mode 100644 index 000000000..690a19816 --- /dev/null +++ b/app/modules/discord-webhook.server.ts @@ -0,0 +1,77 @@ +import { logger } from "~/utils/logger"; +import { SENDOU_INK_BASE_URL, userAdminPage, userPage } from "~/utils/urls"; + +const EMBED_FIELD_VALUE_MAX_LENGTH = 1000; + +interface ModWebhookEmbed { + title: string; + fields: Array<{ name: string; value: string }>; +} + +export interface WebhookUser { + username: string; + discordId: string; + customUrl: string | null; +} + +/** + * Posts a rich embed to the mod channel Discord webhook. + * Fire-and-forget: meant to be called without awaiting, never throws, skipped with a log + * line when `MOD_DISCORD_WEBHOOK_URL` is unset (e.g. in development). + */ +export function sendModDiscordWebhook(embed: ModWebhookEmbed) { + const webhookUrl = process.env.MOD_DISCORD_WEBHOOK_URL; + if (!webhookUrl) { + logger.info( + "MOD_DISCORD_WEBHOOK_URL not set, skipping mod Discord webhook", + ); + return; + } + + const body = { + embeds: [{ ...embed, timestamp: new Date().toISOString() }], + allowed_mentions: { parse: [] }, + }; + + fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + .then((response) => { + if (!response.ok) { + logger.error( + `Mod Discord webhook responded with status ${response.status}`, + ); + } + }) + .catch((error) => { + logger.error("Failed to send mod Discord webhook", error); + }); +} + +/** + * Escapes then truncates free-text user input so it fits inside a Discord embed field + * value without breaking the embed's markdown formatting. + */ +export function truncateEmbedValue(text: string) { + const escaped = escapeMarkdown(text); + if (escaped.length <= EMBED_FIELD_VALUE_MAX_LENGTH) return escaped; + + return `${escaped.slice(0, EMBED_FIELD_VALUE_MAX_LENGTH)}…`; +} + +/** Markdown link to the user's admin tab (moderation actions & notes). */ +export function userAdminPageLink(user: WebhookUser) { + return `[${escapeMarkdown(user.username)}](${SENDOU_INK_BASE_URL}${userAdminPage(user)})`; +} + +/** Markdown link to the user's profile page. */ +export function userPageLink(user: WebhookUser) { + return `[${escapeMarkdown(user.username)}](${SENDOU_INK_BASE_URL}${userPage(user)})`; +} + +/** Backslash-escapes Discord markdown so user text can't forge links or break formatting. */ +function escapeMarkdown(text: string) { + return text.replace(/[\\`*_~|()[\]]/g, "\\$&"); +}