From ad454041321d8c1d67d384067859c1b393d9de8e Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 7 Jul 2026 04:29:24 +0700 Subject: [PATCH] feat: support ranged S3 GetObject responses Wire createGetObjectResponse into single-part GetObject handler for proper Range/Content-Range support (200, 206, 416). Update presigned E2E test to require 200. Add SDK Range request test. --- src/routes/s3.ts | 56 ++++++++++++++++++++++++------------- src/utils/s3/xml.ts | 2 ++ test/production-e2e.test.ts | 33 ++++++++++++++-------- test/s3-sdk.test.ts | 9 ++++++ 4 files changed, 69 insertions(+), 31 deletions(-) diff --git a/src/routes/s3.ts b/src/routes/s3.ts index 3074e13..bfa617b 100644 --- a/src/routes/s3.ts +++ b/src/routes/s3.ts @@ -20,6 +20,8 @@ import { config } from '../env'; import { cleanupTempFile, computeHash, ensureExtension, getErrorMessage } from '../utils/file'; import logger from '../utils/logger'; import { verifyPresignedUrl, verifySignature } from '../utils/s3/auth'; +import { createGetObjectResponse, type ObjectPartSource } from '../utils/s3/object-stream'; +import { parseRangeHeader, unsatisfiedContentRange } from '../utils/s3/range'; import { completeMultipartUploadXml, copyObjectResultXml, @@ -316,7 +318,21 @@ const handleGetObject = async ( const fileInfo = await getFileInfo(file.telegramFileId); const redirectUrl = `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`; - // Proxy the content from Telegram CDN so real S3 clients get the body directly. + const totalSize = file.sizeBytes; + const range = parseRangeHeader(headers.range || null, totalSize); + if (range.type === 'invalid') { + return s3ErrorResponse( + 'InvalidRange', + 'The requested range is not satisfiable.', + `/${bucket}/${key}`, + 416, + reqId, + { + 'content-range': unsatisfiedContentRange(totalSize), + }, + ); + } + if (!config.proxyS3Get) { // Legacy 302 redirect path (when proxy is disabled) return new Response(null, { @@ -325,11 +341,27 @@ const handleGetObject = async ( }); } - const tgResponse = await fetch(redirectUrl); - if (!tgResponse.ok) { + const part: ObjectPartSource = { + telegramFileId: file.telegramFileId, + telegramUrl: redirectUrl, + sizeBytes: file.sizeBytes, + partNumber: 1, + }; + + try { + return await createGetObjectResponse({ + reqId, + contentType: file.mimeType, + etag: file.fileHash || '', + lastModified: file.createdAt instanceof Date ? file.createdAt : new Date(file.createdAt), + totalSize: file.sizeBytes, + parts: [part], + range, + }); + } catch (error) { logger.warn('Telegram content fetch failed', { - status: tgResponse.status, fileId: file.telegramFileId, + error: getErrorMessage(error), }); return s3ErrorResponse( 'InternalError', @@ -339,22 +371,6 @@ const handleGetObject = async ( reqId, ); } - - return new Response(tgResponse.body, { - status: 200, - headers: { - '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', - }, - }); }; const handleGetMultipartObject = async ( diff --git a/src/utils/s3/xml.ts b/src/utils/s3/xml.ts index 2874317..d22df96 100644 --- a/src/utils/s3/xml.ts +++ b/src/utils/s3/xml.ts @@ -221,12 +221,14 @@ export const s3ErrorResponse = ( resource: string, status: number, requestId: string = '', + extraHeaders: Record = {}, ): Response => new Response(s3ErrorXml(code, message, resource, requestId), { status, headers: { 'content-type': 'application/xml', ...(requestId ? { 'x-amz-request-id': requestId } : {}), + ...extraHeaders, }, }); diff --git a/test/production-e2e.test.ts b/test/production-e2e.test.ts index d27740c..49af16c 100644 --- a/test/production-e2e.test.ts +++ b/test/production-e2e.test.ts @@ -369,17 +369,28 @@ describe('S3 API (production, SigV4)', () => { const presignedUrl = `${BASE_URL}/${bucketName}/presigned-test.txt?${sp.toString()}`; const r = await fetch(presignedUrl); - // Presigned URL: 200 (proxied body), 302 (redirect), or 403 (auth fail) - if (r.status === 403) { - console.warn('⚠️ Presigned URL returned 403 — verification mismatch'); - } - 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(); - } + expect(r.status).toBe(200); + const text = await r.text(); + expect(text).toContain('presigned content'); + }); + + it('GetObject Range — returns partial single-part content', async () => { + const r = await s3Request('GET', `/${bucketName}/test-file.txt`, { + headers: { range: 'bytes=0-4' }, + }); + expect(r.status).toBe(206); + expect(r.headers.get('content-range')).toBe('bytes 0-4/8'); + expect(await r.text()).toBe('hello'); + }); + + it('GetObject Range — invalid range returns 416 XML', async () => { + const r = await s3Request('GET', `/${bucketName}/test-file.txt`, { + headers: { range: 'bytes=999-1000' }, + }); + expect(r.status).toBe(416); + expect(r.headers.get('content-range')).toBe('bytes */8'); + const xml = await r.text(); + expect(xml).toContain('InvalidRange'); }); it('Delete bucket — must be empty first', async () => { diff --git a/test/s3-sdk.test.ts b/test/s3-sdk.test.ts index 73c7532..3087325 100644 --- a/test/s3-sdk.test.ts +++ b/test/s3-sdk.test.ts @@ -174,6 +174,15 @@ describe('S3 SDK compatibility', () => { expect(ETag).toBeTruthy(); }); + it('GetObject supports Range requests', async () => { + const { Body, ContentRange, ContentLength } = await s3.send( + new GetObjectCommand({ Bucket: BUCKET, Key: 'hello-sdk.txt', Range: 'bytes=0-4' }), + ); + expect(ContentRange).toMatch(/^bytes 0-4\//); + expect(ContentLength).toBe(5); + expect(await Body!.transformToString()).toBe('Hello'); + }); + it('GetObject returns 404 (NoSuchKey) for missing key', async () => { await expect( s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: 'does-not-exist.txt' })),