From 3c5e31c6455695f854e9df7a3592c12b899fa1e1 Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Tue, 18 Oct 2022 10:46:47 +0200 Subject: [PATCH] fix(medusa, medusa-file-*): Protected uploads for file services (#2433) --- .changeset/brown-dancers-film.md | 10 +++ .../medusa-file-minio/src/services/minio.js | 22 ++++- packages/medusa-file-s3/src/services/s3.js | 12 ++- .../src/services/digital-ocean.js | 12 ++- .../medusa-js/src/resources/admin/uploads.ts | 9 ++ .../src/hooks/admin/uploads/mutations.ts | 15 ++++ .../admin/uploads/create-protected-upload.ts | 89 +++++++++++++++++++ .../api/routes/admin/uploads/create-upload.ts | 23 ++--- .../src/api/routes/admin/uploads/index.ts | 6 ++ .../medusa/src/interfaces/file-service.ts | 10 +++ packages/medusa/src/services/file.ts | 8 ++ 11 files changed, 196 insertions(+), 20 deletions(-) create mode 100644 .changeset/brown-dancers-film.md create mode 100644 packages/medusa/src/api/routes/admin/uploads/create-protected-upload.ts diff --git a/.changeset/brown-dancers-film.md b/.changeset/brown-dancers-film.md new file mode 100644 index 0000000000..cab67a8419 --- /dev/null +++ b/.changeset/brown-dancers-film.md @@ -0,0 +1,10 @@ +--- +"medusa-file-minio": patch +"medusa-file-s3": patch +"medusa-file-spaces": patch +"@medusajs/medusa-js": patch +"medusa-react": patch +"@medusajs/medusa": patch +--- + +Add protected uploads to fileservices diff --git a/packages/medusa-file-minio/src/services/minio.js b/packages/medusa-file-minio/src/services/minio.js index 0d02cf6bdb..0963ee454a 100644 --- a/packages/medusa-file-minio/src/services/minio.js +++ b/packages/medusa-file-minio/src/services/minio.js @@ -26,13 +26,24 @@ class MinioService extends AbstractFileService { upload(file) { this.updateAwsConfig_() + return this.uploadFile(file) + } + + uploadProtected(file) { + this.validatePrivateBucketConfiguration_(true) + this.updateAwsConfig_(true) + + return this.uploadFile(file, { isProtected: true }) + } + + uploadFile(file, options = { isProtected: false }) { const parsedFilename = parse(file.originalname) const fileKey = `${parsedFilename.name}-${Date.now()}${parsedFilename.ext}` const s3 = new aws.S3() const params = { - ACL: "public-read", - Bucket: this.bucket_, + ACL: options.isProtected ? "private" : "public-read", + Bucket: options.isProtected ? this.private_bucket_ : this.bucket_, Body: fs.createReadStream(file.path), Key: fileKey, } @@ -137,9 +148,12 @@ class MinioService extends AbstractFileService { } validatePrivateBucketConfiguration_(usePrivateBucket) { - if (usePrivateBucket && !this.private_bucket_) { + if ( + usePrivateBucket && + (!this.private_access_key_id_ || !this.private_bucket_) + ) { throw new MedusaError( - MedusaError.Types.INVALID_CONFIGURATION, + MedusaError.Types.UNEXPECTED_STATE, "Private bucket is not configured" ) } diff --git a/packages/medusa-file-s3/src/services/s3.js b/packages/medusa-file-s3/src/services/s3.js index b2007c3078..aa492a1553 100644 --- a/packages/medusa-file-s3/src/services/s3.js +++ b/packages/medusa-file-s3/src/services/s3.js @@ -19,9 +19,19 @@ class S3Service extends AbstractFileService { upload(file) { this.updateAwsConfig() + return this.uploadFile(file) + } + + uploadProtected(file) { + this.updateAwsConfig() + + return this.uploadFile(file, { acl: "private" }) + } + + uploadFile(file, options = { isProtected: false, acl: undefined }) { const s3 = new aws.S3() const params = { - ACL: "public-read", + ACL: options.acl ?? (options.isProtected ? "private" : "public-read"), Bucket: this.bucket_, Body: fs.createReadStream(file.path), Key: `${file.originalname}`, diff --git a/packages/medusa-file-spaces/src/services/digital-ocean.js b/packages/medusa-file-spaces/src/services/digital-ocean.js index 93c650cfdd..df67f45266 100644 --- a/packages/medusa-file-spaces/src/services/digital-ocean.js +++ b/packages/medusa-file-spaces/src/services/digital-ocean.js @@ -20,12 +20,22 @@ class DigitalOceanService extends AbstractFileService { upload(file) { this.updateAwsConfig() + return this.uploadFile(file) + } + + uploadProtected(file) { + this.updateAwsConfig() + + return this.uploadFile(file, { acl: "private" }) + } + + uploadFile(file, options = { isProtected: false, acl: undefined }) { const parsedFilename = parse(file.originalname) const fileKey = `${parsedFilename.name}-${Date.now()}${parsedFilename.ext}` const s3 = new aws.S3() const params = { - ACL: "public-read", + ACL: options.acl ?? (options.isProtected ? "private" : "public-read"), Bucket: this.bucket_, Body: fs.createReadStream(file.path), Key: fileKey, diff --git a/packages/medusa-js/src/resources/admin/uploads.ts b/packages/medusa-js/src/resources/admin/uploads.ts index 3d83cc2f79..612db158b6 100644 --- a/packages/medusa-js/src/resources/admin/uploads.ts +++ b/packages/medusa-js/src/resources/admin/uploads.ts @@ -23,6 +23,15 @@ class AdminUploadsResource extends BaseResource { return this.client.request("POST", path, payload, {}, this.headers) } + + createProtected(file: IAdminPostUploadsFileReq): ResponsePromise { + const path = `/admin/uploads/protected` + + const payload = new FormData() + payload.append("files", file) + + return this.client.request("POST", path, payload, {}, this.headers) + } delete( payload: AdminDeleteUploadsReq, diff --git a/packages/medusa-react/src/hooks/admin/uploads/mutations.ts b/packages/medusa-react/src/hooks/admin/uploads/mutations.ts index b5b136684e..ffd79eaa56 100644 --- a/packages/medusa-react/src/hooks/admin/uploads/mutations.ts +++ b/packages/medusa-react/src/hooks/admin/uploads/mutations.ts @@ -26,6 +26,21 @@ export const useAdminUploadFile = ( }, buildOptions(queryClient, undefined, options)) } +export const useAdminUploadProtectedFile = ( + options?: UseMutationOptions< + Response, + Error, + IAdminPostUploadsFileReq + > +) => { + const { client } = useMedusa() + const queryClient = useQueryClient() + + return useMutation((payload: IAdminPostUploadsFileReq) => { + return client.admin.uploads.createProtected(payload) + }, buildOptions(queryClient, undefined, options)) +} + export const useAdminCreatePresignedDownloadUrl = ( options?: UseMutationOptions< Response, diff --git a/packages/medusa/src/api/routes/admin/uploads/create-protected-upload.ts b/packages/medusa/src/api/routes/admin/uploads/create-protected-upload.ts new file mode 100644 index 0000000000..87f1894fd0 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/uploads/create-protected-upload.ts @@ -0,0 +1,89 @@ +import fs from "fs" +import { IFileService } from "../../../../interfaces" + +/** + * @oas [post] /uploads/protected + * operationId: "PostUploadsProtected" + * summary: "Upload files with acl or in a non-public bucket" + * description: "Uploads at least one file to the specific fileservice that is installed in Medusa." + * x-authenticated: true + * requestBody: + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * files: + * type: string + * format: binary + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.uploads.createProtected(file) + * .then(({ uploads }) => { + * console.log(uploads.length); + * }); + * - lang: Shell + * label: cURL + * source: | + * curl --location --request POST 'https://medusa-url.com/admin/uploads/protected' \ + * --header 'Authorization: Bearer {api_token}' \ + * --header 'Content-Type: image/jpeg' \ + * --form 'files=@""' \ + * --form 'files=@""' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - Upload + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * uploads: + * type: array + * items: + * type: object + * properties: + * url: + * type: string + * description: The URL of the uploaded file. + * format: uri + * "400": + * $ref: "#/components/responses/400_error" + * "401": + * $ref: "#/components/responses/unauthorized" + * "404": + * $ref: "#/components/responses/not_found_error" + * "409": + * $ref: "#/components/responses/invalid_state_error" + * "422": + * $ref: "#/components/responses/invalid_request_error" + * "500": + * $ref: "#/components/responses/500_error" + */ +export default async (req, res) => { + const fileService: IFileService = req.scope.resolve("fileService") + + const result = await Promise.all( + req.files.map(async (f) => { + return fileService.uploadProtected(f).then((result) => { + fs.unlinkSync(f.path) + return result + }) + }) + ) + + res.status(200).json({ uploads: result }) +} +export class IAdminPostUploadsFileReq { + originalName: string + path: string +} diff --git a/packages/medusa/src/api/routes/admin/uploads/create-upload.ts b/packages/medusa/src/api/routes/admin/uploads/create-upload.ts index 08c1958497..e6ec535823 100644 --- a/packages/medusa/src/api/routes/admin/uploads/create-upload.ts +++ b/packages/medusa/src/api/routes/admin/uploads/create-upload.ts @@ -69,23 +69,18 @@ import fs from "fs" * $ref: "#/components/responses/500_error" */ export default async (req, res) => { - try { - const fileService = req.scope.resolve("fileService") + const fileService = req.scope.resolve("fileService") - const result = await Promise.all( - req.files.map(async (f) => { - return fileService.upload(f).then((result) => { - fs.unlinkSync(f.path) - return result - }) + const result = await Promise.all( + req.files.map(async (f) => { + return fileService.upload(f).then((result) => { + fs.unlinkSync(f.path) + return result }) - ) + }) + ) - res.status(200).json({ uploads: result }) - } catch (err) { - console.log(err) - throw err - } + res.status(200).json({ uploads: result }) } export class IAdminPostUploadsFileReq { diff --git a/packages/medusa/src/api/routes/admin/uploads/index.ts b/packages/medusa/src/api/routes/admin/uploads/index.ts index c5d0daa042..92391f9d4b 100644 --- a/packages/medusa/src/api/routes/admin/uploads/index.ts +++ b/packages/medusa/src/api/routes/admin/uploads/index.ts @@ -18,6 +18,12 @@ export default (app) => { middlewares.wrap(require("./create-upload").default) ) + route.post( + "/protected", + upload.array("files"), + middlewares.wrap(require("./create-protected-upload").default) + ) + route.delete( "/", transformBody(AdminDeleteUploadsReq), diff --git a/packages/medusa/src/interfaces/file-service.ts b/packages/medusa/src/interfaces/file-service.ts index 11ac50668f..ab9c2c9c94 100644 --- a/packages/medusa/src/interfaces/file-service.ts +++ b/packages/medusa/src/interfaces/file-service.ts @@ -37,6 +37,12 @@ export interface IFileService extends TransactionBaseService { * */ upload(file: Express.Multer.File): Promise + /** + * upload private file to fileservice + * @param file Multer file from express multipart/form-data + * */ + uploadProtected(file: Express.Multer.File): Promise + /** * remove file from fileservice * @param fileData Remove file described by record @@ -76,6 +82,10 @@ export abstract class AbstractFileService fileData: Express.Multer.File ): Promise + abstract uploadProtected( + fileData: Express.Multer.File + ): Promise + abstract delete(fileData: DeleteFileType): Promise abstract getUploadStreamDescriptor( diff --git a/packages/medusa/src/services/file.ts b/packages/medusa/src/services/file.ts index 8ec29c648a..82cb1cbb8d 100644 --- a/packages/medusa/src/services/file.ts +++ b/packages/medusa/src/services/file.ts @@ -17,6 +17,14 @@ class DefaultFileService extends AbstractFileService { "Please add a file service plugin in order to manipulate files in Medusa" ) } + async uploadProtected( + fileData: Express.Multer.File + ): Promise { + throw new MedusaError( + MedusaError.Types.UNEXPECTED_STATE, + "Please add a file service plugin in order to manipulate files in Medusa" + ) + } async delete(fileData: Record): Promise { throw new MedusaError( MedusaError.Types.UNEXPECTED_STATE,