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!