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).
This commit is contained in:
@@ -19,6 +19,7 @@ interface AppConfig {
|
|||||||
s3AccessKey: string;
|
s3AccessKey: string;
|
||||||
s3SecretKey: string;
|
s3SecretKey: string;
|
||||||
s3DefaultRegion: string;
|
s3DefaultRegion: string;
|
||||||
|
proxyS3Get: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requiredEnv = {
|
const requiredEnv = {
|
||||||
@@ -78,6 +79,7 @@ export const config: AppConfig = {
|
|||||||
s3AccessKey: process.env.S3_ACCESS_KEY || 'teleuploader-admin',
|
s3AccessKey: process.env.S3_ACCESS_KEY || 'teleuploader-admin',
|
||||||
s3SecretKey: process.env.S3_SECRET_KEY || '',
|
s3SecretKey: process.env.S3_SECRET_KEY || '',
|
||||||
s3DefaultRegion: process.env.S3_DEFAULT_REGION || 'us-east-1',
|
s3DefaultRegion: process.env.S3_DEFAULT_REGION || 'us-east-1',
|
||||||
|
proxyS3Get: process.env.PROXY_S3_GET !== 'false',
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.info('Environment variables loaded', {
|
logger.info('Environment variables loaded', {
|
||||||
|
|||||||
+69
-6
@@ -313,11 +313,43 @@ 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}`;
|
||||||
|
|
||||||
return new Response(null, {
|
// Proxy the content from Telegram CDN so real S3 clients get the body directly.
|
||||||
status: 302,
|
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: {
|
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,
|
'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 fileInfo = await getFileInfo(parts[0].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}`;
|
||||||
|
|
||||||
return new Response(null, {
|
if (!config.proxyS3Get) {
|
||||||
status: 302,
|
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: {
|
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,
|
'x-amz-request-id': reqId,
|
||||||
|
'accept-ranges': 'bytes',
|
||||||
|
'cache-control': 'public, max-age=31536000',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -236,10 +236,12 @@ describe('S3 API (production, SigV4)', () => {
|
|||||||
expect(Number(r.headers.get('content-length'))).toBeGreaterThan(0);
|
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`);
|
const r = await s3Request('GET', `/${bucketName}/test-file.txt`);
|
||||||
expect([200, 302]).toContain(r.status);
|
expect(r.status).toBe(200);
|
||||||
if (r.status === 302) expect(r.headers.get('location')).toBeTruthy();
|
const text = await r.text();
|
||||||
|
expect(text).toContain('hello s3');
|
||||||
|
expect(r.headers.get('content-type')).toMatch(/text|octet/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ListObjectsV1 (GET /{bucket})', async () => {
|
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 presignedUrl = `${BASE_URL}/${bucketName}/presigned-test.txt?${sp.toString()}`;
|
||||||
|
|
||||||
const r = await fetch(presignedUrl);
|
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) {
|
if (r.status === 403) {
|
||||||
console.warn('⚠️ Presigned URL returned 403 — verification mismatch');
|
console.warn('⚠️ Presigned URL returned 403 — verification mismatch');
|
||||||
}
|
}
|
||||||
expect([302, 403]).toContain(r.status);
|
expect([200, 302, 403]).toContain(r.status);
|
||||||
if (r.status === 302) expect(r.headers.get('location')).toBeTruthy();
|
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 () => {
|
it('Delete bucket — must be empty first', async () => {
|
||||||
|
|||||||
+10
-20
@@ -31,11 +31,8 @@ import {
|
|||||||
CopyObjectCommand,
|
CopyObjectCommand,
|
||||||
DeleteObjectCommand,
|
DeleteObjectCommand,
|
||||||
DeleteObjectsCommand,
|
DeleteObjectsCommand,
|
||||||
CreateMultipartUploadCommand,
|
|
||||||
UploadPartCommand,
|
|
||||||
CompleteMultipartUploadCommand,
|
|
||||||
AbortMultipartUploadCommand,
|
|
||||||
ListMultipartUploadsCommand,
|
ListMultipartUploadsCommand,
|
||||||
|
AbortMultipartUploadCommand,
|
||||||
NoSuchKey,
|
NoSuchKey,
|
||||||
NotFound,
|
NotFound,
|
||||||
} from '@aws-sdk/client-s3';
|
} from '@aws-sdk/client-s3';
|
||||||
@@ -158,22 +155,15 @@ describe('S3 SDK compatibility', () => {
|
|||||||
expect(ContentType).toBe('text/plain');
|
expect(ContentType).toBe('text/plain');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GetObject returns redirect to Telegram (302)', async () => {
|
it('GetObject returns stored content (proxied from Telegram)', async () => {
|
||||||
// TeleUploader returns 302 -> Telegram CDN rather than the raw object body.
|
const { Body, ContentType, ContentLength, ETag } = await s3.send(
|
||||||
// The AWS SDK follows the redirect but the target (api.telegram.org) returns
|
new GetObjectCommand({ Bucket: BUCKET, Key: 'hello-sdk.txt' }),
|
||||||
// non-XML, so the deserializer throws UnknownError.
|
);
|
||||||
// We accept either a 200 with body OR an error with 302 status.
|
const text = await Body!.transformToString();
|
||||||
try {
|
expect(text).toBe('Hello from AWS SDK v3!');
|
||||||
const { Body, ContentType } = await s3.send(
|
expect(ContentType).toBe('text/plain');
|
||||||
new GetObjectCommand({ Bucket: BUCKET, Key: 'hello-sdk.txt' }),
|
expect(ContentLength).toBeGreaterThan(0);
|
||||||
);
|
expect(ETag).toBeTruthy();
|
||||||
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 404 (NoSuchKey) for missing key', async () => {
|
it('GetObject returns 404 (NoSuchKey) for missing key', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user