Roman

Roman

If you are distressed by anything external, the pain is not due to the thing itself, but to your estimate of it; and this you have the power to revoke at any moment ― Marcus Aurelius, Meditations

GoBlog, taken apart

Veröffentlicht am in Posts
Aktualisiert am
Kurz-Link: https://rospies.dev/blog/s/13
Serie: SiteArchitectureTeil 2 von 3
  1. 1Introduction
  2. 2GoBlog revamped
  3. 3nowplaying
Diesen Beitrag teilen
A ⇄ 文

The application serving this page started life as GoBlog, written by Jan-Lukas Else, and I’d recommend it warmly if what you want is a blog. It’s a single Go binary which speaks the whole IndieWeb alphabet, has no build step and no JavaScript framework anywhere near it, and that last part is why I picked it over roughly a dozen alternatives.

Then, I took it apart.

Not contributed to it, mind - forked it, hard, with no intention of ever merging upstream again. That’s a decision worth being honest about, for it sounds like carelessness and is mostly just incompatible taste. I wanted things a general-purpose blog engine has no business wanting: one database instead of two supported ones, with no light mode at all, OpenTelemetry threaded through everything, and a willingness to delete half the feature set because I had no need for it.

What I removed

Let’s start there, since it’s the cheapest work and it made everything after it easier.

  • Statistics. A words-per-post page nobody was ever going to read twice. Out went the page, the route, the JavaScript, and - pleasingly - two columns on the posts table that only existed to feed it.
  • Geo. GPX tracks and Leaflet maps, tile proxying, and the location parameter threaded through eight files. I don’t post hikes.
  • The IndieWeb protocols. IndieAuth, Micropub, Webmention. This one took the longest to agree to, because it’s the most interesting part of the original, and it was also plainly the right call: the Fediverse does what I wanted those protocols for, and it does it with one implementation instead of three.

What survives from the webmention machinery is the table. The protocol’s gone; the inbox it filled is now where comments and federated replies land, and that turned out to be the useful half all along.

Every one of those cuts took call sites out of ui.go, the post model, and the config. The port that came next only had to move what was left.

Switching the database engine

The original supports SQLite. That’s not a limitation, it’s a design: embedded, zero-configuration, and one file you can copy. I replaced it with PostgreSQL anyway, and the reason has less to do with scale than you might think, for I already run a Postgres instance, already back it up, and I’d rather have one thing to operate than two. I’m not afraid of using SQLite myself when the shoe fits either - my BankingSync project surely does; yet, with regards to a publicly available blog, that shoe did indeed not fit.

What I hadn’t yet appreciated is that SQLite, sitting inside the process, isn’t a database behind an interface. It’s a participant in the program, shares the address space and borrows the host language, and the code I inherited had used both, sensibly, in places I’d never have thought to look.

Eight Go functions were registered into the driver at connection time and called from production SQL, not from a migration - from the queries that serve requests. Postgres won’t host a Go callback, and there’s no translation for that, only demolition.

One of them is worth a quick detour as well, and it is lowerunescaped, which normalised a URL; it stood on both sides of every comparison in the webmention code. No index would ever apply, so every lookup was a full table scan. Worse than that, and much quieter: the uniqueness constraint sat on the raw columns while the application matched on the normalised forms, which defeats the entire purpose of the constraint.

Then there’s the matter of time. There is not one single Scan into a time.Time in the code I started from - timestamps are text or Unix integers throughout, and comparisons against them are lexicographic. where expires < @now compares two strings. It works, mind. It works because RFC 3339 and ten-digit Unix seconds are fixed-width, so the string order just so happens to be the numeric order, and it’ll keep working perfectly until the year 2286.

Furthermore, the full-text index rebuilt itself on every write. Five call sites, all on the write path, each triggering a complete reindex of the corpus because a mere one post had changed. A generated tsvector column makes that entire function disappear, which was the most satisfying deletion of the whole project.

The initial 42 (and yes, that’s a coincidence) migrations got squashed into one initial schema. I did consider translating them, for about a minute, and then recognised it for what it was: completely unneccessary.

The parts which weren’t a port after all

Somewhere in the middle of all this I went to read the ActivityPub code, because I wanted to add a follower-management page and needed to know how followers were stored.

Here’s what I found instead. The inbox handler verifies the HTTP signature as its very first action, before reading the body and before parsing anything, and that is exactly the right order. To verify a signature you need the sender’s public key, so it fetches the actor document. Where from, you may ask? From the URL in the keyId header - which the sender controls entirely.

And, even worse, it fetched it with the unguarded HTTP client. The SSRF protection existed in the codebase. There’s a whole dialler for it, and it hung off the webmention client only.

So: an unauthenticated POST /activitypub/inbox/{blog} with a forged keyId made the server issue a GET against any URL you liked. Loopback, RFC 1918, cloud metadata endpoints. Blind - the response gets parsed as ActivityPub JSON and thrown away - but before any authentication whatsoever, uncached, and with no rate limit anywhere near it. One request in, one request out, and that was that.

That particular finding came from upstream, and I’m not the one who should be pleased about finding it, because I only found it by accident while looking for something else entirely. It’s fixed now, and not only once, but twice: the guarded client, and an actor-key cache with negative caching so a spam request doesn’t automatically become an outbound one.

That’s the moment the fork stopped feeling like a preference and started feeling like a responsibility. There’s no upstream security diff in my future. Whatever’s in oauth.go and authentication.go and the signature verification is mine now.

Telemetry

By now, you should know me well enough to be aware that any long-running application I write gets instrumented with OpenTelemetry, for time is precious, and with my studies and work taking up a huge chunk of mine, I am unwilling to spend time on debugging any errors when I can track them down with OTel way faster. With that out of the way, let’s begin:

The earlier post in this series covered the sampling decision moving from the start of a request to its end. What that post left out is the policy the mechanism exists to serve, and it comes down to three categories.

Anything I cause myself is always exported: publishing, settings, moderation, and, because it writes, a visitor leaving a comment. Anonymous reading creates spans and exports none of them unless something failed. Traffic from other servers, on the other hand, is sampled at a tenth, plus every failure. Consequently, trace volume here is not traffic volume and never will be: ten thousand page views ideally produce no traces at all, and the dashboard that wants to know how busy the blog is reads a counter concering article visits instead.

That policy has a consequence I hadn’t planned for, and it’s the part of the telemetry I’d defend hardest.

Publishing a post isn’t over when the response goes out. The Fediverse fan-out, the Telegram announcement, the Bluesky record, the IndexNow ping and the text-to-speech job all run in goroutines that keep the trace context and outlive the request by a wide margin. Their spans end long after the root span has finished, so the verdict can’t be thrown away when the root ends - it has to outlive it, because otherwise every one of those spans arrives unclassified, looks like a trace of its own, and walks straight past the policy that was supposed to govern it. Therefore, it survives for five minutes, and an anonymous read that failed drags its detached work along with it.

Labels get the opposite treatment: as closed as I can make them. Metrics answer whether the machine is healthy, never which URL, because a label that can take an unbounded set of values grows the series count with traffic. So the HTTP metrics carry a route type, an enumeration declared in the code with seventeen values, and a new route lands in other until somebody classifies it.

The obvious alternative is chi’s own route pattern, and it’s unusable for three independent reasons. It’s empty for everything the fallback dispatcher serves, which is every post URL. The static file router registers one route per file on disk. And asset routes carry a content hash in the path, so every deployment would enlarge the series set permanently.

The two signals therefore follow different rules, and the database is where that shows - a query span is only created inside an operation that’s already being traced, since starting one unconditionally would hand every anonymous page view a trace of its own, which is exactly what the policy exists to avoid. The query duration, on the other hand, is recorded whenever telemetry is on, traced or not, because a latency metric that only covers the paths with spans would be a latency metric that misses every page view on the site.

Those spans carry the statement and its bound parameters, and that needed a closer look and a smidge of care as well. A TOTP secret and a session id are both just parameters to the database, and a trace backend is not a secret store, even when access is restricted. Any secrets are replaced before the span is built, by parameter name, and for the two tables where every row calls its parameter value, by row key.

Per-article statistics, then, are handed to the LGTM stack via Metrics, to be displayed in a dashboard.

Making it look like it belongs here

The blog lives at /blog on the same domain as the static site, and what I wanted was an integrated design, to the point where the blog just feels like an extension of the original page.

The original ships light and dark. I kept dark and threw light away, print stylesheet excepted, which stays cheerfully white. Both projects now draw their colour tokens from a single file. They used to be declared twice, because the Hugo theme pipes its values through Sass colour functions that won’t accept a CSS custom property.

That’s the kind of merge where you very easily believe yourself finished when you aren’t, so the check was mechanical: build the site before and after, compare the multiset of generated CSS rules. Same rules, same count, same bytes, with the :root block relocated from the middle of the file to the front. Only then did I believe it.

The SCSS build went entirely, by the way. It ran in neither CI nor the Dockerfile, the compiled output was checked in, and the tokens are custom properties rather than Sass variables - so the preprocessor was buying nesting and nothing else. It’s handwritten CSS now.

What I’ve added since

The port was the foundation, which everything since has been built upon:

Mathematics. My projects delve into topics where I want to quote the literature, so posts contain formulas, and those were being served as raw $$-fenced text. They’re rendered to MathML server-side now - no KaTeX, no JavaScript, and the <math> element carries its own LaTeX source in an annotation, which is what lets Mastodon’s sanitiser turn it back into $$…$$ instead of into alphabet soup. A formula that won’t parse shows its source and gets logged on the save path rather than the read path, because an error log on a read means every anonymous page view produces a complete trace.

Series navigation. This post is part of one. Numbering comes from publication order, so inserting a part renumbers the rest for free, and the whole construct is three CSS-switched blocks with no JavaScript at all.

Link previews. The blog now serves Open Graph, which the original didn’t emit. Mastodon has fallbacks for the title and the description and none whatsoever for the image, so a shared link arrived as a card with nothing in it.

Follower management. A page to see who follows the blog and remove someone. That turns out to be a genuinely unsettled question: ActivityPub defines no mechanism for removing an accepted follower. Reject(Follow) is the de-facto convention, and I had to delve into Mastodon’s reject.rb to find out what it does with one.

An MCP server, so I can have Claude pull information from the blog or make changes to a post on the go without having to bother with the editor in a mobile browser.


So: one Go binary, one Postgres, one nginx in front of it, and a collector shipping traces, metrics and logs to a Grafana stack on another machine. Thirty-two thousand lines of Go with another twenty-seven thousand of tests, three thousand of which build HTML programmatically because there is no template engine anywhere in this thing.

It’s not finished, nor is it perfect - but it’s an excellent tool for the job, and will receive further updates in the near future.

Tags: , , , ,

Roman Spies
Interaktionen & Kommentare