feat: update file handling to redirect to Telegram CDN and adjust Swagger documentation
This commit is contained in:
+4
-11
@@ -109,19 +109,12 @@ export const handleFileRedirect = async (req: RequestWithParams): Promise<Respon
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fileInfo = await getTelegramFileInfo(file.telegramFileId, public_id);
|
const fileInfo = await getTelegramFileInfo(file.telegramFileId, public_id);
|
||||||
const tgResponse = await fetch(buildTelegramFileUrl(fileInfo.file_path));
|
const redirectUrl = buildTelegramFileUrl(fileInfo.file_path);
|
||||||
|
|
||||||
if (!tgResponse.ok) {
|
return new Response(null, {
|
||||||
logger.error('File download failed', { public_id, status: tgResponse.status });
|
status: 302,
|
||||||
return fail(502, 'Server error');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(tgResponse.body, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': file.mimeType || 'application/octet-stream',
|
Location: redirectUrl,
|
||||||
'Content-Disposition': `attachment; filename="${sanitizeFilenameHeader(file.fileName)}"`,
|
|
||||||
'Content-Length': String(file.sizeBytes),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -157,11 +157,11 @@ export const handleSwaggerJson = async (): Promise<Response> => {
|
|||||||
'/f/{public_id}': {
|
'/f/{public_id}': {
|
||||||
get: {
|
get: {
|
||||||
summary: 'Download File',
|
summary: 'Download File',
|
||||||
description: 'Proxies file from Telegram storage as a streamed download. Rate-limited by IP.',
|
description: 'Redirects to Telegram CDN for direct download. Rate-limited by IP.',
|
||||||
parameters: [publicIdParameter],
|
parameters: [publicIdParameter],
|
||||||
responses: {
|
responses: {
|
||||||
'200': {
|
'302': {
|
||||||
description: 'File binary stream.',
|
description: 'Redirect to Telegram CDN URL.',
|
||||||
},
|
},
|
||||||
'404': {
|
'404': {
|
||||||
description: 'File not found.',
|
description: 'File not found.',
|
||||||
|
|||||||
+5
-14
@@ -120,7 +120,7 @@ describe('File Route Handlers', () => {
|
|||||||
expect(body.error).toBe('File not found');
|
expect(body.error).toBe('File not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should proxy download (200 stream) instead of 302 redirect', async () => {
|
it('should redirect to telegram file url with 302', async () => {
|
||||||
mockSelect.mockImplementationOnce(() => ({
|
mockSelect.mockImplementationOnce(() => ({
|
||||||
from: () => ({
|
from: () => ({
|
||||||
where: () => ({
|
where: () => ({
|
||||||
@@ -142,19 +142,10 @@ describe('File Route Handlers', () => {
|
|||||||
const req = requestWithPublicId('http://localhost:3000/f/test-id', 'test-id');
|
const req = requestWithPublicId('http://localhost:3000/f/test-id', 'test-id');
|
||||||
const res = await handleFileRedirect(req);
|
const res = await handleFileRedirect(req);
|
||||||
|
|
||||||
// No longer 302 redirect
|
expect(res.status).toBe(302);
|
||||||
expect(res.status).toBe(200);
|
expect(res.headers.get('Location')).toBe(
|
||||||
|
'https://api.telegram.org/file/bot123456:ABC-DEF/photos/file_0.jpg',
|
||||||
// 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 () => {
|
it('should return 500 on database or external errors', async () => {
|
||||||
|
|||||||
+8
-22
@@ -1,4 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it } from 'bun:test';
|
import { beforeEach, describe, expect, it } from 'bun:test';
|
||||||
|
import { config } from '../src/env';
|
||||||
import { checkRateLimit, cleanupRateLimitCache, clearRateLimitCache } from '../src/utils/rateLimit';
|
import { checkRateLimit, cleanupRateLimitCache, clearRateLimitCache } from '../src/utils/rateLimit';
|
||||||
|
|
||||||
describe('Rate Limiter', () => {
|
describe('Rate Limiter', () => {
|
||||||
@@ -8,38 +9,23 @@ describe('Rate Limiter', () => {
|
|||||||
|
|
||||||
it('should allow requests up to the configured limit then block', () => {
|
it('should allow requests up to the configured limit then block', () => {
|
||||||
const key = 'user-1';
|
const key = 'user-1';
|
||||||
|
const limit = config.rateLimitMaxRequests;
|
||||||
|
|
||||||
// Default config maxRequests is 30; all 20 should pass
|
for (let i = 0; i < limit; i++) {
|
||||||
for (let i = 0; i < 20; i++) {
|
|
||||||
expect(checkRateLimit(key)).toBe(true);
|
expect(checkRateLimit(key)).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
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', () => {
|
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.1')).toBe(true);
|
expect(checkRateLimit('10.0.0.1')).toBe(true);
|
||||||
expect(checkRateLimit('10.0.0.2')).toBe(true);
|
expect(checkRateLimit('10.0.0.2')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should no-op on cleanup of active entries', () => {
|
||||||
|
checkRateLimit('user-3');
|
||||||
|
expect(() => cleanupRateLimitCache()).not.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ describe('Swagger Documentation Endpoints', () => {
|
|||||||
const uploadResponses = uploadPath.post.responses;
|
const uploadResponses = uploadPath.post.responses;
|
||||||
expect(uploadResponses).toHaveProperty('429');
|
expect(uploadResponses).toHaveProperty('429');
|
||||||
|
|
||||||
// Verify download is no longer documented as 302 redirect
|
// Verify download is 302 redirect to Telegram CDN
|
||||||
const downloadResponses = downloadPath.get.responses;
|
const downloadResponses = downloadPath.get.responses;
|
||||||
expect(downloadResponses['200'].description).toContain('stream');
|
expect(downloadResponses).toHaveProperty('302');
|
||||||
expect(downloadResponses).not.toHaveProperty('302');
|
expect(downloadResponses['302'].description).toContain('Redirect');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns Swagger UI HTML page', async () => {
|
it('returns Swagger UI HTML page', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user