Handle 429 & add admin webhook support (#2775)

This commit is contained in:
Kalle 2026-01-25 20:17:04 +02:00 committed by GitHub
parent 57892fb571
commit e7feef15af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 87 additions and 2 deletions

View File

@ -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=

View File

@ -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"),
}),
]);

View File

@ -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<void> {
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);
}
}

View File

@ -112,7 +112,8 @@ function AdminActions() {
return (
<div className="stack lg">
{DANGEROUS_CAN_ACCESS_DEV_CONTROLS && <Seed />}
{DANGEROUS_CAN_ACCESS_DEV_CONTROLS ? <Seed /> : null}
{DANGEROUS_CAN_ACCESS_DEV_CONTROLS ? <TestAdminNotification /> : null}
{DANGEROUS_CAN_ACCESS_DEV_CONTROLS || isAdmin ? <Impersonate /> : null}
{isStaff ? <LinkPlayer /> : null}
@ -459,4 +460,21 @@ function Seed() {
);
}
function TestAdminNotification() {
const fetcher = useFetcher();
return (
<fetcher.Form method="post">
<h2>Test Admin Notification</h2>
<SubmitButton
type="submit"
_action="TEST_ADMIN_NOTIFICATION"
state={fetcher.state}
>
Send Test
</SubmitButton>
</fetcher.Form>
);
}
export const ErrorBoundary = Catcher;

View File

@ -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([