diff --git a/docs/local-api/overview.mdx b/docs/local-api/overview.mdx index 8c7ecd692..b43d48f20 100644 --- a/docs/local-api/overview.mdx +++ b/docs/local-api/overview.mdx @@ -96,6 +96,11 @@ const post = await payload.create({ // If creating verification-enabled auth doc, // you can optionally disable the email that is auto-sent disableVerificationEmail: true, + + // If your collection supports uploads, you can upload + // a file directly through the Local API by providing + // its full, absolute file path. + filePath: path.resolve(__dirname, './path-to-image.jpg'), }) ``` @@ -152,6 +157,11 @@ const result = await payload.update({ user: dummyUser, overrideAccess: false, showHiddenFields: true, + + // If your collection supports uploads, you can upload + // a file directly through the Local API by providing + // its full, absolute file path. + filePath: path.resolve(__dirname, './path-to-image.jpg'), }) ``` diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx index c1a4a711b..2fbe9f83c 100644 --- a/docs/upload/overview.mdx +++ b/docs/upload/overview.mdx @@ -115,7 +115,7 @@ Behind the scenes, Payload relies on [`sharp`](https://sharp.pixelplumbing.com/a Important:
- Uploading files is currently only possible through the REST API due to how GraphQL works. It's difficult and fairly nonsensical to support uploading files through GraphQL. + Uploading files is currently only possible through the REST and Local APIs due to how GraphQL works. It's difficult and fairly nonsensical to support uploading files through GraphQL.
To upload a file, use your collection's [`create`](/docs/rest-api/overview#collections) endpoint. Send it all the data that your Collection requires, as well as a `file` key containing the file that you'd like to upload. diff --git a/package.json b/package.json index dce8d4655..4a1ba3d2e 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "lodash.merge": "^4.6.2", "method-override": "^3.0.0", "micro-memoize": "^4.0.9", + "mime": "^2.5.0", "mini-css-extract-plugin": "1.3.3", "minimist": "^1.2.0", "mkdirp": "^1.0.4", diff --git a/src/collections/operations/create.ts b/src/collections/operations/create.ts index 9011f064b..78aa400fa 100644 --- a/src/collections/operations/create.ts +++ b/src/collections/operations/create.ts @@ -18,6 +18,7 @@ import { AfterChangeHook, BeforeOperationHook, BeforeValidateHook, Collection } import { PayloadRequest } from '../../express/types'; import { Document } from '../../types'; import { Payload } from '../..'; +import saveBufferToFile from '../../uploads/saveBufferToFile'; export type Arguments = { collection: Collection @@ -152,7 +153,7 @@ async function create(this: Payload, incomingArgs: Arguments): Promise const fsSafeName = await getSafeFilename(staticPath, file.name); try { - await file.mv(`${staticPath}/${fsSafeName}`); + await saveBufferToFile(file.data, `${staticPath}/${fsSafeName}`); if (isImage(file.mimetype)) { const dimensions = await getImageSize(`${staticPath}/${fsSafeName}`); diff --git a/src/collections/operations/local/create.ts b/src/collections/operations/local/create.ts index 9d1d19b96..95e86e25a 100644 --- a/src/collections/operations/local/create.ts +++ b/src/collections/operations/local/create.ts @@ -1,4 +1,5 @@ import { Document } from '../../../types'; +import getFileByPath from '../../../uploads/getFileByPath'; export type Options = { collection: string @@ -10,8 +11,8 @@ export type Options = { overrideAccess?: boolean disableVerificationEmail?: boolean showHiddenFields?: boolean + filePath?: string } - export default async function create(options: Options): Promise { const { collection: collectionSlug, @@ -23,6 +24,7 @@ export default async function create(options: Options): Promise { overrideAccess = true, disableVerificationEmail, showHiddenFields, + filePath, } = options; const collection = this.collections[collectionSlug]; @@ -40,6 +42,7 @@ export default async function create(options: Options): Promise { locale, fallbackLocale, payload: this, + file: getFileByPath(filePath), }, }); } diff --git a/src/collections/operations/local/local.spec.js b/src/collections/operations/local/local.spec.js new file mode 100644 index 000000000..3dbec208d --- /dev/null +++ b/src/collections/operations/local/local.spec.js @@ -0,0 +1,50 @@ +import path from 'path'; +import payload from '../../..'; + +let createdMediaID; + +payload.init({ + secret: 'SECRET_KEY', + mongoURL: 'mongodb://localhost/payload', + local: true, +}); + +describe('Collections - Local', () => { + describe('Create', () => { + it('should allow an upload-enabled file to be created and uploaded', async () => { + const alt = 'Alt Text Here'; + + const result = await payload.create({ + collection: 'media', + data: { + alt, + }, + filePath: path.resolve(__dirname, '../../../admin/assets/images/generic-block-image.svg'), + }); + + expect(result.id).not.toBeNull(); + expect(result.alt).toStrictEqual(alt); + expect(result.filename).toStrictEqual('generic-block-image.svg'); + createdMediaID = result.id; + }); + }); + + describe('Update', () => { + it('should allow an upload-enabled file to be re-uploaded and alt-text to be changed.', async () => { + const newAltText = 'New Alt Text Here'; + + const result = await payload.update({ + collection: 'media', + id: createdMediaID, + data: { + alt: newAltText, + }, + filePath: path.resolve(__dirname, '../../../admin/assets/images/og-image.png'), + }); + + expect(result.alt).toStrictEqual(newAltText); + expect(result.sizes.mobile.width).toStrictEqual(320); + expect(result.width).toStrictEqual(640); + }); + }); +}); diff --git a/src/collections/operations/local/update.ts b/src/collections/operations/local/update.ts index bbf2bfb67..a86164494 100644 --- a/src/collections/operations/local/update.ts +++ b/src/collections/operations/local/update.ts @@ -1,4 +1,5 @@ import { Document } from '../../../types'; +import getFileByPath from '../../../uploads/getFileByPath'; export type Options = { collection: string @@ -10,6 +11,7 @@ export type Options = { user?: Document overrideAccess?: boolean showHiddenFields?: boolean + filePath?: string } export default async function update(options: Options): Promise { @@ -23,6 +25,7 @@ export default async function update(options: Options): Promise { user, overrideAccess = true, showHiddenFields, + filePath, } = options; const collection = this.collections[collectionSlug]; @@ -40,6 +43,7 @@ export default async function update(options: Options): Promise { locale, fallbackLocale, payload: this, + file: getFileByPath(filePath), }, }; diff --git a/src/collections/operations/update.ts b/src/collections/operations/update.ts index 87066f54c..db6f7dda2 100644 --- a/src/collections/operations/update.ts +++ b/src/collections/operations/update.ts @@ -19,6 +19,7 @@ import { FileData } from '../../uploads/types'; import { PayloadRequest } from '../../express/types'; import { hasWhereAccessResult, UserDocument } from '../../auth/types'; +import saveBufferToFile from '../../uploads/saveBufferToFile'; export type Arguments = { collection: Collection @@ -199,7 +200,7 @@ async function update(incomingArgs: Arguments): Promise { const fsSafeName = await getSafeFilename(staticPath, file.name); try { - await file.mv(`${staticPath}/${fsSafeName}`); + await saveBufferToFile(file.data, `${staticPath}/${fsSafeName}`); fileData.filename = fsSafeName; fileData.filesize = file.size; diff --git a/src/uploads/getFileByPath.ts b/src/uploads/getFileByPath.ts new file mode 100644 index 000000000..527fca1f0 --- /dev/null +++ b/src/uploads/getFileByPath.ts @@ -0,0 +1,22 @@ +import fs from 'fs'; +import mime from 'mime'; +import { File } from './types'; + +const getFileByPath = (filePath: string): File => { + if (typeof filePath === 'string') { + const data = fs.readFileSync(filePath); + const mimetype = mime.getType(filePath); + + const name = filePath.split('/').pop(); + + return { + data, + mimetype, + name, + }; + } + + return undefined; +}; + +export default getFileByPath; diff --git a/src/uploads/saveBufferToFile.ts b/src/uploads/saveBufferToFile.ts new file mode 100644 index 000000000..69699b19b --- /dev/null +++ b/src/uploads/saveBufferToFile.ts @@ -0,0 +1,21 @@ +import { Readable } from 'stream'; +import fs from 'fs'; + +/** + * Save buffer data to a file. + * @param {Buffer} buffer - buffer to save to a file. + * @param {string} filePath - path to a file. + */ +const saveBufferToFile = async (buffer: Buffer, filePath: string): Promise => { + // Setup readable stream from buffer. + let streamData = buffer; + const readStream = new Readable(); + readStream._read = () => { + readStream.push(streamData); + streamData = null; + }; + // Setup file system writable stream. + return fs.writeFileSync(filePath, buffer); +}; + +export default saveBufferToFile; diff --git a/src/uploads/types.ts b/src/uploads/types.ts index a8f90031b..3d800d168 100644 --- a/src/uploads/types.ts +++ b/src/uploads/types.ts @@ -41,3 +41,9 @@ export type Upload = { staticDir: string adminThumbnail?: string } + +export type File = { + data: Buffer + mimetype: string + name: string +} diff --git a/yarn.lock b/yarn.lock index c9b025ca3..c0566d9a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8567,6 +8567,11 @@ mime@^2.3.1: resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.7.tgz#962aed9be0ed19c91fd7dc2ece5d7f4e89a90d74" integrity sha512-dhNd1uA2u397uQk3Nv5LM4lm93WYDUXFn3Fu291FJerns4jyTudqhIWe4W04YLy7Uk1tm1Ore04NpjRvQp/NPA== +mime@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.0.tgz#2b4af934401779806ee98026bb42e8c1ae1876b1" + integrity sha512-ft3WayFSFUVBuJj7BMLKAQcSlItKtfjsKDDsii3rqFDAZ7t11zRe8ASw/GlmivGwVUYtwkQrxiGGpL6gFvB0ag== + mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"