Error Uploading Images with Admin API: 'Please select an image.'

Hi guys! I’m struggling with the Admin API for Image uploads. I have a local install with the latest version of Ghost (4.1.2) and I’ve been trying to get a simple example working to upload images with Node.

I’m following the instructions in the Admin API reference but I keep getting the following error message:

{
  message: 'Please select an image.',
  context: null,
  type: 'ValidationError',
  details: null,
  property: null,
  help: null,
  code: null,
  id: '170f67c0-96d8-11eb-a501-f5882445ee8b'
}

It looks like others might be having a similar problem based on this post and this post.

Here’s my sample code:

// Create a token without the client
const jwt = require('jsonwebtoken');
const axios = require('axios');
const fs = require('fs');
const FormData = require('form-data');

// Admin API key goes here
const key = 'ADMIN_KEY';

// Split the key into ID and SECRET
const [id, secret] = key.split(':');

// Create the token (including decoding secret)
const token = jwt.sign({}, Buffer.from(secret, 'hex'), {
    keyid: id,
    algorithm: 'HS256',
    expiresIn: '5m',
    audience: `/v4/admin/`
});

const img = fs.readFileSync('./image.jpg');

// Make an authenticated request to create a post
const url = 'http://localhost:2368/ghost/api/v4/admin/images/upload/';

var payload = new FormData();
payload.append('file', img);
payload.append('purpose', 'image');

const headers = {
  Authorization: `Ghost ${token}`,
  'Content-Type': `multipart/form-data;`
};

axios.post(url, payload, { headers })
    .then(response => console.log(response.data))
    .catch(error => console.error(error.response.data));

Any ideas what I’m missing here? Thanks.

I can’t immediately see what’s wrong, but is there a reason you’re choosing not to use the Admin API Client?

This wraps all the code you need to do this, meaning you don’t have to figure out issues like this where it’s likely a header is off somewhere or something.

It even uses axios so it is literally the same code you’ve written - so if you really do need your own it’s also a good place to go to play spot the difference.

3 Likes

Thanks Hannah! For some reason I was thinking that images weren’t in the API Client, but that was my mistake…

I tried it with the API Client and it worked, but I’m actually porting the code over to Google App Script so I can’t bring in packages. (that also made the JWT token generation pretty challenging!)

Good idea though to compare between my code and the API Client - I was able to finally get it working by pulling out the applicable code from there. I’m still not sure where my mistake was but must be in one of the headers as you suggested or how the form data is constructed.

Here’s the code that works in case anyone else comes across the same issue and can’t use the API Client for some reason:

// Create a token without the client
const jwt = require('jsonwebtoken');
const axios = require('axios');
const fs = require('fs');
const FormData = require('form-data');

// Admin API key goes here
const key = 'ADMIN_API';

// Split the key into ID and SECRET
const [id, secret] = key.split(':');

// Create the token (including decoding secret)
const token = jwt.sign({}, Buffer.from(secret, 'hex'), {
    keyid: id,
    algorithm: 'HS256',
    expiresIn: '5m',
    audience: `/v4/admin/`
});


const makeRequest = ({url, method, data, params = {}, headers = {}}) => {
    return axios({
        url,
        method,
        params,
        data,
        headers,
        maxContentLength: Infinity,
        paramsSerializer(parameters) {
            return Object.keys(parameters).reduce((parts, key) => {
                const val = encodeURIComponent([].concat(parameters[key]).join(','));
                return parts.concat(`${key}=${val}`);
            }, []).join('&');
        }
    }).then((res) => {
        return res.data;
    });
}


function getFormData(data) {
    let formData;

    if (data instanceof FormData) {
        return data;
    }

    if (data.file) {
        formData = new FormData();
        formData.append('file', fs.createReadStream(data.file));
        formData.append('purpose', data.purpose || 'image');

        if (data.ref) {
            formData.append('ref', data.ref);
        }

        return formData;
    }
}


function makeUploadRequest(resourceType, data, url) {
    const headers = {
        'Content-Type': `multipart/form-data; boundary=${data._boundary}`
    };

    return makeApiRequest({
        url: url,
        method: 'POST',
        body: data,
        headers
    }).then((apiData) => {
        if (!Array.isArray(apiData[resourceType])) {
            return apiData[resourceType];
        }
        if (apiData[resourceType].length === 1 && !apiData.meta) {
            return apiData[resourceType][0];
        }
    });
}


function makeApiRequest({url, method, body, queryParams = {}, headers = {}}) {

        headers = Object.assign({}, headers, {
            Authorization: `Ghost ${token}`
        });

        return makeRequest({
            url,
            method,
            data: body,
            params: queryParams,
            headers
        }).catch((err) => {
            /**
             * @NOTE:
             *
             * If you are overriding `makeRequest`, we can't garantee that the returned format is the same, but
             * we try to detect & return a proper error instance.
             */
            if (err.response && err.response.data && err.response.data.errors) {
                const props = err.response.data.errors[0];
                const toThrow = new Error(props.message);
                const keys = Object.keys(props);

                toThrow.name = props.type;

                keys.forEach((k) => {
                    toThrow[k] = props[k];
                });

                // @TODO: bring back with a better design idea. if you log the error, the stdout is hard to read
                //        if we return the full response object, which includes also the request etc.
                // toThrow.response = err.response;
                throw toThrow;
            } else {
                delete err.request;
                delete err.config;
                delete err.response;
                throw err;
            }
        });
    }

    let formData = getFormData({file: './image.jpg', ref: 'image-1.jpg'});
    makeUploadRequest('images', formData, 'http://localhost:2368/ghost/api/v4/admin/images/upload/')
    .then(response => console.log(response))
    .catch(error => console.error(error));

Glad you were able to figure it out!

1 Like

I was a little curious as to what made the difference so I looked into it.

I think the problem with your first code example is probably a missing boundary or content-length header. The following would likely fix it:

const headers = {
  Authorization: `Ghost ${token}`,
  'Content-Type': `multipart/form-data;`,
  ...payload.getHeaders(),
};

Our admin-api package automatically adds the boundary header for you so it’s still the best approach to avoid having to debug issues like this :)

2 Likes

Thanks both for the quick replies! Definitely something with the boundary header I think as well.

For sure, I won’t be rolling my own with this again any time soon - api client is the way to go. :grinning: