From c4a0b63778559420972adefc6279d1fddde5aa73 Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Fri, 18 Apr 2025 10:22:00 +0200 Subject: [PATCH] feat: Add support for uploading a file directly to the file provider from the client (#12224) * feat: Add support for uploading a file directly to the file provider from the client * fix: Add missing types and add a couple of module tests * fix: Allow nested routes, add test for it --- packages/core/types/src/file/common.ts | 16 ++++ packages/core/types/src/file/mutations.ts | 23 ++++- packages/core/types/src/file/provider.ts | 43 +++++++++- packages/core/types/src/file/service.ts | 41 ++++++++- .../providers/default-provider.ts | 9 ++ .../__tests__/module.spec.ts | 25 ++++++ .../file/src/services/file-module-service.ts | 23 +++++ .../src/services/file-provider-service.ts | 20 +++++ .../__fixtures__/catphoto.jpg | Bin 0 -> 24003 bytes .../__tests__/services.spec.ts | 79 ++++++++++++++++++ .../providers/file-s3/src/services/s3-file.ts | 40 +++++++++ 11 files changed, 314 insertions(+), 5 deletions(-) diff --git a/packages/core/types/src/file/common.ts b/packages/core/types/src/file/common.ts index b364d9379f..31fe4ba532 100644 --- a/packages/core/types/src/file/common.ts +++ b/packages/core/types/src/file/common.ts @@ -1,3 +1,8 @@ +/** + * The access level of the file. + */ +export type FileAccessPermission = "public" | "private" + /** * The File details. */ @@ -24,3 +29,14 @@ export interface FilterableFileProps { */ id?: string } + +export interface UploadFileUrlDTO { + /** + * The URL of the file. + */ + url: string + /** + * The key of the file. + */ + key: string +} diff --git a/packages/core/types/src/file/mutations.ts b/packages/core/types/src/file/mutations.ts index 9b92e1848d..cc26b09401 100644 --- a/packages/core/types/src/file/mutations.ts +++ b/packages/core/types/src/file/mutations.ts @@ -1,3 +1,5 @@ +import { FileAccessPermission } from "./common" + /** * The File to be created. */ @@ -9,7 +11,7 @@ export interface CreateFileDTO { /** * The mimetype of the uploaded file - * + * * @example * image/png */ @@ -23,5 +25,22 @@ export interface CreateFileDTO { /** * The access level of the file. Defaults to private if not passed */ - access?: "public" | "private" + access?: FileAccessPermission +} + +export interface GetUploadFileUrlDTO { + /** + * The name of the file to be uploaded + */ + filename: string + + /** + * The mimetype of the file to be uploaded + */ + mimeType?: string + + /** + * The access level of the file to be uploaded. Defaults to private if not passed + */ + access?: FileAccessPermission } diff --git a/packages/core/types/src/file/provider.ts b/packages/core/types/src/file/provider.ts index 34c6167804..5cdb340d7a 100644 --- a/packages/core/types/src/file/provider.ts +++ b/packages/core/types/src/file/provider.ts @@ -1,3 +1,5 @@ +import { FileAccessPermission } from "./common" + /** * @interface * @@ -71,7 +73,34 @@ export type ProviderUploadFileDTO = { /** * The access level of the file. Defaults to private if not passed */ - access?: "public" | "private" + access?: FileAccessPermission +} + +/** + * @interface + * + * The details of the file to get a presigned upload URL for. + */ +export type ProviderGetPresignedUploadUrlDTO = { + /** + * The filename of the file to get a presigned upload URL for. + */ + filename: string + + /** + * The mimetype of the file to get a presigned upload URL for. + */ + mimeType?: string + + /** + * The access level of the file to get a presigned upload URL for. + */ + access?: FileAccessPermission + + /** + * The validity of the presigned upload URL in seconds. + */ + expiresIn?: number } export interface IFileProvider { @@ -103,4 +132,16 @@ export interface IFileProvider { * */ getPresignedDownloadUrl(fileData: ProviderGetFileDTO): Promise + + /** + * This method is used to get a presigned upload URL for a file. + * If the file provider does not support direct upload, an exception will be thrown when calling this method. + * + * @param {ProviderGetPresignedUploadUrlDTO} fileData - The details of the file to get a presigned upload URL for. + * @returns {Promise} The presigned URL and file key to upload the file to + * + */ + getPresignedUploadUrl?( + fileData: ProviderGetPresignedUploadUrlDTO + ): Promise } diff --git a/packages/core/types/src/file/service.ts b/packages/core/types/src/file/service.ts index cb779b6689..5accc578e2 100644 --- a/packages/core/types/src/file/service.ts +++ b/packages/core/types/src/file/service.ts @@ -1,8 +1,8 @@ import { IModuleService } from "../modules-sdk" -import { FileDTO, FilterableFileProps } from "./common" +import { FileDTO, FilterableFileProps, UploadFileUrlDTO } from "./common" import { FindConfig } from "../common" import { Context } from "../shared-context" -import { CreateFileDTO } from "./mutations" +import { CreateFileDTO, GetUploadFileUrlDTO } from "./mutations" export interface IFileModuleService extends IModuleService { /** @@ -41,6 +41,43 @@ export interface IFileModuleService extends IModuleService { createFiles(data: CreateFileDTO, sharedContext?: Context): Promise + /** + * This method gets the upload URL for a file. + * + * @param {GetUploadFileUrlDTO} data - The file information to get the upload URL for. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The upload URL for the file. + * + * @example + * const uploadInfo = await fileModuleService.getUploadFileUrls({ + * filename: "product.png", + * mimeType: "image/png", + * }) + */ + + getUploadFileUrls( + data: GetUploadFileUrlDTO, + sharedContext?: Context + ): Promise + + /** + * This method uploads files to the designated file storage system. + * + * @param {GetUploadFileUrlDTO[]} data - The file information to get the upload URL for. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The upload URLs for the files. + * + * @example + * const [uploadInfo] = await fileModuleService.getUploadFileUrls([{ + * filename: "product.png", + * mimeType: "image/png", + * }]) + */ + getUploadFileUrls( + data: GetUploadFileUrlDTO[], + sharedContext?: Context + ): Promise + /** * This method deletes files by their IDs. * diff --git a/packages/modules/file/integration-tests/__fixtures__/providers/default-provider.ts b/packages/modules/file/integration-tests/__fixtures__/providers/default-provider.ts index 08cbcb12fe..8c39201a3b 100644 --- a/packages/modules/file/integration-tests/__fixtures__/providers/default-provider.ts +++ b/packages/modules/file/integration-tests/__fixtures__/providers/default-provider.ts @@ -27,6 +27,15 @@ export class FileProviderServiceFixtures extends AbstractFileProviderService { return "" } + + async getPresignedUploadUrl( + fileData: FileTypes.ProviderGetPresignedUploadUrlDTO + ): Promise { + return { + url: "presigned-url/" + fileData.filename, + key: fileData.filename, + } + } } export const services = [FileProviderServiceFixtures] diff --git a/packages/modules/file/integration-tests/__tests__/module.spec.ts b/packages/modules/file/integration-tests/__tests__/module.spec.ts index 57e38ef6a2..c10f60d6b0 100644 --- a/packages/modules/file/integration-tests/__tests__/module.spec.ts +++ b/packages/modules/file/integration-tests/__tests__/module.spec.ts @@ -63,6 +63,31 @@ moduleIntegrationTestRunner({ const downloadUrl = await service.retrieveFile("test.jpg") expect(await new Response(downloadUrl.url).text()).toEqual("test") }) + + it("generates a presigned upload URL", async () => { + const res = await service.getUploadFileUrls({ + filename: "test.jpg", + mimeType: "image/jpeg", + }) + + expect(res).toEqual({ + url: "presigned-url/test.jpg", + key: "test.jpg", + }) + }) + + it("fails to get a presigned upload URL if a filename isn't provided", async () => { + const err = await service + .getUploadFileUrls({ + filename: "", + mimeType: "image/jpeg", + }) + .catch((err) => err) + + expect(err.message).toEqual( + "File name is required to get a presigned upload URL" + ) + }) }) }, }) diff --git a/packages/modules/file/src/services/file-module-service.ts b/packages/modules/file/src/services/file-module-service.ts index e238e461b8..c9b1473546 100644 --- a/packages/modules/file/src/services/file-module-service.ts +++ b/packages/modules/file/src/services/file-module-service.ts @@ -1,7 +1,9 @@ import { Context, CreateFileDTO, + GetUploadFileUrlDTO, FileDTO, + UploadFileUrlDTO, FileTypes, FilterableFileProps, FindConfig, @@ -49,6 +51,27 @@ export default class FileModuleService implements FileTypes.IFileModuleService { return Array.isArray(data) ? result : result[0] } + getUploadFileUrls( + data: GetUploadFileUrlDTO[], + sharedContext?: Context + ): Promise + getUploadFileUrls( + data: GetUploadFileUrlDTO, + sharedContext?: Context + ): Promise + + async getUploadFileUrls( + data: GetUploadFileUrlDTO[] | GetUploadFileUrlDTO + ): Promise { + const input = Array.isArray(data) ? data : [data] + + const result = await Promise.all( + input.map((file) => this.fileProviderService_.getPresignedUploadUrl(file)) + ) + + return Array.isArray(data) ? result : result[0] + } + async deleteFiles(ids: string[], sharedContext?: Context): Promise async deleteFiles(id: string, sharedContext?: Context): Promise async deleteFiles(ids: string[] | string): Promise { diff --git a/packages/modules/file/src/services/file-provider-service.ts b/packages/modules/file/src/services/file-provider-service.ts index 0293424578..6e9ab0f55d 100644 --- a/packages/modules/file/src/services/file-provider-service.ts +++ b/packages/modules/file/src/services/file-provider-service.ts @@ -48,4 +48,24 @@ export default class FileProviderService { ): Promise { return this.fileProvider_.getPresignedDownloadUrl(fileData) } + + getPresignedUploadUrl( + fileData: FileTypes.ProviderGetPresignedUploadUrlDTO + ): Promise { + if (!this.fileProvider_.getPresignedUploadUrl) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Provider does not support presigned upload URLs" + ) + } + + if (!fileData.filename) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "File name is required to get a presigned upload URL" + ) + } + + return this.fileProvider_.getPresignedUploadUrl(fileData) + } } diff --git a/packages/modules/providers/file-s3/integration-tests/__fixtures__/catphoto.jpg b/packages/modules/providers/file-s3/integration-tests/__fixtures__/catphoto.jpg index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..6d44d5e1dde39713e44d7b7cf5479118b68fefdb 100644 GIT binary patch literal 24003 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 { expect(response.status).toEqual(404) }) }) + + it("gets a presigned upload URL and uploads a file successfully", async () => { + const fileContent = await fs.readFile(fixtureImagePath) + const fixtureAsBinary = fileContent.toString("binary") + + const resp = await s3Service.getPresignedUploadUrl({ + filename: "catphoto.jpg", + mimeType: "image/jpeg", + access: "private", + }) + + expect(resp).toEqual({ + key: expect.stringMatching(/tests\/catphoto.*\.jpg/), + url: expect.stringMatching(/https:\/\/.*catphoto\.jpg/), + }) + + const uploadResp = await axios.put(resp.url, fileContent, { + headers: { + // On Digitalocean, among others, despite the ACL set on the upload URL, the caller can set the acl to anything they want. + // On AWS passing the ACL in the upload will fail since it's set on the signed URL. + // "x-amz-acl": "private", + "Content-Type": "image/jpeg", + }, + }) + + expect(uploadResp.status).toEqual(200) + + const signedUrl = await s3Service.getPresignedDownloadUrl({ + fileKey: resp.key, + }) + + const signedUrlFile = Buffer.from( + await axios + .get(signedUrl, { responseType: "arraybuffer" }) + .then((r) => r.data) + ) + + expect(signedUrlFile.toString("binary")).toEqual(fixtureAsBinary) + + await s3Service.delete({ fileKey: resp.key }) + }) + + it("gets a presigned upload URL for a nested filename structure and uploads a file successfully", async () => { + const fileContent = await fs.readFile(fixtureImagePath) + const fixtureAsBinary = fileContent.toString("binary") + + const resp = await s3Service.getPresignedUploadUrl({ + filename: "testfolder/catphoto.jpg", + mimeType: "image/jpeg", + access: "private", + }) + + expect(resp).toEqual({ + key: expect.stringMatching(/tests\/testfolder\/catphoto.*\.jpg/), + url: expect.stringMatching(/https:\/\/.*testfolder\/catphoto\.jpg/), + }) + + const uploadResp = await axios.put(resp.url, fileContent, { + headers: { + "Content-Type": "image/jpeg", + }, + }) + + expect(uploadResp.status).toEqual(200) + + const signedUrl = await s3Service.getPresignedDownloadUrl({ + fileKey: resp.key, + }) + + const signedUrlFile = Buffer.from( + await axios + .get(signedUrl, { responseType: "arraybuffer" }) + .then((r) => r.data) + ) + + expect(signedUrlFile.toString("binary")).toEqual(fixtureAsBinary) + + await s3Service.delete({ fileKey: resp.key }) + }) }) diff --git a/packages/modules/providers/file-s3/src/services/s3-file.ts b/packages/modules/providers/file-s3/src/services/s3-file.ts index 2f4dbf387b..cb872af181 100644 --- a/packages/modules/providers/file-s3/src/services/s3-file.ts +++ b/packages/modules/providers/file-s3/src/services/s3-file.ts @@ -1,6 +1,7 @@ import { DeleteObjectCommand, GetObjectCommand, + ObjectCannedACL, PutObjectCommand, S3Client, S3ClientConfigType, @@ -36,6 +37,8 @@ interface S3FileServiceConfig { additionalClientConfig?: Record } +const DEFAULT_UPLOAD_EXPIRATION_DURATION_SECONDS = 60 * 60 + export class S3FileService extends AbstractFileProviderService { static identifier = "s3" protected config_: S3FileServiceConfig @@ -175,4 +178,41 @@ export class S3FileService extends AbstractFileProviderService { expiresIn: this.config_.downloadFileDuration, }) } + + // Note: Some providers (eg. AWS S3) allows IAM policies to further restrict what can be uploaded. + async getPresignedUploadUrl( + fileData: FileTypes.ProviderGetPresignedUploadUrlDTO + ): Promise { + if (!fileData?.filename) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `No filename provided` + ) + } + + const fileKey = `${this.config_.prefix}${fileData.filename}` + + let acl: ObjectCannedACL | undefined + if (fileData.access) { + acl = fileData.access === "public" ? "public-read" : "private" + } + + // Using content-type, acl, etc. doesn't work with all providers, and some simply ignore it. + const command = new PutObjectCommand({ + Bucket: this.config_.bucket, + ContentType: fileData.mimeType, + ACL: acl, + Key: fileKey, + }) + + const signedUrl = await getSignedUrl(this.client_, command, { + expiresIn: + fileData.expiresIn ?? DEFAULT_UPLOAD_EXPIRATION_DURATION_SECONDS, + }) + + return { + url: signedUrl, + key: fileKey, + } + } }