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 { cleanupTempFile, computeHash, ensureExtension, getErrorMessage } from '../utils/file';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
import { verifyPresignedUrl, verifySignature } from '../utils/s3/auth';
|
import { verifyPresignedUrl, verifySignature } from '../utils/s3/auth';
|
||||||
|
import { createGetObjectResponse, type ObjectPartSource } from '../utils/s3/object-stream';
|
||||||
|
import { parseRangeHeader, unsatisfiedContentRange } from '../utils/s3/range';
|
||||||
import {
|
import {
|
||||||
completeMultipartUploadXml,
|
completeMultipartUploadXml,
|
||||||
copyObjectResultXml,
|
copyObjectResultXml,
|
||||||
@@ -316,7 +318,21 @@ const handleGetObject = async (
|
|||||||
const fileInfo = await getFileInfo(file.telegramFileId);
|
const fileInfo = await getFileInfo(file.telegramFileId);
|
||||||
const redirectUrl = `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`;
|
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) {
|
if (!config.proxyS3Get) {
|
||||||
// Legacy 302 redirect path (when proxy is disabled)
|
// Legacy 302 redirect path (when proxy is disabled)
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
@@ -325,11 +341,27 @@ const handleGetObject = async (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const tgResponse = await fetch(redirectUrl);
|
const part: ObjectPartSource = {
|
||||||
if (!tgResponse.ok) {
|
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', {
|
logger.warn('Telegram content fetch failed', {
|
||||||
status: tgResponse.status,
|
|
||||||
fileId: file.telegramFileId,
|
fileId: file.telegramFileId,
|
||||||
|
error: getErrorMessage(error),
|
||||||
});
|
});
|
||||||
return s3ErrorResponse(
|
return s3ErrorResponse(
|
||||||
'InternalError',
|
'InternalError',
|
||||||
@@ -339,22 +371,6 @@ const handleGetObject = async (
|
|||||||
reqId,
|
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 (
|
const handleGetMultipartObject = async (
|
||||||
|
|||||||
@@ -221,12 +221,14 @@ export const s3ErrorResponse = (
|
|||||||
resource: string,
|
resource: string,
|
||||||
status: number,
|
status: number,
|
||||||
requestId: string = '',
|
requestId: string = '',
|
||||||
|
extraHeaders: Record<string, string> = {},
|
||||||
): Response =>
|
): Response =>
|
||||||
new Response(s3ErrorXml(code, message, resource, requestId), {
|
new Response(s3ErrorXml(code, message, resource, requestId), {
|
||||||
status,
|
status,
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/xml',
|
'content-type': 'application/xml',
|
||||||
...(requestId ? { 'x-amz-request-id': requestId } : {}),
|
...(requestId ? { 'x-amz-request-id': requestId } : {}),
|
||||||
|
...extraHeaders,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+22
-11
@@ -369,17 +369,28 @@ describe('S3 API (production, SigV4)', () => {
|
|||||||
const presignedUrl = `${BASE_URL}/${bucketName}/presigned-test.txt?${sp.toString()}`;
|
const presignedUrl = `${BASE_URL}/${bucketName}/presigned-test.txt?${sp.toString()}`;
|
||||||
|
|
||||||
const r = await fetch(presignedUrl);
|
const r = await fetch(presignedUrl);
|
||||||
// Presigned URL: 200 (proxied body), 302 (redirect), or 403 (auth fail)
|
expect(r.status).toBe(200);
|
||||||
if (r.status === 403) {
|
const text = await r.text();
|
||||||
console.warn('⚠️ Presigned URL returned 403 — verification mismatch');
|
expect(text).toContain('presigned content');
|
||||||
}
|
});
|
||||||
expect([200, 302, 403]).toContain(r.status);
|
|
||||||
if (r.status === 200) {
|
it('GetObject Range — returns partial single-part content', async () => {
|
||||||
const text = await r.text();
|
const r = await s3Request('GET', `/${bucketName}/test-file.txt`, {
|
||||||
expect(text).toContain('presigned content');
|
headers: { range: 'bytes=0-4' },
|
||||||
} else if (r.status === 302) {
|
});
|
||||||
expect(r.headers.get('location')).toBeTruthy();
|
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 () => {
|
it('Delete bucket — must be empty first', async () => {
|
||||||
|
|||||||
@@ -174,6 +174,15 @@ describe('S3 SDK compatibility', () => {
|
|||||||
expect(ETag).toBeTruthy();
|
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 () => {
|
it('GetObject returns 404 (NoSuchKey) for missing key', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: 'does-not-exist.txt' })),
|
s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: 'does-not-exist.txt' })),
|
||||||
|
|||||||
Reference in New Issue
Block a user