- Ada 69.8%
- Go 17.2%
- C 3.1%
- Handlebars 2.7%
- JavaScript 2.5%
- Other 4.7%
|
All checks were successful
continuous-integration/drone/push Build is passing
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. |
||
|---|---|---|
| alire | ||
| smoke | ||
| src | ||
| static | ||
| tools | ||
| .dockerignore | ||
| .drone.yml | ||
| .gitignore | ||
| alire.toml | ||
| DOCKER_INSTALL.md | ||
| Dockerfile | ||
| INSTALL.md | ||
| README.md | ||
| wortwerk.gpr | ||
| wortwerk_spark.gpr | ||
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.orgwriting atblog.example.org),https://example.org/.well-known/webfingerhas 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/nameon Mastodon,/nameor/@nameelsewhere). The reliable way to get it is to ask the remote server's WebFinger and copy theselflink. - 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_ROOTholds posts and assets, and is publishable.WORTWERK_KEYSTORE_ROOTholds private state: the credential wallet, SMTP settings, subscribers, sessions and statistics. - The wallet is encrypted with a key derived from
WORTWERK_KEYSTORE_SECRETand 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. Seestatic/toast/Toast-UI-MIT.txt. - Ghost Journal (MIT, © Ghost Foundation) is the default theme, under
static/themes/journal. See itsLICENSE. - The GoAccess 1.9.3 report shell (MIT) is used for the statistics
dashboard, under
static/statsandstatic/admin/goaccess-*.