From bbf3725acf8463b47dec8bb1c203682d67f1042b Mon Sep 17 00:00:00 2001 From: b3ni15 Date: Thu, 16 Oct 2025 13:18:25 +0200 Subject: [PATCH] refactor: Implement DiscordRateLimiter for API requests to manage rate limits --- src/app/api/cron/cleanup/route.ts | 29 +++++++----- src/app/api/delete/route.ts | 19 ++++---- .../[file_id]/[part_index]/route.ts | 19 ++++---- src/app/api/upload-part/route.ts | 19 +++++--- src/lib/discordRateLimiter.ts | 46 +++++++++++++++++++ 5 files changed, 94 insertions(+), 38 deletions(-) create mode 100644 src/lib/discordRateLimiter.ts diff --git a/src/app/api/cron/cleanup/route.ts b/src/app/api/cron/cleanup/route.ts index 6921367..2dca810 100644 --- a/src/app/api/cron/cleanup/route.ts +++ b/src/app/api/cron/cleanup/route.ts @@ -1,6 +1,9 @@ import { NextResponse } from 'next/server'; import pool from '@/lib/db'; import { RowDataPacket } from 'mysql2/promise'; +import { DiscordRateLimiter } from '@/lib/discordRateLimiter'; + +const DISCORD_API_BASE_URL = 'https://discord.com/api/v10'; // Or the appropriate version interface FileId { id: string; @@ -14,6 +17,8 @@ if (!process.env.DISCORD_BOT_TOKEN || !process.env.DISCORD_CHANNEL_ID || !proces throw new Error('Discord or Cron secret environment variables are not configured'); } +const rateLimiter = new DiscordRateLimiter(DISCORD_API_BASE_URL, process.env.DISCORD_BOT_TOKEN); + export async function POST(request: Request) { try { const authHeader = request.headers.get('authorization'); @@ -44,19 +49,19 @@ export async function POST(request: Request) { if (parts[0].length > 0) { for (const part of parts[0] as FilePart[]) { if (!part.discord_message_id) continue; - const deleteUrl = `https://discord.com/api/v10/channels/${process.env.DISCORD_CHANNEL_ID}/messages/${part.discord_message_id}`; try { - const res = await fetch(deleteUrl, { - method: 'DELETE', - headers: { 'Authorization': `Bot ${process.env.DISCORD_BOT_TOKEN}` }, - }); + const res = await rateLimiter.request( + `/channels/${process.env.DISCORD_CHANNEL_ID}/messages/${part.discord_message_id}`, + { + method: 'DELETE', + } + ); if (!res.ok && res.status !== 404) { console.warn(`Cleanup: Failed to delete message ${part.discord_message_id}. Status: ${res.status}`); } } catch (e) { console.error(`Cleanup: Error deleting message ${part.discord_message_id}:`, e); } - await new Promise(resolve => setTimeout(resolve, 1000)); // 1s delay } } @@ -83,19 +88,19 @@ export async function POST(request: Request) { if (partsRows[0].length > 0) { for (const part of partsRows[0] as FilePart[]) { if (!part.discord_message_id) continue; - const deleteUrl = `https://discord.com/api/v10/channels/${process.env.DISCORD_CHANNEL_ID}/messages/${part.discord_message_id}`; try { - const res = await fetch(deleteUrl, { - method: 'DELETE', - headers: { 'Authorization': `Bot ${process.env.DISCORD_BOT_TOKEN}` }, - }); + const res = await rateLimiter.request( + `/channels/${process.env.DISCORD_CHANNEL_ID}/messages/${part.discord_message_id}`, + { + method: 'DELETE', + } + ); if (!res.ok && res.status !== 404) { console.warn(`Cleanup: Failed to delete message ${part.discord_message_id}. Status: ${res.status}`); } } catch (e) { console.error(`Cleanup: Error deleting message ${part.discord_message_id}:`, e); } - await new Promise(resolve => setTimeout(resolve, 1000)); // 1s delay } } diff --git a/src/app/api/delete/route.ts b/src/app/api/delete/route.ts index 9acda5f..39dc482 100644 --- a/src/app/api/delete/route.ts +++ b/src/app/api/delete/route.ts @@ -2,6 +2,9 @@ import { NextResponse } from 'next/server'; import pool from '@/lib/db'; import bcrypt from 'bcrypt'; import { RowDataPacket } from 'mysql2/promise'; +import { DiscordRateLimiter } from '@/lib/discordRateLimiter'; + +const DISCORD_API_BASE_URL = 'https://discord.com/api/v10'; // Or the appropriate version interface FileData extends RowDataPacket { token_hash: string; @@ -15,6 +18,8 @@ if (!process.env.DISCORD_BOT_TOKEN || !process.env.DISCORD_CHANNEL_ID) { throw new Error('Discord bot token or channel ID is not configured'); } +const rateLimiter = new DiscordRateLimiter(DISCORD_API_BASE_URL, process.env.DISCORD_BOT_TOKEN); + export async function POST(request: Request) { try { const { file_id, token } = await request.json(); @@ -48,12 +53,13 @@ export async function POST(request: Request) { for (const part of partsRows[0] as FilePart[]) { if (!part.discord_message_id) continue; - const deleteUrl = `https://discord.com/api/v10/channels/${process.env.DISCORD_CHANNEL_ID}/messages/${part.discord_message_id}`; try { - const res = await fetch(deleteUrl, { - method: 'DELETE', - headers: { 'Authorization': `Bot ${process.env.DISCORD_BOT_TOKEN}` }, - }); + const res = await rateLimiter.request( + `/channels/${process.env.DISCORD_CHANNEL_ID}/messages/${part.discord_message_id}`, + { + method: 'DELETE', + } + ); if (!res.ok && res.status !== 404) { // Don't warn on 404 (already deleted) console.warn(`Failed to delete message ${part.discord_message_id}. Status: ${res.status}`); @@ -61,9 +67,6 @@ export async function POST(request: Request) { } catch (e) { console.error(`Error deleting message ${part.discord_message_id}:`, e); } - - // Wait 1 second between delete requests to avoid hitting Discord's rate limits. - await new Promise(resolve => setTimeout(resolve, 1000)); } // 4. Hard delete the file from the database diff --git a/src/app/api/download-part/[file_id]/[part_index]/route.ts b/src/app/api/download-part/[file_id]/[part_index]/route.ts index ab54e54..ac2f3d8 100644 --- a/src/app/api/download-part/[file_id]/[part_index]/route.ts +++ b/src/app/api/download-part/[file_id]/[part_index]/route.ts @@ -1,6 +1,9 @@ import { NextResponse, NextRequest } from 'next/server'; import pool from '@/lib/db'; import { RowDataPacket } from 'mysql2/promise'; +import { DiscordRateLimiter } from '@/lib/discordRateLimiter'; + +const DISCORD_API_BASE_URL = 'https://discord.com/api/v10'; // Or the appropriate version interface FilePart extends RowDataPacket { discord_message_id: string | null; @@ -17,6 +20,8 @@ if (!process.env.DISCORD_BOT_TOKEN || !process.env.DISCORD_CHANNEL_ID) { throw new Error('Discord bot token or channel ID is not configured'); } +const rateLimiter = new DiscordRateLimiter(DISCORD_API_BASE_URL, process.env.DISCORD_BOT_TOKEN); + export async function GET(request: NextRequest, context: RouteContext) { try { const { file_id, part_index } = await context.params; @@ -43,18 +48,10 @@ export async function GET(request: NextRequest, context: RouteContext) { } // 1. Get fresh message data from Discord - const messageUrl = `https://discord.com/api/v10/channels/${process.env.DISCORD_CHANNEL_ID}/messages/${part.discord_message_id}`; - const discordRes = await fetch(messageUrl, { - headers: { - 'Authorization': `Bot ${process.env.DISCORD_BOT_TOKEN}`, - } - }); + const messageData = await rateLimiter.request( + `/channels/${process.env.DISCORD_CHANNEL_ID}/messages/${part.discord_message_id}` + ); - if (!discordRes.ok) { - return NextResponse.json({ error: 'Failed to fetch message from Discord' }, { status: 500 }); - } - - const messageData = await discordRes.json(); const attachment = messageData.attachments[0]; if (!attachment || !attachment.url) { diff --git a/src/app/api/upload-part/route.ts b/src/app/api/upload-part/route.ts index fef77d0..f833e8c 100644 --- a/src/app/api/upload-part/route.ts +++ b/src/app/api/upload-part/route.ts @@ -1,5 +1,8 @@ import { NextResponse } from 'next/server'; import pool from '@/lib/db'; +import { DiscordRateLimiter } from '@/lib/discordRateLimiter'; + +const DISCORD_API_BASE_URL = 'https://discord.com/api/v10'; // Or the appropriate version if (!process.env.DISCORD_BOT_TOKEN || !process.env.DISCORD_CHANNEL_ID) { throw new Error('Please define DISCORD_BOT_TOKEN and DISCORD_CHANNEL_ID in .env.local'); @@ -7,6 +10,8 @@ if (!process.env.DISCORD_BOT_TOKEN || !process.env.DISCORD_CHANNEL_ID) { const DISCORD_API_URL = `https://discord.com/api/v10/channels/${process.env.DISCORD_CHANNEL_ID}/messages`; +const rateLimiter = new DiscordRateLimiter(DISCORD_API_BASE_URL, process.env.DISCORD_BOT_TOKEN); + export async function POST(request: Request) { try { const formData = await request.formData(); @@ -22,13 +27,13 @@ export async function POST(request: Request) { const discordFormData = new FormData(); discordFormData.append('file', chunk, `chunk-${partIndex}.bin`); - const discordRes = await fetch(DISCORD_API_URL, { - method: 'POST', - headers: { - 'Authorization': `Bot ${process.env.DISCORD_BOT_TOKEN}`, - }, - body: discordFormData, - }); + const discordRes = await rateLimiter.request( + `/channels/${process.env.DISCORD_CHANNEL_ID}/messages`, + { + method: 'POST', + body: discordFormData, + } + ); if (!discordRes.ok) { const errorBody = await discordRes.json(); diff --git a/src/lib/discordRateLimiter.ts b/src/lib/discordRateLimiter.ts new file mode 100644 index 0000000..bd7ebcd --- /dev/null +++ b/src/lib/discordRateLimiter.ts @@ -0,0 +1,46 @@ + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +export class DiscordRateLimiter { + private readonly baseUrl: string; + private readonly botToken: string; + + constructor(baseUrl: string, botToken: string) { + this.baseUrl = baseUrl; + this.botToken = botToken; + } + + /** + * Makes a request to the Discord API, handling rate limits automatically. + * @param path The API endpoint path (e.g., '/channels/123/messages'). + * @param options Fetch options (method, headers, body, etc.). + * @returns The JSON response from the Discord API. + */ + public async request(path: string, options: RequestInit = {}): Promise { + const url = `${this.baseUrl}${path}`; + + const headers = { + 'Authorization': `Bot ${this.botToken}`, + 'Content-Type': 'application/json', + ...options.headers, + }; + + const response = await fetch(url, { ...options, headers }); + + if (response.status === 429) { + const retryAfter = response.headers.get('Retry-After'); + const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : 1000; // Default to 1 second if header is missing + + console.warn(`Discord API rate limit hit. Retrying after ${delay / 1000} seconds.`); + await sleep(delay); + return this.request(path, options); // Retry the request + } + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(`Discord API error: ${response.status} - ${errorData.message || JSON.stringify(errorData)}`); + } + + return response.json() as Promise; + } +}