No description
  • Ada 69.8%
  • Go 17.2%
  • C 3.1%
  • Handlebars 2.7%
  • JavaScript 2.5%
  • Other 4.7%
Find a file
LowEel 61a544875f
All checks were successful
continuous-integration/drone/push Build is passing
Document the country table and WORTWERK_RIR_SOURCE
Where the registry files come from, how big they are, where they are
kept, and how a host that may not reach out runs with files put there
by hand.
2026-09-29 16:12:30 +02:00
alire Replace AWS with libmicrohttpd and libcurl 2026-09-17 12:22:21 +02:00
smoke Show the country a subscription request came from, not its browser 2026-09-29 16:12:07 +02:00
src Show the country a subscription request came from, not its browser 2026-09-29 16:12:07 +02:00
static Show the country a subscription request came from, not its browser 2026-09-29 16:12:07 +02:00
tools Serve a file without putting it on the stack, and read the markdown under a <br> 2026-09-18 23:37:31 +02:00
.dockerignore Remove ActivityPub and add Docker runtime assets 2026-08-18 22:31:38 +02:00
.drone.yml Take the smoke tests out of the image build 2026-09-17 14:07:20 +02:00
.gitignore Write the archive's permissions into the archive 2026-09-18 22:31:44 +02:00
alire.toml Replace AWS with libmicrohttpd and libcurl 2026-09-17 12:22:21 +02:00
DOCKER_INSTALL.md Document the country table and WORTWERK_RIR_SOURCE 2026-09-29 16:12:30 +02:00
Dockerfile Take posts in as RSS and out as Atom, and nothing else 2026-09-25 22:50:39 +02:00
INSTALL.md Take posts in as RSS and out as Atom, and nothing else 2026-09-25 22:50:39 +02:00
README.md Document the country table and WORTWERK_RIR_SOURCE 2026-09-29 16:12:30 +02:00
wortwerk.gpr Take posts in as RSS and out as Atom, and nothing else 2026-09-25 22:50:39 +02:00
wortwerk_spark.gpr Know which country an address was registered to 2026-09-29 16:12:07 +02:00

Wortwerk

Wortwerk is a single-user blog engine with a newsletter, written in Ada. Every pure decision layer (routing, slugs, paths, login policy, mail headers, quotas, sanitising, parsing helpers) is written in SPARK and proved with GNATprove.

It is a small, explicit take on what WriteFreely does. There is one author, one process and one volume, and no database: posts are plain Markdown files in a Hugo-compatible layout, so the blog can always be taken elsewhere as it is.

Wortwerk is in production use. For deployment, see DOCKER_INSTALL.md. For building from source, see INSTALL.md.

What it does

The blog. The public blog uses the Ghost Journal theme, with paginated front pages (/page/N), one page per post, pinned posts first, and an Atom feed at /feed.atom (also /rss). Markdown is rendered by cmark-gfm, so tables and the rest of GFM work. The tagfilter is always on, which means a post cannot carry a <script>.

The editor. The owner writes at /newpost in a Toast UI Markdown editor, in a plain field that nothing rewrites. Images are uploaded into the post's bundle. Posts can be drafts or published, and can be pinned or deleted.

Images. Every stored image goes through one place (Save_Post_Asset), whatever door it came through: it is stored under md5(content).ext and shrunk with ImageMagick to fit the column it lands in.

Feed import. The owner pastes the URL of another blog's RSS or Atom feed, and a background task turns its entries into posts:

  • it fetches over http/https only, capped in size and time, and refuses loopback, private, link-local and CGNAT targets at the resolved address;
  • it parses with libxml2 (SAX, no external entities);
  • it cleans the markup at the door;
  • it copies images locally instead of hotlinking them;
  • it is idempotent on the entry's guid, so re-importing the same feed does nothing new;
  • an entry that carries only a summary is reported and not saved as if it were the article.

Newsletter. A reader asks to subscribe from the blog, and the request waits in a queue: nothing is mailed to an address a stranger typed. The newsletter panel lists each request with the time it arrived, the IP and the browser it came from, and how many waiting requests share them. "Go ahead" sends the one confirmation (double opt-in), "Kill" drops the request without a word. Every mail carries an unsubscribe link. Sending a post to subscribers is one button in the admin. A task inside the server drains the queue, within the daily and monthly limits set for the relay. A post held back by those limits goes out by itself when the window reopens, and when the relay refuses a mail, the admin shows what the relay answered. Headers are RFC-correct: a valid Date, and the sender name and subject encoded when they are not ASCII. The subscriber list can be exported and imported.

Statistics. The admin has a dashboard built on the GoAccess 1.9.3 report shell. It shows readers, pages, referrers, browser languages, crawlers by name, and how many newsletter messages went out and on which days. No address is ever written down: a reader is a fingerprint under a salt that is drawn each day and thrown away with that day's file, so the figure means "distinct readers per day" and never "who". See static/stats/README.md.

Federation. Wortwerk is not a fediverse server. It answers WebFinger for exactly one account, the author's (say alice@example.org), and the answer sends whoever asks on to an account that lives on another server (say alice@social.example.net), whatever software that server runs. The self link is the remote actor, so a search for the local handle lands on the remote account. Every answer is signed (RFC 9421, rsa-v1_5-sha256, with a Content-Digest) using an RSA-2048 key. The key is made in the keystore on the first start and kept after that. Its public half is published in a small actor document at /ap/actor, which points the same way (alsoKnownAs, movedTo). Everything is set in the admin's Federation panel: the local account, the remote account and the remote actor URL. The feature is off until it is enabled there, and while it is off both addresses return 404.

Three things are the deployer's job:

  • Routing. The fediverse asks the domain of the handle. If that is not the blog's own host (alice@example.org writing at blog.example.org), https://example.org/.well-known/webfinger has to reach Wortwerk. The Federation panel says so when the two differ.
  • The remote actor URL. It is the account's id on its own server, and its shape depends on the software (/users/name on Mastodon, /name or /@name elsewhere). The reliable way to get it is to ask the remote server's WebFinger and copy the self link.
  • The alias on the other side. Add the local account as an alias of the remote one (Mastodon: Account settings → Moving from a different account; other servers have their own equivalent), so the pairing is accepted both ways.

Administration. /admin has panels for posts, the editor, feed import, federation, newsletter, SMTP, blog settings, profile and avatar, login (changing the password), and statistics.

Security model

  • Two roots, never mixed. WORTWERK_CONTENT_ROOT holds posts and assets, and is publishable. WORTWERK_KEYSTORE_ROOT holds private state: the credential wallet, SMTP settings, subscribers, sessions and statistics.
  • The wallet is encrypted with a key derived from WORTWERK_KEYSTORE_SECRET and the owner username, and passwords are hashed with scrypt. The keystore secret is key material only. It is never accepted or compared on an HTTP request.
  • Sessions. Login issues a session cookie, and everything under /admin, /newpost, /api/..., /static/admin/ and /static/toast/ requires it.
  • No inline code. The Content-Security-Policy allows no inline script and no inline style, and every page, the dashboard included, loads its code from files.
  • No archives. Importing a zip was the most dangerous thing the application did, so it was removed. Posts come in only through the editor and the RSS/Atom feed import. They go out through the Atom feed, which carries whole posts: that is the export.

Storage layout

blog/                       (WORTWERK_CONTENT_ROOT)
  content/posts/<date>/<title>/index.md
  content/pinned/<date>/<title>/index.md
  static/images/<md5>.<ext>
keystore/                   (WORTWERK_KEYSTORE_ROOT)
  wallet, sessions, SMTP config, federation key, subscribers, queue, stats
  geo/<registry>.txt          the registries' delegation files, refreshed weekly

The content tree is plain Hugo: a Hugo site pointed at it builds.

Configuration

All configuration is done through environment variables. The full table with defaults is in DOCKER_INSTALL.md. The required ones are:

Variable Meaning
WORTWERK_KEYSTORE_SECRET Opens the wallet. 16–4096 chars. Never change it after the first boot.
WORTWERK_INITIAL_USER Owner username. Part of the wallet key: keep it set and unchanged.
WORTWERK_INITIAL_PASSWORD Seeds the wallet on the first boot only.

These are optional tunables:

Variable Default Meaning
WORTWERK_NEWSLETTER_INTERVAL 300 Seconds between newsletter queue passes (1–3600).

SMTP (host, port, credentials, STARTTLS, sender, daily and monthly limits) is set from the admin's SMTP panel and stored in the keystore, never in the content.

Routes

These routes are public:

Route
/, /page/N Front page, paginated
/<slug>/ (e.g. /2026-09-17/my_post/) A post
/feed.atom, /rss Atom feed
/subscribe Newsletter subscription
/email/confirm/..., /email/unsubscribe/... Links sent by mail
/login, /logout Owner session
/health Liveness probe for healthchecks
/.well-known/webfinger?resource=acct:... Signed WebFinger answer for the author (when enabled)
/ap/actor Signed actor document carrying the public key (when enabled)

The owner routes need the session cookie:

Route
/admin, /newpost Administration and editor
GET /api/posts/list, GET /api/posts/get?slug= Read posts
POST /api/posts/create?date=&title= New draft (raw text/markdown, ≤ 50,000 bytes)
POST /api/posts/save, /draft, /publish ?slug= Save the body in that state
POST /api/posts/pin?slug=&pinned= Pin or unpin
DELETE /api/posts/delete?slug= Delete the bundle
POST /api/images?slug= Upload an image (png, jpeg, gif, webp)
POST /api/posts/distribute?slug=&go=true Queue a post for the newsletter
POST /api/posts/import?url=, GET /api/posts/import/status Feed import
/api/admin/config/{smtp,blog,profile,login,federation} Settings
/api/admin/subscribers, /api/admin/subscribers/import Subscriber list
GET /api/admin/subscribers/requests, POST .../requests/{approve,kill} (id=) Subscription requests

Building

Wortwerk is built with Alire, using GNAT 16.1 and GPRbuild 26. The native libraries it needs are libmicrohttpd, libcurl, OpenSSL, zlib, cmark-gfm (with extensions), libxml2 and MagickWand.

alr build

By default the link uses ImageMagick 7 (Debian 13). On a system with ImageMagick 6:

MAGICK_WAND_LINK=-lMagickWand-6.Q16 MAGICK_CORE_LINK=-lMagickCore-6.Q16 alr build

To run the SPARK proof gate:

alr exec -- gnatprove -P wortwerk_spark.gpr --mode=all --level=2 --timeout=10 --prover=all --report=fail -j0

The smoke suite is written in Go and starts the real binary:

cd smoke && go test ./...

The Docker build runs the build and the proof gate, and an image is only produced if both pass. The smoke suite runs locally before a commit.

License

Wortwerk is licensed under the European Union Public Licence, version 1.2 (EUPL-1.2).

Credits

  • TOAST UI Editor (MIT) is vendored under static/toast. See static/toast/Toast-UI-MIT.txt.
  • Ghost Journal (MIT, © Ghost Foundation) is the default theme, under static/themes/journal. See its LICENSE.
  • The GoAccess 1.9.3 report shell (MIT) is used for the statistics dashboard, under static/stats and static/admin/goaccess-*.