Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 16 additions & 28 deletions apps/sim/blocks/blocks/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ export const S3Block: BlockConfig<S3Response> = {
},
required: true,
},
{
id: 'getObjectRegion',
title: 'AWS Region',
type: 'short-input',
placeholder: 'Used when S3 URL does not include region',
condition: {
field: 'operation',
value: ['get_object'],
},
},
{
id: 'bucketName',
title: 'Bucket Name',
Expand Down Expand Up @@ -291,34 +301,11 @@ export const S3Block: BlockConfig<S3Response> = {
if (!params.s3Uri) {
throw new Error('S3 Object URL is required')
}

// Parse S3 URI for get_object
try {
const url = new URL(params.s3Uri)
const hostname = url.hostname
const bucketName = hostname.split('.')[0]
const regionMatch = hostname.match(/s3[.-]([^.]+)\.amazonaws\.com/)
const region = regionMatch ? regionMatch[1] : params.region
const objectKey = url.pathname.startsWith('/')
? url.pathname.substring(1)
: url.pathname

if (!bucketName || !objectKey) {
throw new Error('Could not parse S3 URL')
}

return {
accessKeyId: params.accessKeyId,
secretAccessKey: params.secretAccessKey,
region,
bucketName,
objectKey,
s3Uri: params.s3Uri,
}
} catch (_error) {
throw new Error(
'Invalid S3 Object URL format. Expected: https://bucket-name.s3.region.amazonaws.com/path/to/file'
)
return {
accessKeyId: params.accessKeyId,
secretAccessKey: params.secretAccessKey,
region: params.getObjectRegion || params.region,
s3Uri: params.s3Uri,
}
}

Expand Down Expand Up @@ -401,6 +388,7 @@ export const S3Block: BlockConfig<S3Response> = {
acl: { type: 'string', description: 'Access control list' },
// Download inputs
s3Uri: { type: 'string', description: 'S3 object URL' },
getObjectRegion: { type: 'string', description: 'Optional AWS region override for downloads' },
// List inputs
prefix: { type: 'string', description: 'Prefix filter' },
maxKeys: { type: 'number', description: 'Maximum results' },
Expand Down
15 changes: 11 additions & 4 deletions apps/sim/tools/s3/get_object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ export const s3GetObjectTool: ToolConfig = {
visibility: 'user-only',
description: 'Your AWS Secret Access Key',
},
region: {
type: 'string',
required: false,
visibility: 'user-only',
description:
'Optional region override when URL does not include region (e.g., us-east-1, eu-west-1)',
},
s3Uri: {
type: 'string',
required: true,
Expand All @@ -37,7 +44,7 @@ export const s3GetObjectTool: ToolConfig = {
request: {
url: (params) => {
try {
const { bucketName, region, objectKey } = parseS3Uri(params.s3Uri)
const { bucketName, region, objectKey } = parseS3Uri(params.s3Uri, params.region)

params.bucketName = bucketName
params.region = region
Expand All @@ -46,7 +53,7 @@ export const s3GetObjectTool: ToolConfig = {
return `https://${bucketName}.s3.${region}.amazonaws.com/${encodeS3PathComponent(objectKey)}`
} catch (_error) {
throw new Error(
'Invalid S3 Object URL format. Expected format: https://bucket-name.s3.region.amazonaws.com/path/to/file'
'Invalid S3 Object URL. Use a valid S3 URL and optionally provide region if the URL omits it.'
)
}
},
Expand All @@ -55,7 +62,7 @@ export const s3GetObjectTool: ToolConfig = {
try {
// Parse S3 URI if not already parsed
if (!params.bucketName || !params.region || !params.objectKey) {
const { bucketName, region, objectKey } = parseS3Uri(params.s3Uri)
const { bucketName, region, objectKey } = parseS3Uri(params.s3Uri, params.region)
params.bucketName = bucketName
params.region = region
params.objectKey = objectKey
Expand Down Expand Up @@ -102,7 +109,7 @@ export const s3GetObjectTool: ToolConfig = {
transformResponse: async (response: Response, params) => {
// Parse S3 URI if not already parsed
if (!params.bucketName || !params.region || !params.objectKey) {
const { bucketName, region, objectKey } = parseS3Uri(params.s3Uri)
const { bucketName, region, objectKey } = parseS3Uri(params.s3Uri, params.region)
params.bucketName = bucketName
params.region = region
params.objectKey = objectKey
Expand Down
60 changes: 54 additions & 6 deletions apps/sim/tools/s3/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,66 @@ export function getSignatureKey(
return kSigning
}

export function parseS3Uri(s3Uri: string): {
export function parseS3Uri(
s3Uri: string,
fallbackRegion?: string
): {
bucketName: string
region: string
objectKey: string
} {
try {
const url = new URL(s3Uri)
const hostname = url.hostname
const bucketName = hostname.split('.')[0]
const regionMatch = hostname.match(/s3[.-]([^.]+)\.amazonaws\.com/)
const region = regionMatch ? regionMatch[1] : 'us-east-1'
const objectKey = url.pathname.startsWith('/') ? url.pathname.substring(1) : url.pathname
const normalizedPath = url.pathname.startsWith('/') ? url.pathname.slice(1) : url.pathname

const virtualHostedDualstackMatch = hostname.match(
/^(.+)\.s3\.dualstack\.([^.]+)\.amazonaws\.com(?:\.cn)?$/
)
const virtualHostedRegionalMatch = hostname.match(
/^(.+)\.s3[.-]([^.]+)\.amazonaws\.com(?:\.cn)?$/
)
const virtualHostedGlobalMatch = hostname.match(/^(.+)\.s3\.amazonaws\.com(?:\.cn)?$/)

const pathStyleDualstackMatch = hostname.match(
/^s3\.dualstack\.([^.]+)\.amazonaws\.com(?:\.cn)?$/
)
const pathStyleRegionalMatch = hostname.match(/^s3[.-]([^.]+)\.amazonaws\.com(?:\.cn)?$/)
const pathStyleGlobalMatch = hostname.match(/^s3\.amazonaws\.com(?:\.cn)?$/)

const isPathStyleHost = Boolean(
pathStyleDualstackMatch || pathStyleRegionalMatch || pathStyleGlobalMatch
)

const firstSlashIndex = normalizedPath.indexOf('/')
const pathStyleBucketName =
firstSlashIndex === -1 ? normalizedPath : normalizedPath.slice(0, firstSlashIndex)
const pathStyleObjectKey =
firstSlashIndex === -1 ? '' : normalizedPath.slice(firstSlashIndex + 1)

const bucketName = isPathStyleHost
? pathStyleBucketName
: (virtualHostedDualstackMatch?.[1] ??
virtualHostedRegionalMatch?.[1] ??
virtualHostedGlobalMatch?.[1] ??
'')

const rawObjectKey = isPathStyleHost ? pathStyleObjectKey : normalizedPath
const objectKey = (() => {
try {
return decodeURIComponent(rawObjectKey)
} catch {
return rawObjectKey
}
})()

const normalizedFallbackRegion = fallbackRegion?.trim()
const regionFromHost =
virtualHostedDualstackMatch?.[2] ??
virtualHostedRegionalMatch?.[2] ??
pathStyleDualstackMatch?.[1] ??
pathStyleRegionalMatch?.[1]
const region = regionFromHost || normalizedFallbackRegion || 'us-east-1'

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