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"