Uploading images to ghost Admin-API

I’m currently working on a plugin to Obsidian to upload blog-posts to ghost. And with that I also want it to upload any pictures used in that document. I’ve spent about 30 hours on this now with a little progress but would need some help to try and speed things along. It was just yesterday I think I bypassed the CORS errors I’ve been having with nginx.

Back to the issue in hand - I now get 500 errors when sending to the API - I suspect I’ve constructed the formdata incorrectly. All information can be seen bellow:

Source:

			// Building the image data
			let imageData;
			if (frontmatter.imageDirectory) {
				const imageNamePrefix = frontmatter.imageDirectory.replace(/\//g, "");
				imageData = {
					"images": [
						{
							"url": `${BASE_URL}/content/images/${year}/${month}/${imageNamePrefix}-${filename}`,
							"ref": imagePath
						}
					]
				}
			} else {
				imageData = {
					"images": [
						{
							"url": `${BASE_URL}/content/images/${year}/${month}/${filename}`,
							"ref": imagePath
						}
					]
				}
			}
			console.log("imagedata", imageData);
			
			// Make blob https://developer.mozilla.org/en-US/docs/Web/API/Blob
			const blob = new Blob([JSON.stringify(imageData)], {
				type: "application/json",
			});

			// Construct formdata of blob
			const formData = new FormData();
			formData.append("file", blob);
			formData.append("purpose", "image");
			formData.append("ref", imagePath);

			try {
				const response = await fetch(`${settings.url}/ghost/api/${version}/admin/images/upload/`, {
					method: "POST",
					headers: {
						"Content-Type": "multipart/form-data",
						Authorization: `Ghost ${token}`,	
					},
					body: formData
				});
				if (response.ok) {
					// Handle success
					const data = await response.json();
					console.log(data);
				} else {
					// Handle errors
					console.error("Error:", response.statusText);
					console.error("Error:", response.statusText);
					console.error("Status Code:", response.status); // Add status code
					console.error("Response Headers:", response.headers); // Log response headers
					response.text().then(errorText => {
						console.error("Error Response Text:", errorText); // Log the response body as text
					}).catch(error => {
						console.error("Error parsing response text:", error);
					});
				}
				} catch (error) {
					console.error("Request error:", error);
				}
			}

Request headers:

:Authority:
mydomain.com
:Method:
POST
:Path:
/ghost/api/v4/admin/images/upload/
:Scheme:
https
Accept:
*/*
Accept-Encoding:
gzip, deflate, br
Accept-Language:
en-US
Authorization:
Ghost <MY-TOKEN>
Content-Length:
580
Content-Type:
multipart/form-data
Origin:
app://obsidian.md
Sec-Ch-Ua-Mobile:
?0
Sec-Ch-Ua-Platform:
"Linux"
Sec-Fetch-Mode:
cors
Sec-Fetch-Site:
cross-site
User-Agent:
Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.4.5 Chrome/114.0.5735.289 Electron/25.8.0 Safari/537.36

Response headers:

Access-Control-Allow-Headers:
Authorization,DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range
Access-Control-Allow-Methods:
GET, POST, OPTIONS
Access-Control-Allow-Origin:
*
Access-Control-Expose-Headers:
Content-Length,Content-Range
Alt-Svc:
h3=":443"; ma=86400
Cache-Control:
no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0
Cf-Cache-Status:
DYNAMIC
Cf-Ray:
8097d87c0b84190d-FRA
Content-Length:
280
Content-Type:
application/json; charset=utf-8
Content-Version:
v5.49
Date:
Wed, 20 Sep 2023 06:00:00 GMT
Deprecation:
version="v4"
Etag:
W/"118-TO41uly8SaFcUo6z2CfflFSlP3I"
Link:
<https://mydomain.com/ghost/api/admin/images/upload/>; rel="latest-version"
Nel:
{"success_fraction":0,"report_to":"cf-nel","max_age":604800}
Report-To:
{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=nUEexVPgiZM6W0xtL2SDloBn1%2F%2FmZeisLhIh1Dbgx0PZvlofdEvKRI6w1dU9quzeeFjcEQzNis7YSs1XhAmS1rHKlOv9zKdG8Q7lSdoRtJoHekCB%2FjKWLErdqDH9L6rmfEuPuXJRD8nC"}],"group":"cf-nel","max_age":604800}
Server:
cloudflare
Vary:
Accept-Version, Accept-Encoding
X-Powered-By:
Express

Console error message:

Error Response Text: {"errors":[{"message":"An unexpected error occurred, please try again.","context":"Multipart: Boundary not found","type":"InternalServerError","details":null,"property":null,"help":null,"code":"UNEXPECTED_ERROR","id":"ef416110-577a-11ee-ad51-8f5ac464e714","ghostErrorCode":null}]}

Thank you for any help!

Your code is confusing the upload request format, which is “form data”, with the response format, which is in JSON.

It appears your code is trying to upload images using the response format.

I’m reviewing the API documentation here:

It provides a working example with curl

curl -X POST -F ‘file=@/path/to/images/my-image.jpg’ -F ‘ref=path/to/images/my-image.jpg’ -H “Authorization: ‘Ghost $token’” -H “Accept-Version: $version” https://{admin_domain}/ghost/api/admin/images/upload/

That’s submitting a file field and an optional ref field in the “form data” format, along with an Authorization header. That’s all that JavaScript would need to do as well.

Another good sourced of example JavaScript for working with Ghost can be the Ghost test suite itself. For example, here’s a test for image uploading:

It’s using an agent object like superagent, but you could use another library for the upload. Here’s the key code:

const form = new FormData();
    form.append('file', fileContents, {
        filename,
        contentType
    });

    form.append('purpose', 'image');
    if (ref) {
        form.append('ref', ref);
    }

    return agent
        .post('/images/upload/')
        .body(form);

I hope that helps!

1 Like

Thank you so much for the comment!

You seem to be right - I’ve confused the request data with the expected response. But I still seem to be getting the same error (still assuming it’s because of the formdata somehow). In regards to the code segments you sent me - I’ve played around a bit but still no luck.

From what I understand fileContents is a fs.readFile(absolutePathToImage), filename is just the name of the file and contentType is ex image/png. With this approach (see code below) I get a warning Expected 2-3 arguments, but got 1.ts(2554) from the fs.readFile as if it’s acting differently? If I try to execute it anyways I get this error caught (in promise) TypeError: The "cb" argument must be of type function. Received undefined at __node_internal_captureLargerStackTrace (.

Code:

			const fileContent = await fs.readFile(imagePath);
			const contentType = "image/png"
			const formData = new FormData();
			formData.append("file", fileContent, {
				filename,
				contentType
			});
			formData.append("purpose", "image");
			formData.append("ref", imagePath);

			try {
				const response = await fetch(`${settings.url}/ghost/api/${version}/admin/images/upload/`, {
					method: "POST",
					headers: {
						"Content-Type": "multipart/form-data",
						Authorization: `Ghost ${token}`,	
					},
					body: formData
				});
                               // Error handling etc...

Also when I try this: formData.append("file", fs.createReadStream(absolutePathToImage)) which I’ve also seen, I get a complaint of it not being a Blob.

I’m very grateful for any additional help!

Now it sounds to me like you are getting tripped on the difference between the old and new fs APIs, which both have a function named readFile().

https://nodejs.org/api/fs.html

You didn’t show how you imported fs, but my guess is that’s where the problem is, and you should have something like one of these:

import * as fs from 'node:fs/promises';
// Or if you are using `require()` statements
const fs = require('node:fs/promises');

Now fs will have the promise-based API that you expect.

Oh, it it seems like a mistake that the $version is included in the URL here. I don’t see that in the official docs.

Since I’m using typescript I import it now with import * as fs from 'fs/promises'; which seems to work well and makes the output of fs.readFile a buffer (as in the example above). But according to the ghost documentation it wants a Blob or File - and so I get the error TypeError: Failed to execute 'append' on 'FormData': parameter 2 is not of type 'Blob'.. I also tried to make it a blob but haven’t gotten that to work either (same 500 error as before):

			const fileContent = await fs.readFile(imagePath);
			const contentType = "image/png"
			const fileBlob = new Blob([fileContent], { type: contentType });
			const formData = new FormData();
			formData.append("file", fileBlob);
			formData.append("purpose", "image");
			formData.append("ref", imagePath);

I also tried removing the version from the url, both seems to “work” just as well, it was there in the code I got inspired by which is why it was there to begin with.

You should not need to do the extra blob conversion. Look how the file is handled in the the test suite:

The only thing that’s done is that is await fs.readFile() is called on it.

Then it’s passed to form.append('file, fileContents):

Yeah that’s what I tried… but I get complaints when I run it :/

			const fileContent = await fs.readFile(imagePath);
			const contentType = "image/png"
			const formData = new FormData();
			formData.append("file", fileContent, {
				filename,
				contentType
			});
			formData.append("purpose", "image");
			formData.append("ref", imagePath);

			try {
				const response = await fetch(`${settings.url}/ghost/api/admin/images/upload/`, {
					method: "POST",
					headers: {
						"Content-Type": "multipart/form-data",
						Authorization: `Ghost ${token}`,	
					},
					body: formData
				});

Error:

Uncaught (in promise) TypeError: Failed to execute 'append' on 'FormData': parameter 2 is not of type 'Blob'.
    at eval (VM1417 plugin:obsidian-ghost-publish:21562:18)
    at Generator.next (<anonymous>)
    at fulfilled (VM1417 plugin:obsidian-ghost-publish:50:24)

Have you considered using the official Ghost JavaScript SDK? With it, you don’t have to worry about content types, formData or blobs. It would be like this:

import GhostAdminAPI from '@tryghost/admin-api'

const token = 'SECRET';

const api = new GhostAdminAPI({
  url: 'http://localhost:2368',
  key: token,
  version: "v5.0",
});

const apiRes = await api.images.upload({file: imagePath});

console.log(apiRes);

It’s documented here:

Yeah I wish it was that easy, I’ve tried it both before and after I solved my CORS issues with nginx. However due to the plugins use of axios I still get CORS Issues:

Code: const response = await api.images.upload({ref: imagePath, file: imagePath});
Error(s):

Access to XMLHttpRequest at 'https://mydomain.com/ghost/api/v4/admin/images/upload/' from origin 'app://obsidian.md' has been blocked by CORS policy: Request header field accept-version is not allowed by Access-Control-Allow-Headers in preflight response.
xhr.js:251 
 POST https://admin.hailstormsec.com/ghost/api/v4/admin/images/upload/ net::ERR_FAILED
plugin:obsidian-ghost-publish:59 Uncaught (in promise) 
AxiosError {message: 'Network Error', name: 'AxiosError', code: 'ERR_NETWORK', stack: 'AxiosError: Network Error\n    at XMLHttpRequest.handleError (plugin:obsidian-ghost-publish:20125:18)'}

This is the background to why I am using fetch to begin with - it both supports sending formdata and enabled me to bypass CORS in a viable manner. But then the issues above hit me instead (which they shouldn’t?).

So yes… still at trying to construct a request something the server will accept :confused:

I see.

One thing I see that’s unlikely to be the root case is the call to formData.append(). The latest docs show the final argument is a single filename, not an object:

According do the docs for Blob, it an can be an array of TypedArrays.

According to the docs for fs.readFile(), that method returns a promise which resolves to an object of type Buffer.

The docs also say that Buffer instances are also TypedArray instances but with some minor differences.

So far, so good.

It says if you want to completely convert a Buffer to a TypedArray, you could do this:

const uint32array = new Uint32Array(buf);

You could try that.

Yeah it didn’t wok too well either…

			const fileContent = await fs.readFile(imagePath);
			// 1. const uint32array = new Uint32Array(fileContent);
			// 2. const blob = new Blob([fileContent]);

			const formData = new FormData();
			// 1. formData.append("file", uint32array);
			// 2. formData.append("file", blob, filename);
			formData.append("purpose", "image");
			formData.append("ref", imagePath);

			try {
				const response = await fetch(`${settings.url}/ghost/api/admin/images/upload/`, {
					method: "POST",
					headers: {
						"Content-Type": "multipart/form-data",
						Authorization: `Ghost ${token}`,	
					},
					body: formData
				});

1: What you suggested, gave the same error about not being a blob.
2: Same 500 error as before - also a combination of 1 and 2 gave this error. I managed to get the entire error. I managed to get the entire error log from my server - not that I think it has more useful information (?):

{"name":"Log","hostname":"home-server","pid":969,"level":50,"version":"5.49.0","req":{"meta":{"requestId":"09d027e4-b4a1-4871-8809-63a3686bf571","userId":null},"url":"/images/upload/","method":"POST","originalUrl":"/ghost/api/admin/images/upload/","params":{},"headers":{"x-forwarded-for":"83.250.20.209, 172.71.102.36","x-forwarded-proto":"https","x-real-ip":"172.71.102.36","host":"admin.mydoman.com","connection":"close","content-length":"14843","cf-connecting-ip":"83.250.20.209","cf-ipcountry":"SE","accept-encoding":"gzip","cf-ray":"80b965854f0ed0d9-AMS","cf-visitor":"{\"scheme\":\"https\"}","accept":"*/*","accept-language":"en-GB","authorization":"**REDACTED**","content-type":"multipart/form-data","origin":"app://obsidian.md","sec-fetch-mode":"cors","sec-fetch-site":"cross-site","user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.3.5 Chrome/112.0.5615.183 Electron/24.3.1 Safari/537.36","sec-ch-ua-mobile":"?0","sec-ch-ua-platform":"\"Windows\"","priority":"u=1, i","cdn-loop":"cloudflare"},"query":{}},"res":{"_headers":{"x-powered-by":"Express","content-version":"v5.49","vary":"Accept-Version, Accept-Encoding","cache-control":"no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0","content-type":"application/json; charset=utf-8","content-length":"280","etag":"W/\"118-+DBQysdUY8GtoJMEA2/L49ROf58\""},"statusCode":500,"responseTime":"10ms"},"err":{"id":"0c864700-5aae-11ee-bdce-0fb68b067b1d","domain":"https://mydoman.com","code":"UNEXPECTED_ERROR","name":"InternalServerError","statusCode":500,"level":"critical","message":"An unexpected error occurred, please try again.","context":"\"Multipart: Boundary not found\"","stack":"Error: Multipart: Boundary not found\n    at module.exports.prepareError (/var/www/mydoman/versions/5.49.0/node_modules/@tryghost/mw-error-handler/lib/mw-error-handler.js:92:19)\n    at new Multipart (/var/www/mydoman/versions/5.49.0/node_modules/busboy/lib/types/multipart.js:58:11)\n    at Multipart (/var/www/mydoman/versions/5.49.0/node_modules/busboy/lib/types/multipart.js:26:12)\n    at Busboy.parseHeaders (/var/www/mydoman/versions/5.49.0/node_modules/busboy/lib/main.js:71:22)\n    at new Busboy (/var/www/mydoman/versions/5.49.0/node_modules/busboy/lib/main.js:22:10)\n    at multerMiddleware (/var/www/mydoman/versions/5.49.0/node_modules/multer/lib/make-middleware.js:33:16)\n    at /var/www/mydoman/versions/5.49.0/core/server/web/api/middleware/upload.js:53:5\n    at Layer.handle [as handle_request] (/var/www/mydoman/versions/5.49.0/node_modules/express/lib/router/layer.js:95:5)\n    at next (/var/www/mydoman/versions/5.49.0/node_modules/express/lib/router/route.js:144:13)\n    at notImplemented (/var/www/mydoman/versions/5.49.0/core/server/web/api/endpoints/admin/middleware.js:51:20)\n    at Layer.handle [as handle_request] (/var/www/mydoman/versions/5.49.0/node_modules/express/lib/router/layer.js:95:5)\n    at next (/var/www/mydoman/versions/5.49.0/node_modules/express/lib/router/route.js:144:13)\n    at uncapitalise (/var/www/mydoman/versions/5.49.0/core/server/web/shared/middleware/uncapitalise.js:60:5)\n    at Layer.handle [as handle_request] (/var/www/mydoman/versions/5.49.0/node_modules/express/lib/router/layer.js:95:5)\n    at next (/var/www/mydoman/versions/5.49.0/node_modules/express/lib/router/route.js:144:13)\n    at slashes (/var/www/mydoman/versions/5.49.0/node_modules/connect-slashes/lib/connect-slashes.js:81:9)\n    at Layer.handle [as handle_request] (/var/www/mydoman/versions/5.49.0/node_modules/express/lib/router/layer.js:95:5)","hideStack":false},"msg":"An unexpected error occurred, please try again.","time":"2023-09-24T07:43:27.603Z","v":0}

I just now realised looking at this forum that theres a boundary header of some sort - which is also mentioned in the error message… I’ll see if that can do something for me, and you (or anyone else) know how to before I get back here; feel free to post the solution. Error Uploading Images with Admin API: 'Please select an image.' - #3 by gjdickens

You may need to not set the Content-Type header in this case. This is explained in the MDN docs on FormData

Here’s that warning:

Warning: When using FormData to submit POST requests using XMLHttpRequest or the Fetch_API with the multipart/form-data Content-Type (e.g. when uploading Files and Blobs to the server), do not explicitly set the Content-Type header on the request. Doing so will prevent the browser from being able to set the Content-Type header with the boundary expression it will use to delimit form fields in the request body.

@Hailst0rm Here’s code I wrote that works for me. I confirmed it returns a successful response and that the file is indeed on the file system after the upload.

Here I don’t send the Content-Type as I advised above.

import * as fs from 'node:fs/promises';

// Construct the token from the key exactly as Ghost would.
import keyToToken from '@tryghost/admin-api/lib/token.js'
const token = keyToToken(key, '/admin');

const key = 'ADMIN-KEY-FROM-GHOST';

const imagePath = '/home/mark/Downloads/face.png';
const filename = 'face.png';
const settings = {
  url: 'http://127.0.0.1:2368',
};
 
const fileContent = await fs.readFile(imagePath);
			const contentType = "image/png"
			const fileBlob = new Blob([fileContent], { type: contentType });
			const formData = new FormData();
			formData.append("file", fileBlob, filename);
			formData.append("purpose", "image");
			formData.append("ref", imagePath);


      let response;
			try {
				response = await fetch(`${settings.url}/ghost/api/admin/images/upload/`, {
					method: "POST",
					headers: {
						// "Content-Type": "multipart/form-data", // Don't include!
						Authorization: `Ghost ${token}`,	
					},
					body: formData
				});
     }
     catch (err) {
        console.error("ERROR",err);
     };

     console.log(await response?.json()); 

That produces this output for me:

{
  images: [
    {
      url: 'http://localhost:2368/content/images/2023/09/face.png',
      ref: '/home/mark/Downloads/face.png'
    }
  ]
}

While reading the source code of the admin-api module I gained some insight into why some API URLs have versions in them in some don’t.

It said that has of Ghost 5, including version numbers in the URLs is being phased out. The comment is:

This method can go away in favor of only sending 'Accept-Version` headers once the Ghost API removes a concept of version from it’s URLS (with Ghost v5)

So to future-proof your code, send the Accept-Version header with `v5 as the value, but leave the version out of the URL.

Removing the header was the final piece of the puzzle - thank you so much for all the help! I just had to play around with making it a blob again (like your code) and it all worked out. For anyone interested here’s the final code:

			let imagePath = `${imageDirectory}/${result[1]}`;
			let filename = result[1];

			// If extended directory - add image prefix
			if (frontmatter.imageDirectory) {
				filename = `${frontmatter.imageDirectory.replace(/\//g, "")}-${filename}`;
			}

			// Get the image data buffer
			const fileContent = await fs.readFile(imagePath);

			// Determine the file type based on the filename's extension
			const fileExtension = filename.split('.').pop();
			let fileType = '';

			if (fileExtension === 'png') {
			fileType = 'image/png';
			} else if (fileExtension === 'jpeg' || fileExtension === 'jpg') {
			fileType = 'image/jpeg';
			} // Add more file types if needed

			// Make blob of buffer to allow formdata.append
			const blob = new Blob([fileContent], { type: fileType });

			console.log("filename", filename);
			// console.log("imagePath", imagePath);
			const formData = new FormData();
			formData.append("file", blob, filename);
			formData.append("purpose", "image");
			formData.append("ref", filename);

			try {
				const response = await fetch(`${settings.url}/ghost/api/${version}/admin/images/upload/`, {
					method: "POST",
					headers: {
						Authorization: `Ghost ${token}`,
					},
					body: formData
				});
				if (response.ok) {
					// Handle success
					const data = await response.json();
					console.log(data);
				} else {
					// Handle errors
					console.error("Error:", response.statusText);
					console.error("Error:", response.statusText);
					console.error("Status Code:", response.status); // Add status code
					console.error("Response Headers:", response.headers); // Log response headers
					response.text().then(errorText => {
						console.error("Error Response Text:", errorText); // Log the response body as text
					}).catch(error => {
						console.error("Error parsing response text:", error);
					});
				}
				} catch (error) {
					console.error("Request error:", error);
				}
			}
	}
2 Likes