10 Commits
legacy ... main

Author SHA1 Message Date
b905c37c8a refactor: Simplify message deletion logic and improve response handling in DiscordRateLimiter
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 7s
Vercel Production Deployment / Deploy-Production (push) Successful in 1m31s
2025-10-16 13:48:57 +02:00
ca4a696093 refactor: Ensure proper handling of empty responses in Discord API request 2025-10-16 13:46:58 +02:00
df677a345a refactor: Correctly iterate over file parts array in delete operation 2025-10-16 13:44:57 +02:00
56726adaad refactor: Correctly assign file parts array in response object 2025-10-16 13:40:41 +02:00
841923c7ff refactor: Enhance error handling for file metadata fetching and parsing 2025-10-16 13:38:20 +02:00
bc67634411 refactor: Simplify Discord API response handling and define response interface 2025-10-16 13:37:39 +02:00
49a6131b23 refactor: Improve error handling for Discord API response parsing 2025-10-16 13:33:35 +02:00
968a843120 refactor: Add type annotations for response objects in API request handling
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 7s
Vercel Production Deployment / Deploy-Production (push) Successful in 1m32s
2025-10-16 13:21:01 +02:00
bbf3725acf refactor: Implement DiscordRateLimiter for API requests to manage rate limits 2025-10-16 13:18:25 +02:00
22ed99da86 refactor: Adjust chunk size to 4 MB and increase parallel uploads to 5
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 7s
Vercel Production Deployment / Deploy-Production (push) Successful in 1m33s
2025-10-16 13:12:05 +02:00
8 changed files with 140 additions and 59 deletions

View File

@@ -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: Response = 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: Response = 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
}
}

View File

@@ -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();
@@ -45,25 +50,21 @@ export async function POST(request: Request) {
);
// 3. Delete each message from Discord sequentially to avoid rate limits
for (const part of partsRows[0] as FilePart[]) {
for (const part of partsRows 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}` },
});
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}`);
}
await rateLimiter.request(
`/channels/${process.env.DISCORD_CHANNEL_ID}/messages/${part.discord_message_id}`,
{
method: 'DELETE',
}
);
// No need to check res.ok or res.status here, as rateLimiter.request throws on !response.ok
// and returns null for 204.
} 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

View File

@@ -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;
@@ -13,10 +16,22 @@ interface RouteContext {
}>;
}
interface DiscordAttachment {
url: string;
filename: string;
size: number;
}
interface DiscordMessageData {
attachments: DiscordAttachment[];
}
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 +58,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: DiscordMessageData = await rateLimiter.request<DiscordMessageData>(
`/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) {

View File

@@ -64,7 +64,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
filename: file.filename,
size: file.size,
num_parts: file.num_parts,
parts: partsRows[0] as FilePartMetadata[],
parts: partsRows as FilePartMetadata[],
};
return NextResponse.json(response);

View File

@@ -1,11 +1,23 @@
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');
}
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);
interface DiscordMessageResponse {
id: string;
attachments: Array<{
id: string;
url: string;
size: number;
}>;
}
export async function POST(request: Request) {
try {
@@ -22,21 +34,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,
});
if (!discordRes.ok) {
const errorBody = await discordRes.json();
console.error('Discord API Error:', errorBody);
return NextResponse.json({ error: 'Failed to upload to Discord' }, { status: 500 });
}
const discordData = await discordRes.json();
const discordData = await rateLimiter.request<DiscordMessageResponse>(
`/channels/${process.env.DISCORD_CHANNEL_ID}/messages`,
{
method: 'POST',
body: discordFormData,
}
);
const attachment = discordData.attachments[0];
if (!attachment) {

View File

@@ -100,17 +100,28 @@ export default function DownloadPage() {
try {
const metaRes = await fetch(`/api/file/${file_id}?token=${token}`);
if (!metaRes.ok) {
const err = await metaRes.json();
throw new Error(err.error || 'Failed to fetch file metadata.');
let metadata;
try {
metadata = await metaRes.json();
} catch (jsonError) {
console.error('Failed to parse metadata response as JSON:', jsonError);
throw new Error('Failed to parse file metadata.');
}
const metadata = await metaRes.json();
if (!metaRes.ok) {
throw new Error(metadata.error || 'Failed to fetch file metadata.');
}
setFilename(metadata.filename);
setTotalBytes(metadata.size);
setDownloadState('downloading');
const encryptedParts = new Array(metadata.num_parts);
if (!Array.isArray(metadata.parts)) {
throw new Error('Invalid metadata: parts is not an array.');
}
const downloadPromises = metadata.parts.map(async (part: MetadataPart) => {
const response = await fetchWithRetry(`/api/download-part/${file_id}/${part.part_index}`);
const buffer = await response.arrayBuffer();

View File

@@ -2,8 +2,8 @@
import { useState, useEffect, useRef } from 'react';
const CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB
const PARALLEL_UPLOADS = 4;
const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MB
const PARALLEL_UPLOADS = 5;
function bufferToBase64(buffer: ArrayBuffer) {
let binary = '';

View File

@@ -0,0 +1,53 @@
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<T>(path: string, options: RequestInit = {}): Promise<T> {
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
'Authorization': `Bot ${this.botToken}`,
...(options.headers as Record<string, string>),
};
// Only set Content-Type to application/json if body is a string and not explicitly set
if (typeof options.body === 'string' && !('Content-Type' in headers)) {
headers['Content-Type'] = 'application/json';
}
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<T>(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)}`);
}
if (response.status === 204) {
return null as T; // Return null for 204 No Content
}
return response.json() as Promise<T>;
}
}