Creating a Comment System For a Static Blog: Part I

What options do you have for the comment system when it comes to static blogs?

A static website doesn’t have a backend to store comments left by visitors. You could create one. It would be just a couple of serverless functions—one for handling comment submissions, and one for returning the comments on a blog post. Also, you would need a database to store the comments.

It doesn’t sound like a lot of work to create it, but maintaining it for years afterwards can become a tedious task. Probably one of the reasons you chose the static website as a platform for your blog was to minimize the time and energy you would have to spend on maintaining the blog infrastructure.

Building and deploying a full backend for a comment system would be the same as going back to WordPress.

What would you say if I offered the compromise of having one serverless function instead of a web server and a database?

This would require only one serverless function hosted on a cloud platform like AWS Lambda or Cloudflare completely for free (up to a certain limit, of course).

But where the comments will be stored if there is no database?

It’s simple. We will store them in the same code repository where the blog posts are stored.

So what would the workflow of a visitor posting a comment look like?

Let’s say you use a static website generator, keep the code on GitHub and use Cloudflare Pages to host the website. Then it should go something like this:

You might have spotted a couple of possible issues here:

Despite those little inconveniences, I still think it’s a good idea because:

If you also find this idea interesting, move on to the next section.

Build a serverless function that posts a file to a GitHub repository

There are a few prerequisites for this project before you can start.

Create a GitHub access token

We will the use GitHub REST API to upload a comment to a git repository hosted on GitHub. The GitHub REST API requires authenticating in order to access private repositories. You can authenticate a REST API request by sending a token in the Authorization header with the request.

For example, to get a list of repositories belonging to the authenticated account, the request should look like this:

curl https://api.github.com/user/follower \
  --request GET \
  --header "Authorization: Bearer YOUR_PERSONAL_ACCESS_TOKEN" 

So, to get the personal access token, head to the settings page of your GitHub account. Click the Developer Settings at the bottom left of the page. Once there, select the Personal access tokens, and then you will have to choose between a fine-grained token and a classic token.

The main difference between them is that fine-grained token may be limited to a particular repository, whereas a classic token gives access to all repositories the user could access. I recommend choosing the fine-grained token and allowing it to access only the repository where your blog is stored.

In the permission list, you need to find Contents permission and give it the read and write access.

After the token was created, save it somewhere temporarily because you will need it later, but you won’t be able to see it again.

Write the function

Let’s start by creating a folder and initializing a package.json file with the default configuration.

$ mkdir githubupload && cd githubupload

$ npm init --yes ; npm pkg set type="module"

We will use the Octokit JavaScript library to access the GitHub REST API. Install it with the following command:

$ npm install octokit

Authenticate with the GitHub REST API

Create a new file called github.js inside the src folder. We will write a function for uploading a comment to a GitHub repository in this file, and then will call it from a serverless function handler written in the index.js file. I’m doing it this way because I plan to create different examples for two different cloud providers.

Below is a simple code just to test if the GitHub access token works:

// github.js
import { Octokit } from "octokit"

/**
 * Creates a comment inside the GitHub repository
 * @param token {string}
 * @returns {Promise<void>}
 */
export async function createComment(token) {
    const octokit = new Octokit({auth: token})
    const authResult = await octokit.rest.users.getAuthenticated()
    const username = authResult.data.login

    console.log(`hi ${username}`)
}

Create an index.js file inside the src folder. For now, we will run it from the terminal and use it to call the createComment function.

// index.js
import { createComment } from "./github.js"

(async () => {
    await createComment(process.env.GITHUB_TOKEN)
})()

See if it works:

$ GITHUB_TOKEN=$(cat .token) node src/index.js

You should see “hi” and your GitHub username.

I placed my GitHub token in the token file, which is read into the GITHUB_TOKEN environment variable when the command was run. You can do the same or replace the cat .token) with the actual token (it’s not recommended though, read why here).

Upload the comment to the GitHub repository

After we’ve verified that we can successfully access the GitHub API, we can move on to adding code to upload the comment to the GitHub repository.

A few notes on where we will store the comments in the repository.

Each comment will be encoded as JSON and stored in a separate file. Those files will go to a separate folder for each blog post, identified by the slug of the blog post. The location of the directories containing the comment files depends on the specific static file generator.

Static file generators when building a website use two types of content—main and additional. The content for a generated standalone page comes from the main content folder, and the content that can be used across multiple pages comes from the additional content folder.

The names of these folders vary between different static generators, but usually the main content goes inside the posts (11ty, Jekyll) or content (Hugo) folders for the main content, and inside the data (11ty, Jekyll, Hugo) folder for the additional content.

Having said that, it means that we should store the comments inside the data folder. Also, the content of the file must be encoded as Base64 before transferring it to the GitHub repository.

The final version of the function, with added additional parameters and validation, is as follows:

// github.js 

import {Octokit} from "octokit"
import {v4 as uuidv4} from "uuid"
import {Base64} from "js-base64";

/**
 * Creates a comment inside the GitHub repository
 * @param token {string}
 * @param repository {string}
 * @param slug {string}
 * @param name {string}
 * @param email {string | null}
 * @param website {string | null}
 * @param parent {string | null}
 * @param comment {string}
 * @return {Promise<Error>}
 */
export async function createComment(token, repository, slug, name, email, website, parent, comment) {
    const octokit = new Octokit({auth: token})

    try {
        const authResult = await octokit.rest.users.getAuthenticated()
        const username = authResult.data.login

        if (!/^[a-z](-?[a-z])*$/.test(slug)) {
            return new Error(`invalid slug: "${slug}"`)
        }

        if (!/^[A-Za-z\s]+$/.test(name)) {
            return new Error(`invalid name: "${name}"`)
        }

        if (typeof email !== 'undefined' && email !== null && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
            return new Error(`invalid email: "${email}"`)
        }

        if (typeof website !== 'undefined' && website !== null && !/^https:\/\/(?:www\.)?[a-zA-Z0-9-]+(?:\.[a-zA-Z]+)+(?::\d{2,5})?(?:\/[^\s]*)?$/.test(website)) {
            return new Error(`invalid website: "${website}"`)
        }

        if (typeof comment === 'undefined' || comment.length === 0) {
            return new Error(`empty comment`)
        }

        const data = {
            id: uuidv4(),
            name: name,
            email: email,
            website: website,
            parent: parent,
            comment: comment,
        };

        const content = Base64.encode(JSON.stringify(data))

        await octokit.rest.repos.createOrUpdateFileContents({
            owner: username,
            repo: repository,
            path: `data/comments/${slug}/${data.id}.json`,
            message: 'New comment',
            content: content,
        })

    } catch (error) {
        return error
    }

    return null
}

To test the code above, re-run index.js after updating it to the following:

// index.js 

import {createComment} from "./github.js"

(async () => {
    const error = await createComment(
        process.env.GITHUB_TOKEN, 
        'test', 
        'slug-of-the-blog-post', 
        'John', 
        '[email protected]', 
        'https://example.com', 
        null, 
        'This is a test comment'
    )
    
    if (error != null) {
        console.error(error.message)
    }
})()

Now that the function responsible for posting the comment to the GitHub repository is complete, we can move on making it accessible from a website.

Deploy the function to Cloudflare

Cloudflare Workers is a platform for running serverless function. It offers a free tier for small websites, which is sufficient in most cases.

If you’re new to the Cloudflare Workers platform, I can suggest reading a short guide on how to get started. If not, please read on.

The function needs to use the GitHub access token to identify itself with the GitHub API. Can you store the access tokens directly in your code? You can, but it would be a bad idea because it can lead to accidental disclosure of secrets.

In the example before, we accessed the GitHub access token using environment variables. We will do the same here except we will store the access token in the Cloudflare Secrets. Cloudflare Secrets is a service that allows you to store sensitive information in a secure way and then retrieve it later in your Worker function.

Inside your Workers project, run the following command to put your GitHub access token to the Secrets storage:

$ wrangler secrets put GITHUB_TOKEN

You will be prompted to enter the value of the secret key. Inside the Workers function, the access token can be accessed through the GITHUB_TOKEN property of the env object.

In addition to the access token, we will also need to provide a name of the GitHub repository to be used for storing comments. We could set it using Secrets, but that would be completely unnecessary. You can set project environment variable using a Wrangler configuration file.

# wrangler.toml

[vars]
GITHUB_REPOSITORY = "blog"

The GITHUB_REPOSITORY variable will be available as a property of the env object with the same name.

Below is the code for the Workers function that would handle the submission of a comment.

// index.js

import { createComment } from './github';

/**
 * @typedef {Object} Env
 * @property {string} GITHUB_TOKEN Access token for GitHub REST API
 * @property {string} GITHUB_REPOSITORY Name of the repository to be used for uploading comments
 */

export default {
    /**
     * Worker handler
     * @param request {Request}
     * @param env {Env}
     * @param ctx {ExecutionContext}
     * @returns {Promise<Response>}
     */
    async fetch(request, env, ctx) {
        const form = await request.formData()

        const err = await createComment(
            env.GITHUB_TOKEN,
            env.GITHUB_REPOSITORY,
            form.get('slug'),
            form.get('name'),
            form.get('email'),
            form.get('website'),
            form.get('parent'),
            form.get('comment')
        );

        if (err != null) {
            return new Response(`An error has occurred: ${err.message}`, {status: 400})
        }

        return new Response('You comment has been successfully posted');
    }
};

Deploy the serverless function to the cloud:

$ wrangler deploy

...

Uploaded githubupload (3.33 sec)
Published githubupload (1.80 sec)
  https://githubupload.aquarius.workers.dev

After the deployment is finished, we can test if everything is working as expected:

$ curl -X "POST" "https://githubupload.aquarius.workers.dev" \
     -H 'Content-Type: application/x-www-form-urlencoded' \
     --data-urlencode "slug=slug-of-the-blog-post" \
     --data-urlencode "name=John" \
     --data-urlencode "comment=This is a test comment"
     
Your comment has been successfully posted

What’s next?

In this tutorial, we deployed a serverless function that handles the submission of a blog comment and stores it to the GitHub repository.

In the next part, we will build a frontend for the function—an HTML form for submitting a comment to this serverless function and a template for Hugo and Jekyll static website generators to output the comment in a blog post.