Mody Bick is built on Hugo, a static site generator, using the Blowfish theme. Since Hugo is for static sites, it doesn’t come prebuilt with a backend, so the comment system isn’t included in Hugo. However, it does support third-party comment systems such as Disqus.
I wanted to integrate a comment system on Mody Bick, and tried a lot of third-party comment systems. One of my concerns with Disqus is regarding privacy, and Disqus is also blocked by default when browsers turn on tracking prevention, making it seem like there isn’t any way to leave comments. There are other privacy-first alternatives such as Cusdis, but the main issue was that since it’s embedded on an iframe, it won’t change to dark mode when Mody Bick’s dark mode is turned on.
So the solution was to build my own backend in Firebase and Firestore. It should technically work with most sites.
Setting up Firebase #
The Blowfish theme has some guidance on how to set up Firebase for views and likes, but I’ll let you know here in case you’re using a different theme on Hugo, using a different static site generator, or building your own website with your own HTML/CSS/JS.
The basic steps should be the same: first, create an account on Firebase. Create a new project and set analytics location to be the closest to the country your site is most active in.
On Settings > General, Firebase will provide you with a file. If you click CDN, it should look like this:
<script type="module">
// Import the functions you need from the SDKs you need
import { initializeApp } from "https://www.gstatic.com/firebasejs/12.12.0firebase-app.js";
import { getAnalytics } from "https://www.gstatic.com/firebasejs/12.12.0firebase-analytics.js";
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries
// Your web app's Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
const firebaseConfig = {
apiKey: "YOUR_API_KEY",
authDomain: "YOUR_AUTH_DOMAIN",
databaseURL: "YOUR_DB_URL",
projectId: "YOUR_PROJECT_ID",
storageBucket: "YOUR_STORAGE_BUCKET",
messagingSenderId: "YOUR_MSG_SENDER_ID",
appId: "YOUR_APP_ID",
measurementId: "YOUR_MEASUREMENT_ID"
};
// Initialize Firebase
const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app);
</script>If you check the TODO, they provide you with a link to the docs. The ones we need are in the Firestore docs.
Add the HTML form #
Following Hugo conventions, add the HTML form in your template. Make sure data-post-id is the unique ID of the Hugo file to ensure that every post has their own separate set of comments. If you’re not using Hugo, you can set the data-post-id to the name of the post.
<section class="comments">
<h3>Comments</h3>
<form id="comment-form" data-post-id="{{ .File.UniqueID }}">
<input type="text" id="username" placeholder="Your Name" required>
<textarea id="comment-text" placeholder="Write a comment..." required><textarea>
<button type="submit" id="submit-btn">Post Comment</button>
</form>
<div id="comments-container"></div>
</section>Setting up Firestore #
On the Firebase panel, expand Build > Firestore database. You’ll have to select your project then Create Database > Start in Production Mode. It will ask you for a location; select the location closest to where your site is most active. Finally, click Create.
From the Firestore docs: Cloud Firestore stores data in Documents, which are stored in Collections. Cloud Firestore creates collections and documents implicitly the first time you add data to the document.
You’ll have to import certain functions from Firestore like so:
import {
getFirestore, collection, addDoc, onSnapshot, query, where, orderBy, serverTimestamp
} from "https://www.gstatic.com/firebasejs/12.12.0/firebase-firestore.js";Here’s an overview of the functions we are importing.
getFirestore: accesses the Firestore of a web app.collection: accesses a collection in the Firestore.addDoc: adds a document to a collection.onSnapshot: listens to a document.query: alongsidewhere, lets you query multiple documents.where: alongsidequery, lets you query multiple documents.orderBy: sorts the documents retrievedserverTimestamp: tracks time
Setting up comments #
We would like a collection called comments which contains each comment as a document.
const db = getFirestore(app);
const commentsCol = collection(db, 'comments');Setup the form, container (where our comments would be displayed) and postId (the post containing comments)
const commentForm = document.querySelector('#comment-form');
const commentsContainer = document.querySelector('#comments-container');
const postId = commentForm?.getAttribute('data-post-id');We want each postId to show different set of comments.
if (postId)
Obtain the user input from the form.
commentForm.addEventListener('submit', async (e) => {
e.preventDefault();
const userField = document.querySelector('#username');
const textField = document.querySelector('#comment-text');
const btn = document.querySelector('#submit-btn');
})We want the button to be disabled at the beginning;
btn.disabled = true;Let’s use try to add the form contents into the database. If successful, it enables the button. Otherwise, it catches the error.
try {
await addDoc(commentsCol, {
postId: postId,
username: userField.value,
text: textField.value,
createdAt: serverTimestamp()
});
commentForm.reset();
} catch (err) {
console.error("Firebase Error:", err);
} finally {
btn.disabled = false;
}Next, we want a comment section that displays the comments based on the database.
First, we need to order the comments by the time it was posted.
const q = query(
commentsCol,
where("postId", "==", postId),
orderBy("createdAt", "desc")
);Then, display the comments:
onSnapshot(q, (snapshot) => {
commentsContainer.innerHTML = '';
snapshot.forEach((doc) => {
const data = doc.data();
const Name = data.username;
const Text = data.text;
const date = data.createdAt?.toDate().toLocaleString("en-GB", {hour12:true}) || "Pending...";
const div = document.createElement('div');
div.innerHTML = `
<strong>${Name}</strong>
<small>${date}</small>
<p>${Text}</p>
`;
commentsContainer.appendChild(div);
});
});Add handling in case there are no comments.
if (snapshot.empty) {
commentsContainer.innerHTML = '<p>No comments yet.</p>';
return;
}Add error handling if comments don’t appear.
(error) => {
console.error("Firestore error.", error);
};Security #
One of the ways of increasing security is adding a honeypot. This is an invisible form that humans don’t fill, but bots do.
Add the HTML like so:
<div style="display: none !important; visibility: hidden;" aria-hidden="true">
<input type="text" id="telephone" name="telephone" tabindex="-1"autocomplete="off">
</div>Add a honey variable that resets the form if it is detected to be filled.
const honey = document.querySelector('#telephone').value;
if (honey.length > 0) {
console.warn("Bot detected.");
commentForm.reset();
return;
}Another security addition is adding XSS filters to prevent XSS attacks. We will add one from Yahoo’s XSS filter. You can download the min.js file, or you can just include the CDN like I did:
<script src='https://cdn.jsdelivr.net/gh/yahoo/xss-filters@master/dist/xss-filters.js'></script>You can modify content output (comments display) like so:
snapshot.forEach((doc) => {
const data = doc.data();
const safeName = xssFilters.inHTMLData(data.username);
const safeText = xssFilters.inHTMLData(data.text);
const date = data.createdAt?.toDate().toLocaleString("en-GB", {hour12:true}) || "Pending...";
const div = document.createElement('div');
div.innerHTML = `
<strong>${safeName}</strong>
<small>${date}</small>
<p>${safeText}</p>
`;
commentsContainer.appendChild(div);
});Composite index error #
When first running the code, the code will catch an error because it needs to create composite indexes.
You can automatically create a composite index by clicking the link in the console when the error is shown. Firebase will automatically generate those for you. Otherwise, you can create it manually in your Firebase console.
Firebase rules #
You also need to setup Rules on Firebase.
- Ensure that input is only strings.
- Ensure that there isn’t too much payloads.
- Ensure no extra fields are added.
- Make sure timestamp follows the server timestamp.
Copy and paste this section:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /comments/{commentId} {
allow read: if true;
allow create: if
request.resource.data.username is string &&
request.resource.data.text is string &&
request.resource.data.postId is string &&
request.resource.data.username.size() <= 50 &&
request.resource.data.text.size() <= 1000 &&
request.resource.data.keys().hasOnly(['username', 'text', 'postId','createdAt']) &&
request.resource.data.createdAt == request.time;
}
}
}Summary (Copy-paste code) #
Here’s the entire code with security measures added (for those who would like to skip the explanation and copy paste). Make sure to replace firebaseConfig with your own project configuration.
<section class="comments">
<h3>Comments</h3>
<form id="comment-form" data-post-id="{{ .File.UniqueID }}">
<input type="text" id="username" placeholder="Your Name" required>
<textarea id="comment-text" placeholder="Write a comment..." required><textarea>
<div style="display: none !important; visibility: hidden;" aria-hidden="true">
<input type="text" id="telephone" name="telephone" tabindex="-1"autocomplete="off">
</div>
<button type="submit" id="submit-btn">Post Comment</button>
</form>
<div id="comments-container"></div>
</section>
<script src='https://cdn.jsdelivr.net/gh/yahoo/xss-filters@master/dist/xss-filtersjs'></script>
<script type="module">
import { initializeApp } from "https://www.gstatic.com/firebasejs/12.12.0firebase-app.js";
import {
getFirestore, collection, addDoc, onSnapshot, query, where, orderBy,serverTimestamp
} from "https://www.gstatic.com/firebasejs/12.12.0/firebase-firestore.js";
const firebaseConfig = {
apiKey: "YOUR_API_KEY",
authDomain: "YOUR_AUTH_DOMAIN",
databaseURL: "YOUR_DB_URL",
projectId: "YOUR_PROJECT_ID",
storageBucket: "YOUR_STORAGE_BUCKET",
messagingSenderId: "YOUR_MSG_SENDER_ID",
appId: "YOUR_APP_ID",
measurementId: "YOUR_MEASUREMENT_ID"
};
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
const commentForm = document.querySelector('#comment-form');
const commentsContainer = document.querySelector('#comments-container');
const postId = commentForm?.getAttribute('data-post-id');
if (postId) {
commentForm.addEventListener('submit', async (e) => {
e.preventDefault();
const honey = document.querySelector('#telephone').value;
if (honey.length > 0) {
console.warn("Bot detected.");
commentForm.reset();
return;
}
const userField = document.querySelector('#username');
const textField = document.querySelector('#comment-text');
const btn = document.querySelector('#submit-btn');
btn.disabled = true;
try {
await addDoc(commentsCol, {
postId: postId,
username: userField.value,
text: textField.value,
createdAt: serverTimestamp()
});
commentForm.reset();
} catch (err) {
console.error("Firebase Error:", err);
} finally {
btn.disabled = false;
}
});
const q = query(
commentsCol,
where("postId", "==", postId),
orderBy("createdAt", "desc")
);
onSnapshot(q, (snapshot) => {
commentsContainer.innerHTML = '';
if (snapshot.empty) {
commentsContainer.innerHTML = '<p>No comments yet.</p>';
return;
}
snapshot.forEach((doc) => {
const data = doc.data();
const safeName = xssFilters.inHTMLData(data.username);
const safeText = xssFilters.inHTMLData(data.text);
const date = data.createdAt?.toDate().toLocaleString("en-GB", {hour12:true}) || "Pending...";
const div = document.createElement('div');
div.innerHTML = `
<strong>${safeName}</strong>
<small>${date}</small>
<p>${safeText}</p>
`;
commentsContainer.appendChild(div);
});
}, (error) => {
console.error("Firestore error.", error);
});
}
</script>The demo is on the comments section of this post (and every other post, really).