Adding support for comments through integration with Mastodon

May 18th 2022 on Dušan's blog

Preface

Ever since I wrote the first line of code for this blog, I knew I wanted to have some kind of comment system one day. The problem was that I didn't want to require people to register to leave one, and I also didn't want to deal with spam if I allowed anonymous comments.

It wasn't until a few months ago that I stumbled upon an idea to leverage my Mastodon membership to do so.

The humble beginnings

I had a solid idea, but it was still without form and void. How hard can it be? Turns out a lot harder than I initially thought.

So I posted this poll, asking people how I should go about doing it. A helpful fella, going by the name of Joel (Thank you! 🙏), suggested I take a look at his client side implementation. Now, client side isn't what I really wanted, but the post helped me understand how I would go about fetching the comment data using Mastodon's APIs.

Some other people suggested I should do both, server and client side. At the time I thought It was a good idea, but after some deliberation I decided It would increase the complexity of an already pretty complex system a tad too much.

Authenticating with Mastodon

The exact implementation details can be found in the source code of this website, this is going to be a high level overview of the process, rather than an in-depth analysis.

To allow my blog to use my Mastodon account to read and write toots I needed to authenticate it.

A rough overview of the authentication flow is as follows:

The first thing that needs to be done is to register an application on your instance of Mastodon. This is done easily enough by going to the </> Development section of your Settings page.

After registering your application, you get two crucial pieces of information in return. The Client ID and the Client Secret. These must remain secret at all times and shared only with the Mastodon server, transmitted over TLS for maximum security.

Now we must obtain the authentication code. To do so we construct an HTTP GET request, that looks something like this:

https://fosstodon.org/oauth/authorize
?client_id=CLIENT_ID
&scope=read:statuses+write:statuses
&redirect_uri=https://dusanmitrovic.xyz/admin/mastodon-authentication-redirect
&response_type=code

The Mastodon server responds by calling the provided redirect_uri endpoint and attaching the authentication code in a query parameter.

This code can be exchanged for the authentication token exactly once. Every subsequent attempt must request a new code, so it's a good idea to cache the authentication token indefinitely.

The following code snippet does the actual exchange:

const host = process.env.MASTODON_HOST;
const url = `${host}/oauth/token`;
const client_id = process.env.MASTODON_CLIENT_ID;
const client_secret = process.env.MASTODON_CLIENT_SECRET;
const scope = process.env.MASTODON_OAUTH_SCOPE;
const grant_type = 'authorization_code';
const redirect_uri = `${getAppURI()}/admin/mastodon-authentication-redirect`;

const response = await fetch(url, {
  method: 'POST',
  headers: {
    'Accept': 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    client_id,
    client_secret,
    redirect_uri,
    scope,
    grant_type,
    code,
  }),
});

If everything went okay, Mastodon will now respond with an authentication token. This token is used in every subsequent request that requires proof of identity, by setting it in the Authorization HTTP header, like this:

Authorization: Bearer $authentication_token

Posting a toot for every new article

I deliberated for a while on the best way to associate the Mastodon toot, from which the comments are pulled, to an actual blog post on this website.

At first I wound up on an idea of filtering all public toots that mention the article URL, which quickly proved to be impractical.

But then it clicked. What if I could use my website to post a toot on my behalf in the exact moment before the article is saved to a database? And that is exactly what I ended up doing.

When I publish a new article, the website will send the article description and the soon to be URL that the article lives on to Mastodon. This is the code snippet that does that:

const { access_token, token_type } = auth;
const host = process.env.MASTODON_HOST;
const url = `${host}/api/v1/statuses`;
const status = null === postUrl ? text : `${text}\n\n${postUrl}`;

try {
  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
      'Authorization': `${token_type} ${access_token}`,
    },
    body: JSON.stringify({
      status,
      visibility: 'public',
    }),
  });

  const data = await response.json();

  if (data.error !== undefined) {
    console.error(data.error);

    return null;
  }

  return data;
} catch (error) {
  console.error(error);

  return null;
}

The toots are being posted on a best-effort basis. If the request fails, for whatever reason, the article simply gets saved as normal. Then I can (annoyingly) associate the toots manually.

If on the other hand the request is successful, I take the resulting toot_id and store it in my database along with the article.

Getting and displaying comments from Mastodon

The first time the new blog post is requested from my server, the website will try to pull the comments from the associated toot and display them at the bottom of the page. Exactly how that happens can be seen by reading the source code. The comments are then cached, for half an hour, to massively improve page load times and to avoid spamming Mastodon with HTTP requests every time the page is loaded.

Once I do successfully load the comments, they are formatted, with Mastodon's custom emoji being replaced by their actual images. (Once again a big thank you to Joel!). A REPLY WITH MASTODON button, with a link to the original Mastodon thread is added and that's about it.

Conclusion

This project definitely had some unique challenges. One major advantage that this approach has compared to client side implementations is that none of this required JavaScript on the frontend. And that's always a good thing.

Now, after 3 years of this blog's existence, I can finally say: Please, leave some replies, advice or criticism to this article. It's very much appreciated. 😁