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.
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:
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! ;)
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:
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
requires the following parameters:
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
requires the following parameters:
If said post with the identifier does not exist in the database, it will be INSERT
ed with a star count of 0.
After INSERT
ing 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
requires the following parameters:
Very similar to commentPost
, starPost
looks for the post associated with the identifier, and if it doesn’t exist, will be INSERT
ed 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.
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:
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!
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:
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.
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]
.
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.
We have already covered a lot of what the frontend should do when talking about backend code. Still though, here’s a summary:
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!
Comments