chore: prepare release v0.4.0
Test / test (push) Successful in 1m7s
Release / release (push) Successful in 1m11s

This commit is contained in:
2026-07-26 18:15:29 +02:00
parent 19c6161ae0
commit 3b8ed31f67
61 changed files with 6614 additions and 2704 deletions
+14 -4
View File
@@ -33,6 +33,7 @@ Posts with `all_langs = true` in their frontmatter match every `lang` filter.
|-------|--------|---------|--------------------|
| lang | string | — | Filter by language |
| tag | string | — | Filter by tag |
| q | string | — | Case-insensitive search across title and body |
| page | int | 1 | Page number |
| limit | int | 20 | Per page (1100) |
@@ -41,6 +42,8 @@ Posts with `all_langs = true` in their frontmatter match every `lang` filter.
"page": 1,
"page_size": 20,
"total": 1,
"has_next": false,
"has_prev": false,
"posts": [
{
"slug": "hello",
@@ -64,7 +67,7 @@ Posts with `all_langs = true` in their frontmatter match every `lang` filter.
Single post including the raw Markdown body and rendered HTML. Optional
`?lang=` selects a translation. Returns `404` with `{"error":"not_found"}` if
no match.
no match. Drafts return `404` with `{"error":"draft"}`.
```json
{
@@ -93,7 +96,14 @@ Tag cloud with counts (published posts only), most frequent first.
{ "tags": [{ "name": "intro", "count": 1 }] }
```
## `GET /api/volumen/feed.xml`, `GET /api/volumen/sitemap.xml`
## `GET /api/volumen/feed.xml`, `GET /api/volumen/feed.json`, `GET /api/volumen/sitemap.xml`
RSS 2.0 feed (latest 20 posts) and a sitemap of post URLs, built from
`site.base_url`.
- `feed.xml`RSS 2.0 feed of the 20 most recent posts.
- `feed.json` — [JSON Feed 1.1](https://www.jsonfeed.org/) document with
`content_html` and `date_published` per item.
- `sitemap.xml` — XML sitemap built from `site.base_url`.
## `OPTIONS /api/volumen/*`
Preflight requests return an empty `204` with the same CORS headers as the GET
endpoints, so browsers can reach the JSON API cross-origin.
+50 -34
View File
@@ -3,7 +3,7 @@
> Status: the core library, the public JSON API, and the server-rendered admin
> (login, CSRF, post CRUD, Markdown editor + live preview) are implemented.
**volumen** is a small, file-based Markdown blog engine written in CRuby. It
**volumen** is a small, file-based Markdown blog engine written in Python. It
has no database: every post is a `.md` file on disk with a TOML frontmatter
block. The engine reads those files, exposes them as a JSON API, and ships a
server-rendered admin for editing them.
@@ -12,35 +12,35 @@ server-rendered admin for editing them.
- **File-based** — posts are plain `.md` files, safe to commit to Git and edit
by hand or through the admin.
- **Single-admin** — one password, one content directory, one user.
- **Self-contained** — runs as a single Ruby process; no Node build step is
required to operate the engine or its admin.
- **Dependency-light** — a small, well-chosen set of gems (Sinatra, kramdown,
toml-rb, puma) rather than a full framework.
- **Self-contained** — runs as a single Python process (Uvicorn) under FastAPI;
no Node build step is required to operate the engine or its admin.
- **Dependency-light** — a small, well-chosen set of PyPI packages (FastAPI,
python-markdown, Jinja2, itsdangerous, python-multipart) rather than a
full-stack framework. The standard library covers cryptography.
- **Markdown-first** — the visual editor round-trips through the same renderer
that powers the public API, so stored content is always Markdown.
## Components
```
lib/volumen/
version.rb # gem version
config.rb # loads /etc/volumen/config.toml, applies CLI overrides
frontmatter.rb # parse/serialise the `+++ … +++` TOML block
post.rb # Post model: metadata + raw body + rendered HTML
store.rb # reads the content directory into Post objects (mtime-cached)
markdown.rb # kramdown wrapper (render + <figure>/<figcaption> for titled images)
feed_helpers.rb # RSS / JSON Feed / sitemap generation
users.rb # users.toml + admin/author roles
password.rb # scrypt hashing/verification (OpenSSL::KDF)
api_routes.rb # /api/volumen/* (Sinatra routes)
admin_routes.rb # /admin/* (Sinatra routes)
server.rb # Sinatra app: composes the two route modules, shared helpers
cli.rb # command dispatcher
src/volumen/
__init__.py # VERSION + in-memory login-attempt rate limiter
cli.py # argparse entry point: serve, hash-password, version
config.py # loads config.toml via stdlib tomllib, deep-merges defaults
frontmatter.py # parse/serialise the `+++ … +++` TOML block
post.py # Post model: metadata + raw body + rendered HTML
store.py # reads the content directory into Post objects (mtime-cached)
markdown.py # python-markdown + pymdown-extensions wrapper, <figure>/<figcaption>
feed_helpers.py # RSS / JSON Feed / sitemap generation
users.py # users.toml + admin/author roles
password.py # scrypt hashing/verification (hashlib + secrets.compare_digest)
api_routes.py # /api/volumen/* (FastAPI APIRouter)
admin_routes.py # /admin/* + /media/* (FastAPI APIRouter)
server.py # create_app factory, middleware, dependency helpers
web/
views/ # ERB templates for the admin
public/ # static assets (volumen-logo.svg, volumen-icon.svg)
exe/volumen # CLI: serve, hash-password, version, help
templates/ # Jinja2 templates for the admin
static/ # static assets (volumen-logo.svg, volumen-icon.svg)
exe/volumen # thin shim that calls volumen.cli:main
```
## Request flow
@@ -48,22 +48,38 @@ exe/volumen # CLI: serve, hash-password, version, help
```mermaid
graph TD
A[HTTP request] --> B{Route}
B -->|/api/volumen/*| C[api_routes.rb]
B -->|/admin/*| D[admin_routes.rb + CSRF + CSP]
C --> E[Store]
D --> E
B -->|/api/volumen/*| C[api_routes.py]
B -->|/admin/*| D[admin_routes.py + CSRF + CSP]
B -->|/media/*| E[admin_routes.py media_router]
C --> F[Store]
D --> F
D --> U[Users]
C --> F[FeedHelpers]
C --> H[FeedHelpers]
C --> G[Markdown renderer]
E --> H[(content_dir/*.md)]
E --> I[(content_dir/media/*)]
U --> J[(users.toml)]
F --> E
G --> K[<figure> for titled images]
F --> I[(content_dir/*.md)]
F --> J[(content_dir/media/*)]
U --> K[(users.toml)]
H --> F
G --> L[<figure> for titled images]
D --> M[SessionMiddleware + CSPMiddleware]
C --> M
E --> M
```
## Middleware stack
- `SessionMiddleware` (Starlette) — signed cookie session, signed with
`itsdangerous` using the secret derived from `[admin].session_key`. Set to
`HttpOnly` and `SameSite=Strict`; `Secure` is added by `SecureCookieMiddleware`
when the request was forwarded over HTTPS.
- `SecureCookieMiddleware` (custom) — inspects `X-Forwarded-Proto` (when
`[server].trust_proxy = true`) and exposes `request.state.session_secure`
for downstream code that needs to know whether the request is HTTPS.
- `CSPMiddleware` (custom) — injects the `Content-Security-Policy` header on
every `/admin/*` response.
## Public site integration
The public website (e.g. `petrbalvin.org`, a Vue 3 + Vite + Tailwind SPA)
consumes volumen's JSON API. volumen does not render the public site itself; it
is headless for the front-end and only renders its own admin.
is headless for the front-end and only renders its own admin.
+61 -11
View File
@@ -1,20 +1,28 @@
# Configuration
> Status: initial draft. Field semantics are finalised as the loader lands.
> Status: the loader in `src/volumen/config.py` is the source of truth. This
> document lists every key it understands, with type, default, and meaning.
volumen is configured by a single **TOML** file. By default it lives at
`/etc/volumen/config.toml` and is auto-generated on first start. CLI flags
passed to `volumen serve` override the file.
A template you can copy as a starting point is at
[`config.toml.example`](../config.toml.example) in the repository root. The
template ships with development defaults (`host = "::"`, `env = "development"`,
`cookie_secure = false`) — flip them for production.
## Example `config.toml`
```toml
[server]
host = "0.0.0.0"
host = "::1"
port = 9090
env = "production"
trust_proxy = true
# Directory containing your Markdown posts.
content_dir = "/var/lib/volumen/posts"
users_file = "/var/lib/volumen/users.toml"
[site]
title = "My Blog"
@@ -22,22 +30,60 @@ description = "A blog powered by volumen."
base_url = "https://example.com"
language = "en"
author = "Anonymous"
# Site-wide fediverse handle (e.g. Mastodon). Exposed as `fediverse_creator`
# on /api/volumen/site and used as the fallback in the RSS / JSON feeds when
# a post does not set its own `fediverse_creator` frontmatter field. Leave
# empty if you do not publish to the fediverse.
fediverse_creator = "@you@example.social"
[admin]
# scrypt hash produced by `volumen hash-password`.
password_hash = ""
# Secret used to sign session cookies. Leave empty to auto-generate per start
# (sessions reset on restart).
session_key = ""
# Session lifetime in seconds (default 24h).
session_ttl = 86400
min_password_length = 10
max_password_length = 1024
max_upload_bytes = 10485760 # 10 MB
cookie_secure = true
```
## Key reference
### `[server]`
| Key | Type | Default | Description |
|----------------|---------|---------------|-------------|
| `host` | string | `"::"` | Bind address. `"::"` listens on IPv6 with automatic IPv4 fallback; use `"::1"` (or `"127.0.0.1"`) when running behind a reverse proxy. |
| `port` | integer | `9090` | TCP port the Uvicorn worker binds to. |
| `env` | string | `"development"` | Runtime environment. `"production"` enables strict startup safety checks (refuses to boot with an empty `admin.password_hash`, warns on a short `admin.session_key`, enforces `cookie_secure`); `"development"` is more forgiving for local runs. |
| `trust_proxy` | boolean | `false` | When `true`, honour the `X-Forwarded-Proto` header set by an upstream reverse proxy (nginx). Set `true` ONLY when running behind a trusted proxy that strips client-supplied values; otherwise an attacker could fake `https://` and force `Secure` cookies. |
| `cookie_secure`| boolean | `false` | Mark session cookies as `Secure` (browser only sends them over HTTPS). Default `false` so local HTTP development works without TLS; set to `true` in production behind HTTPS. The runtime also auto-promotes cookies to `Secure` when `[server].trust_proxy = true` and `X-Forwarded-Proto: https` is observed. |
### Top-level
| Key | Type | Default | Description |
|----------------|---------|----------------------------------|-------------|
| `content_dir` | string | `"/var/lib/volumen/posts"` | Directory of `.md` files with `+++` TOML frontmatter. |
| `users_file` | string | `"/var/lib/volumen/users.toml"` | File storing admin users (managed from the admin Settings page). |
### `[site]`
| Key | Type | Default | Description |
|---------------------|-----------------|--------------------------|-------------|
| `title` | string | `"My Blog"` | Exposed as `site.title`. |
| `description` | string | `"A blog powered by volumen."` | Exposed as `site.description`. |
| `base_url` | string | `"https://example.com"` | Used to build absolute URLs in the RSS feed, JSON feed, sitemap, and the public API. |
| `language` | string | `"en"` | Default language for posts and the admin UI. |
| `author` | string | `"Anonymous"` | Default author when a post frontmatter omits it. |
| `fediverse_creator` | string \| null | `null` | Site-wide fediverse handle (e.g. `@user@instance.tld`). Exposed as `site.fediverse_creator` and rendered as `<dc:creator>` in the RSS feed and `authors[].name` in the JSON feed. Per-post frontmatter overrides it. |
### `[admin]`
| Key | Type | Default | Description |
|-----------------------|---------|-----------|-------------|
| `password_hash` | string | `""` | Bootstrap admin login: paste a hash from `volumen hash-password` to sign in as user `admin`. Once you create users from the Settings page, `users_file` wins. |
| `session_key` | string | `""` | Secret used to sign session cookies. Must be `>= 64` bytes; empty value = a random secret per start (sessions reset on restart, do not use in production). |
| `session_ttl` | integer | `86400` | Session lifetime in seconds (default 24h). `0` or negative = no expiry until the browser session ends. |
| `min_password_length` | integer | `10` | Minimum length required for admin-chosen passwords on the Settings page. Must be `>= 8`; the bootstrap hash is not affected. |
| `max_password_length` | integer | `1024` | Maximum length for admin-chosen passwords (cap to avoid scrypt abuse; scrypt is CPU-bound by design). |
| `max_upload_bytes` | integer | `10485760` | Maximum upload size in bytes (default 10 MB). Uploads larger than this are rejected with HTTP 413. |
| `cookie_secure` | boolean | `false` | Mark session cookies as `Secure` (browser only sends them over HTTPS). Default `false` so local HTTP development works without TLS; set to `true` in production behind HTTPS. The runtime also auto-promotes cookies to `Secure` when `[server].trust_proxy = true` and `X-Forwarded-Proto: https` is observed. |
## CLI overrides
```bash
@@ -55,3 +101,7 @@ volumen serve --config /etc/volumen/config.toml \
The same format is used for post frontmatter; see the post format section in
the project [`README.md`](../README.md).
The Python standard library `tomllib` (3.11+) parses the file at load time; no
extra TOML dependency is required at runtime. `tomli-w` is bundled for the
admin to round-trip TOML frontmatter on post edits.
+139 -99
View File
@@ -2,95 +2,113 @@
> Status: production deployment on Linux with systemd + nginx.
volumen runs as a single Ruby process (Puma) bound to localhost, behind nginx
which terminates TLS and reverse-proxies the API and admin. The public website
(e.g. a Vue SPA) is served separately and consumes the JSON API.
volumen runs as a single Python process (Uvicorn via `fastapi[standard]`) bound
to localhost, behind nginx which terminates TLS and reverse-proxies the API and
admin. The public website (e.g. a Vue SPA) is served separately and consumes
the JSON API.
The CLI binary is installed system-wide via `uv tool install` (or `pipx
install`); the *operational* bootstrap — config, data dirs, admin password,
optional systemd unit — is performed by the `volumen init` Python subcommand
that replaces the old `install.sh` bash script.
## Requirements
- **Ruby ≥ 3.3** with a C toolchain (Puma compiles a native extension).
- Fedora: `sudo dnf install ruby ruby-devel @development-tools openssl-devel`
- Debian/Ubuntu: `sudo apt install ruby-full build-essential libssl-dev`
- **nginx** and a TLS certificate (e.g. via certbot).
- A dedicated system user, e.g. `volumen`.
- **Python ≥ 3.14** (the bundled wheel ships its own interpreter when you use
`uv tool install` / `pipx install`; the system Python is only required if
you're building from source).
- Fedora: `sudo dnf install python3.14`
- Debian/Ubuntu: `sudo apt install python3.14 python3-venv python3-pip`
- **[`uv`](https://docs.astral.sh/uv/)** (recommended) — install with
`curl -LsSf https://astral.sh/uv/install.sh | sh`. Alternatively,
[`pipx`](https://pipx.pypa.io/) ships in most distro repos.
- **nginx** and a TLS certificate (e.g. via certbot) for the public site.
- A dedicated system user, `volumen`, created automatically by
`volumen init` when run as root.
## Layout
## Install the CLI
```bash
# Pick one:
uv tool install volumen
# or
pipx install volumen
# Both put a self-contained `volumen` binary on PATH. Verify:
volumen version
```
For platforms where the wheel is not available (or for development), build it
from a source checkout:
```bash
git clone https://sourcedock.dev/petrbalvin/volumen.git
cd volumen
uv tool install .
```
## Bootstrap the deployment
### System install (root, systemd)
```bash
sudo volumen init
sudo systemctl status volumen
```
`volumen init` walks through:
1. Whether `config_path` already exists — if so, leaves it untouched unless
`--force` is passed (and even then it makes a timestamped `.bak.<ts>.toml`
first).
2. Creates `/etc/volumen/`, `/var/lib/volumen/posts` (and a `media/`
subdirectory), `/var/lib/volumen/users.toml`.
3. Creates the system user `volumen` if missing (`useradd --system …`).
4. Prompts for an admin password (`getpass`), hashes it via scrypt, writes the
`password_hash` into the rendered config.
5. Generates a fresh 64-byte session key (`secrets.token_hex(64)`) and writes
it into `[admin].session_key`.
6. Forces production-style settings: `host = "::1"`, `env = "production"`,
`trust_proxy = true`, `cookie_secure = true`.
7. With `--systemd` (default under root), writes
`/etc/systemd/system/volumen.service` and runs
`systemctl daemon-reload && systemctl enable --now volumen`.
Pass `--no-enable` to install the unit without starting it; pass `--force` to
back up and overwrite an existing config. The full flag list lives in
`volumen init --help`.
### Per-user install (no root)
```bash
volumen init --local
```
Per-user paths: `~/.config/volumen/config.toml`,
`~/.local/share/volumen/posts`, `~/.local/share/volumen/users.toml`. No
system user is created and no systemd unit is installed. `--systemd` is
rejected in this mode.
## Configuration locations
| Path | Purpose |
|------|---------|
| `/opt/volumen` | source checkout (or installed gem) |
| `/etc/volumen/config.toml` | configuration (auto-generated on first run) |
| `/etc/volumen/config.toml` | configuration (rendered by `volumen init` on first run) |
| `/var/lib/volumen/posts` | Markdown posts |
| `/var/lib/volumen/users.toml` | admin users (created from Settings) |
| `/var/lib/volumen/posts/media` | uploaded images |
| `/var/lib/volumen/users.toml` | admin users (created from Settings) |
| `/etc/systemd/system/volumen.service` | systemd unit (only when `--systemd`) |
| `~/.config/volumen/config.toml` | per-user config (`--local`) |
| `~/.local/share/volumen/posts` | per-user data dir (`--local`) |
| `~/.local/share/volumen/users.toml` | per-user users file (`--local`) |
```bash
sudo useradd --system --home /var/lib/volumen --shell /usr/sbin/nologin volumen
sudo mkdir -p /etc/volumen /var/lib/volumen/posts
sudo chown -R volumen:volumen /var/lib/volumen
```
## Install
### Quick install (Linux + systemd)
From a cloned repository, run the installer as root. It installs the Ruby
toolchain from the distro repositories on **Fedora** and **openEuler** (prints
guidance on other distros), creates the `volumen` user and directories, runs
`bundle install`, generates `config.toml`, and installs and enables the systemd
unit:
```bash
sudo git clone https://codeberg.org/petrbalvin/volumen.git /opt/volumen
cd /opt/volumen
sudo ./install.sh
```
The service is enabled but not started — finish by setting the admin password
(printed steps), then `sudo systemctl start volumen`. The manual steps below
describe what the script does.
### Manual install
From a source checkout (reproducible, no publishing needed):
```bash
sudo git clone https://codeberg.org/petrbalvin/volumen.git /opt/volumen
cd /opt/volumen
sudo bundle install --deployment
```
Alternatively build and install the gem (`just build` produces `pkg/volumen.gem`,
then `gem install pkg/volumen.gem` puts `volumen` on the PATH).
## First run and the admin password
The first `serve` writes a commented `config.toml` if it is missing:
```bash
sudo -u volumen bundle exec exe/volumen serve \
--config /etc/volumen/config.toml \
--content /var/lib/volumen/posts
# → "volumen: wrote a default config to /etc/volumen/config.toml; review it and restart."
```
Generate a password hash and a stable session secret, then edit the config:
```bash
bundle exec exe/volumen hash-password # → scrypt$...
openssl rand -hex 64 # → session_key
sudo -u volumen $EDITOR /etc/volumen/config.toml
```
Set `host = "127.0.0.1"`, your `site.base_url`, the `admin.password_hash`, and a
`admin.session_key` (so sessions survive restarts). You can now sign in as user
`admin`; from the **Settings** page you can add more users (roles `admin` /
`author`) and change passwords. From then on `users.toml` is the source of
truth.
The config and users files are owned by `volumen:volumen` and `chmod 600` after
a system install — they may carry password hashes, so never make them
world-readable.
## systemd
`/etc/systemd/system/volumen.service`:
`/etc/systemd/system/volumen.service` (rendered by `volumen init --systemd`):
```ini
[Unit]
@@ -98,10 +116,12 @@ Description=volumen blog engine
After=network.target
[Service]
Type=simple
User=volumen
Group=volumen
WorkingDirectory=/opt/volumen
ExecStart=/usr/bin/bundle exec exe/volumen serve --config /etc/volumen/config.toml --content /var/lib/volumen/posts
WorkingDirectory=/etc/volumen
Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/local/bin/volumen serve --config /etc/volumen/config.toml --content /var/lib/volumen/posts
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
@@ -114,26 +134,34 @@ PrivateTmp=true
WantedBy=multi-user.target
```
Hardening notes — `NoNewPrivileges`, `ProtectSystem=strict` (the entire
filesystem is read-only except for `ReadWritePaths`), `ProtectHome`
(`/home`, `/root`, `/run/user` are inaccessible), `PrivateTmp` (private
`/tmp`), and a two-second restart delay on failure.
Inspect health with:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now volumen
sudo systemctl status volumen
journalctl -u volumen -f
volumen status # config / data dir / unit / service state
volumen status --json # machine-readable
```
## FreeBSD (rc.d)
volumen runs on FreeBSD too — it is plain CRuby plus gems; only Puma (and its
`nio4r` dependency) build a native extension, which compiles with the base
clang toolchain.
volumen runs on FreeBSD too — it is plain CPython with no compiled extensions.
```sh
pkg install ruby
# then install volumen as above (git clone + bundle install)
pkg install python3
# install uv (https://docs.astral.sh/uv/getting-started/installation/)
uv tool install volumen
sudo volumen init --local # no systemd on FreeBSD
```
Conventional FreeBSD paths: config in `/usr/local/etc/volumen/`, data in
`/var/db/volumen/`. Service script `/usr/local/etc/rc.d/volumen`:
`/var/db/volumen/`. Drop a tiny `rc.d` script in `/usr/local/etc/rc.d/volumen`
that wraps the `volumen` console script:
```sh
#!/bin/sh
@@ -150,14 +178,13 @@ load_rc_config $name
: ${volumen_enable:="NO"}
: ${volumen_user:="volumen"}
: ${volumen_dir:="/usr/local/volumen"}
: ${volumen_config:="/usr/local/etc/volumen/config.toml"}
: ${volumen_content:="/var/db/volumen/posts"}
pidfile="/var/run/${name}.pid"
command="/usr/sbin/daemon"
command_args="-f -r -P ${pidfile} -u ${volumen_user} \
/bin/sh -c 'cd ${volumen_dir} && exec bundle exec exe/volumen serve --config ${volumen_config} --content ${volumen_content}'"
/bin/sh -c 'exec volumen serve --config ${volumen_config} --content ${volumen_content}'"
run_rc_command "$1"
```
@@ -165,6 +192,7 @@ run_rc_command "$1"
Enable and start:
```sh
chmod +x /usr/local/etc/rc.d/volumen
sysrc volumen_enable=YES
service volumen start
```
@@ -266,22 +294,34 @@ right block. Apply with `sudo nginx -t && sudo systemctl reload nginx`.
## Updating
```bash
cd /opt/volumen
sudo git pull
sudo bundle install --deployment
uv tool upgrade volumen
sudo systemctl restart volumen
volumen status # confirm: config_exists, password_hash_set, session_key_ok, …
```
The CLI binary is replaced in place by `uv tool upgrade`; the systemd unit's
`ExecStart` already points at the on-PATH `volumen` console script, so a
restart is all that's needed. The persistent state (`config.toml`,
`users.toml`, posts) is untouched.
If you used the legacy `install.sh` workflow (no longer documented, the script
was removed), you can migrate forward by deleting `/etc/systemd/system/volumen.service`
+ `/opt/volumen` and then running `uv tool install volumen && sudo volumen init`.
## Security notes
- Bind to `127.0.0.1`; only nginx is public. TLS is terminated by nginx.
- Bind to `::1` (or `127.0.0.1`); only nginx is public. TLS is terminated by
nginx.
- Session cookies are `HttpOnly` and `SameSite=Strict`, and gain the `Secure`
attribute automatically on HTTPS requests (detected via `X-Forwarded-Proto`,
which the nginx snippet above sets) — so the cookie never travels over plain
HTTP in production, while local HTTP testing still works. Combined with
per-form CSRF tokens this protects the admin. Set a stable `admin.session_key`
so sessions survive restarts.
which the nginx snippet above sets, and enabled by
`[server].cookie_secure = true`, set automatically by `volumen init
--systemd`) — so the cookie never travels over plain HTTP in production,
while local HTTP testing still works. Combined with per-form CSRF tokens
this protects the admin. A stable `[admin].session_key` (also written by
`volumen init`) lets sessions survive restarts.
- Keep `config.toml` and `users.toml` readable only by the `volumen` user
(`chmod 600`). Both may contain password hashes.
- `volumen hash-password` reads the password without echo; never pass passwords
on the command line.
(`chmod 600`). Both may contain password hashes. The installer enforces
this automatically.
- Use `volumen hash-password` to rotate; it reads the password without echo.
Never pass passwords on the command line.
+44 -34
View File
@@ -2,7 +2,7 @@
> Status: threat model and security posture for the engine as implemented.
volumen is a small, file-based blog engine: a single Ruby process exposing a
volumen is a small, file-based blog engine: a single Python process exposing a
read-only public JSON API and a session-authenticated admin, behind nginx. This
document describes the trust model, the controls in place, and the known
limitations.
@@ -13,33 +13,37 @@ than opening a public issue.
## Trust model
- **Content authors are trusted.** Posts are written by authenticated admin
users. kramdown renders Markdown and **passes raw HTML through** on purpose,
so an author can embed HTML. The stored/served `html` is therefore only as
safe as the people who can log in. If you need to accept posts from untrusted
authors, add an HTML sanitiser before serving.
users. python-markdown renders Markdown with a few pymdown-extensions
extensions. The engine does **not** strip raw HTML from post bodies by
default (authors are the trusted admin); if you need to accept posts from
untrusted authors, add a sanitiser (e.g. `bleach`) before serving.
- **The public API is read-only.** It exposes published (non-draft) posts, tags,
a feed and a sitemap. No public endpoint mutates state.
## Authentication
- Passwords are hashed with **scrypt** via `OpenSSL::KDF` (`N=16384`, `r=8`,
- Passwords are hashed with **scrypt** via `hashlib.scrypt` (`n=16384`, `r=8`,
`p=1`, 32-byte key, 16-byte random salt). Verification uses
`OpenSSL.fixed_length_secure_compare` (constant-time).
`secrets.compare_digest` (constant-time) on the derived bytes.
- Login takes a **username + password**. The error message is intentionally
generic ("Invalid username or password.") to avoid user enumeration.
- Sessions use signed cookies (`Rack::Session::Cookie`) marked **`HttpOnly`**
and **`SameSite=Strict`**. The **`Secure`** attribute is added automatically on
HTTPS requests (detected via `request.ssl?` / `X-Forwarded-Proto`), so the
cookie never travels over plain HTTP in production. The signing secret comes
from `admin.session_key`; if it is shorter than 64 bytes a random secret is
generated per start (sessions then reset on restart, so set a stable key in
production).
- Sessions use **signed cookies** via Starlette's `SessionMiddleware`
(`itsdangerous` TimestampSigner under the hood), marked **`HttpOnly`** and
**`SameSite=Strict`**. The **`Secure`** attribute is added by
`SecureCookieMiddleware` when `X-Forwarded-Proto: https` is observed (only
honoured when `[server].trust_proxy = true`) so the cookie never travels
over plain HTTP in production while local HTTP testing still works. The
signing secret comes from `admin.session_key`; if it is shorter than 64
bytes a random secret is generated per start (sessions then reset on
restart, so set a stable key in production).
- A minimum password length of `[admin].min_password_length` (default `10`,
must be `>= 8`) is enforced on the Settings page password-change form.
## Authorization (roles)
- Two roles: **admin** (full access, manages users) and **author** (posts and
own account only).
- User management (`add`/`delete`/role change) is gated by `require_admin!`
- User management (`add`/`delete`/role change) is gated by `require_admin`
**on the server**, not merely hidden in the UI.
- Safeguards prevent lockout: the **last admin** cannot be deleted or demoted,
and a user cannot delete their own account or change their own role.
@@ -47,13 +51,14 @@ than opening a public issue.
## CSRF
- Every state-changing form (`POST`) carries a per-session token
(`SecureRandom.hex(32)`), validated with `Rack::Utils.secure_compare`.
(`secrets.token_hex(32)`), validated with `secrets.compare_digest`.
Requests without a valid token get `403`.
- `SameSite=Strict` cookies provide a second, independent layer.
## Content Security Policy (admin)
All `/admin/*` responses send a strict **Content-Security-Policy** header:
All `/admin/*` responses send a strict **Content-Security-Policy** header
(via `CSPMiddleware`):
```
default-src 'self';
@@ -66,8 +71,8 @@ form-action 'self';
frame-ancestors 'none'
```
The inline allowances are required by the server-rendered admin (ERB templates
emit small inline `<script>` blocks and inline `style=""` attributes);
The inline allowances are required by the server-rendered admin (Jinja2
templates emit small inline `<script>` blocks and inline `style=""` attributes);
`frame-ancestors 'none'` prevents clickjacking via iframe embedding. The public
JSON API does not set a CSP — it returns no HTML, so none is needed.
@@ -76,20 +81,19 @@ JSON API does not set a CSP — it returns no HTML, so none is needed.
- The public read API sends `Access-Control-Allow-Origin: *` (read-only, no
credentials) so any front-end may consume it. The admin sends **no** CORS
headers and is same-origin only.
- `Rack::Protection::HostAuthorization` is disabled (`permitted_hosts: []`)
because volumen is intended to run behind nginx, which controls the `Host`
header. Do not expose the Puma port directly to the internet.
- volumen is intended to run behind nginx, which controls the `Host` header.
Do not expose the Uvicorn port directly to the internet.
## File uploads and path traversal
- Uploaded media is stored under `content_dir/media` with a **sanitised,
randomised** filename (`<hex>-<slug>.<ext>`), so uploads cannot overwrite
each other or escape the directory.
- `GET /media/*` resolves names with `File.basename` and an explicit
- `GET /media/*` resolves names with `Path(...).name` and an explicit
containment check, so `../` traversal cannot read files outside the media
directory.
- Uploads are **rejected (HTTP 413)** when the file exceeds **10 MB**
(`MAX_UPLOAD_BYTES`).
- Uploads are **rejected (HTTP 413)** when the file exceeds the
`[admin].max_upload_bytes` ceiling (default **10 MB**, `10485760` bytes).
- Upload MIME type is **whitelisted** to `image/webp` and `image/avif`
(`ALLOWED_UPLOAD_TYPES`); anything else returns **HTTP 415** with a
conversion hint pointing at `cwebp` / `avifenc`. This matches the
@@ -107,21 +111,27 @@ JSON API does not set a CSP — it returns no HTML, so none is needed.
## Transport
- Bind to `127.0.0.1` and terminate TLS at nginx. The browser ↔ nginx hop is
HTTPS; the nginx ↔ Puma hop is local.
- Bind to `::1` (loopback) and terminate TLS at nginx. The browser ↔ nginx hop
is HTTPS; the nginx ↔ Uvicorn hop is local.
## Dependencies
A small, audited set: `sinatra`, `kramdown` (+ `kramdown-parser-gfm`),
`toml-rb`, `puma`, `rackup`. Cryptography uses the Ruby standard library
(`openssl`, `securerandom`).
A small, audited set declared in `pyproject.toml`:
- Runtime: `fastapi[standard]` (FastAPI, Uvicorn, itsdangerous, Jinja2 transitively),
`python-markdown`, `pymdown-extensions`, `tomli-w`, `python-multipart`.
(`itsdangerous`, `Jinja2`, and `python-multipart` are pulled in directly
too — see `pyproject.toml`.)
- Crypto: Python standard library (`hashlib`, `secrets`, `hmac`).
- Dev: `pytest`, `httpx`, `ruff`.
## Known limitations / non-goals
- **No rate limiting or lockout** on `/admin/login` at the application layer
(the engine does have an in-memory rate limiter, but nginx is the durable
line of defence — see `docs/deployment.md`). Put nginx `limit_req` in
front of it, or use fail2ban, to slow brute-force attempts.
beyond an in-memory per-IP sliding window (`MAX_LOGIN_ATTEMPTS = 10` per 60s
in `__init__.py`). nginx is the durable line of defence — see
`docs/deployment.md`. Put nginx `limit_req` in front of `/admin/login`, or use
fail2ban, to slow brute-force attempts.
- **No 2FA** and **no audit log**.
- **Raw HTML in posts is trusted** (see the trust model). There is no built-in
HTML sanitiser.
HTML sanitiser.