feat: Add support for private files to file module (#8169)

This commit is contained in:
Stevche Radevski authored and GitHub committed 2024-07-18 09:46:10 +02:00
1 parent 76173afdfc
commit c58a35f0c0
11 files changed
+62 -27

No files matched your search

@@ -7,6 +7,7 @@ type UploadFilesStepInput = {
filename: string filename: string
mimeType: string mimeType: string
content: string content: string
access: "public" | "private"
}[] }[]
} }
@@ -7,6 +7,7 @@ type WorkflowInput = {
filename: string filename: string
mimeType: string mimeType: string
content: string content: string
access: "public" | "private"
}[] }[]
} }
@@ -16,4 +16,9 @@ export interface CreateFileDTO {
* The file content as a binary-encoded string * The file content as a binary-encoded string
*/ */
content: string content: string
/**
* The access level of the file. Defaults to private if not passed
*/
access?: "public" | "private"
} }
+6 -4
View File
@@ -24,10 +24,6 @@ export type ProviderGetFileDTO = {
* The file's key. * The file's key.
*/ */
fileKey: string fileKey: string
/**
* Whether the file is private.
*/
isPrivate?: boolean
[x: string]: unknown [x: string]: unknown
} }
@@ -65,6 +61,11 @@ export type ProviderUploadFileDTO = {
* The file content as a binary-encoded string * The file content as a binary-encoded string
*/ */
content: string content: string
/**
* The access level of the file. Defaults to private if not passed
*/
access?: "public" | "private"
} }
export interface IFileProvider { export interface IFileProvider {
@@ -85,6 +86,7 @@ export interface IFileProvider {
* *
*/ */
delete(fileData: ProviderDeleteFileDTO): Promise<void> delete(fileData: ProviderDeleteFileDTO): Promise<void>
/** /**
* This method is used to retrieve a download URL of the file. For some file services, such as S3, a presigned URL indicates a temporary URL to get access to a file. * This method is used to retrieve a download URL of the file. For some file services, such as S3, a presigned URL indicates a temporary URL to get access to a file.
* *
@@ -1,4 +1,5 @@
export interface LocalFileServiceOptions { export interface LocalFileServiceOptions {
upload_dir?: string upload_dir?: string
private_upload_dir?: string
backend_url?: string backend_url?: string
} }
@@ -25,6 +25,7 @@ export const POST = async (
filename: f.originalname, filename: f.originalname,
mimeType: f.mimetype, mimeType: f.mimetype,
content: f.buffer.toString("binary"), content: f.buffer.toString("binary"),
access: "public",
})), })),
}, },
}) })
@@ -6,11 +6,14 @@ import path from "path"
export class LocalFileService extends AbstractFileProviderService { export class LocalFileService extends AbstractFileProviderService {
static identifier = "localfs" static identifier = "localfs"
protected uploadDir_: string protected uploadDir_: string
protected privateUploadDir_: string
protected backendUrl_: string protected backendUrl_: string
constructor(_, options: LocalFileServiceOptions) { constructor(_, options: LocalFileServiceOptions) {
super() super()
this.uploadDir_ = options?.upload_dir || path.join(process.cwd(), "static") this.uploadDir_ = options?.upload_dir || path.join(process.cwd(), "static")
this.privateUploadDir_ =
options?.private_upload_dir || path.join(process.cwd(), "private")
this.backendUrl_ = options?.backend_url || "http://localhost:9000/static" this.backendUrl_ = options?.backend_url || "http://localhost:9000/static"
} }
@@ -29,14 +32,19 @@ export class LocalFileService extends AbstractFileProviderService {
} }
const parsedFilename = path.parse(file.filename) const parsedFilename = path.parse(file.filename)
await this.ensureDirExists(parsedFilename.dir) const baseDir =
file.access === "public" ? this.uploadDir_ : this.privateUploadDir_
const dir = await this.ensureDirExists(baseDir, parsedFilename.dir)
const fileKey = path.join( const fileKey = path.join(
parsedFilename.dir, parsedFilename.dir,
`${Date.now()}-${parsedFilename.base}` // We append "private" to the file key so deletions and presigned URLs can know which folder to look into
`${Date.now()}-${parsedFilename.base}${
file.access === "public" ? "" : "-private"
}`
) )
const filePath = this.getUploadFilePath(fileKey) const filePath = this.getUploadFilePath(baseDir, fileKey)
const fileUrl = this.getUploadFileUrl(fileKey) const fileUrl = this.getUploadFileUrl(fileKey)
const content = Buffer.from(file.content as string, "binary") const content = Buffer.from(file.content as string, "binary")
@@ -49,7 +57,11 @@ export class LocalFileService extends AbstractFileProviderService {
} }
async delete(file: FileTypes.ProviderDeleteFileDTO): Promise<void> { async delete(file: FileTypes.ProviderDeleteFileDTO): Promise<void> {
const filePath = this.getUploadFilePath(file.fileKey) const baseDir = file.fileKey.endsWith("-private")
? this.privateUploadDir_
: this.uploadDir_
const filePath = this.getUploadFilePath(baseDir, file.fileKey)
try { try {
await fs.access(filePath, fs.constants.F_OK) await fs.access(filePath, fs.constants.F_OK)
await fs.unlink(filePath) await fs.unlink(filePath)
@@ -60,26 +72,34 @@ export class LocalFileService extends AbstractFileProviderService {
return return
} }
// For private files, we simply return the file path, which can then be loaded manually by the backend.
// The local file provider doesn't support presigned URLs for private files.
async getPresignedDownloadUrl( async getPresignedDownloadUrl(
fileData: FileTypes.ProviderGetFileDTO file: FileTypes.ProviderGetFileDTO
): Promise<string> { ): Promise<string> {
const isPrivate = file.fileKey.endsWith("-private")
const baseDir = isPrivate ? this.privateUploadDir_ : this.uploadDir_
const filePath = this.getUploadFilePath(baseDir, file.fileKey)
try { try {
await fs.access( await fs.access(filePath, fs.constants.F_OK)
this.getUploadFilePath(fileData.fileKey),
fs.constants.F_OK
)
} catch { } catch {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.NOT_FOUND, MedusaError.Types.NOT_FOUND,
`File with key ${fileData.fileKey} not found` `File with key ${file.fileKey} not found`
) )
} }
return this.getUploadFileUrl(fileData.fileKey) if (isPrivate) {
return filePath
} }
private getUploadFilePath = (fileKey: string) => { return this.getUploadFileUrl(file.fileKey)
return path.join(this.uploadDir_, fileKey) }
private getUploadFilePath = (baseDir: string, fileKey: string) => {
return path.join(baseDir, fileKey)
} }
private getUploadFileUrl = (fileKey: string) => { private getUploadFileUrl = (fileKey: string) => {
@@ -88,12 +108,14 @@ export class LocalFileService extends AbstractFileProviderService {
return baseUrl.href return baseUrl.href
} }
private async ensureDirExists(dirPath: string) { private async ensureDirExists(baseDir: string, dirPath: string) {
const relativePath = path.join(this.uploadDir_, dirPath) const relativePath = path.join(baseDir, dirPath)
try { try {
await fs.access(relativePath, fs.constants.F_OK) await fs.access(relativePath, fs.constants.F_OK)
} catch (e) { } catch (e) {
await fs.mkdir(relativePath, { recursive: true }) await fs.mkdir(relativePath, { recursive: true })
} }
return relativePath
} }
} }
@@ -35,7 +35,7 @@ describe.skip("S3 File Plugin", () => {
} }
) )
}) })
;(["public", "private"] as const).forEach((access) => {
it("uploads, reads, and then deletes a file successfully", async () => { it("uploads, reads, and then deletes a file successfully", async () => {
const fileContent = await fs.readFile(fixtureImagePath) const fileContent = await fs.readFile(fixtureImagePath)
const fixtureAsBinary = fileContent.toString("binary") const fixtureAsBinary = fileContent.toString("binary")
@@ -44,6 +44,7 @@ describe.skip("S3 File Plugin", () => {
filename: "catphoto.jpg", filename: "catphoto.jpg",
mimeType: "image/jpeg", mimeType: "image/jpeg",
content: fixtureAsBinary, content: fixtureAsBinary,
access,
}) })
expect(resp).toEqual({ expect(resp).toEqual({
@@ -51,6 +52,9 @@ describe.skip("S3 File Plugin", () => {
url: expect.stringMatching(/https:\/\/.*\.jpg/), url: expect.stringMatching(/https:\/\/.*\.jpg/),
}) })
const urlResp = await axios.get(resp.url).catch((e) => e.response)
expect(urlResp.status).toEqual(access === "public" ? 200 : 403)
const signedUrl = await s3Service.getPresignedDownloadUrl({ const signedUrl = await s3Service.getPresignedDownloadUrl({
fileKey: resp.key, fileKey: resp.key,
}) })
@@ -76,4 +80,5 @@ describe.skip("S3 File Plugin", () => {
expect(response.status).toEqual(404) expect(response.status).toEqual(404)
}) })
})
}) })
@@ -92,14 +92,12 @@ export class S3FileService extends AbstractFileProviderService {
const content = Buffer.from(file.content, "binary") const content = Buffer.from(file.content, "binary")
const command = new PutObjectCommand({ const command = new PutObjectCommand({
// TODO: Add support for private files
// We probably also want to support a separate bucket altogether for private files // We probably also want to support a separate bucket altogether for private files
// protected private_bucket_: string // protected private_bucket_: string
// protected private_access_key_id_: string // protected private_access_key_id_: string
// protected private_secret_access_key_: string // protected private_secret_access_key_: string
// ACL: options.acl ?? (options.isProtected ? "private" : "public-read"), ACL: file.access === "public" ? "public-read" : "private",
ACL: "public-read",
Bucket: this.config_.bucket, Bucket: this.config_.bucket,
Body: content, Body: content,
Key: fileKey, Key: fileKey,
@@ -377,7 +377,7 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
asFunction(() => "test") asFunction(() => "test")
) )
const spy = await createScheduled("remove-scheduled", { const spy = await createScheduled("shared-container-job", {
cron: "* * * * * *", cron: "* * * * * *",
}) })
await jest.runOnlyPendingTimersAsync() await jest.runOnlyPendingTimersAsync()
@@ -74,12 +74,11 @@ describe("Workflow Orchestrator module", function () {
}, },
}) })
await onApplicationStart()
query = remoteQuery query = remoteQuery
sharedContainer_ = sharedContainer! sharedContainer_ = sharedContainer!
await runMigrations() await runMigrations()
await onApplicationStart()
workflowOrcModule = modules.workflows as unknown as IWorkflowEngineService workflowOrcModule = modules.workflows as unknown as IWorkflowEngineService
}) })
@@ -391,7 +390,7 @@ describe("Workflow Orchestrator module", function () {
asFunction(() => "test") asFunction(() => "test")
) )
const spy = await createScheduled("remove-scheduled", { const spy = await createScheduled("shared-container-job", {
cron: "* * * * * *", cron: "* * * * * *",
}) })
await setTimeout(1100) await setTimeout(1100)