Sign in Sign up
1 branch
README.md

structwafel blog

#

A blog engine in one small Rust binary. You write in the browser (a phone works fine), posts are Markdown files, and readers get static HTML.

  • Editor on its own address (write.example.com): CodeMirror with Vim keys, front matter as form fields, live preview, drafts, publishing, history with restore, image uploads.
  • Static output per site, with .br/.gz copies, RSS, sitemap and tag pages.
  • Math ($…$, $$…$$) becomes MathML at build time; code is highlighted at build time.
  • Images are stripped of metadata (GPS included) and served as AVIF + JPEG in several sizes.
  • Several sites on one instance, picked by domain.
  • Passkey sign-in only, with invite links and per-site access for other people.
  • No database: posts, media, users and settings are files in one directory.

docs/DESIGN.md explains the design and the reasons behind it.

blog serve                  build every site, then serve them and the editor
blog build                  render every site into the publish directory
blog new-site ID            create a site
blog invite --id ID         one-time link that creates a user (the first one is admin)
blog invite --user ID       one-time link that adds a passkey to an existing user
blog logout-all             sign out every session

Develop

#

Requires Rust (stable) and just. nasm makes AVIF encoding faster; bun is only needed for the browser tests and the editor bundle.

just dev-setup   # once: checks tools, installs the git hooks
just dev         # editor at http://write.localhost:8080, no sign-in

just dev creates a demo site on first run. Each site is served at http://<site id>.localhost:8080; *.localhost needs no DNS setup. If 8080 is taken, the next free port is used and printed. Set PORT=9000 to prefer another one.

In debug builds the editor's templates, CSS and JS are read from src/editor/ on every request, and the site theme from theme/ on every build, so changes show up on refresh or on the next save. Rust changes need a restart.

Other recipes:

just dev-auth     # with passkey sign-in; create an account with `just invite <id>`
just dev-ts       # the same over Tailscale HTTPS (passkeys need https off localhost)
just test         # clippy and unit tests
just e2e          # browser tests: editor and passkeys, via Chromium's virtual authenticator
just build        # optimised binary in target/release/blog
just docker       # build the container image
just docker-run   # run it locally with a named volume
just editor       # rebuild the committed CodeMirror bundle

just dev-ts needs sudo tailscale set --operator=$USER once. A passkey is tied to the host it was made on, so localhost and Tailscale each need their own.

Layout

#
src/
  main.rs         CLI
  serve.rs        one listener: the editor on its host, each site on its domain
  app.rs          shared state: sites, rebuilds, background image processing
  store.rs        key-value storage (local disk now, S3/R2 later), conditional writes
  site.rs         sites and site.toml
  posts.rs        posts in the store: save, rename, revisions, trash
  content.rs      front matter and post parsing
  markdown.rs     comrak, MathML, highlighting, table of contents, <picture>
  media.rs        image processing: orientation, metadata, sizes, AVIF
  build.rs        renders a site into static files
  feeds.rs        RSS, sitemap, robots.txt
  theme.rs        the built-in theme, or a theme directory on disk
  auth.rs         users, passkeys, signed sessions, invites
  editor/         the editor: handlers, templates/, static/
theme/            the default theme (compiled in): templates/ and static/
editor/           CodeMirror bundle source (built into src/editor/static/vendor/)
e2e/              browser tests
scripts/          migrate-from-go.py
docs/DESIGN.md    design and roadmap

Storage

#

Everything lives under the store directory (BLOG_STORE, default ./data):

auth/users.json                        users, passkeys, roles
auth/secret                            signs session cookies (created on first start)
sites/<id>/site.toml                   title, domain, nav, editors, section intros
sites/<id>/content/<kind>/<slug>.md    posts
sites/<id>/revisions/…                 every saved version
sites/<id>/trash/…                     deleted posts
sites/<id>/media/…                     uploads as served (processed images)
sites/<id>/originals/…                 uploads as received, private

Generated sites go to BLOG_PUBLISH (default ./data/public), one directory per site. They can be deleted at any time; the next start rebuilds them.

Back up the store directory. It holds only plain files, so any file copy works.

Posts

#
+++
title = "Go needs immutable pointers"
date = 2025-04-25
updated = 2025-05-01    # optional
description = "…"       # optional
tags = ["go"]
draft = true            # not published while true
wip = true              # shows a work-in-progress notice
toc = true              # shows a table of contents
+++

Markdown, with $math$, fenced code and raw HTML.

A post at content/articles/foo.md is served at /articles/foo/; posts under pages/ at /<slug>/. The editor writes these files for you; editing them by hand works too.

Configuration

#
Variable Default
BLOG_STORE data store directory
BLOG_PUBLISH data/public generated sites
BLOG_THEME theme theme directory; the built-in theme is used if it doesn't exist
BLOG_ADDR 127.0.0.1:8080 listen address
BLOG_EDITOR_ORIGIN http://write.localhost:<port> the editor's public URL; passkeys are bound to its host

Each site's public domain is domain in its site.toml.

Security

#
  • Sign-in is passkey-only, with user verification. New users and new passkeys need a one-time link (30 minutes): blog invite on the server, or the Users and Account pages.
  • Sessions are 30-day signed cookies (__Host-, HttpOnly, Secure, SameSite=Lax). Nothing is stored per session; blog logout-all revokes them all.
  • Admins can do everything; editors only touch sites whose site.toml lists them in editors.
  • State-changing requests must come from the editor's own origin.
  • The editor has a strict Content Security Policy. Previews run in a sandboxed iframe with an opaque origin, so nothing in a post can act as the person viewing it.
  • SVG and HTML uploads are refused; uploads are served with CSP: sandbox and nosniff.

Deploy

#

The Dockerfile builds a static musl binary (in rust:1-alpine) onto an empty scratch image of about 20 MB. It runs /blog serve on port 8080 as uid/gid 65532.

docker build -t blog .
docker run -p 8080:8080 -v blog-data:/data \
  -e BLOG_EDITOR_ORIGIN=https://write.example.com blog
docker run --rm -it -v blog-data:/data blog invite --id you
docker run --rm -it -v blog-data:/data blog new-site <id>   # then set its domain in site.toml
  • Volume /data holds the store; generated sites go in /data/public.
  • Set BLOG_EDITOR_ORIGIN to the editor's exact URL; passkeys depend on it. The image already sets BLOG_STORE, BLOG_PUBLISH, BLOG_ADDR and MIMALLOC_ALLOW_THP=0 (which keeps idle memory around 1.6 MB on hosts with transparent huge pages always on).
  • Optional: BLOG_THEME plus a mount, to use a theme from disk without rebuilding.
  • Memory limit: at least 256 MB. Encoding a 12 MP photo to AVIF peaks around 190 MB, then drops back to a few MB.
  • The image has no time zone data or CA certificates: times are UTC, and a future S3/R2 store will need certificates added.
  • docker stop exits cleanly: the server handles SIGTERM.

With Dokploy: create an application from this repo with the Dockerfile build type, mount a volume at /data, and point both the site's domain and the editor's domain at container port 8080 with HTTPS on. Set BLOG_EDITOR_ORIGIN to the editor's exact URL.

Moving from the Go version

#
blog new-site structwafel --store /data
python3 scripts/migrate-from-go.py <old data dir> /data structwafel

This converts the posts in blog.db to Markdown files and copies the uploads. Media URLs stay the same. Passkeys can't move (they're bound to the old origin), so create a new admin with blog invite --id <you>, then set domain in sites/structwafel/site.toml.