Updated: 2024-10-28 Mon 09:43

How I added self-hosted Comments on my Website

As you may have noticed, my website is heavily inspired by Derek Sivers. On his site, he has a comment section under every post which he implemented himself using PostgreSQL and Ruby. I always found this feature really cool, but couldn't get myself to do the work and implement it on my server as well - until now. I decided to "sit together" with claude.ai and implement my own comment section. Here is how "we" have done it.

Overview

What System am I implementing this on?

I am running my website on an OpenBSD-VPS (Virtual Private Server) with httpd as a web-server. This influences mostly the part where we need to forward our API-requests to the Flask-server, which would be different if you were using another web server like NGINX or Apache HTTP.

Structure of the Comment-System

We need a frontend so the user can submit a new comment and read existing ones under the post. We need a backend which sanitizes the user-input and stores the comments in a database.

We decided to use the following technologies:

Browser <-> HTML/CSS/JS (comments.js)
		|
		| HTTP requests (GET/POST to /api/comments/*)
		▼
relayd
- Handles SSL/TLS
- Routes requests:
  - Website content -> httpd (Port 8080)
  - Comments API -> Flask (Port 5000)
		|
		| Forwards API requests
		▼
gunicorn (Port 5000)
- Production web server
- Runs multiple workers
    |
    | Passes requests to Flask
    ▼
Flask (app.py)
- Handles API endpoints
- Processes requests
- Sanitizes input
    |
    | Database operations
    ▼
SQLite (comments.db)
- Stores comments

We embed a JS-class at the bottom of my posts, which should load existing comments on startup/refresh and also display a form with which the user can submit new comments. New submissions are also handled within this class. For each post, we create a new instace of our CommentSystem, where each instance is responsible for the comments under its post.

The CommentSystem (JS) gets the needed information by making http-requests to /api/comments/*. These requests are getting redirected to our Flask-server (gunicorn) running on port 5000. Here, the "GET" request reads the comment-database and returns the comments under the specified posts. The "POST" request adds a new comment into the database and refreshes the display.

In conclusion, we need to create the following files:

  • comments.js - to handle the frontend
  • app.py + run.sh (script to handle the API endpoints)
  • schema.sql (to set up the DB)
  • comments.db (the DB itself)
  • /etc/rc.d/comments (service definition for OpenBSD)

And we need to change the following files:

  • relayd.conf (to redirect API-requests to the Gunicorn server)
  • httpd.conf (possibly)

Implementation

Setup

First let us install some software which we certainly need:

doas pkg_add python3 py3-flask py3-bleach

Now we create a dedicated comments-user. If we then run the created script (Flask-App for example) as this comments-user, it can only access its own files (which we will assign to it). In that way, our system files should be safe.

# - No login shell (/sbin/nologin)
# - Home directory in /var/www/comments
doas useradd -s /sbin/nologin -d /var/www/comments comments

# Service files owned by comments user
doas chown -R comments:comments /var/www/comments/
doas chown comments:comments /var/log/comments*.log

# Set proper permissions
doas chmod 755 /var/www/comments
doas chmod 644 /var/www/comments/*.py
doas chmod 644 /var/www/comments/comments.db
doas chmod 755 /var/www/comments/run.sh

Now we can go on and create our virtual environment where we will install the required python-packages and which will be used when running the Flask-app:

# As the comments user
doas -u comments python3 -m venv /var/www/comments/venv
doas -u comments /var/www/comments/venv/bin/pip install flask flask-limiter bleach gunicorn

# flask app:
touch /var/www/comments/app.py
touch /var/www/comments/run.sh
doas chmod +x /var/www/comments/run.sh

app.py

  • creates Flask application
  • handles database connections
  • provides two API-endpoins (GET/POST)
  • sanitizes input using bleach
  • there is some input-validation going
  • rate-limiting using limiter
  • double-entry detection using hashes (also stored in the db)

run.sh

  • Will be run in a daemon
  • we change into the correct working directory
  • activate the python venv
  • execute the gunicorn server

I use gunicorn instead of the default Flask-development server because the default server is single threaded and not designed for production. Gunicorn can in principle handle multiple requests simultaneously and is overall a better choice. Technically you could also skip this step and just run 'app.py' in run.sh, which would then use Flask's dev-server.

Database

Now we can set up our database:

# create schema.sql
doas vi /var/www/comments/schema.sql

And paste into it something like this

CREATE TABLE IF NOT EXISTS comments (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    post_id TEXT NOT NULL,
    name TEXT NOT NULL,
    comment TEXT NOT NULL,
    created_at TEXT NOT NULL,
    comment_hash TEXT
);

CREATE INDEX IF NOT EXISTS idx_post_id ON comments(post_id);

then:

# Create database file
doas touch /var/www/comments/comments.db

# Set ownership
doas chown comments:comments /var/www/comments/comments.db
doas chmod 644 /var/www/comments/comments.db

# Initialize database with our schema
doas -u comments /var/www/comments/venv/bin/python3 -c "
from app import init_db
init_db()
"

Service

We need a service so our server runs automatically in the background:

doas vi /etc/rc.d/comments

# Content:
#!/bin/ksh

daemon="/var/www/comments/run.sh"
daemon_user="comments"

. /etc/rc.d/rc.subr

# runs in background:
rc_bg=YES
rc_reload=NO
# cleanup:
rc_pre() {
    pkill -u comments python >/dev/null 2>&1 || true
    sleep 1
}

rc_cmd $1

# make it executable
doas chmod +x /etc/rc.d/comments
# create log-files
doas touch /var/log/comments_access.log
doas touch /var/log/comments_error.log
doas chown comments:comments /var/log/comments_*.log
doas chmod 644 /var/log/comments_*.log

## enable and start the service
doas rcctl enable comments
doas rcctl start comments

# check if it is running
doas rcctl check comments
ps aux | grep gunicorn

Frontend

Lets create the frontend-code that will handle displaying and submitting comments. We only need onw file: comments.js

doas vi /var/www/htdocs/js/comments.js

Notice that we create comments.js in the htdocs folder. This is so we can easyly access it from our html files in /var/www/htdocs/index etc.

This little class creates a form for submitting new comments and loads existing comments to be displayed. It should also escape HTML in the comments for security. Notice there is a minimum of spam protection. In the app.py there is also a rate-limit, although I never tested if it really works.

In the blog-posts we insert the comment section then like this:

<p>YOUR POST COMES HERE</p>

<h2>Tell me what you think:</h2>
<div id="comments"></div>
<script src="/js/comments.js"></script>
<script>
    const postTitle = document.title || 'default-post';
    const safePostId = postTitle
        .toLowerCase()
        .replace(/[^a-z0-9]+/g, '-')
        .replace(/^-+|-+$/g, '');
    new CommentSystem(safePostId, document.getElementById('comments'));
</script>


The inline script fetches and 'cleans' the post-title so it can be used as PostID.

Forwarding Requests

I do not have much to say about this, because I don't understand a lot about networking. Its just that I needed a long time to dial in the way the request-forwarding worked and wanted to share the final result:

  • relayd.conf:
    log state changes
    log connection errors
    prefork 5
    
    table <httpd> { 127.0.0.1 }
    table <flask> { 127.0.0.1 }
    
    http protocol "https" {
      tls keypair "example.com"
      return error
      match request header set "X-Forwarded-For" value "$REMOTE_ADDR"
      match request header set "X-Forwarded-By" value "$SERVER_ADDR:$SERVER_PORT"
      # test in https://securityheaders.com
      match response header remove "Server"
      match response header append "Strict-Transport-Security" value "max-age=31536000"
      match response header append "X-Frame-Options" value "SAMEORIGIN"
      match response header append "X-XSS-Protection" value "1; mode=block"
      match response header append "X-Content-Type-Options" value "nosniff"
      match response header append "Referrer-Policy" value "strict-origin"
      match response header append "Content-Security-Policy" value "default-src https: 'unsafe-inline'"
      match response header append "Permissions-Policy" value "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
    
      pass request quick path "/api/comments*" forward to <flask>
      pass request quick header "Host" value "example.com" forward to <httpd>
    }
    
    relay "https" {
      listen on 0.0.0.0 port 443 tls
      protocol https
      forward to <httpd> port 8080
      forward to <flask> port 5000
    }
    
  • httpd.conf:
    # Global settings
    prefork 5
    default type text/html
    
    # Main HTTPS server
    server "example.com" {
        listen on * port 8080
    
        # Remove .html extension from URLs
        location match "(.*)%.html" {
            block return 301 "https://$HTTP_HOST%1"
        }
    
        # Add .html extension for files without extension
        location not found match "^/[^.]+" {
            request rewrite "$REQUEST_URI.html"
    
        }
    
        location "/" {
            request rewrite "/index.html"
        }
    }
    
    # HTTP server - redirect to HTTPS
    server "lukasleuba.ch" {
        listen on * port 80
    
        # Allow ACME challenges for Let's Encrypt
        location "/.well-known/acme-challenge/*" {
            root "/var/www/acme"
            request strip 2
        }
    
        # Redirect everything else to HTTPS
        location "/" {
            block return 301 "https://$HTTP_HOST$REQUEST_URI"
        }
    }
    
    # Include MIME types
    types {
        include "/usr/share/misc/mime.types"
        text/html        html htm
        text/css         css
        text/javascript  js
        image/svg+xml    svg svgz
        application/json json
    }
    
    

Final Remarks

This is a really basic implementation, just the way I like it. Things you may consider to implement additionally are:

  • better content-moderation
  • an admin-mask so one could delete/answer/change comments from the web-interface
  • notification per email when a new comment is posted
  • backup-scripts (for the DB) as well as better error logging
  • nicer presentation
  • Capability to answer comments

If you find some errors or potential for improvement, please let me know!

SHARE: lukasleuba.ch/comments

Tell me what you think:

Emacs 28.2 (Org mode 9.5.5)