diff --git a/.changeset/slow-emus-bow.md b/.changeset/slow-emus-bow.md new file mode 100644 index 00000000000..4d8d72432d1 --- /dev/null +++ b/.changeset/slow-emus-bow.md @@ -0,0 +1,5 @@ +--- +'@aws-amplify/storage': minor +--- + +feat(storage): add PUT method support for getUrl presigned upload URLs diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index 3a6b59eb7e8..5d0b3e992a3 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -511,7 +511,7 @@ "name": "[Storage] getUrl (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ getUrl }", - "limit": "18.12 kB" + "limit": "18.5 kB" }, { "name": "[Storage] list (S3)", diff --git a/packages/predictions/package.json b/packages/predictions/package.json index e6db017ddda..4af90a30b30 100644 --- a/packages/predictions/package.json +++ b/packages/predictions/package.json @@ -66,7 +66,7 @@ "name": "Predictions", "path": "./dist/esm/index.mjs", "import": "{ Predictions }", - "limit": "77 kB" + "limit": "77.5 kB" } ] } diff --git a/packages/storage/__tests__/providers/s3/apis/internal/getUrl.test.ts b/packages/storage/__tests__/providers/s3/apis/internal/getUrl.test.ts index c9b18d8beac..ac9047e476d 100644 --- a/packages/storage/__tests__/providers/s3/apis/internal/getUrl.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/internal/getUrl.test.ts @@ -7,6 +7,7 @@ import { Amplify, StorageAccessLevel } from '@aws-amplify/core'; import { getUrl } from '../../../../../src/providers/s3/apis/internal/getUrl'; import { getPresignedGetObjectUrl, + getPresignedPutObjectUrl, headObject, } from '../../../../../src/providers/s3/utils/client/s3data'; import { @@ -79,6 +80,7 @@ describe('getUrl test with key', () => { $metadata: {} as any, }); jest.mocked(getPresignedGetObjectUrl).mockResolvedValue(mockURL); + jest.mocked(getPresignedPutObjectUrl).mockResolvedValue(mockURL); }); afterEach(() => { jest.clearAllMocks(); @@ -225,6 +227,87 @@ describe('getUrl test with key', () => { ); }); }); + + describe('method PUT for presigned upload URLs', () => { + it('should generate PUT presigned URL and skip validation', async () => { + await getUrlWrapper({ + key: 'key', + options: { + method: 'PUT', + validateObjectExistence: true, + }, + }); + expect(getPresignedPutObjectUrl).toHaveBeenCalledTimes(1); + expect(headObject).not.toHaveBeenCalled(); + await expect(getPresignedPutObjectUrl).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + expiration: expect.any(Number), + }, + { + Bucket: bucket, + Key: 'public/key', + }, + ); + }); + + it('should include content type and disposition for PUT', async () => { + const contentType = 'image/jpeg'; + const contentDisposition = 'attachment; filename="test.jpg"'; + const cacheControl = 'max-age=3600'; + await getUrlWrapper({ + key: 'key', + options: { + method: 'PUT', + contentType, + contentDisposition, + cacheControl, + }, + }); + expect(getPresignedPutObjectUrl).toHaveBeenCalledTimes(1); + await expect(getPresignedPutObjectUrl).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + expiration: expect.any(Number), + }, + { + Bucket: bucket, + Key: 'public/key', + ContentType: contentType, + ContentDisposition: contentDisposition, + CacheControl: cacheControl, + }, + ); + }); + + it('should handle object content disposition for PUT', async () => { + await getUrlWrapper({ + key: 'key', + options: { + method: 'PUT', + contentDisposition: { + type: 'attachment', + filename: 'test.pdf', + }, + }, + }); + expect(getPresignedPutObjectUrl).toHaveBeenCalledTimes(1); + await expect(getPresignedPutObjectUrl).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + expiration: expect.any(Number), + }, + { + Bucket: bucket, + Key: 'public/key', + ContentDisposition: 'attachment; filename="test.pdf"', + }, + ); + }); + }); }); describe('Error cases : With key', () => { afterAll(() => { @@ -285,6 +368,7 @@ describe('getUrl test with path', () => { $metadata: {} as any, }); jest.mocked(getPresignedGetObjectUrl).mockResolvedValue(mockURL); + jest.mocked(getPresignedPutObjectUrl).mockResolvedValue(mockURL); }); afterEach(() => { jest.clearAllMocks(); @@ -418,6 +502,56 @@ describe('getUrl test with path', () => { ); }); }); + + describe('method PUT for presigned upload URLs with path', () => { + it('should generate PUT presigned URL with path and skip validation', async () => { + const inputPath = 'uploads/file.jpg'; + await getUrlWrapper({ + path: inputPath, + options: { + method: 'PUT', + validateObjectExistence: true, + }, + }); + expect(getPresignedPutObjectUrl).toHaveBeenCalledTimes(1); + expect(headObject).not.toHaveBeenCalled(); + await expect(getPresignedPutObjectUrl).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + expiration: expect.any(Number), + }, + { + Bucket: bucket, + Key: inputPath, + }, + ); + }); + + it('should include expectedBucketOwner for PUT with path', async () => { + const inputPath = 'uploads/file.jpg'; + await getUrlWrapper({ + path: inputPath, + options: { + method: 'PUT', + expectedBucketOwner: validBucketOwner, + }, + }); + expect(getPresignedPutObjectUrl).toHaveBeenCalledTimes(1); + await expect(getPresignedPutObjectUrl).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + expiration: expect.any(Number), + }, + { + Bucket: bucket, + Key: inputPath, + ExpectedBucketOwner: validBucketOwner, + }, + ); + }); + }); }); describe('Happy cases: With path and Content Disposition, Content Type', () => { const config = { @@ -435,6 +569,7 @@ describe('getUrl test with path', () => { $metadata: {} as any, }); jest.mocked(getPresignedGetObjectUrl).mockResolvedValue(mockURL); + jest.mocked(getPresignedPutObjectUrl).mockResolvedValue(mockURL); }); afterEach(() => { jest.clearAllMocks(); @@ -500,6 +635,7 @@ describe('getUrl test with path', () => { $metadata: {} as any, }); jest.mocked(getPresignedGetObjectUrl).mockResolvedValue(mockURL); + jest.mocked(getPresignedPutObjectUrl).mockResolvedValue(mockURL); }); afterEach(() => { @@ -660,4 +796,30 @@ describe(`getURL with path and Expected Bucket Owner`, () => { expect(getPresignedGetObjectUrl).not.toHaveBeenCalled(); }); + + it('should pass expectedBucketOwner to getPresignedPutObjectUrl for PUT method', async () => { + const path = 'public/expectedbucketowner_test'; + + await getUrlWrapper({ + path, + options: { + method: 'PUT', + expectedBucketOwner: validBucketOwner, + }, + }); + + expect(getPresignedPutObjectUrl).toHaveBeenCalledTimes(1); + await expect(getPresignedPutObjectUrl).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + expiration: expect.any(Number), + }, + { + Bucket: bucket, + ExpectedBucketOwner: validBucketOwner, + Key: path, + }, + ); + }); }); diff --git a/packages/storage/__tests__/providers/s3/utils/client/S3/getPresignedPutObjectUrl.test.ts b/packages/storage/__tests__/providers/s3/utils/client/S3/getPresignedPutObjectUrl.test.ts new file mode 100644 index 00000000000..1369cfdadcf --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/client/S3/getPresignedPutObjectUrl.test.ts @@ -0,0 +1,134 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + UNSIGNED_PAYLOAD, + presignUrl, +} from '@aws-amplify/core/internals/aws-client-utils'; + +import { getPresignedPutObjectUrl } from '../../../../../../src/providers/s3/utils/client/s3data'; + +import { defaultConfigWithStaticCredentials } from './cases/shared'; + +jest.mock('@aws-amplify/core/internals/aws-client-utils', () => { + const original = jest.requireActual( + '@aws-amplify/core/internals/aws-client-utils', + ); + const { presignUrl: getPresignedUrl } = original; + + return { + ...original, + presignUrl: jest.fn((...args) => getPresignedUrl(...args)), + }; +}); + +const mockPresignUrl = presignUrl as jest.Mock; + +describe('getPresignedPutObjectUrl', () => { + it('should return put object API request', async () => { + const actual = await getPresignedPutObjectUrl( + { + ...defaultConfigWithStaticCredentials, + signingRegion: defaultConfigWithStaticCredentials.region, + signingService: 's3', + expiration: 900, + userAgentValue: 'UA', + }, + { + Bucket: 'bucket', + Key: 'key', + }, + ); + const actualUrl = actual; + expect(actualUrl.hostname).toEqual( + `bucket.s3.${defaultConfigWithStaticCredentials.region}.amazonaws.com`, + ); + expect(actualUrl.pathname).toEqual('/key'); + expect(actualUrl.searchParams.get('X-Amz-Expires')).toEqual('900'); + expect(actualUrl.searchParams.get('x-amz-user-agent')).toEqual('UA'); + }); + + it('should call presignUrl with uriEscapePath param set to false', async () => { + await getPresignedPutObjectUrl( + { + ...defaultConfigWithStaticCredentials, + signingRegion: defaultConfigWithStaticCredentials.region, + signingService: 's3', + expiration: 900, + userAgentValue: 'UA', + }, + { + Bucket: 'bucket', + Key: 'key', + }, + ); + + expect(mockPresignUrl).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + uriEscapePath: false, + }), + ); + }); + + it('should return put object API request with content type and disposition', async () => { + const actual = await getPresignedPutObjectUrl( + { + ...defaultConfigWithStaticCredentials, + signingRegion: defaultConfigWithStaticCredentials.region, + signingService: 's3', + expiration: 900, + userAgentValue: 'UA', + }, + { + Bucket: 'bucket', + Key: 'key', + ContentType: 'image/jpeg', + ContentDisposition: 'attachment; filename="photo.jpg"', + }, + ); + + expect(actual).toEqual( + expect.objectContaining({ + hostname: `bucket.s3.${defaultConfigWithStaticCredentials.region}.amazonaws.com`, + pathname: '/key', + searchParams: expect.objectContaining({ + get: expect.any(Function), + }), + }), + ); + + expect(actual.searchParams.get('X-Amz-Expires')).toBe('900'); + expect(actual.searchParams.get('content-type')).toBe('image/jpeg'); + expect(actual.searchParams.get('content-disposition')).toBe( + 'attachment; filename="photo.jpg"', + ); + expect(actual.searchParams.get('x-amz-user-agent')).toBe('UA'); + }); + + it('should use UNSIGNED-PAYLOAD for presigned URLs', async () => { + mockPresignUrl.mockClear(); + + const result = await getPresignedPutObjectUrl( + { + ...defaultConfigWithStaticCredentials, + signingRegion: defaultConfigWithStaticCredentials.region, + signingService: 's3', + expiration: 900, + }, + { + Bucket: 'bucket', + Key: 'key', + }, + ); + + expect(mockPresignUrl).toHaveBeenCalledWith( + expect.objectContaining({ + body: UNSIGNED_PAYLOAD, + }), + expect.anything(), + ); + + expect(result.searchParams.get('x-amz-content-sha256')).toBeNull(); + }); +}); diff --git a/packages/storage/src/internals/apis/getUrl.ts b/packages/storage/src/internals/apis/getUrl.ts index ef82f107c67..9a32dae1286 100644 --- a/packages/storage/src/internals/apis/getUrl.ts +++ b/packages/storage/src/internals/apis/getUrl.ts @@ -25,6 +25,7 @@ export const getUrl = (input: GetUrlInput) => // Advanced options locationCredentialsProvider: input?.options?.locationCredentialsProvider, customEndpoint: input?.options?.customEndpoint, + method: input?.options?.method, }, // Type casting is necessary because `getPropertiesInternal` supports both Gen1 and Gen2 signatures, but here // given in input can only be Gen2 signature, the return can only ben Gen2 signature. diff --git a/packages/storage/src/providers/s3/apis/getUrl.ts b/packages/storage/src/providers/s3/apis/getUrl.ts index aafe1f282b3..39869eedf4c 100644 --- a/packages/storage/src/providers/s3/apis/getUrl.ts +++ b/packages/storage/src/providers/s3/apis/getUrl.ts @@ -13,7 +13,7 @@ import { import { getUrl as getUrlInternal } from './internal/getUrl'; /** - * Get a temporary presigned URL to download the specified S3 object. + * Get a temporary presigned URL to download or upload the specified S3 object. * The presigned URL expires when the associated role used to sign the request expires or * the option `expiresIn` is reached. The `expiresAt` property in the output object indicates when the URL MAY expire. * diff --git a/packages/storage/src/providers/s3/apis/internal/getUrl.ts b/packages/storage/src/providers/s3/apis/internal/getUrl.ts index 6334a9717cd..303fb59529f 100644 --- a/packages/storage/src/providers/s3/apis/internal/getUrl.ts +++ b/packages/storage/src/providers/s3/apis/internal/getUrl.ts @@ -6,7 +6,10 @@ import { StorageAction } from '@aws-amplify/core/internals/utils'; import { GetUrlInput, GetUrlOutput, GetUrlWithPathOutput } from '../../types'; import { StorageValidationErrorCode } from '../../../../errors/types/validation'; -import { getPresignedGetObjectUrl } from '../../utils/client/s3data'; +import { + getPresignedGetObjectUrl, + getPresignedPutObjectUrl, +} from '../../utils/client/s3data'; import { resolveS3ConfigAndInput, validateBucketOwnerID, @@ -39,8 +42,12 @@ export const getUrl = async ( const finalKey = inputType === STORAGE_INPUT_KEY ? keyPrefix + objectKey : objectKey; + const operation = getUrlOptions?.method ?? 'GET'; - if (getUrlOptions?.validateObjectExistence) { + if ( + getUrlOptions?.validateObjectExistence && + getUrlOptions?.method !== 'PUT' + ) { await getProperties(amplify, input, StorageAction.GetUrl); } @@ -63,7 +70,34 @@ export const getUrl = async ( StorageValidationErrorCode.UrlExpirationMaxLimitExceed, ); - // expiresAt is the minimum of credential expiration and url expiration + if (operation === 'PUT') { + return { + url: await getPresignedPutObjectUrl( + { + ...s3Config, + credentials: resolvedCredential, + expiration: urlExpirationInSec, + }, + { + Bucket: bucket, + Key: finalKey, + ...(getUrlOptions?.contentType && { + ContentType: getUrlOptions.contentType, + }), + ...(getUrlOptions?.contentDisposition && { + ContentDisposition: + typeof getUrlOptions.contentDisposition === 'string' + ? getUrlOptions.contentDisposition + : constructContentDisposition(getUrlOptions.contentDisposition), + }), + CacheControl: getUrlOptions?.cacheControl, + ExpectedBucketOwner: getUrlOptions?.expectedBucketOwner, + }, + ), + expiresAt: new Date(Date.now() + urlExpirationInSec * 1000), + }; + } + return { url: await getPresignedGetObjectUrl( { diff --git a/packages/storage/src/providers/s3/types/options.ts b/packages/storage/src/providers/s3/types/options.ts index cfaba32565d..e6aba96120b 100644 --- a/packages/storage/src/providers/s3/types/options.ts +++ b/packages/storage/src/providers/s3/types/options.ts @@ -158,6 +158,11 @@ export type ListPaginateWithPathOptions = Omit< * Input options type for S3 getUrl API. */ export type GetUrlOptions = CommonOptions & { + /** + * The HTTP method for the presigned URL. + * @default 'GET' + */ + method?: 'GET' | 'PUT'; /** * Whether to head object to make sure the object existence before downloading. * @default false diff --git a/packages/storage/src/providers/s3/utils/client/s3data/index.ts b/packages/storage/src/providers/s3/utils/client/s3data/index.ts index d703fa502de..17785c2103e 100644 --- a/packages/storage/src/providers/s3/utils/client/s3data/index.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/index.ts @@ -13,7 +13,12 @@ export { ListObjectsV2Input, ListObjectsV2Output, } from './listObjectsV2'; -export { putObject, PutObjectInput, PutObjectOutput } from './putObject'; +export { + putObject, + PutObjectInput, + PutObjectOutput, + getPresignedPutObjectUrl, +} from './putObject'; export { createMultipartUpload, CreateMultipartUploadInput, diff --git a/packages/storage/src/providers/s3/utils/client/s3data/putObject.ts b/packages/storage/src/providers/s3/utils/client/s3data/putObject.ts index 0b6f584da4c..01773deb058 100644 --- a/packages/storage/src/providers/s3/utils/client/s3data/putObject.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/putObject.ts @@ -5,11 +5,16 @@ import { Endpoint, HttpRequest, HttpResponse, + PresignUrlOptions, + UNSIGNED_PAYLOAD, + UserAgentOptions, parseMetadata, + presignUrl, } from '@aws-amplify/core/internals/aws-client-utils'; import { AmplifyUrl, AmplifyUrlSearchParams, + USER_AGENT_HEADER, } from '@aws-amplify/core/internals/utils'; import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; @@ -26,6 +31,7 @@ import { validateObjectUrl } from '../../validateObjectUrl'; import { defaultConfig, parseXmlError } from './base'; import type { PutObjectCommandInput, PutObjectCommandOutput } from './types'; +import type { S3EndpointResolverOptions } from './base'; export type PutObjectInput = Pick< PutObjectCommandInput, @@ -112,3 +118,50 @@ export const putObject = composeServiceApi( putObjectDeserializer, { ...defaultConfig, responseType: 'text' }, ); + +type S3PutObjectPresignedUrlConfig = Omit< + UserAgentOptions & PresignUrlOptions & S3EndpointResolverOptions, + 'signingService' | 'signingRegion' +> & { + signingService?: string; + signingRegion?: string; +}; + +/** + * Get a presigned URL for the `putObject` API. + * + * @internal + */ +export const getPresignedPutObjectUrl = async ( + config: S3PutObjectPresignedUrlConfig, + input: Omit, +): Promise => { + const endpoint = defaultConfig.endpointResolver(config, input); + const { url, headers, method } = await putObjectSerializer( + { ...input, Body: undefined }, + endpoint, + ); + + if (config.userAgentValue) { + url.searchParams.append( + config.userAgentHeader ?? USER_AGENT_HEADER, + config.userAgentValue, + ); + } + + for (const [headerName, value] of Object.entries(headers).sort( + ([key1], [key2]) => key1.localeCompare(key2), + )) { + url.searchParams.append(headerName, value); + } + + return presignUrl( + { method, url, body: UNSIGNED_PAYLOAD }, + { + signingService: defaultConfig.service, + signingRegion: config.region, + ...defaultConfig, + ...config, + }, + ); +};