A semi-curated blog of computer graphics and rendering.
Writing a Comment System For My Blog

I know I have just finished writing the blog and it was just up & running, and its a static blog with not a lot of viewers, and even though I had no idea how many people are actually reading it now (no analytic tools,) I just think a comment system would be pretty nice (it’s also suggested by a special someone.) If you scroll to the bottom-est of my blog, you can see that indeed there is a comment system in place, all designed & implemented by yours truly. Leave a comment or click the little star below if you feel like it! It will definitely make my day. Also, since I made it all by myself, there is no tracker, no background information gathering, no whatever. You can comment, then change your username and comment again, and it will seem like there’s two distinct users commenting my post! How exciting.

Requirements

This post is used to document how the comment system comes to be, how I designed & implemented it. So first, to start it all off, we need to take a look at what a comment system needs:

  • Users can like a post
  • Users can comment by specifying a username and a comment content
  • Users can view other comments
  • Some method to verify the user is real to reduce spam

That’s it! At least for my tiny blog here, with not much traffic going on, I think that’s what we need. However, since the blog itself is powered by Jekyll, which is a static blogging site, we can’t implement a comment system by simply building it on Jekyll. It’s just not possible. So instead, we are going to write our very own backend server, with a tiny SQLite database to store star count and post comments, and a frontend written in JavaScript (ugh) to interact with the backend. Also yes, users can’t dislike a post. If you liked my post, you liked it… forever. Thanks! ;)

Database

Since I am using JavaScript anyway, I might as well write the backend in Node.js as well. It does come with pretty good packaging support, and Express is simple & easy to set up. Of course, the API server powered by express needs some means to access the database, and that’s why we are using sqlite3 library. It also has built-in injection prevention support.

First and foremost, the very first thing we need to do would be designing our database tables. In my case, I have created a posts table, to store post-relevant information & star count, and a comments table, to store comments. Each post has a unique handle, defined by its relative URL, and when looking for comments for a given post, we will SELECT all comments with that post identifier. Here’s how I created those tables:

-- posts table
CREATE TABLE posts (
    id INTEGER PRIMARY KEY,
    identifier TEXT NOT NULL,
    stars INTEGER DEFAULT 0
);

-- comments table
CREATE TABLE comments (
    id INTEGER PRIMARY KEY,
    identifier TEXT NOT NULL,
    username TEXT NOT NULL,
    content TEXT NOT NULL,
    created DATETIME DEFAULT CURRENT_TIMESTAMP
);

With these fresh tables in hand, it is now time to design the database API, so we can interact with it. Since commenting is anonymous, (also I am too lazy to make any session-related stuffs,) comments can never be edited, only added, or deleted by admin. Posts on the other hand, since the backend has no idea about how many posts the blog actually has, are created on the fly, when users try to star or comment on a post. We create a new post when the post is not found in the database. So, in conclusion, here’s the SQL transactions we need:

  • Get all relevant information about a post (getPost)
  • Create & delete comments (commentPost, deleteComment)
  • Create & delete posts - no related functions in post creation, it is created implicitly by commentPost and starPost
  • Update the star count in a post (starPost)

Since code can take up a lot of space, we will not be posting code snippets here. We will treat it like a black box and discuss what parameters do they have and what do they spit out. The backend is open source anyway (in fact, so is the frontend) and you can find it at here. Let’s go!

getPost

getPost requires the following parameters:

  • db: The database handle. Created by the sqlite3 library.
  • identifier: Post identifier.
  • callback: The callback function, which getPost will call after successfully retrieving post information.

getPost should return the following in the callback function:

{
    "post": "/meta/2023/01/18/comment-system.html",
    "stars": 1,
    "comments": [
        {
            id: 25, 
            username: "42yeah", 
            content: "Blah blah blah", 
            created: "2069-04-20 10:10:10"
        }
    ]
}

So basically all post-relevant information. It is worth noting that, when the post entry does not exist in the database, it won’t throw an error; instead, it will return an empty, but valid result. Like this:

{
    "post": "/blah",
    "stars": 0,
    "comments": []
}

No new database entry will be created if getPost misses, since posts can only be lazily created by commentPost and starPost.

commentPost

commentPost requires the following parameters:

  • db: The database handle.
  • identifier: Post identifier.
  • username: User-specified username.
  • content: User-specified comment body.
  • callback: function which commentPost will call after success or failure

If said post with the identifier does not exist in the database, it will be INSERTed with a star count of 0.

After INSERTing the comment into database, commentPost will call the callback function with an error parameter. If no error was found, then it was set to null.

starPost

starPost requires the following parameters:

  • db: The database handle.
  • identifier: Post identifier.

Very similar to commentPost, starPost looks for the post associated with the identifier, and if it doesn’t exist, will be INSERTed with a star count of 1. If it exists, we UPDATE the star count by setting it to star + 1.

starPost is actually very lax due to my laziness and does not have a callback function. Frontend will have to just assume that the starring always succeeds; or, maybe call getPost again and get the star count, but that’s very expensive.

Also yes, this can be called repeatedly. It is up to frontend to use cookie magic to guarantee that same user cannot star twice. Of course this still makes starring a post multiple times possible, since the end user can just… call it again, but I mean, if that happens, they must like your post a lot.

I am not going to say a lot about deleteComment and deletePost here, because they aren’t really that important, but just know that when deletePost was called, all comments associated with that identifier must be deleted as well, otherwise they’d be dangling and wasting space.

Backend

Next up is the backend. With getPost in hand, which basically replies all post relevant information, our backend can just pass it along to the frontend. But first, let’s take a look at what backend needs to serve:

  • GET /comments?post={identifier}: responds with all relevant information of a post
  • POST /comment: add a comment to the post specified by its identifier
  • POST /star: star a post specified by its identifier

Seems easy enough, right? Well no. Because it is here where we have to think about the problem of the bad actors. Users might inject the request with the <script> tag, or maybe use a lot of <h1>s or whatever to break the page layout, just for the fun of it. As a result, we will need something to sanitize the input. Some library. Something such as sanitize-html.

But is that enough? Bad guys can also call /comment repeatedly to flood the database with useless comments. It is here where a new requirement arises, which is to distinguish humans from automated robots. Introducing… the CAPTCHA!

GET /captcha

Due to the requirement of the whole comment system being lightweight and easy to adapt, the CAPTCHA generator needs to be lightweight as well. I have decided to write a CAPTCHA generator by myself, using a canvas-like library pureimage. pureimage is good because it does not depend on Cairo, which has a lot of different dependencies on different platforms, and as a result is difficult to track. pureimage also implements most of the HTML canvas’s 2D functionalities, especially text rendering, so that’s good enough for me.

In addition to the three backend APIs above, we need one more API:

  • GET /captcha: returns a CAPTCHA image with a unique identifier X-UUID.

The frontend is responsible for requesting the /captcha. A newly generated CAPTCHA, together with a CAPTCHA UUID, is sent back to the frontend. The frontend should store both, in order for it to be used later at /comment.

CAPTCHAs are not stored in the database; since they come and go fast, they can be stored as a variable. Accessing it this way is pretty quick as well. If every check passes, the backend code calls commentPost - but not before sanitizing all fields, including username and comment body. In order to prevent CAPTCHAs from piling up, we need to clean them up from time to time. Under the hood, an automated function is executed by the backend every once in a while to clean up stale CAPTCHA challenges.

POST /comment

Frontend is responsible for requesting /comment when the end user clicks on the comment button. /comment requires a set of parameters, encoded in the form of JSON, and here’s how it looks like:

{
    "post": "/meta/2023/01/18/comment-system.html",
    "username": "<INSERT USERNAME HERE>",
    "content": "<COMMENT BODY>",
    "captcha": "<USER-INPUT CAPTCHA>",
    "captchaID": "<CAPTCHA's UUID>"
}

Any of it failing will result in an error being reported back to the frontend, which looks like this:

{
    "error": "<REASON>"
}

Once the response is sent back to the frontend, the frontend can request /comments?post=<IDENTIFIER> again, to refresh the comment section and see the newly posted comment.

The backend code will check if the captcha matches up with the backend version: this is done by comparing it to captchas[captchaID].

POST /star

Frontend is responsible for requesting /star when the end user clicks on the star button. Frontend should also guarantee that the end user won’t star a post twice. Calling /star is like a simpler form of /comment, with no username, content, captcha, and captchaID. It also won’t notify if the starring fails. I think it’s alright to assume starring always succeeds, since its pretty trivial, and safe (I assume,) so there’s really not much to talk about here.

The backend code will simply check if the post field exists in the request JSON body, and if it does, calls starPost, and just be done with it.

Frontend

We have already covered a lot of what the frontend should do when talking about backend code. Still though, here’s a summary:

  • Render the list comments after requesting /comments?post=<IDENTIFIER>
  • Check the end user’s inputs: have they filled all fields accordingly?
  • Send POST request to /comment after the user clicks on the comment button
  • Send POST request to /star after the user click’s on the star button
  • Notify user when comment has failed
  • An interface that is (hopefully) straightforward to use and navigate
  • Make it retro because I like it

If you scroll further down, you can see that I have tried my best on the last two bullet points. Nothing much is going on in the frontend part because it’s all just UI JavaScript stuff. You can check out the source code at here if you want.

This was quite an interesting journey, and I have learned quite a lot from frontend to backend. Haven’t been making these things a lot since forever. It feels good to get back to them from time to time; web development still has its charms. I have developed the whole thing using Vanilla JS because I have no idea about how any other JavaScript frameworks work. But so long as it works, right?

And boom! Here’s what we get. You can check out the the backend code at GitHub, the Frontend JavaScript code, as well as an interactive demo available on-site. Especially the interactive demo. Check it out. Byeeee!

+ Loading comments +
Copyleft 2023 42yeah.