Skip to content

Commit 2944579

Browse files
authored
fix(s3): support get-object region override and robust S3 URL parsing (#3206)
* fix(s3): support get-object region override and robust S3 URL parsing * ack pr comments
1 parent 81dfeb0 commit 2944579

File tree

3 files changed

+81
-38
lines changed

3 files changed

+81
-38
lines changed

apps/sim/blocks/blocks/s3.ts

Lines changed: 16 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ export const S3Block: BlockConfig<S3Response> = {
5858
},
5959
required: true,
6060
},
61+
{
62+
id: 'getObjectRegion',
63+
title: 'AWS Region',
64+
type: 'short-input',
65+
placeholder: 'Used when S3 URL does not include region',
66+
condition: {
67+
field: 'operation',
68+
value: ['get_object'],
69+
},
70+
},
6171
{
6272
id: 'bucketName',
6373
title: 'Bucket Name',
@@ -291,34 +301,11 @@ export const S3Block: BlockConfig<S3Response> = {
291301
if (!params.s3Uri) {
292302
throw new Error('S3 Object URL is required')
293303
}
294-
295-
// Parse S3 URI for get_object
296-
try {
297-
const url = new URL(params.s3Uri)
298-
const hostname = url.hostname
299-
const bucketName = hostname.split('.')[0]
300-
const regionMatch = hostname.match(/s3[.-]([^.]+)\.amazonaws\.com/)
301-
const region = regionMatch ? regionMatch[1] : params.region
302-
const objectKey = url.pathname.startsWith('/')
303-
? url.pathname.substring(1)
304-
: url.pathname
305-
306-
if (!bucketName || !objectKey) {
307-
throw new Error('Could not parse S3 URL')
308-
}
309-
310-
return {
311-
accessKeyId: params.accessKeyId,
312-
secretAccessKey: params.secretAccessKey,
313-
region,
314-
bucketName,
315-
objectKey,
316-
s3Uri: params.s3Uri,
317-
}
318-
} catch (_error) {
319-
throw new Error(
320-
'Invalid S3 Object URL format. Expected: https://bucket-name.s3.region.amazonaws.com/path/to/file'
321-
)
304+
return {
305+
accessKeyId: params.accessKeyId,
306+
secretAccessKey: params.secretAccessKey,
307+
region: params.getObjectRegion || params.region,
308+
s3Uri: params.s3Uri,
322309
}
323310
}
324311

@@ -401,6 +388,7 @@ export const S3Block: BlockConfig<S3Response> = {
401388
acl: { type: 'string', description: 'Access control list' },
402389
// Download inputs
403390
s3Uri: { type: 'string', description: 'S3 object URL' },
391+
getObjectRegion: { type: 'string', description: 'Optional AWS region override for downloads' },
404392
// List inputs
405393
prefix: { type: 'string', description: 'Prefix filter' },
406394
maxKeys: { type: 'number', description: 'Maximum results' },

apps/sim/tools/s3/get_object.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ export const s3GetObjectTool: ToolConfig = {
2626
visibility: 'user-only',
2727
description: 'Your AWS Secret Access Key',
2828
},
29+
region: {
30+
type: 'string',
31+
required: false,
32+
visibility: 'user-only',
33+
description:
34+
'Optional region override when URL does not include region (e.g., us-east-1, eu-west-1)',
35+
},
2936
s3Uri: {
3037
type: 'string',
3138
required: true,
@@ -37,7 +44,7 @@ export const s3GetObjectTool: ToolConfig = {
3744
request: {
3845
url: (params) => {
3946
try {
40-
const { bucketName, region, objectKey } = parseS3Uri(params.s3Uri)
47+
const { bucketName, region, objectKey } = parseS3Uri(params.s3Uri, params.region)
4148

4249
params.bucketName = bucketName
4350
params.region = region
@@ -46,7 +53,7 @@ export const s3GetObjectTool: ToolConfig = {
4653
return `https://${bucketName}.s3.${region}.amazonaws.com/${encodeS3PathComponent(objectKey)}`
4754
} catch (_error) {
4855
throw new Error(
49-
'Invalid S3 Object URL format. Expected format: https://bucket-name.s3.region.amazonaws.com/path/to/file'
56+
'Invalid S3 Object URL. Use a valid S3 URL and optionally provide region if the URL omits it.'
5057
)
5158
}
5259
},
@@ -55,7 +62,7 @@ export const s3GetObjectTool: ToolConfig = {
5562
try {
5663
// Parse S3 URI if not already parsed
5764
if (!params.bucketName || !params.region || !params.objectKey) {
58-
const { bucketName, region, objectKey } = parseS3Uri(params.s3Uri)
65+
const { bucketName, region, objectKey } = parseS3Uri(params.s3Uri, params.region)
5966
params.bucketName = bucketName
6067
params.region = region
6168
params.objectKey = objectKey
@@ -102,7 +109,7 @@ export const s3GetObjectTool: ToolConfig = {
102109
transformResponse: async (response: Response, params) => {
103110
// Parse S3 URI if not already parsed
104111
if (!params.bucketName || !params.region || !params.objectKey) {
105-
const { bucketName, region, objectKey } = parseS3Uri(params.s3Uri)
112+
const { bucketName, region, objectKey } = parseS3Uri(params.s3Uri, params.region)
106113
params.bucketName = bucketName
107114
params.region = region
108115
params.objectKey = objectKey

apps/sim/tools/s3/utils.ts

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,66 @@ export function getSignatureKey(
2020
return kSigning
2121
}
2222

23-
export function parseS3Uri(s3Uri: string): {
23+
export function parseS3Uri(
24+
s3Uri: string,
25+
fallbackRegion?: string
26+
): {
2427
bucketName: string
2528
region: string
2629
objectKey: string
2730
} {
2831
try {
2932
const url = new URL(s3Uri)
3033
const hostname = url.hostname
31-
const bucketName = hostname.split('.')[0]
32-
const regionMatch = hostname.match(/s3[.-]([^.]+)\.amazonaws\.com/)
33-
const region = regionMatch ? regionMatch[1] : 'us-east-1'
34-
const objectKey = url.pathname.startsWith('/') ? url.pathname.substring(1) : url.pathname
34+
const normalizedPath = url.pathname.startsWith('/') ? url.pathname.slice(1) : url.pathname
35+
36+
const virtualHostedDualstackMatch = hostname.match(
37+
/^(.+)\.s3\.dualstack\.([^.]+)\.amazonaws\.com(?:\.cn)?$/
38+
)
39+
const virtualHostedRegionalMatch = hostname.match(
40+
/^(.+)\.s3[.-]([^.]+)\.amazonaws\.com(?:\.cn)?$/
41+
)
42+
const virtualHostedGlobalMatch = hostname.match(/^(.+)\.s3\.amazonaws\.com(?:\.cn)?$/)
43+
44+
const pathStyleDualstackMatch = hostname.match(
45+
/^s3\.dualstack\.([^.]+)\.amazonaws\.com(?:\.cn)?$/
46+
)
47+
const pathStyleRegionalMatch = hostname.match(/^s3[.-]([^.]+)\.amazonaws\.com(?:\.cn)?$/)
48+
const pathStyleGlobalMatch = hostname.match(/^s3\.amazonaws\.com(?:\.cn)?$/)
49+
50+
const isPathStyleHost = Boolean(
51+
pathStyleDualstackMatch || pathStyleRegionalMatch || pathStyleGlobalMatch
52+
)
53+
54+
const firstSlashIndex = normalizedPath.indexOf('/')
55+
const pathStyleBucketName =
56+
firstSlashIndex === -1 ? normalizedPath : normalizedPath.slice(0, firstSlashIndex)
57+
const pathStyleObjectKey =
58+
firstSlashIndex === -1 ? '' : normalizedPath.slice(firstSlashIndex + 1)
59+
60+
const bucketName = isPathStyleHost
61+
? pathStyleBucketName
62+
: (virtualHostedDualstackMatch?.[1] ??
63+
virtualHostedRegionalMatch?.[1] ??
64+
virtualHostedGlobalMatch?.[1] ??
65+
'')
66+
67+
const rawObjectKey = isPathStyleHost ? pathStyleObjectKey : normalizedPath
68+
const objectKey = (() => {
69+
try {
70+
return decodeURIComponent(rawObjectKey)
71+
} catch {
72+
return rawObjectKey
73+
}
74+
})()
75+
76+
const normalizedFallbackRegion = fallbackRegion?.trim()
77+
const regionFromHost =
78+
virtualHostedDualstackMatch?.[2] ??
79+
virtualHostedRegionalMatch?.[2] ??
80+
pathStyleDualstackMatch?.[1] ??
81+
pathStyleRegionalMatch?.[1]
82+
const region = regionFromHost || normalizedFallbackRegion || 'us-east-1'
3583

3684
if (!bucketName || !objectKey) {
3785
throw new Error('Invalid S3 URI format')
@@ -40,7 +88,7 @@ export function parseS3Uri(s3Uri: string): {
4088
return { bucketName, region, objectKey }
4189
} catch (_error) {
4290
throw new Error(
43-
'Invalid S3 Object URL format. Expected format: https://bucket-name.s3.region.amazonaws.com/path/to/file'
91+
'Invalid S3 Object URL format. Expected S3 virtual-hosted or path-style URL with object key.'
4492
)
4593
}
4694
}

0 commit comments

Comments
 (0)