feat: enhance configuration and rate limiting

- Added new configuration options: trustProxy, uploadConcurrency, batchMaxItems, batchMaxSizeBytes, and maxRequestBodyBytes to AppConfig.
- Implemented utility functions for parsing environment variables and masking sensitive data.
- Updated rate limiting logic to use configurable window size and maximum requests per window.
- Introduced a middleware for rate limiting on specific routes.
- Refactored file handling routes to support streaming downloads instead of redirects.
- Improved error handling and response formatting in file routes.
- Added support for oversized request rejection based on Content-Length header.
- Updated Swagger documentation to reflect changes in API behavior and responses.
- Enhanced tests to cover new features and ensure proper functionality.
This commit is contained in:
MythEclipse
2026-05-29 03:33:39 +07:00
parent be813b1c0e
commit 5425f6d33d
16 changed files with 466 additions and 201 deletions
+62 -27
View File
@@ -1,10 +1,12 @@
import { createReadStream } from 'node:fs';
import { unlink } from 'node:fs/promises';
import { nanoid } from 'nanoid';
import { findFileByPublicId } from '../db/files';
import { fileInfoCache } from '../utils/cache';
import { formatCreatedAt, getErrorMessage } from '../utils/file';
import logger from '../utils/logger';
import { checkRateLimit } from '../utils/rateLimit';
import { getBot } from '../utils/telegram';
import { extractZipEntry } from '../utils/zip';
import { locateZipEntry } from '../utils/zip';
type RequestWithParams = Request & {
params?: {
@@ -36,20 +38,31 @@ const getTelegramFileInfo = async (telegramFileId: string, public_id: string) =>
const buildTelegramFileUrl = (filePath: string): string =>
`https://api.telegram.org/file/bot${process.env.BOT_TOKEN}/${filePath}`;
const cleanupTempFile = async (tempPath: string): Promise<void> => {
try {
await unlink(tempPath);
} catch (err) {
logger.warn('Failed to cleanup temp file', { tempPath, error: getErrorMessage(err) });
}
};
const sanitizeFilenameHeader = (fileName: string): string =>
fileName.replace(/[\\"]/g, '').replace(/[\n\r]/g, '');
const fail = (status: number, error: string): Response =>
Response.json({ error }, { status });
export const handleFileRedirect = async (req: RequestWithParams): Promise<Response> => {
const public_id = req.params?.public_id;
try {
const ip = req.headers.get('x-forwarded-for') || '127.0.0.1';
if (!public_id || !checkRateLimit(ip)) {
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 });
if (!public_id) {
return fail(400, 'Missing file id');
}
const file = await findFileByPublicId(public_id);
if (!file) {
logger.warn('File not found', { public_id });
return Response.json({ error: 'File not found' }, { status: 404 });
return fail(404, 'File not found');
}
const archiveEntryName = file.archiveEntryName;
@@ -60,37 +73,60 @@ export const handleFileRedirect = async (req: RequestWithParams): Promise<Respon
if (!archiveResponse.ok) {
logger.error('Archive download failed', { public_id, status: archiveResponse.status });
return Response.json({ error: 'Server error' }, { status: 500 });
return fail(500, 'Server error');
}
const archiveBuffer = Buffer.from(await archiveResponse.arrayBuffer());
const extractedFile = await extractZipEntry(archiveBuffer, archiveEntryName);
if (!extractedFile) {
const tempZipPath = `/tmp/teleuploader-dl-${nanoid()}.zip`;
await Bun.write(tempZipPath, archiveResponse);
const loc = await locateZipEntry(tempZipPath, archiveEntryName);
if (!loc) {
await cleanupTempFile(tempZipPath);
logger.error('Archive entry not found', { public_id, archiveEntryName });
return Response.json({ error: 'File not found' }, { status: 404 });
return fail(404, 'File not found');
}
return new Response(extractedFile, {
const fileStream = createReadStream(tempZipPath, {
start: loc.start,
end: loc.start + loc.length - 1,
});
fileStream.on('close', () => {
void cleanupTempFile(tempZipPath);
});
fileStream.on('error', () => {
void cleanupTempFile(tempZipPath);
});
return new Response(fileStream as any, {
status: 200,
headers: {
'Content-Type': file.mimeType,
'Content-Disposition': `attachment; filename="${file.fileName.replace(/"/g, '')}"`,
'Content-Length': String(extractedFile.byteLength),
'Content-Type': file.mimeType || 'application/octet-stream',
'Content-Disposition': `attachment; filename="${sanitizeFilenameHeader(file.fileName)}"`,
'Content-Length': String(loc.length),
},
});
}
const fileInfo = await getTelegramFileInfo(file.telegramFileId, public_id);
const redirectUrl = buildTelegramFileUrl(fileInfo.file_path);
return new Response(null, {
status: 302,
const tgResponse = await fetch(buildTelegramFileUrl(fileInfo.file_path));
if (!tgResponse.ok) {
logger.error('File download failed', { public_id, status: tgResponse.status });
return fail(502, 'Server error');
}
return new Response(tgResponse.body, {
status: 200,
headers: {
Location: redirectUrl,
'Content-Type': file.mimeType || 'application/octet-stream',
'Content-Disposition': `attachment; filename="${sanitizeFilenameHeader(file.fileName)}"`,
'Content-Length': String(file.sizeBytes),
},
});
} catch (error: unknown) {
logger.error('File redirect error', { public_id, error: getErrorMessage(error) });
return Response.json({ error: 'Server error' }, { status: 500 });
return fail(500, 'Server error');
}
};
@@ -98,15 +134,15 @@ export const handleFileInfo = async (req: RequestWithParams): Promise<Response>
const public_id = req.params?.public_id;
try {
if (!public_id) {
return Response.json({ error: 'Missing file id' }, { status: 400 });
return fail(400, 'Missing file id');
}
const file = await findFileByPublicId(public_id);
if (!file) {
logger.warn('File not found', { public_id });
return Response.json({ error: 'File not found' }, { status: 404 });
return fail(404, 'File not found');
}
return Response.json(
{
public_id: file.publicId,
@@ -114,13 +150,12 @@ export const handleFileInfo = async (req: RequestWithParams): Promise<Response>
mime_type: file.mimeType,
size_bytes: file.sizeBytes,
file_type: file.fileType,
uploader_id: file.uploaderId,
created_at: formatCreatedAt(file.createdAt),
},
{ status: 200 },
);
} catch (error: unknown) {
logger.error('File info error', { public_id, error: getErrorMessage(error) });
return Response.json({ error: 'Server error' }, { status: 500 });
return fail(500, 'Server error');
}
};
+15 -27
View File
@@ -25,7 +25,6 @@ const fileInfoProperties = {
mime_type: { type: 'string', example: 'application/pdf' },
size_bytes: { type: 'integer', example: 1048576 },
file_type: { type: 'string', example: 'document' },
uploader_id: { type: 'integer', example: 0 },
created_at: {
type: 'string',
format: 'date-time',
@@ -35,10 +34,6 @@ const fileInfoProperties = {
const uploadProperties = {
...fileInfoProperties,
telegram_file_id: { type: 'string', example: 'BQACAgQAAxkBA...' },
telegram_file_unique_id: { type: 'string', example: 'AgAD8w...' },
storage_chat_id: { type: 'integer', example: -1001234567890 },
storage_message_id: { type: 'integer', example: 42 },
download_url: {
type: 'string',
example: `${config.baseUrl}/f/xYz123`,
@@ -56,7 +51,7 @@ export const handleSwaggerJson = async (): Promise<Response> => {
info: {
title: 'TeleUploader API',
version: '1.0.0',
description: 'Telegram-backed file uploader API with redirect-based downloads.',
description: 'Telegram-backed file uploader API with stream-based downloads.',
},
servers: [
{
@@ -95,7 +90,7 @@ export const handleSwaggerJson = async (): Promise<Response> => {
'/api/upload': {
post: {
summary: 'Upload File',
description: 'Uploads a file to Telegram storage via multipart/form-data or JSON base64.',
description: 'Uploads a file to Telegram storage via multipart/form-data or JSON base64. Rate-limited by IP.',
requestBody: {
required: true,
content: {
@@ -144,6 +139,14 @@ export const handleSwaggerJson = async (): Promise<Response> => {
description: 'Bad request.',
content: jsonContent(errorSchema('No file provided')),
},
'413': {
description: 'Request body too large.',
content: jsonContent(errorSchema('Request body too large')),
},
'429': {
description: 'Rate limit exceeded.',
content: jsonContent(errorSchema('Rate limit exceeded')),
},
'500': {
description: 'Internal server error.',
content: jsonContent(errorSchema('Upload failed')),
@@ -153,21 +156,12 @@ export const handleSwaggerJson = async (): Promise<Response> => {
},
'/f/{public_id}': {
get: {
summary: 'Redirect to Telegram File URL',
description:
'Gets a fresh Telegram download URL and redirects with 302. Rate-limited by IP.',
summary: 'Download File',
description: 'Proxies file from Telegram storage as a streamed download. Rate-limited by IP.',
parameters: [publicIdParameter],
responses: {
'302': {
description: 'Redirect to Telegram CDN URL.',
headers: {
Location: {
schema: {
type: 'string',
example: 'https://api.telegram.org/file/botTOKEN/documents/file_0.pdf',
},
},
},
'200': {
description: 'File binary stream.',
},
'404': {
description: 'File not found.',
@@ -212,12 +206,7 @@ export const handleSwaggerJson = async (): Promise<Response> => {
},
};
return Response.json(spec, {
status: 200,
headers: {
'access-control-allow-origin': '*',
},
});
return Response.json(spec, { status: 200 });
};
export const handleSwaggerHtml = async (): Promise<Response> => {
@@ -256,7 +245,6 @@ export const handleSwaggerHtml = async (): Promise<Response> => {
status: 200,
headers: {
'content-type': 'text/html; charset=utf-8',
'access-control-allow-origin': '*',
'x-content-type-options': 'nosniff',
},
});
+30 -3
View File
@@ -39,6 +39,23 @@ const normalizeFileType = (mimeType: string, fileName: string): string => {
const JSON_UPLOAD_LIMIT_BYTES = 50 * 1024 * 1024;
const SIGNATURE_BYTES = 16;
const getContentLength = (req: Request): number | null => {
const value = req.headers.get('content-length');
if (!value) return null;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
};
const rejectOversizedRequest = (req: Request): Response | null => {
const contentLength = getContentLength(req);
if (contentLength !== null && contentLength > config.maxRequestBodyBytes) {
return Response.json({ error: 'Request body too large' }, { status: 413 });
}
return null;
};
const cleanupTempFile = async (tempPath: string): Promise<void> => {
try {
await unlink(tempPath);
@@ -47,7 +64,7 @@ const cleanupTempFile = async (tempPath: string): Promise<void> => {
}
};
const streamFileToTemp = async (file: File): Promise<PreparedUpload> => {
const streamFileToTemp = async (file: File, maxSizeBytes: number): Promise<PreparedUpload> => {
const tempPath = `/tmp/teleuploader-${nanoid()}`;
const writer = createWriteStream(tempPath);
const hasher = new Bun.CryptoHasher('sha256');
@@ -79,6 +96,10 @@ const streamFileToTemp = async (file: File): Promise<PreparedUpload> => {
const chunk = Buffer.from(value);
sizeBytes += chunk.byteLength;
if (sizeBytes > maxSizeBytes) {
throw new Error('File size exceeds upload limit');
}
hasher.update(chunk);
await writeChunk(chunk);
@@ -126,6 +147,8 @@ const writeBufferToTemp = async (fileBuffer: Buffer, fileHash: string): Promise<
export const handleUpload = async (req: Request): Promise<Response> => {
try {
const contentType = req.headers.get('content-type') || '';
const oversizedResponse = rejectOversizedRequest(req);
if (oversizedResponse) return oversizedResponse;
if (contentType.includes('multipart/form-data')) {
return handleMultipartUpload(req);
@@ -155,7 +178,11 @@ const handleMultipartUpload = async (req: Request): Promise<Response> => {
return Response.json({ error: 'No file provided' }, { status: 400 });
}
const prepared = await streamFileToTemp(file);
if (file.size > config.maxRequestBodyBytes) {
return Response.json({ error: 'File size exceeds upload limit' }, { status: 413 });
}
const prepared = await streamFileToTemp(file, config.maxRequestBodyBytes);
const existingFile = await findFileByHash(prepared.fileHash);
if (existingFile) {
@@ -204,7 +231,7 @@ const handleJSONUpload = async (req: Request): Promise<Response> => {
const { base64Data, mimeType: rawMimeType } = parseBase64File(file);
const estimatedSizeBytes = Math.floor((base64Data.length * 3) / 4);
if (estimatedSizeBytes > JSON_UPLOAD_LIMIT_BYTES) {
if (estimatedSizeBytes > JSON_UPLOAD_LIMIT_BYTES || estimatedSizeBytes > config.maxRequestBodyBytes) {
return Response.json(
{
error: