05 Oct 2025
Secure comment system for Zola via Mastodon posts
6 minutes reading time
#Intro
#GitHub
The first comment system that I implemented for Halve-Z was Giscus. It is based on GitHub discussions. I like GitHub, despite its limited security (no way to set Content Security Policy for GitHub Pages was the reason I moved to Netlify). I guess it’s just a habit. And I don’t really care about LLM training. It’s not like me or anybody else from training models on the code one cou…
05 Oct 2025
Secure comment system for Zola via Mastodon posts
6 minutes reading time
#Intro
#GitHub
The first comment system that I implemented for Halve-Z was Giscus. It is based on GitHub discussions. I like GitHub, despite its limited security (no way to set Content Security Policy for GitHub Pages was the reason I moved to Netlify). I guess it’s just a habit. And I don’t really care about LLM training. It’s not like me or anybody else from training models on the code one could pull from public sources, regardless of where it is hosted. The brutal reality is that if you upload anything online, it is public and forever. Yea, of course there are licensing and policy violations, but, unfortunately, there is no concrete way to really stop somebody from doing this. Plus, I don’t mind contributing my code. Taking the current quality of code that LLMs produce—everybody wins. But I am already spending too much time on GitHub, and I do not like the way they do things now (still no FreeBSD runners in 2025, ffs!). So enforcing it for stuff like website comments does not feel right.
#Matrix
My second system was Cactus Comments, based on Matrix rooms. Matrix is a great evolution of IRC (oh, I miss those days) and a pretty solid federated protocol. It is still somewhat young, so some features I like to use are unstable/broken (IRC bridges, unfortunately). The concept is great, and I managed to make it work as expected. But then Cactus’s development slowed down, and with that I got a chance to think about it more, concluding that a chat message is a little different from a post. Another bump is logging into an account from a random site that might feel a bit awkward, if not to say more.
#Mastodon
Last year I noticed a new trend—Mastodon comments for static websites. I got hooked right away. The idea is simple: just use a Mastodon post to collect comments for a web page. Users would open a website, follow the link to a Mastodon post, and comment with their account. No log-ins, no fuss! But with the strict Content Security Policies (CSPs) that I implement in my Zola theme, this sounded a bit complicated, especially because I do not want to use a dedicated server just for this (which is needed for dynamic policy generation/injection). This might not be such a big deal if you deploy your website to GitHub Pages or similar platforms, but I like to cover everything, as usual, so for dedicated server-side cases, this is definitely a no-go. Since I was busy with my other projects, I decided to slow down and wait for protocol updates (or new services) that could potentially make it possible.
Some time ago I finished a few intense projects and wanted to relax. I noticed that the comment system is still on my to-do list, and then I found this template by Cassidy James Blaede. Perfect time to revisit! Looking at the code, I noticed that I had already solved some of the problems that I expected, so I gave it another shot. And after I started to make progress, it felt like I could nail it and get everything to work just the way I needed.
#Deployment
I decided to start light. With strict CSP and without any backend, I have no way to pull avatars or any other images, so I decided on touching the Mastodon API only (I already have another ongoing project, so I am somewhat familiar with the protocol). The first challenge was the implementation of a dynamic server address.
I wanted accounts from different Mastodon instances to be able to interact with the post, not just my home base users. For this I needed to provide a server address with a toot
ID to a JavaScript that handles the comments. CSP requires a checksum for the corresponding script, and Zola cannot render JS files (at least right now). I solved a similar issue a couple of years ago by using an HTML element as an argument (PWA’s cache payload). But I wanted something simpler in this case. Another trick I have up my sleeve is to use literal strings to generate checksum hashes. I would wrap CSS or JavaScript as Zola’s macros and then use it as literal
argument for get_hash()
function. I can get required dynamics in the Zola’s macro and then calculate the macro’s output hash like for any other native CSS/JS object. That way I can dynamically set Mastodon’s server address in Zola’s theme config.
{%- if config.extra.csp == true %}
{%- if page.extra.mastodon_id %}
{%- set mastodon_hash = get_hash(literal=macros::mastodon_comments(server=config.extra.comments.mastodon.server, id=page.extra.mastodon_id), base64=true, sha_type=512) %}
{%- endif %}
{%- if config.extra.comments.system == "cactus" %}
{%- set cactus_hash = get_hash(path="/cactus.js", base64=true, sha_type=512) %}
{%- endif %}
{%- endif %}
The partial element for the comment system looks like this:
<section id="comments" class="comments">
<p>Comment by replying to <a href="https://{{ config.extra.comments.mastodon.server }}/@{{ config.extra.comments.mastodon.username }}/{{ page.extra.mastodon_id }}">this</a> post using a <b>Mastodon</b> or other ActivityPub/Fediverse account.</p>
<div id="mastodon-comments"></div>
<script src="/purify.min.js"></script>
<script>{{ macros::mastodon_comments(server=config.extra.comments.mastodon.server, id=page.extra.mastodon_id) }}</script>
</section>
As expected, it works flawlessly. Now to activate the comments, I just need to provide the Mastodon’s toot
ID via variable (mastodon_id
) in the website post:
+++
title = "T480 and FreeBSD 14"
date = 2025-01-08T03:01:21Z
[taxonomies]
categories = ["software"]
tags = ["laptop", "thinkpad", "ansible", "hyprland", "freebsd"]
[extra]
subtitle = "ThinkPad the beast machine"
image = "t480_lid.png"
music = ["Frontierer", "Tunnel Jumper"]
mastodon_id = "113795366797327101"
+++
The template checks which one of the comment systems is active and kicks in the required partial:
{% extends "page.html" %}
{% block comments %}
{% if not page.extra.disable_comments %}
{% if config.extra.comments.system == "mastodon" %}
{% if page.extra.mastodon_id %}
{%- include "partials/mastodon-comments.html" %}
{% endif %}
{% elif config.extra.comments.system == "cactus" %}
{%- include "partials/cactus.html" %}
{% elif config.extra.comments.system == "giscus" %}
{%- include "partials/giscus.html" %}
{% endif %}
<noscript><div class="frame-dim"><p>Activate <code>JavaScript</code> to load <u>comments</u>.</p></div></noscript>
{% endif %}
{% endblock comments %}
After getting the comments to work, I decided to experiment and see if there is any way I could dynamically adjust CSP and inject host addresses. I had two options: use a 3rd party proxy to handle image URLs or outsource the backend to Netlify’s serverless functions. The first option is a security nightmare. I liked the second option, but these functions might be pretty expensive for an OSS project, so the management is somewhat complicated. Not to mention another vector of potential service interruptions.
#Mechanics
It turned out the home base Mastodon instance handles foreign images internally, so I only need to manage CSP rules for a home base instance! This makes both of my options redundant since all images would come from a server that is already in the theme’s configuration. There is one issue, though. There is no fixed pattern for a subdomain prefix that handles assets (images, videos, etc.)! My home base instance uses files.instance.com
, some other big instances that use assets
or cdn
subdomains. And these are only the most popular ones.
After searching for a while, I got the list of most common prefixes I needed to handle:
storage
files
assets
cdn
media
static
Not that bad. But I also encountered a mixed use of these in the wild. Well, I guess I just need to handle all of them to make my theme work for users with (almost) any Mastodon instance. This is what I pushed as a result. Not perfect, but I got it to work without easing the policy rules.
Then it was time for the last and easy part that includes emojis, verification and bot badges, lazy loading, and security. The result is this comment macro that takes two arguments for a server address and the toot
ID:
{%- macro mastodon_comments(server, id) -%}
const commentsSection = document.getElementById("mastodon-comments");
let commentsLoaded = false;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !commentsLoaded) {
commentsLoaded = true;
loadComments();
observer.disconnect();
}
});
}, {
rootMargin: '100px'
});
observer.observe(commentsSection);
function loadComments() {
const commentsWrapper = document.getElementById("mastodon-comments");
const loadingMsg = document.createElement("p");
const loadingSmall = document.createElement("small");
loadingSmall.textContent = "Loading comments...";
loadingMsg.appendChild(loadingSmall);
commentsWrapper.appendChild(loadingMsg);
console.log('Loading comments');
const postUrl = "https://{{ server | safe }}/api/v1/statuses/{{ id | safe }}";
fetch(postUrl)
.then(res => res.json())
.then(op => {
fetch(postUrl + "/context")
.then(res => res.json())
.then(data => {
const descendants = data.descendants || [];
while (commentsWrapper.firstChild) {
commentsWrapper.removeChild(commentsWrapper.firstChild);
}
if (descendants.length === 0) {
const noComments = document.createElement("p");
noComments.innerHTML = "<small>No comments yet.</small>";
commentsWrapper.appendChild(noComments);
return;
}
const statsBar = document.createElement("div");
statsBar.innerHTML = `
<div class="frame-dim">
<p title="Post stats (replies/boosts/favourites)">${op.replies_count}•${op.reblogs_count}•${op.favourites_count}</p>
</div>
`;
commentsWrapper.prepend(statsBar);
const commentMap = {};
descendants.forEach(status => {
commentMap[status.id] = { ...status, children: [] };
});
const roots = [];
descendants.forEach(status => {
const parentId = status.in_reply_to_id;
if (parentId && commentMap[parentId]) {
commentMap[parentId].children.push(commentMap[status.id]);
} else {
roots.push(commentMap[status.id]);
}
});
function renderComment(status, depth = 0) {
const comment = document.createElement("article");
comment.id = `comment-${status.id}`;
comment.className = "m-comment";
comment.setAttribute("itemprop", "comment");
comment.setAttribute("itemtype", "http://schema.org/Comment");
const account = document.createElement("a");
var domain = new URL(status.account.url);
const host = domain.host;
const accountFull = `@${status.account.username}@${host}`;
account.className = "account";
account.href = status.account.url;
account.itemprop = "author";
account.itemtype = "http://schema.org/Person";
account.rel = "external nofollow";
account.title = `View profile ${status.account.username}`
account.textContent = `${accountFull}`;
let avatarSource = document.createElement("source");
avatarSource.setAttribute("srcset", escapeHtml(status.account.avatar));
avatarSource.setAttribute("media", "(prefers-reduced-motion: no-preference)");
let avatarImg = document.createElement("img");
avatarImg.className = "avatar";
avatarImg.setAttribute("src", escapeHtml(status.account.avatar_static));
avatarImg.setAttribute("alt", `Avatar for ${accountFull}`);
let avatarPicture = document.createElement("picture");
avatarPicture.appendChild(avatarSource);
avatarPicture.appendChild(avatarImg);
const verifiedFields = (status.account.fields || []).filter(field => field.verified_at !== null);
const header = document.createElement("h5");
header.className = "author";
header.appendChild(account);
if (verifiedFields.length > 0) {
const verifiedBadge = document.createElement("span");
verifiedBadge.title = "Verified account";
verifiedBadge.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" width="23" height="23" shape-rendering="crispEdges">
<rect x="5" y="11" width="2" height="2"/>
<rect x="7" y="13" width="2" height="2"/>
<rect x="9" y="15" width="2" height="2"/>
<rect x="11" y="13" width="2" height="2"/>
<rect x="13" y="11" width="2" height="2"/>
<rect x="15" y="9" width="2" height="2"/>
<rect x="17" y="7" width="2" height="2"/>
<rect x="19" y="5" width="2" height="2"/>
</svg>
`;
header.appendChild(verifiedBadge);
}
if (status.account.bot) {
const botBadge = document.createElement("span");
botBadge.className = "bot-badge";
botBadge.title = "Automated account";
botBadge.textContent = "BOT";
header.appendChild(botBadge);
}
const permalink = document.createElement("a");
permalink.href = status.url;
permalink.itemprop = "url";
permalink.title = `View comment on ${host}`;
permalink.rel = "external nofollow";
permalink.textContent = new Date(status.created_at).toLocaleString('en-US', {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
timeZoneName: "short",
hour12: false
});
const timestamp = document.createElement("time");
timestamp.datetime = status.created_at;
timestamp.appendChild(permalink);
const meta = document.createElement("div");
meta.className = "meta";
const textContainer = document.createElement("div");
textContainer.appendChild(header);
textContainer.appendChild(timestamp);
meta.appendChild(avatarPicture);
meta.appendChild(textContainer);
const main = document.createElement("main");
main.itemprop = "text";
status.content = emojify(status.content, status.emojis);
const sanitizedContent = DOMPurify.sanitize(status.content, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a', 'span', 'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'picture', 'source', 'img'],
ALLOWED_ATTR: ['href', 'rel', 'class', 'target', 'src', 'srcset', 'alt', 'title', 'width', 'height', 'media'],
ALLOW_DATA_ATTR: false,
ADD_ATTR: ['target', 'rel'],
RETURN_TRUSTED_TYPE: true,
FORCE_BODY: true
});
const tempDiv = document.createElement("div");
tempDiv.innerHTML = sanitizedContent;
tempDiv.querySelectorAll('a').forEach(link => {
link.rel = 'nofollow noopener noreferrer';
link.target = '_blank';
});
Array.from(tempDiv.childNodes).forEach(node => {
if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() !== "") {
const p = document.createElement("p");
p.textContent = node.textContent;
main.appendChild(p);
} else {
main.appendChild(node.cloneNode(true));
}
});
comment.appendChild(meta);
comment.appendChild(main);
if (status.children && status.children.length > 0) {
status.children.forEach(child => {
comment.appendChild(renderComment(child, depth + 1));
});
}
return comment;
}
roots.forEach(root => {
commentsWrapper.appendChild(renderComment(root));
});
});
})
.catch(error => {
while (commentsWrapper.firstChild) {
commentsWrapper.removeChild(commentsWrapper.firstChild);
}
const errorMsg = document.createElement("p");
const errorSmall = document.createElement("small");
errorSmall.textContent = "Failed to load comments.";
errorMsg.appendChild(errorSmall);
commentsWrapper.appendChild(errorMsg);
console.error('Failed to load comments:', error);
});
}
function emojify(input, emojis) {
let output = input;
emojis.forEach(emoji => {
let picture = document.createElement("picture");
let source = document.createElement("source");
source.setAttribute("srcset", escapeHtml(emoji.url));
source.setAttribute("media", "(prefers-reduced-motion: no-preference)");
let img = document.createElement("img");
img.className = "emoji";
img.setAttribute("src", escapeHtml(emoji.static_url));
img.setAttribute("alt", `:${ emoji.shortcode }:`);
img.setAttribute("title", `:${ emoji.shortcode }:`);
img.setAttribute("width", "20");
img.setAttribute("height", "20");
picture.appendChild(source);
picture.appendChild(img);
output = output.replace(`:${ emoji.shortcode }:`, picture.outerHTML);
});
return output;
}
function escapeHtml(unsafe) {
return unsafe.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/'/g, "'").replace(/"/g, """)
}
{%- endmacro mastodon_comments -%}
This theme is available here. As always, there is room for improvement and expansion, but the current version is pretty functional and secure. Tomorrow I will start to optimize. Toot zoot!