From 06f93e30f633d8f244d0be06665ae81217baeadd Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 7 Jul 2026 02:54:51 +0700 Subject: [PATCH] feat: proxy GetObject from Telegram CDN for real S3 client compatibility - Add proxyS3Get config (PROXY_S3_GET env, default true) to env.ts - Proxy handleGetObject and handleGetMultipartObject: fetch from Telegram CDN and return 200 with streaming body instead of 302 redirect - Real S3 clients (AWS SDK v3) expect 200+body on GetObject, not redirect - Legacy 302 redirect path preserved when proxyS3Get=false - Updated production-e2e: GetObject asserts 200 with body content - Updated s3-sdk.test.ts: GetObject asserts 200 with body (removed try/catch) - Cleaned up unused multipart imports in s3-sdk.test.ts All 49 tests pass (29 production-e2e + 20 s3-sdk). --- src/env.ts | 2 + src/routes/s3.ts | 75 ++++++++++++++++++++++++++++++++++--- test/production-e2e.test.ts | 19 +++++++--- test/s3-sdk.test.ts | 30 +++++---------- 4 files changed, 94 insertions(+), 32 deletions(-) diff --git a/src/env.ts b/src/env.ts index 67e9d41..0f3f0de 100644 --- a/src/env.ts +++ b/src/env.ts @@ -19,6 +19,7 @@ interface AppConfig { s3AccessKey: string; s3SecretKey: string; s3DefaultRegion: string; + proxyS3Get: boolean; } const requiredEnv = { @@ -78,6 +79,7 @@ export const config: AppConfig = { s3AccessKey: process.env.S3_ACCESS_KEY || 'teleuploader-admin', s3SecretKey: process.env.S3_SECRET_KEY || '', s3DefaultRegion: process.env.S3_DEFAULT_REGION || 'us-east-1', + proxyS3Get: process.env.PROXY_S3_GET !== 'false', }; logger.info('Environment variables loaded', { diff --git a/src/routes/s3.ts b/src/routes/s3.ts index 8429923..6a20ebe 100644 --- a/src/routes/s3.ts +++ b/src/routes/s3.ts @@ -313,11 +313,43 @@ const handleGetObject = async ( const fileInfo = await getFileInfo(file.telegramFileId); const redirectUrl = `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`; - return new Response(null, { - status: 302, + // Proxy the content from Telegram CDN so real S3 clients get the body directly. + if (!config.proxyS3Get) { + // Legacy 302 redirect path (when proxy is disabled) + return new Response(null, { + status: 302, + headers: { Location: redirectUrl, 'x-amz-request-id': reqId }, + }); + } + + const tgResponse = await fetch(redirectUrl); + if (!tgResponse.ok) { + logger.warn('Telegram content fetch failed', { + status: tgResponse.status, + fileId: file.telegramFileId, + }); + return s3ErrorResponse( + 'InternalError', + 'Failed to fetch object content from storage', + `/${bucket}/${key}`, + 502, + reqId, + ); + } + + return new Response(tgResponse.body, { + status: 200, headers: { - Location: redirectUrl, + 'content-type': file.mimeType, + 'content-length': String(file.sizeBytes), + etag: `"${file.fileHash || ''}"`, + 'last-modified': + file.createdAt instanceof Date + ? file.createdAt.toUTCString() + : new Date(file.createdAt).toUTCString(), 'x-amz-request-id': reqId, + 'accept-ranges': 'bytes', + 'cache-control': 'public, max-age=31536000', }, }); }; @@ -344,11 +376,42 @@ const handleGetMultipartObject = async ( const fileInfo = await getFileInfo(parts[0].telegramFileId); const redirectUrl = `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`; - return new Response(null, { - status: 302, + if (!config.proxyS3Get) { + return new Response(null, { + status: 302, + headers: { Location: redirectUrl, 'x-amz-request-id': reqId }, + }); + } + + const tgResponse = await fetch(redirectUrl); + if (!tgResponse.ok) { + logger.warn('Telegram multipart content fetch failed', { + status: tgResponse.status, + uploadId: file.multipartUploadId, + }); + return s3ErrorResponse( + 'InternalError', + 'Failed to fetch object content from storage', + `/${bucket}/${key}`, + 502, + reqId, + ); + } + + const totalSize = parts.reduce((sum, p) => sum + p.sizeBytes, 0); + return new Response(tgResponse.body, { + status: 200, headers: { - Location: redirectUrl, + 'content-type': file.mimeType, + 'content-length': String(totalSize || file.sizeBytes), + etag: `"${file.fileHash || ''}"`, + 'last-modified': + file.createdAt instanceof Date + ? file.createdAt.toUTCString() + : new Date(file.createdAt).toUTCString(), 'x-amz-request-id': reqId, + 'accept-ranges': 'bytes', + 'cache-control': 'public, max-age=31536000', }, }); }; diff --git a/test/production-e2e.test.ts b/test/production-e2e.test.ts index bc196c3..19f519b 100644 --- a/test/production-e2e.test.ts +++ b/test/production-e2e.test.ts @@ -236,10 +236,12 @@ describe('S3 API (production, SigV4)', () => { expect(Number(r.headers.get('content-length'))).toBeGreaterThan(0); }); - it('GetObject (GET /{bucket}/{key}) — redirects to Telegram', async () => { + it('GetObject (GET /{bucket}/{key}) — proxies content from Telegram', async () => { const r = await s3Request('GET', `/${bucketName}/test-file.txt`); - expect([200, 302]).toContain(r.status); - if (r.status === 302) expect(r.headers.get('location')).toBeTruthy(); + expect(r.status).toBe(200); + const text = await r.text(); + expect(text).toContain('hello s3'); + expect(r.headers.get('content-type')).toMatch(/text|octet/); }); it('ListObjectsV1 (GET /{bucket})', async () => { @@ -337,12 +339,17 @@ describe('S3 API (production, SigV4)', () => { const presignedUrl = `${BASE_URL}/${bucketName}/presigned-test.txt?${sp.toString()}`; const r = await fetch(presignedUrl); - // Presigned URL should return 302 (redirect to Telegram) or 403 (auth fail) + // Presigned URL: 200 (proxied body), 302 (redirect), or 403 (auth fail) if (r.status === 403) { console.warn('⚠️ Presigned URL returned 403 — verification mismatch'); } - expect([302, 403]).toContain(r.status); - if (r.status === 302) expect(r.headers.get('location')).toBeTruthy(); + expect([200, 302, 403]).toContain(r.status); + if (r.status === 200) { + const text = await r.text(); + expect(text).toContain('presigned content'); + } else if (r.status === 302) { + expect(r.headers.get('location')).toBeTruthy(); + } }); it('Delete bucket — must be empty first', async () => { diff --git a/test/s3-sdk.test.ts b/test/s3-sdk.test.ts index fc8f328..70b07d2 100644 --- a/test/s3-sdk.test.ts +++ b/test/s3-sdk.test.ts @@ -31,11 +31,8 @@ import { CopyObjectCommand, DeleteObjectCommand, DeleteObjectsCommand, - CreateMultipartUploadCommand, - UploadPartCommand, - CompleteMultipartUploadCommand, - AbortMultipartUploadCommand, ListMultipartUploadsCommand, + AbortMultipartUploadCommand, NoSuchKey, NotFound, } from '@aws-sdk/client-s3'; @@ -158,22 +155,15 @@ describe('S3 SDK compatibility', () => { expect(ContentType).toBe('text/plain'); }); - it('GetObject returns redirect to Telegram (302)', async () => { - // TeleUploader returns 302 -> Telegram CDN rather than the raw object body. - // The AWS SDK follows the redirect but the target (api.telegram.org) returns - // non-XML, so the deserializer throws UnknownError. - // We accept either a 200 with body OR an error with 302 status. - try { - const { Body, ContentType } = await s3.send( - new GetObjectCommand({ Bucket: BUCKET, Key: 'hello-sdk.txt' }), - ); - const text = await Body!.transformToString(); - expect(text).toBe('Hello from AWS SDK v3!'); - expect(ContentType).toBe('text/plain'); - } catch (e: any) { - // 302 redirect is expected — that's how TeleUploader works - expect(e.$metadata?.httpStatusCode).toBe(302); - } + it('GetObject returns stored content (proxied from Telegram)', async () => { + const { Body, ContentType, ContentLength, ETag } = await s3.send( + new GetObjectCommand({ Bucket: BUCKET, Key: 'hello-sdk.txt' }), + ); + const text = await Body!.transformToString(); + expect(text).toBe('Hello from AWS SDK v3!'); + expect(ContentType).toBe('text/plain'); + expect(ContentLength).toBeGreaterThan(0); + expect(ETag).toBeTruthy(); }); it('GetObject returns 404 (NoSuchKey) for missing key', async () => {