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 e69de29bb2..6d44d5e1dd 100644 Binary files a/packages/modules/providers/file-s3/integration-tests/__fixtures__/catphoto.jpg and b/packages/modules/providers/file-s3/integration-tests/__fixtures__/catphoto.jpg differ diff --git a/packages/modules/providers/file-s3/integration-tests/__tests__/services.spec.ts b/packages/modules/providers/file-s3/integration-tests/__tests__/services.spec.ts index e624879b99..95e1473c52 100644 --- a/packages/modules/providers/file-s3/integration-tests/__tests__/services.spec.ts +++ b/packages/modules/providers/file-s3/integration-tests/__tests__/services.spec.ts @@ -81,4 +81,83 @@ describe.skip("S3 File Plugin", () => { 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, + } + } }