Comments are Back

For a number of years, I was using Disqus to provide comments on my blog. However, I was increasingly unhappy with how bloated the solution was, how many additional entries I was having to put into my Content Security Policy, and unsure how comfortable I was with having a third party own comments to my own site.

So last year, I removed comments from my site entirely.

This worked fine, and I didn't really think about it much, until somebody reached out to me via email recently, with what was essentially a comment on a blog post, and I realized that nobody else but me was going to benefit from it.

So I started thinking about how to go about adding comments again.

Build it?

I started, of course, by building.

I modeled what was essential to a comment; decided I'd use Markdown, with limits, to allow commenters some ability to style and format their comments; even got a schema setup in my database.

And then I realized a few things:

  • I'd need an admin for moderating comments.
  • I'd likely need some way to notify at least myself when a new comment was added.
  • What about letting folks know their comment was submitted successfully? Or that it was published? (I planned to moderate all comments by default.)
  • What about letting folks know when a new comment was published on a post they'd previously commented on?
  • What about allowing folks to unsubscribe from those notifications? And would I allow both granular (per blog post) and general (full site) unsubscription?
  • What about allowing folks to request all comments be deleted (per GDPR)?

The more I thought about it, the more work I was seeing, and I wasn't sure how much I wanted to develop it.

Research

So I started researching commenting systems, and quickly found a variety of generic solutions exist, thankfully. The research then boiled down to identifying which ones are actively maintained (because anything like a commenting system will likely need security updates and occasional updates to ensure compatibility with browsers and evolving security policies), which ones had the features I wanted, and how easily I'd be able to implement the solution.

I eventually settled on Remark42.

Remark42 is privacy-focused, and allows me to keep all the data. While there are a number of SSO integrations, they primarily use OAuth2, meaning that the integration is only for purposes of authentication. I was also able to enable an email authentication option; this sends an email to the user with a token that they then pasted back into the form to authenticate.

As for the email, I was able to set it up to use an existing SMTP user to send out emails. These are used for users who authenticate via email, sending notifications to me of new comments, and managing individual user notifications of comments.

I run it under its own domain (comments.mwop.net), and have functionality to embed comments via JavaScript in my site. I was able to configure it such that it will only accept comments from specific domains, which helps prevent abuse of the system. While I'd love to have the comments integrated on my site without JavaScript, the fact that I did not need to develop any of this on my own was a huge benefit.

Better: it uses limited Markdown for comment styling, and has built-in links to documentation on what you can use.

Configuring Remark42

I run the service using Docker Compose, using a single service in my Compose file:

services:
  remark42:
    image: umputun/remark42:latest
    container_name: "remark42"
    hostname: "comments.mwop.net"
    restart: always

    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "5"

    ports:
      - "127.0.0.1:9010:8080"

    env_file:
      - .env
    volumes:
      - ./var:/srv/var
      - ./templates/email_confirmation_login.html.tmpl:/srv/email_confirmation_login.html.tmpl:ro
      - ./templates/email_confirmation_subscription.html.tmpl:/srv/email_confirmation_subscription.html.tmpl:ro
      - ./templates/email_reply.html.tmpl:/srv/email_reply.html.tmpl:ro
      - ./templates/email_unsubscribe.html.tmpl:/srv/email_unsubscribe.html.tmpl:ro
      - ./templates/error_response.html.tmpl:/srv/error_response.html.tmpl:ro

A few remarks here:

  • I found that the "hostname" field needs to match the host that the service will answer to publicly. This was not documented, but until I made that change, it was inaccessible.
  • The service runs on port 8080 internally. I mapped that to a port on my localhost, which I then reverse proxy to via Caddy (more on that below).
  • There are a number of templates for the emails. I grabbed these from the Remark42 git repository and customized them.

Configuration of Remark42 when run via Docker is done via environment variables. I configured mine to allow email authentication, as well as OAuth2 via GitHub (as a large number of my readers have GitHub accounts). (You can also configure Google, Facebook, Apple, Microsoft, Patreon, Discord, Telegram, and Yandex.)

These were the values I configured; the full list of configuration values is in the documentation:

### This is the actual URL to the Remark42 instance
REMARK_URL=

### This is an identifier used to allow the instance to handle multiple sites.
### It can be a single value, or multiple, comma-separated values
SITE=

### A shared secret key for signing JWTs
SECRET=

### Whether or not to log debug messages
DEBUG=false

### A comma-separated list of hosts that are allowed to interact with the service
ALLOWED_HOSTS=

### The "Same-Site" cookie policy to use.
### I discovered through trial and error it needed to be "none"
AUTH_SAME_SITE=none

### GITHUB config
### If you use GitHub for OAuth2, these are the client ID and secret, respectively
AUTH_GITHUB_CID=
AUTH_GITHUB_CSEC=

### EMAIL SERVER CONNECTION
SMTP_HOST=
SMTP_PORT=465
SMTP_TLS=true
SMTP_INSECURE_SKIP_VERIFY=false
SMTP_USERNAME=
SMTP_PASSWORD=

### USER NOTIFICATION
NOTIFY_USERS=email
### The "From" address when sending email notifications
NOTIFY_EMAIL_FROM=
### The email subject line for email verifications
NOTIFY_EMAIL_VERIFICATION_SUBJ="mwop.net comments email verification"

### ADMIN NOTIFICATIONS
NOTIFY_ADMINS=email
### The "From" address for admin notifications
NOTIFY_EMAIL_FROM=
### A single email or comma-separated list of emails to which to send admin
### notifications
ADMIN_SHARED_EMAIL=
### User identifiers for admin users, to enable admin features for those users
ADMIN_SHARED_ID=

### Enable email authentication
AUTH_EMAIL_ENABLE=true
### The "From" address when sending email verifications
AUTH_EMAIL_FROM=
### The email subject line when sending email confirmation
AUTH_EMAIL_SUBJ="mwop.net confirmation"

### MISC
EMOJI=true

The ADMIN_SHARED_ID value must be setup AFTER you've authenticated a user in the system. You can click on a user from any comment thread, which opens a sidebar. At the top of the sidebar is the user display name, as well as their identifier, and you will copy this identifier to put in the ADMIN_SHARED_ID field. When you do so, you'll need to restart the container.

Serving it with Caddy

The Remark42 docs detail a number of different reverse proxy setups for serving it, including Caddy... but, interestingly, only show Caddy configuration for serving it from a sub-path of an existing site.

My Caddy configuration for this was very minimal:

comments.mwop.net {
    reverse_proxy localhost:9010 {
        header_up X-Real-IP {remote}
        header_down Strict-Transport-Security max-age=3153600
    }
    header {
        Strict-Transport-Security max-age=3153600;
    }
}
Integrating with the application

Once I had setup the server, I needed to integrate it in my application, and this is where things got tricky. Why? Because of some choices I've made along the way while developing my site:

  • I have a pretty robust Content Security Policy, and needed to configure it to allow (a) pulling the JS for Remark42 from my comments host, and (b) allow it to display frames from it.
  • I use HTMX, and have enabled hx-boost, which means I need to (a) load the Remark42 JS on every page, and (b) have some functionality for re-initializing it when a user navigates to a new page. Further, I use Webpack to concatenate and minimize my JS, which means I'd need to ensure that this integration is done in a way that will work correctly.
Content Security Policy

The values used in ALLOW_HOSTS are used to populate a frame-ancestors Content Security Policy header by the Remark42 server.

This is important.

If you are defining a Content Security Policy on your application, you'll need to ensure that you have a frame-src setting that allows your Remark42 server. This is in addition to allowing it as a script-src:

Content-Security-Policy: script-src https://comments.mwop.net; frame-src https://comments.mwop.net

I use paragonie/csp-builder, and you can set these via the following:

<?php
use ParagonIE\CSPBuilder\CSPBuilder;

$csp = new CSPBuilder([
    'frame-src' => [
        'allow' => [
            'https://comments.mwop.net',
        ],
    ],
    'script-src' => [
        'allow' => [
            'https://comments.mwop.net',
        ],
    ],
])

(I clearly have more in it that these values; this is to illustrate the pieces necessary to integrate Remark42.)

HTMX Integration

To start, I added the following tag to the <head> element of my layout:

<script defer src="https://comments.mwop.net/web/embed.js"></script>

This ensures that the first page a user comes to on my site loads the JS, but that it's done in the background.

Next, I needed to make a change to my site JavaScript.

I use Webpack, and have the following in my site JS, among other things:

window.htmx = require("htmx.org");

I discovered through trial and error that you MUST define a remark_config variable at the global (window) level of your JS; otherwise, regardless of how you try and create a Remark42 instance, it will fail.

Additionally, I was going to need to (a) register a listener on the REMARK42::ready event so it would initialize on an initial page load, and (b) register a listener on the htmx:load event so that it would re-initialize after a page swap occurs.

The end result looks like this:

const remark_config = {
    host: "USE SAME VALUE AS 'REMARK_URL' CONFIG HERE",
    site_id: "USE A VALUE FROM 'SITE' CONFIG HERE",
    theme: "dark", // can be light, dark, or system
    no_footer: true, // I didn't want to include the "powered by" footer
};

let remark42Instance = null;

const initComments = function() {
    if (window.REMARK42) {
        if (remark42Instance) {
            remark42Instance.destroy();
        }

        node = document.getElementById('remark42');

        if (node === null) {
            return;
        }

        remark42Instance = window.REMARK42.createInstance({
            node: node,
            ...remark_config,
        });
    }
};

window.remark_config         = remark_config;
window.htmx                  = require("htmx.org");
window.addEventListener('REMARK42::ready', () => {
    initComments();
});
window.htmx.on("htmx:load", () => {
    initComments();
});

The initComments() function checks to see if a global REMARK42 object exists; this is registered by the Remark42 JS when it loads, so if it doesn't exist, it means the JS hasn't been loaded on the page yet. From there, it checks to see if the remark42Instance variable is non-null, and, if so, calls its destroy() method. Next, it checks to see if a DOM element with the ID remark42 is present; if so, it creates a new Remark42 instance, passing that node and the previously defined Remark42 configuration.

When the Remark42 JS emits the REMARK42::ready event, or HTMX emits the htmx:load event, I trigger the function. This ensures it's triggered on an initial page load, and on any subsequent DOM load event triggered by HTMX.

Adding comments to a page

Now, to add comments to a page, I only need to add the following:

<div id="remark42"></div>

And I can use any tag I want here, just so long as the ID is set.

Final thoughts

I'm fairly happy with this solution.

I didn't have to build it all myself, I keep ownership of the data instead of handing it to a third-party, my users get reasonable privacy (including the right to request deletion of all their comments), and I get tools to moderate and manage comments.

I'd love it if I didn't have to use JS for this, and could theme the comments myself. That said, Remark42 has an API, so technically I could build some site integration that delegates to it behind the scenes if I really want to. As it is, the "dark" theme integrates reasonably well with my existing site styles, so there's no immediate need for me to do this currently.

Let me know what you think... comments are on, after all!