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:
+62
-27
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user