Ghost CMS Admin Bulk Update Tool

My primary goal for this was essentially to make it so anyone could really easily use it, but to also explain how I built it in case anyone would just prefer to build their own.
Ghost CMS Admin Bulk Update Tool
Floating ghost getting ready for his big day

On this page

🥏
Want a real book to hold and support my work? Check out Real Books here.

Hey!

So, I love Ghost CMS. Everyone knows that, and easily my favorite thing to do is "hack" it and build something completely new. Whenever I build something, I look for shortcuts to make it easier and faster.

I started building out a few shortcut type of tools, and I thought to myself: "I bet someone else would enjoy using this tool, too."

My primary goal for this was essentially to make it so anyone could really easily use it, but to also explain how I built it in case anyone would just prefer to build their own.

TLDR: Use the Tool!

Ghost Mass Update

Go ahead and just jump right in and use the tool. At its most basic, you just need to put in the domain URL, admin api key (you can get those from the integrations section of your ghost installation!) and then grab your posts back and edit them. I plan on continually adding new and better features, so let me know what else you would like it to be able to do to save you time!


Part I: Building the Tools

Problems with design

Of course there were some problems with the design. Essentially, At its most basic level, the tool just calls the ghost API using an admin token. This is fairly easy to accomplish, but I don't want to store your tokens. I don't want ANYTHING to do with YOUR tokens, by design, which means you have to put the tokens in when you plan on using them.

This is easy enough, but if you refresh the page, the tokens go away. That can be frustrating, so I set it up to use the browser local storage to store things while you are using the page, and I also included a purge button that lets you completely delete your tokens when you are done if security is a priority for you.

💡
I cannot stress enough that local storage is not the beautiful perfect solution to security some people might imagine it to be, but it's also a better solution for just about anyone than handing over your tokens to services or people you don't know. I don't want your tokens!

Building the backend

For the next point, we run into a built in security "design" with ghost that cause s issues. Basically, by design the ghost API tools are not supposed to talk to a UI, just a backend project. That kind of goes against what my original goal with this was (which was to deploy it 100% for free and not bother with it anymore!) but it isn't an insurmountable issue.

It might, however, be a limitation for some people who want to host their own version of this tool for free if they don't want to pay for a backend. My own version is hosted at the link up above and you can use it, and the backend simply asks as a passthrough tool so that ghost sees it is talking to a backend instead of a UI.

Here is the entirety of the fastify code that I use to make this pass through. It is literally just a super simple proxy that let's the UI handle all of the tricky logic and just serves to call ghost and return responses. I will gradually make this more complex and add to it over time as I add new features to the UI, but the backend doesn't actually do or track anything. You could host your own server, point the UI to it, and do the same thing without much trouble.

'use strict'
const fastify = require('fastify');
const {get, put} = require("axios");
const {isNil, isEmpty} = require("lodash");
const app = fastify();

module.exports = async function (fastify, opts) {
    'use strict'

    fastify.route({
        method: 'POST', url: '/ghost-proxy', handler: async (request, reply) => {
            const {adminDomain, adminToken, type, posts} = request.body;
            if (!isNil(posts) && !isEmpty(posts)) {
                let postResponse = []
                posts.map(async post => {
                    postResponse.push(await editGhost(post.id, post, adminDomain, adminToken));
                });

                return reply.type('application/json').send(postResponse);
            }

            let list = await fetchGhost(type, "", adminDomain, adminToken);
            return reply.type('application/json').send(list);
        }
    })

    module.exports = app;
}

async function editGhost(id, post, adminDomain, adminToken) {
    const json = await put(`https://${adminDomain}/ghost/api/admin/posts/${id}/`, {posts: [post]}, {
        headers: {
            'Authorization': `Ghost ${adminToken}`,
            'Content-Type': 'application/json',
            'Accept': 'application/json',
            'Accept-Version': 'v5.0'
        }
    })

    return json.data['posts'][0];
}

async function fetchGhost(basic, path, adminDomain, adminToken) {
    const json = await get(`https://${adminDomain}/ghost/api/admin/${basic}?limit=100${path}`, {
        headers: {
            'Authorization': `Ghost ${adminToken}`,
            'Content-Type': 'application/json',
            'Accept': 'application/json',
            'Accept-Version': 'v5.0'
        }
    })
    if (json.data.meta.pagination.pages > json.data.meta.pagination.page) {
        const innerJson = await fetchGhost(basic, `&page=${json.data.meta.pagination.page + 1}`, adminDomain, adminToken);
        return json.data[basic].concat(innerJson);
    }

    return json.data[basic];
}

So, now we have a backend proxy and the ability to use and store domain and token (short term).

Now the UI design

The next bit of code I'm going to callout is related to actually calling the backend to get data from the actual ghost installation. There is a ghost npm tool you can use for the admin api that does this for you, but again it is designed to call from the backend and not a UI and wouldn't work with the proxy design.

const fetchGhost = (basic, path, setData, posts = null) => {
        if (isBlank(userData.adminUrl) || isBlank(userData.adminApiKey)) {
            return;
        }
        setLoading(true);
        const [id, secret] = userData.adminApiKey.split(':');
        const encoded = Buffer.from(secret, "hex");
        new jose.SignJWT({
            keyid: id,
            algorithm: 'HS256',
            expiresIn: '5m',
            audience: `/admin/`
        })
            .setProtectedHeader({alg: 'HS256', kid: id})
            .setIssuedAt()
            .setIssuer('https://' + userData.adminUrl)
            .setAudience('/admin/')
            .setExpirationTime('5m')
            .sign(encoded)
            .then((token) => {
                adapter.post(`${url_path()}/ghost-proxy`, {
                    adminDomain: userData.adminUrl,
                    adminToken: token,
                    type: basic,
                    posts: posts
                })
                    .then(({data}) => {
                        if (data.length > 0) {
                            setData(data);
                        }
                    })
                    .catch((error) => {
                        console.error('could not get data from ghost ', error);
                    })
                    .finally(() => setLoading(false));
            });
    }

All this does is decide how to fetch data based on the button you press and calls the backend proxy with the information the backend needs to get the data back to the UI.

What else?

The rest of the code I have is literally just the UI code itself that displays the UI and lets you make changes. When you make an update, I call to edit, and when you press retrieve it fetches the new data you need.

It's all super simple and mostly a proof of concept, but it fully allows you to be able to make edits to your posts in bulk very quickly and easily. Again, just play around with the tool and see what it does, and then at any point feel free to let me know what other features you would like to have available.


Part II: Hosting the Tools

Github

Hosting on github is super easy using pages. You can easily host the website without any difficulty with it being free and covered by a CDN, but the tradeoff is that you cannot make the site itself private. That means anyone can use the tool with a link, which is why in my design I built it for that exact purpose. It doesn't include custom links (which you have to supply) nor an actual ghost installation and is instead agnostic.

You could host it anywhere, to be honest, and there are hundreds of different services that can really easily host a react or unchanging API (many of which are free) so there aren't any big limitations.

AWS

The backend code is hosted on AWS because it actually needs to live somewhere. Again, there are a million places you could host it super cheaply, I just picked AWS because that is where I host my stuff.

I just need the backend proxy running and the UI to tell the backend proxy where to call to get data. Eezy Peezy.

🥏
Wish you could get paper copies of my books? Find great deals on My Books Here.

You might also like

Subscribe to Blog Writer newsletter and stay updated.

Don't miss anything. Get all the latest posts delivered straight to your inbox. It's free!
Great! Check your inbox and click the link to confirm your subscription.
Error! Please enter a valid email address!