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.
This commit is contained in:
asepharyana
2026-07-07 04:30:04 +07:00
parent 378a084fd3
commit ad45404132
4 changed files with 69 additions and 31 deletions
+36 -20
View File
@@ -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 (
+2
View File
@@ -221,12 +221,14 @@ export const s3ErrorResponse = (
resource: string,
status: number,
requestId: string = '',
extraHeaders: Record<string, string> = {},
): Response =>
new Response(s3ErrorXml(code, message, resource, requestId), {
status,
headers: {
'content-type': 'application/xml',
...(requestId ? { 'x-amz-request-id': requestId } : {}),
...extraHeaders,
},
});
+20 -9
View File
@@ -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) {
expect(r.status).toBe(200);
const text = await r.text();
expect(text).toContain('presigned content');
} else if (r.status === 302) {
expect(r.headers.get('location')).toBeTruthy();
}
});
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 () => {
+9
View File
@@ -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' })),