diff --git a/.env.example b/.env.example index ef1a361b5..0db0e2f87 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ SESSION_SECRET=secret // Auth https://discord.com/developers DISCORD_CLIENT_ID= DISCORD_CLIENT_SECRET= +DISCORD_ADMIN_WEBHOOK_URL= // Patreon integration to sync supporter status https://www.patreon.com/portal/registration/register-clients PATREON_ACCESS_TOKEN= diff --git a/app/features/admin/actions/admin.server.ts b/app/features/admin/actions/admin.server.ts index ed98cc0e3..02bc583d6 100644 --- a/app/features/admin/actions/admin.server.ts +++ b/app/features/admin/actions/admin.server.ts @@ -14,6 +14,7 @@ import { import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql"; import { assertUnreachable } from "~/utils/types"; import { _action, actualNumber, friendCode } from "~/utils/zod"; +import * as AdminNotifications from "../core/admin-notifications.server"; import { plusTiersFromVotingAndLeaderboard } from "../core/plus-tier.server"; export const action = async ({ request }: ActionFunctionArgs) => { @@ -167,6 +168,14 @@ export const action = async ({ request }: ActionFunctionArgs) => { message = "API access granted"; break; } + case "TEST_ADMIN_NOTIFICATION": { + requireRole(user, "ADMIN"); + + await AdminNotifications.send("Test notification from admin panel"); + + message = "Test notification sent"; + break; + } default: { assertUnreachable(data); } @@ -229,4 +238,7 @@ export const adminActionSchema = z.union([ _action: _action("API_ACCESS"), user: z.preprocess(actualNumber, z.number().positive()), }), + z.object({ + _action: _action("TEST_ADMIN_NOTIFICATION"), + }), ]); diff --git a/app/features/admin/core/admin-notifications.server.ts b/app/features/admin/core/admin-notifications.server.ts new file mode 100644 index 000000000..4f96a65db --- /dev/null +++ b/app/features/admin/core/admin-notifications.server.ts @@ -0,0 +1,29 @@ +import { logger } from "~/utils/logger"; + +const DISCORD_ADMIN_WEBHOOK_URL = process.env.DISCORD_ADMIN_WEBHOOK_URL; + +if (!DISCORD_ADMIN_WEBHOOK_URL) { + logger.info( + "DISCORD_ADMIN_WEBHOOK_URL not set, admin notifications disabled", + ); +} + +export async function send(message: string): Promise { + if (!DISCORD_ADMIN_WEBHOOK_URL) { + return; + } + + try { + const response = await fetch(DISCORD_ADMIN_WEBHOOK_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: message }), + }); + + if (!response.ok) { + logger.error(`Failed to send admin notification: ${response.status}`); + } + } catch (error) { + logger.error("Failed to send admin notification", error); + } +} diff --git a/app/features/admin/routes/admin.tsx b/app/features/admin/routes/admin.tsx index 9424ca465..3652fdcb9 100644 --- a/app/features/admin/routes/admin.tsx +++ b/app/features/admin/routes/admin.tsx @@ -112,7 +112,8 @@ function AdminActions() { return (
- {DANGEROUS_CAN_ACCESS_DEV_CONTROLS && } + {DANGEROUS_CAN_ACCESS_DEV_CONTROLS ? : null} + {DANGEROUS_CAN_ACCESS_DEV_CONTROLS ? : null} {DANGEROUS_CAN_ACCESS_DEV_CONTROLS || isAdmin ? : null} {isStaff ? : null} @@ -459,4 +460,21 @@ function Seed() { ); } +function TestAdminNotification() { + const fetcher = useFetcher(); + + return ( + +

Test Admin Notification

+ + Send Test + +
+ ); +} + export const ErrorBoundary = Catcher; diff --git a/app/features/auth/core/DiscordStrategy.server.ts b/app/features/auth/core/DiscordStrategy.server.ts index fc511a6ab..4e1121106 100644 --- a/app/features/auth/core/DiscordStrategy.server.ts +++ b/app/features/auth/core/DiscordStrategy.server.ts @@ -1,9 +1,13 @@ +import { add } from "date-fns"; import { OAuth2Strategy } from "remix-auth-oauth2"; import { z } from "zod"; +import * as AdminNotifications from "~/features/admin/core/admin-notifications.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import invariant from "~/utils/invariant"; import { logger } from "~/utils/logger"; +let discordApiCooldownUntil: number | null = null; + const partialDiscordUserSchema = z.object({ avatar: z.string().nullish(), discriminator: z.string(), @@ -25,11 +29,28 @@ const discordUserDetailsSchema = z.tuple([ partialDiscordUserSchema, partialDiscordConnectionsSchema, ]); +const discordRateLimitSchema = z.object({ + retry_after: z.number(), +}); export const DiscordStrategy = () => { const envVars = authEnvVars(); - const jsonIfOk = (res: Response) => { + const jsonIfOk = async (res: Response) => { + if (res.status === 429) { + const body = discordRateLimitSchema.safeParse(await res.clone().json()); + const retryAfterSeconds = body.success ? body.data.retry_after : 60; + discordApiCooldownUntil = add(new Date(), { + seconds: retryAfterSeconds, + }).getTime(); + logger.warn( + `Discord API rate limited, cooldown for ${retryAfterSeconds}s${body.success ? "" : " (failed to parse retry_after)"}`, + ); + AdminNotifications.send( + `Discord API rate limited, cooldown for ${retryAfterSeconds}s`, + ); + } + if (!res.ok) { throw new Error( `Auth related call failed with status code ${res.status}`, @@ -40,6 +61,10 @@ export const DiscordStrategy = () => { }; const fetchProfileViaDiscordApi = (token: string) => { + if (discordApiCooldownUntil && Date.now() < discordApiCooldownUntil) { + throw new Error("Discord API is rate limited"); + } + const authHeader: [string, string] = ["Authorization", `Bearer ${token}`]; return Promise.all([