Skip to content

Commit 77765a8

Browse files
authored
fix(storage-browser): multi-region S3 buckets (#6734)
* feat(storage-browser): add multi-region S3 bucket support - Add getBucketRegion helper to extract bucket-specific regions from Amplify config - Update constructBucket to use bucket-specific regions instead of global region - Fix CORS errors when accessing buckets in different AWS regions - Add comprehensive unit tests for getBucketRegion function - Add integration tests for multi-region bucket navigation - Export getBucketRegion from utils for reusability - Add try-catch error handling for robust region extraction Fixes #6722 * add change set
1 parent 6a8d4bd commit 77765a8

File tree

7 files changed

+213
-4
lines changed

7 files changed

+213
-4
lines changed

.changeset/twenty-clocks-flash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@aws-amplify/ui-react-storage': minor
3+
---
4+
5+
fix(storage-browser): multi-region S3 buckets
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import awsExports from '@environments/storage/gen2/amplify_outputs.json';
2+
export default awsExports;
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import React from 'react';
2+
import { Amplify } from 'aws-amplify';
3+
import { signOut } from 'aws-amplify/auth';
4+
5+
import { Button, Flex, withAuthenticator } from '@aws-amplify/ui-react';
6+
import { StorageBrowser } from '@aws-amplify/ui-react-storage';
7+
import config from './aws-exports';
8+
9+
import '@aws-amplify/ui-react-storage/styles.css';
10+
import { parseAmplifyConfig } from 'aws-amplify/utils';
11+
12+
const amplifyConfig = parseAmplifyConfig(config);
13+
14+
const bucketInAnotherRegionOne = {
15+
name: 'MultiRegionOneForStorageBrowser',
16+
bucketName: 'multi-region-1-for-storage-browser',
17+
region: 'eu-central-1',
18+
paths: {
19+
'multi-region-folder-do-not-delete/*': {
20+
guest: ['get', 'list', 'write'],
21+
authenticated: ['get', 'list', 'write', 'delete'],
22+
},
23+
},
24+
};
25+
26+
const bucketInAnotherRegionTwo = {
27+
name: 'MultiRegionTwoForStorageBrowser',
28+
bucketName: 'multi-region-2-for-storage-browser',
29+
region: 'ap-northeast-3',
30+
paths: {
31+
'another-multi-region-folder-do-not-delete/*': {
32+
guest: ['get', 'list', 'write'],
33+
authenticated: ['get', 'list', 'write', 'delete'],
34+
},
35+
},
36+
};
37+
38+
Amplify.configure({
39+
...amplifyConfig,
40+
Storage: {
41+
...amplifyConfig.Storage,
42+
S3: {
43+
...amplifyConfig.Storage.S3,
44+
buckets: {
45+
...amplifyConfig.Storage.S3?.buckets,
46+
[bucketInAnotherRegionTwo.name]: bucketInAnotherRegionTwo,
47+
[bucketInAnotherRegionOne.name]: bucketInAnotherRegionOne,
48+
},
49+
},
50+
},
51+
});
52+
53+
function MultiRegionReproduction() {
54+
return (
55+
<Flex direction="column" width="100vw" height="100vh" padding="xl">
56+
<Button
57+
marginBlockEnd="xl"
58+
alignSelf="flex-start"
59+
size="small"
60+
onClick={() => signOut()}
61+
>
62+
Sign Out
63+
</Button>
64+
65+
<StorageBrowser
66+
displayText={{
67+
LocationsView: {
68+
title: 'This StorageBrowser uses Multi-Region buckets',
69+
},
70+
}}
71+
/>
72+
</Flex>
73+
);
74+
}
75+
76+
export default withAuthenticator(MultiRegionReproduction);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
Feature: Storage Browser Multi-Region Bucket Support
2+
3+
Background:
4+
Given I'm running the example "ui/components/storage/storage-browser/multi-region"
5+
And I type my "email" with status "CONFIRMED"
6+
And I type my password
7+
And I click the "Sign in" button
8+
9+
@react
10+
Scenario: Navigate to bucket in different region
11+
Then I see "This StorageBrowser uses Multi-Region buckets"
12+
When I click the "another-multi-region-folder-do-not-delete/" button
13+
When I click the button with label "multi-region-file-do-not-delete.jpg file"
14+
Then I see "File Preview"
15+
Then I see "File Information"
16+
Then I see the "Image preview for multi-region-file-do-not-delete.jpg" image
17+
18+
@react
19+
Scenario: Navigate to bucket in a third different region
20+
Then I see "This StorageBrowser uses Multi-Region buckets"
21+
When I click the "multi-region-folder-do-not-delete/" button
22+
When I click the button with label "multi-region-file-do-not-delete.jpg file"
23+
Then I see "File Preview"
24+
Then I see "File Information"
25+
Then I see the "Image preview for multi-region-file-do-not-delete.jpg" image

packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/utils.spec.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,17 @@ import { LocationAccess as AccessGrantLocation } from '../../../storage-internal
22

33
import { MULTIPART_UPLOAD_THRESHOLD_BYTES } from '../constants';
44
import { LocationData } from '../types';
5+
6+
const mockGetConfig = jest.fn();
7+
jest.mock('aws-amplify', () => ({
8+
Amplify: {
9+
getConfig: mockGetConfig,
10+
},
11+
}));
12+
513
import {
614
createFileDataItem,
15+
getBucketRegion,
716
getFileKey,
817
getFilteredLocations,
918
isFileDataItem,
@@ -236,4 +245,66 @@ describe('utils', () => {
236245
expect(output).toBe(false);
237246
});
238247
});
248+
249+
describe('getBucketRegion', () => {
250+
beforeEach(() => {
251+
jest.clearAllMocks();
252+
});
253+
254+
it('returns bucket-specific region when found', () => {
255+
mockGetConfig.mockReturnValue({
256+
Storage: {
257+
S3: {
258+
buckets: {
259+
TestBucket: {
260+
bucketName: 'test-bucket',
261+
region: 'us-west-2',
262+
},
263+
},
264+
},
265+
},
266+
});
267+
268+
const result = getBucketRegion('test-bucket', 'us-east-1');
269+
expect(result).toBe('us-west-2');
270+
});
271+
272+
it('returns fallback region when bucket not found', () => {
273+
mockGetConfig.mockReturnValue({
274+
Storage: {
275+
S3: {
276+
buckets: {
277+
OtherBucket: {
278+
bucketName: 'other-bucket',
279+
region: 'us-west-2',
280+
},
281+
},
282+
},
283+
},
284+
});
285+
286+
const result = getBucketRegion('test-bucket', 'us-east-1');
287+
expect(result).toBe('us-east-1');
288+
});
289+
290+
it('returns fallback region when no buckets config', () => {
291+
mockGetConfig.mockReturnValue({
292+
Storage: {
293+
S3: {},
294+
},
295+
});
296+
297+
const result = getBucketRegion('test-bucket', 'us-east-1');
298+
expect(result).toBe('us-east-1');
299+
});
300+
301+
it('returns fallback region when getConfig throws error', () => {
302+
mockGetConfig.mockImplementation(() => {
303+
throw new Error('Config error');
304+
});
305+
306+
const result = getBucketRegion('test-bucket', 'us-east-1');
307+
expect(result).toBe('us-east-1');
308+
});
309+
});
239310
});

packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocationItems.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getBucketRegion } from './utils';
12
import type {
23
StorageSubpathStrategy,
34
ListPaginateInput,
@@ -98,7 +99,7 @@ export const listLocationItemsHandler: ListLocationItemsHandler = async (
9899
bucket: _bucket,
99100
credentials,
100101
customEndpoint,
101-
region,
102+
region: globalRegion,
102103
accountId,
103104
} = config;
104105

@@ -110,7 +111,8 @@ export const listLocationItemsHandler: ListLocationItemsHandler = async (
110111
..._options
111112
} = options ?? {};
112113

113-
const bucket = { bucketName: _bucket, region };
114+
const bucketRegion = getBucketRegion(_bucket, globalRegion);
115+
const bucket = { bucketName: _bucket, region: bucketRegion };
114116
const subpathStrategy: StorageSubpathStrategy = {
115117
delimiter,
116118
strategy: delimiter ? 'exclude' : 'include',

packages/react-storage/src/components/StorageBrowser/actions/handlers/utils.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,41 @@ import type {
1313
LocationType,
1414
} from './types';
1515

16+
import { Amplify } from 'aws-amplify';
17+
18+
export const getBucketRegion = (
19+
bucketName: string,
20+
fallbackRegion: string
21+
): string => {
22+
try {
23+
const config = Amplify.getConfig()?.Storage?.S3;
24+
25+
if (!config?.buckets || typeof config.buckets !== 'object') {
26+
return fallbackRegion;
27+
}
28+
29+
for (const bucketConfig of Object.values(config.buckets)) {
30+
if (bucketConfig.bucketName === bucketName && bucketConfig.region) {
31+
return bucketConfig.region;
32+
}
33+
}
34+
35+
return fallbackRegion;
36+
} catch (error) {
37+
return fallbackRegion;
38+
}
39+
};
40+
1641
export const constructBucket = ({
1742
bucket: bucketName,
18-
region,
43+
region: globalRegion,
1944
}: Pick<ActionInputConfig, 'bucket' | 'region'>): {
2045
bucketName: string;
2146
region: string;
22-
} => ({ bucketName, region });
47+
} => {
48+
const bucketRegion = getBucketRegion(bucketName, globalRegion);
49+
return { bucketName, region: bucketRegion };
50+
};
2351

2452
export const parseAccessGrantLocation = (
2553
location: AccessGrantLocation

0 commit comments

Comments
 (0)