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:
+36
-20
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' })),
|
||||
|
||||
Reference in New Issue
Block a user