Self-host comments in Jekyll, powered by Firebase real-time database

Self-host comments in Jekyll, powered by Firebase real-time database

Overview

It’s convenient to set up a comment system in Jekyll site with external social comment systems like Disqus or Duoshuo (多说). However, as you all know, Disqus was blocked in China and Duoshuo is going to shutdown. It’s the time to rethink about the comment system (although I didn’t get too many comments →_→), simple and controllable.

If you search for “Jekyll comments”, there are several plugins or solutions that can be used. Looked into those posts, Going Static: Episode II — Attack of the Comments using Staticmanseemed most perfect, all the comments become static files in your git repository. Perfect Jekyll way! But, how about situations like mine, hosting site other than GitHub Pages.

Then, Creating a Firebase-Backed Commenting System attracted my attention. Using Firebase real-time database and a little bit JavaScript, it’s easy to set up your custom commenting system. Actually, I used Firebase a lot on this site, pageview counts, trending posts and a tiny “Like” button… Why not benefit more from it!

The following steps are adapted from the above post, and add the Markdown support for the comment system. And thanks to JiYou for the discussion.

Firebase Setup

Firebase is currently a part of Google Developers tool, its real-time database stores data as JSON objects. For example, we can construct the comments like this way:

{
  "/tutorial/2016/01/02/title-tag.html": [
    {
      "name": "Bill",
      "email": "bill@example.org",
      "message": "Hi there, nice blog!",
      "timestamp": 1452042357209
    },
    {
      "name": "Bob",
      "email": "bob@example.org",
      "message": "Wow look at this blog.",
      "timestamp": 145204235846
    }
  ]
}

Using JavaScript, we can access the JSON database by Firebase references, that’s our comment system comes.

Create new Firebase project

The first step is to create a new project in the Firebase console.

Create a new project

Project settings

In the Overview pannel, click the Add Firebase to your web app to get the initialize parameters for later use:

// TODO: Replace with your project's customized code snippet
<script src="https://www.gstatic.com/firebasejs/3.4.0/firebase.js"></script>
<script>
// Initialize Firebase
var config = {
    apiKey: '<your-api-key>',
    authDomain: '<your-auth-domain>',
    databaseURL: '<your-database-url>',
    storageBucket: '<your-storage-bucket>'
};
firebase.initializeApp(config);
</script>

JavaScript implementation

Initialize Firebase

Here, I’m going to script in a separate js file like comment.js together with jQuery. For initializing:

$.getScript('https://www.gstatic.com/firebasejs/3.4.0/firebase.js', function () {
    // Initialize Firebase
    var config = {
        apiKey: '<your-api-key>',
        authDomain: '<your-auth-domain>',
        databaseURL: '<your-database-url>',
        storageBucket: '<your-storage-bucket>'
    };
    firebase.initializeApp(config);

    // TO-DO
}

The following parts of JavaScript will fall into the TO-DO above.

New reference for comments

Add a reference to the Firebase database, that we can store new comments or read exist comments.

var rootRef = firebase.database().ref();
var postComments = rootRef.child('postComments');

Here, rootRef is the root of the Firebase database and a child postComments for all the comments.

Post identity

Similar to Disqus, we need to setup a unique identity for each blog post.

var link = $("link[rel='canonical']").attr("href");
var pathkey = decodeURI(link.replace(new RegExp('\\/|\\.', 'g'),"_"));
var postRef = postComments.child(pathkey);

Here, I used the canonical link for each post. You can replace it with window.location.pathname if you like.

As Firebase doesn’t support certain characters for the node key, replace these characters and used it for post-identification. postRef create a unique reference for each post under the postComments reference.

If you create your own keys, they must be UTF-8 encoded, can be a maximum of 768 bytes, and cannot contain ., $, #, [, ], /, or ASCII control characters 0-31 or 127.

Save new comments

Now, look at your comment form in the Jekyll layout. If you don’t have one, just add it below the content of the post formatted in this way:

<h3>Leave a comment</h3>

<form id="comment">
  <label for="message">Message</label>
  <textarea id="message"></textarea>

  <label for="name">Name</label>
  <input type="text" id="name">

  <label for="email">Email</label>
  <input type="text" id="email">

  <input type="submit" value="Post Comment">
</form>

Override the default submit action in JavaScript:

$("#comment").submit(function() {
    postRef.push().set({
        name: $("#name").val(),
        message: $("#message").val(),
        md5Email: md5($("#email").val()),
        postedAt: firebase.database.ServerValue.TIMESTAMP
  });

  $("input[type=text], textarea").val("");
  return false;
});

postRef.push() creates an array in Firebase database if it doesn’t exist and returns a new reference to the first item. It looks like this in my test project:

New Comments in Firebase

Here, MD5 of the email address is stored and used for display profile images from Gravatar. Thus, include the JavaScript MD5 js before our comment.js. firebase.database.ServerValue.TIMESTAMP is the timestamp from the Firebase that can avoid timezone issues.

Showing comments

Well, we can send new comments to the Firebase database now. Let’s take a further step to pull stored comments to the post page.

Before that, add a HTML container to hold the comments in the Jekyll layout:

<div id="comments-container"></div>

Then a new JavaScript function to trigger existing and new comments:

postRef.on("child_added", function(snapshot) {
    var newComment = snapshot.val();
    
    // Markdown into HTML
    var converter = new showdown.Converter();
    converter.setFlavor('github');
    var markedMessage = converter.makeHtml(newComment.message);
    
    // HTML format
    var html = "<div class='comment'>";
    html += "<h4>" + newComment.name + "</h4>";
    html += "<div class='profile-image'><img src='https://www.gravatar.com/avatar/" + newComment.md5Email + "?s=100&d=retro'/></div>";
    html += "<span class='date'>" + jQuery.timeago(newComment.postedAt) + "</span>"
    html += "<p>" + markedMessage  + "</p></div>";
    
    $("#comments-container").prepend(html);
});

child_added returns a snapshot of the comments data into snapchat.val(). Then format it into HTML and prepend the result into the comments-container.

Formatting the comments, Showdown js is used to convert markdown message into HTML. And timeago js to convert the timestamp into string time. Don’t forget to include these two libraries.

Safety issues

As I’m a newbie in this field, it’s my first time that encounters such issues as Cross-site Scripting (XSS) attack.

From Showdown’s wiki:

Cross-side scripting is a well known technique to gain access to private information of the users of a website. The attacker injects spurious HTML content (a script) on the web page which will read the user’s cookies and do something bad with it (like steal credentials). As a countermeasure, you should filter any suspicious content coming from user input. Showdown doesn’t include an XSS filter, so you must provide your own. But be careful in how you do it…

After a quick test, I found XSS is a problem on my site if scripts involved in the comments. Fortunately, there are several js implementation can filter XSS contents, showdown-xss-filter is such a accessible extension to filter XSS.

So, include the dependency to the XSS filter and update our JavaScript in previous section:

var converter = new showdown.Converter({ extensions: ['xssfilter'] });

That’s all for the XSS filter. But we still need to care about the data safety that stored in Firebase.

Since everyone is allowed to post comments without login, we have to set up some data rules to prevent deleting:

{
  "rules": {
    ".read": true,

    "$title": {
        "$slug": {
            ".write": "!data.exists()",
            "$message": {
            ".write": "!data.exists() || !newData.exists()"
            }
        }
    }
  
  }
}

For the !data.exists() || !newData.exists() rule setting, we can write as long as old data or new data does not exist.

Summarise

Well, our Firebase comment system is ready to use. The js libraries used are:

<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/blueimp-md5/2.7.0/js/md5.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-timeago/1.5.4/jquery.timeago.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.6.4/showdown.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-xss/0.3.3/xss.min.js"></script>
<script src="/assets/js/showdown-xss-filter.js"></script>
<script src="/assets/js/comment.js"></script>

In our comment.js, the complete scripts are:

$.getScript('https://www.gstatic.com/firebasejs/3.4.0/firebase.js', function () {
    var config = {
        apiKey: '<your-api-key>',
        authDomain: '<your-auth-domain>',
        databaseURL: '<your-database-url>',
        storageBucket: '<your-storage-bucket>'
    };
    firebase.initializeApp(config);
    var rootRef = firebase.database().ref();
    var postComments = rootRef.child('postComments');
    var link = $("link[rel='canonical']").attr("href");
    var pathkey = decodeURI(link.replace(new RegExp('\\/|\\.', 'g'),"_"));
    var postRef = postComments.child(pathkey);
    $("#comment").submit(function() {
        postRef.push().set({
            name: $("#name").val(),
            message: $("#message").val(),
            md5Email: md5($("#email").val()),
            postedAt: firebase.database.ServerValue.TIMESTAMP
        });
        $("input[type=text], textarea").val("");
        return false;
    });    
    postRef.on("child_added", function(snapshot) {
        var newComment = snapshot.val();
        var converter = new showdown.Converter({ extensions: ['xssfilter'] });
        converter.setFlavor('github');
        var markedMessage = converter.makeHtml(newComment.message);
        var html = "<div class='comment'>";
        html += "<h4>" + newComment.name + "</h4>";
        html += "<div class='profile-image'><img src='https://www.gravatar.com/avatar/" + newComment.md5Email + "?s=100&d=retro'/></div>";
        html += "<span class='date'>" + jQuery.timeago(newComment.postedAt) + "</span>"
        html += "<p>" + markedMessage  + "</p></div>";
        $("#comments-container").prepend(html);
    });
}

Now, leave your comment below to have a try and help to test…

avatar

Frank Lin

Code learning...

Say something Login