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!