chore: prepare release v0.4.0
This commit is contained in:
+14
-4
@@ -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 (1–100) |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
Reference in New Issue
Block a user