feat: Add support for private files to file module (#8169)
This commit is contained in:
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"
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
@@ -77,3 +81,4 @@ 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)
|
||||||
|
|||||||
Reference in new issue
Block a user