Forgepost 0.2.0: Video Embeds, Zipped Backups, and an Attribution Firewall
Last time I introduced Forgepost: a self-hosted blogging engine built around the publish → measure → experiment → improve loop. 0.1.0 shipped the loop itself. 0.2.0, which just tagged, is the release where a solo blogger’s blog starts feeling like a real publishing tool — videos without trackers, backups that survive you, a demo you can stand up in one command, and numbers you can actually believe.
Video embeds without third-party requests
A line in your Markdown that is exactly one YouTube or Rumble URL (or a raw <iframe> line) now parses into a video block. The renderer shows a click-to-load box: a lazy thumbnail and a play badge, and no iframe at all. The reader’s browser never contacts the provider until they choose to play — clicking swaps in the iframe via a small script, with privacy-host YouTube embeds (youtube-nocookie.com) and referrerpolicy="strict-origin-when-cross-origin".
The details that keep me honest:
- YouTube’s thumbnail is derived from the video id (
i.ytimg.com/vi/<id>/hqdefault.jpg) — no API key, no fetch. - Rumble’s title and thumbnail are fetched once, best-effort, from Rumble’s oEmbed endpoint at save time: a 3-second timeout, non-fatal, idempotent, and it never mints new block versions just because metadata refreshed.
- A URL sitting in prose stays a paragraph. Iframe attributes are whitelisted (
src,title,width,height) andsrcmust be http(s), so a hand-crafted tag can’t smugglejavascript:. - Articles with a video gain
og:video,og:video:type,og:video:secure_url, and a JSON-LDVideoObjectnode, so the video is also an SEO object, not just an embed.
Video content is a single immutable URL, so video blocks are deliberately not experimentable — the experiment engine tests text content, and that’s fine.
One command, a whole blog
forgepost demo now does two things: it installs a ready-made blog and starts the server. One command puts a six-article blog — long-form posts, bundled images, seeded analytics views, and even a live A/B experiment on the “Tracking Every Headline” headline with a populated report — at http://127.0.0.1:8080. Log in with admin@example.com / demo-password.
The demo isn’t a special mode; it’s an ordinary backup archive (demo/forgepost-demo.fpb), so restoring it with backup restore is byte-for-byte the same as demo --no-serve. And because it’s just a file, the test suite validates it on every run — restore into a scratch install, assert the admin login, the published posts, the media on disk, and the live experiment’s counts. Rebuild it deterministically whenever content changes with FORGEPOST_REGEN_DEMO=1.
Backups that verify themselves
forgepost export (a JSON dump) is still there, but 0.2.0 adds real backups: forgepost backup create seals the database and every media file into a single .fpb ZIP archive — a manifest.json with format and schema versions, a VACUUM INTO-taken database snapshot (crash-safe by construction), and a checksums.sha256. It then verifies its own work: integrity check plus a full checksum pass, so a corrupted archive fails at creation time, not at restore time in the middle of the night.
backup verifyreports a.fpb’s manifest, schema, checksums, and database integrity.backup restoremerges media additively, replaces the live database, keeps the pre-restore database as<name>.before-restore-<timestamp>, refuses to write without--yes, and supports--dry-run.- The archive format is versioned, so a future schema bump can refuse a mismatched restore instead of guessing.
Stop the server before restoring, and a bad restore is one file rename away from undone.
The attribution firewall
This is the part of the release I’m most happy about. Side-channel attacks on experiments — resubmitting event payloads, claiming a different variant, double-converting — don’t show up in normal testing. They break the data when you’re not looking.
0.2.0 makes attribution server-decided, not client-asserted. The render path computes a deterministic variant assignment from the visitor cookie (assigned_variant), and the events endpoint now recomputes the same function and only accepts the variant this visitor was actually given. It’s one function, two call sites, so the overlay and the check can structurally never drift. Rejection is a clean generic 400 with the specific reason in a privacy-masked log line (a fingerprint, not the raw visitor cookie).
Three concrete side-channels are closed:
- Claiming a variant — a visitor assigned control can no longer submit a conversion claiming the variant; the server recomputes and rejects it.
- Double-counting conversions — a partial unique index plus an idempotent
ON CONFLICT DO NOTHINGmeans a visitor can convert at most once per experiment; duplicate resubmissions are 204 no-ops. A conversion can never be double-counted by replay. - Two experiments on one block — a second running experiment on a block that already has one is refused with a clean
409.
The rest of the hardening pass I’ll summarize in bullets because they’re the “boring” kind that matters: rate limits now key on the socket peer, never on a spoofable X-Forwarded-For; a layered, proptest-backed security regression suite (authorization matrix, CSRF table, session/cookie lifecycle, upload hardening) plus cargo audit on every merge, so a dependency vulnerability fails CI instead of surfacing later.
Small things
A show/hide password toggle on the login and setup forms (accessible, with aria-pressed and a keyboard-visible focus ring), and the monolith moved into domain / application / infrastructure crates — the same single binary, but with the seams in place for the engine to grow past the solo-mode MVP.
Forgepost is AGPL-3.0. The single Linux binary is attached to the v0.2.0 release, or you can build it yourself with cargo build --release --bin forgepost. The demo is literally one command: ./forgepost demo. If you’ve ever wanted your own writing to get the treatment the big platforms reserve for their paying customers — and to be the one who decides what the numbers mean — give it a spin.
Source: github.com/DavidNeurieder/Forgepost (AGPL-3.0)