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
+38 -28
View File
@@ -16,7 +16,6 @@ type FileInfoBody = {
mime_type: string;
size_bytes: number;
file_type: string;
uploader_id: number;
created_at: string;
};
@@ -70,11 +69,16 @@ mock.module('../src/utils/telegram', () => ({
}),
}));
// Mock rateLimit
const mockCheckRateLimit = mock(() => true);
mock.module('../src/utils/rateLimit', () => ({
checkRateLimit: mockCheckRateLimit,
}));
// Mock global fetch for proxy path
const originalFetch = globalThis.fetch;
const mockGlobalFetch = mock(async (_url: string) =>
Promise.resolve(
new Response('fake-file-content', {
status: 200,
headers: { 'Content-Type': 'application/octet-stream' },
}),
),
);
describe('File Route Handlers', () => {
let handleFileRedirect: typeof import('../src/routes/files').handleFileRedirect;
@@ -83,27 +87,23 @@ describe('File Route Handlers', () => {
beforeEach(async () => {
mockSelect.mockClear();
mockGetFile.mockClear();
mockCheckRateLimit.mockClear();
mockGlobalFetch.mockClear();
// Set up mock token
process.env.BOT_TOKEN = '123456:ABC-DEF';
globalThis.fetch = mockGlobalFetch as any;
const filesRoute = await import('../src/routes/files');
handleFileRedirect = filesRoute.handleFileRedirect;
handleFileInfo = filesRoute.handleFileInfo;
});
afterAll(() => {
mock.restore();
globalThis.fetch = originalFetch;
});
describe('handleFileRedirect', () => {
it('should return 429 if rate limit is exceeded', async () => {
mockCheckRateLimit.mockImplementationOnce(() => false);
const req = requestWithPublicId('http://localhost:3000/f/test-id', 'test-id');
const res = await handleFileRedirect(req);
expect(res.status).toBe(429);
const body = await responseJson<ErrorBody>(res);
expect(body.error).toBe('Rate limit exceeded');
});
it('should return 404 if file is not found in database', async () => {
mockSelect.mockImplementationOnce(() => ({
from: () => ({
@@ -120,7 +120,7 @@ describe('File Route Handlers', () => {
expect(body.error).toBe('File not found');
});
it('should redirect to telegram file url if file is found', async () => {
it('should proxy download (200 stream) instead of 302 redirect', async () => {
mockSelect.mockImplementationOnce(() => ({
from: () => ({
where: () => ({
@@ -131,6 +131,8 @@ describe('File Route Handlers', () => {
publicId: 'test-id',
telegramFileId: 'tg-file-id',
fileName: 'test.jpg',
mimeType: 'image/jpeg',
sizeBytes: 100,
},
]),
}),
@@ -139,10 +141,20 @@ describe('File Route Handlers', () => {
const req = requestWithPublicId('http://localhost:3000/f/test-id', 'test-id');
const res = await handleFileRedirect(req);
expect(res.status).toBe(302);
expect(res.headers.get('Location')).toBe(
'https://api.telegram.org/file/bot123456:ABC-DEF/photos/file_0.jpg',
);
// No longer 302 redirect
expect(res.status).toBe(200);
// No Location header with token
expect(res.headers.get('Location')).toBeNull();
// Should have Content-Disposition
const disposition = res.headers.get('Content-Disposition');
expect(disposition).toBeTruthy();
expect(disposition).toContain('test.jpg');
// fetch should have been called for the proxy
expect(mockGlobalFetch).toHaveBeenCalled();
});
it('should return 500 on database or external errors', async () => {
@@ -175,7 +187,7 @@ describe('File Route Handlers', () => {
expect(body.error).toBe('File not found');
});
it('should return file info JSON if file is found', async () => {
it('should return file info JSON without internal fields', async () => {
const dbFile = {
publicId: 'test-id',
fileName: 'image.png',
@@ -204,9 +216,11 @@ describe('File Route Handlers', () => {
mime_type: 'image/png',
size_bytes: 2048,
file_type: 'photo',
uploader_id: 99999,
created_at: '2026-05-18T00:00:00.000Z',
});
// No internal fields
expect(body).not.toHaveProperty('uploader_id');
expect(body).not.toHaveProperty('telegram_file_id');
});
it('should return 500 on database or external errors', async () => {
@@ -221,8 +235,4 @@ describe('File Route Handlers', () => {
expect(body.error).toBe('Server error');
});
});
afterAll(() => {
mock.restore();
});
});
+34 -14
View File
@@ -1,25 +1,45 @@
import { beforeEach, describe, expect, it, spyOn } from 'bun:test';
import logger from '../src/utils/logger';
import { checkRateLimit, cleanupRateLimitCache } from '../src/utils/rateLimit';
// Spy on logger.warn
const warnSpy = spyOn(logger, 'warn');
import { beforeEach, describe, expect, it } from 'bun:test';
import { checkRateLimit, cleanupRateLimitCache, clearRateLimitCache } from '../src/utils/rateLimit';
describe('Rate Limiter', () => {
beforeEach(() => {
warnSpy.mockClear();
clearRateLimitCache();
});
it('should always allow requests as rate limiter is disabled', () => {
it('should allow requests up to the configured limit then block', () => {
const key = 'user-1';
expect(checkRateLimit(key)).toBe(true);
expect(checkRateLimit(key)).toBe(true);
expect(checkRateLimit(key)).toBe(true);
expect(checkRateLimit(key)).toBe(true);
expect(warnSpy).not.toHaveBeenCalled();
// Default config maxRequests is 30; all 20 should pass
for (let i = 0; i < 20; i++) {
expect(checkRateLimit(key)).toBe(true);
}
});
it('should no-op on cleanup', () => {
it('should block requests when limit exceeded', () => {
const key = 'user-2';
// Exhaust the limit (30 by default)
for (let i = 0; i < 30; i++) {
checkRateLimit(key);
}
expect(checkRateLimit(key)).toBe(false);
});
it('should reset window after cleanup on expired entries', async () => {
const key = 'user-3';
// Use one request then wait past the window
expect(checkRateLimit(key)).toBe(true);
// Simulate expiry by advancing past the window
// We can only test cleanup of non-expired entries (no-op)
expect(() => cleanupRateLimitCache()).not.toThrow();
});
it('should track different IPs independently', () => {
expect(checkRateLimit('10.0.0.1')).toBe(true);
expect(checkRateLimit('10.0.0.1')).toBe(true);
expect(checkRateLimit('10.0.0.2')).toBe(true);
});
});
+21 -5
View File
@@ -11,7 +11,7 @@ describe('Swagger Documentation Endpoints', () => {
const body = (await res.json()) as {
openapi: string;
info: { title: string };
paths: Record<string, { post?: { requestBody: { content: Record<string, unknown> } } }>;
paths: Record<string, { get?: object; post?: object }>;
};
expect(body.openapi).toBe('3.0.0');
expect(body.info.title).toBe('TeleUploader API');
@@ -19,10 +19,21 @@ describe('Swagger Documentation Endpoints', () => {
expect(body.paths).toHaveProperty('/api/upload');
expect(body.paths).toHaveProperty('/f/{public_id}');
expect(body.paths).toHaveProperty('/file/{public_id}/info');
expect(body.paths['/api/upload'].post.requestBody.content).toHaveProperty(
'multipart/form-data',
);
expect(body.paths['/api/upload'].post.requestBody.content).toHaveProperty('application/json');
const uploadPath = body.paths['/api/upload'] as any;
const downloadPath = body.paths['/f/{public_id}'] as any;
expect(uploadPath.post.requestBody.content).toHaveProperty('multipart/form-data');
expect(uploadPath.post.requestBody.content).toHaveProperty('application/json');
// Verify 429 response documented
const uploadResponses = uploadPath.post.responses;
expect(uploadResponses).toHaveProperty('429');
// Verify download is no longer documented as 302 redirect
const downloadResponses = downloadPath.get.responses;
expect(downloadResponses['200'].description).toContain('stream');
expect(downloadResponses).not.toHaveProperty('302');
});
it('returns Swagger UI HTML page', async () => {
@@ -37,4 +48,9 @@ describe('Swagger Documentation Endpoints', () => {
expect(html).toContain('/swagger.json');
expect(html).toContain('swagger-ui-bundle.js');
});
it('should not expose CORS * header', async () => {
const res = await handleSwaggerJson();
expect(res.headers.get('access-control-allow-origin')).toBeNull();
});
});
+37 -18
View File
@@ -13,7 +13,7 @@ beforeAll(async () => {
} catch {
// Fallback 1x1px JPEG
realPhotoBuffer = Buffer.from(
'ffd8ffe000104a46494600010101006000600000ffdb004300080606070605080707070909080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c2837292c30313434341f27393d38323c2e333432ffc0000b080001000101011100ffc4001f0000010501010110000000000000000000000102030405060708ffda000c03010002110311003f00a0ffd9',
'ffd8ffe000104a46494600010101006000600000ffdb004300080606070605080707070909080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c2837292c30313434341f27393d38323c2e333432ffc0b000080100010101011100ffc4001f0000010501010110000000000000000000000102030405060708ffda000c03010002110311003f00a0ffd9',
'hex',
);
}
@@ -22,8 +22,6 @@ beforeAll(async () => {
// Mock db
type UploadResponseBody = {
public_id: string;
telegram_file_id: string;
telegram_file_unique_id: string;
file_name: string;
file_type: string;
download_url: string;
@@ -79,19 +77,17 @@ const mockForwardToStorage = mock(() =>
}),
);
const mockGetFile = mock(() =>
Promise.resolve({
file_id: 'tg-file-id-123',
file_size: 1000,
mime_type: 'image/jpeg',
}),
);
mock.module('../src/utils/telegram', () => ({
forwardToStorage: mockForwardToStorage,
getBot: () => ({
telegram: {
getFile: mockGetFile,
getFile: mock(() =>
Promise.resolve({
file_id: 'tg-file-id-123',
file_size: 1000,
mime_type: 'image/jpeg',
}),
),
},
}),
}));
@@ -106,7 +102,6 @@ describe('Upload Route Handler', () => {
mockWhere.mockClear();
mockLimit.mockClear();
mockForwardToStorage.mockClear();
mockGetFile.mockClear();
mockSelectResult = [];
const uploadRoute = await import('../src/routes/upload');
handleUpload = uploadRoute.handleUpload;
@@ -144,10 +139,15 @@ describe('Upload Route Handler', () => {
const body = await uploadResponseJson(res);
expect(body.public_id).toContain('mocked-nanoid-id');
expect(body.telegram_file_id).toBe('tg-file-id-123');
expect(body.telegram_file_unique_id).toBe('tg-unique-id-abc');
expect(body.file_name).toBe('test.png');
expect(body.file_type).toBe('photo');
expect(body.download_url).toContain('/f/');
// No internal Telegram IDs in public response
expect(body).not.toHaveProperty('telegram_file_id');
expect(body).not.toHaveProperty('telegram_file_unique_id');
expect(body).not.toHaveProperty('storage_chat_id');
expect(body).not.toHaveProperty('storage_message_id');
expect(body).not.toHaveProperty('uploader_id');
});
it('should reject JSON upload without file key', async () => {
@@ -182,6 +182,7 @@ describe('Upload Route Handler', () => {
const body = await uploadResponseJson(res);
expect(body.public_id).toContain('mocked-nanoid-id');
expect(body.file_name).toBe('test_multi.png');
expect(body).not.toHaveProperty('telegram_file_id');
});
it('should deduplicate multipart upload if hash exists', async () => {
@@ -216,10 +217,9 @@ describe('Upload Route Handler', () => {
const body = await uploadResponseJson(res);
expect(body.public_id).toBe('existing-id-123');
expect(body.telegram_file_id).toBe('existing-tg-id');
expect(body.telegram_file_unique_id).toBe('existing-tg-unique');
expect(body.file_name).toBe('existing_name.txt');
expect(body.download_url).toContain('/f/existing-id-123');
expect(body).not.toHaveProperty('telegram_file_id');
// DB query happened
expect(mockSelect).toHaveBeenCalled();
@@ -263,9 +263,9 @@ describe('Upload Route Handler', () => {
const body = await uploadResponseJson(res);
expect(body.public_id).toBe('existing-json-id');
expect(body.telegram_file_id).toBe('existing-tg-json-id');
expect(body.file_name).toBe('existing_json.txt');
expect(body.download_url).toContain('/f/existing-json-id');
expect(body).not.toHaveProperty('telegram_file_id');
// DB query happened
expect(mockSelect).toHaveBeenCalled();
@@ -275,6 +275,25 @@ describe('Upload Route Handler', () => {
expect(mockInsert).not.toHaveBeenCalled();
});
it('should reject oversized request by Content-Length header', async () => {
const req = new Request('http://localhost:3000/api/upload', {
method: 'POST',
headers: {
'content-type': 'application/json',
'content-length': String(3 * 1024 * 1024 * 1024),
},
body: JSON.stringify({
file: Buffer.from('hello').toString('base64'),
fileName: 'test.txt',
}),
});
const res = await handleUpload(req);
expect(res.status).toBe(413);
const body = await uploadResponseJson(res);
expect(body.error).toContain('too large');
});
afterAll(() => {
mock.restore();
});