# Changelog All notable changes to **volumen** are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > Versions ≤ 0.3.0 were implemented in Ruby. The current codebase is Python > (FastAPI). Earlier release notes are kept verbatim as historical record. ## [development] ## [0.4.0] — 2026-07-26 ### Added - **`volumen init`** — a new `init` subcommand is the *operational* bootstrap (config, data dirs, admin password, optional systemd unit). Run as root for a system install (default `--systemd`); pass `--local` for a per-user install (`~/.config/volumen/`, `~/.local/share/volumen/`). The command renders the production-hardened config from the same commented TOML template (`host = "::1"`, `env = "production"`, `trust_proxy = true`, `cookie_secure = true`), prompts for an admin password (`getpass`), hashes it via the existing scrypt helper, generates a 64-byte hex session key, creates the system user (`useradd --system`) and the `/etc/volumen/` / `/var/lib/volumen/` layout, and (with `--systemd`) installs a hardened `/etc/systemd/system/volumen.service` and runs `systemctl daemon-reload && systemctl enable --now`. - **PyPI publish metadata** — `pyproject.toml` gains `readme`, `keywords`, a complete `classifiers` array (Development Status, Framework :: FastAPI, supported Python versions, OS, Topic, Typing), and a `[project.urls]` block (Homepage, Repository, Issues, Changelog, Documentation). PyPI now renders the README on the project page and links back to sourcedock.dev. - **`volumen status`** — read-only inspection of an installation. Prints human output by default or `--json` for tooling; reports `config_exists`, `data_dir_exists`, `users_file_exists`, `password_hash_set`, `session_key_ok`, `systemd_unit_installed`, `service_active`, `admin_user_present`, and any issues found. Exit code is `1` when any issue is reported. - **`volumen check-update`** — compares the installed version (read via `importlib.metadata`) with the latest release on PyPI. Uses only `urllib.request` (stdlib, 5 s timeout); exit code is `0` up-to-date, `1` update available (suggests `uv tool upgrade volumen`), `2` PyPI unreachable / offline / malformed response. `--json` returns the same fields plus `checked_at` for cron-friendly alerting. - **`src/volumen/installer.py`** — a new module exposing `system_install`, `user_install`, `inspect`, plus the private `_mutate_config_text` helper used to apply line-anchored substitutions to the rendered TOML template (preserves comment blocks byte-for-byte). - **Tests** — `tests/test_installer.py` adds 15 tests covering both install flows, the config-template mutator, the systemd-unit rendering, idempotency / backup behaviour, the `inspect` round-trip, and the `--systemd` + `--local` CLI guard. ### Changed - **First-run setup is now `volumen init`** — replaces the legacy `install.sh`. The CLI subcommand renders config, creates data dirs, prompts for an admin password, generates a session key, and (with `--systemd`) installs and enables a hardened systemd unit. No bash required. - **Publishing now targets PyPI** — `release.yml` runs `uv publish --token "$PYPI_TOKEN"` after `python -m build` for `v*` tags. Wheel + sdist continue to be uploaded to sourcedock.dev Releases as a secondary mirror. End users get `uv tool install volumen` and `pipx install volumen` as the one-line install path; no checkout required. - **Distribution path is `uv tool install`** (or `pipx install`) — the CLI binary is installed globally; the operational bootstrap is delegated to `volumen init`. The legacy `uv sync` + project-local venv at `/opt/volumen/.venv` is gone. - **`Config.load` round-trips config-file `content_dir` / `users_file`** — the rendered template places these two keys after the `[server]` header, so TOML table folding puts them inside `[server]`. `Config.load` now hoists them to the root so files produced by `volumen init` (and any hand-edited config) round-trip through the canonical accessors without relying on CLI overrides. - **Migrated from Ruby to Python**: the runtime has been rewritten on top of FastAPI + Uvicorn, replacing Sinatra with FastAPI, kramdown with python-markdown + pymdown-extensions, Puma with Uvicorn, Bundler with uv, minitest with pytest, RuboCop with Ruff, and the TOML parser (`toml-rb`) with the standard library `tomllib` (plus `tomli-w` for round-tripping edits). The public API (`/api/volumen/*`) and the server-rendered admin (`/admin/*`) keep the same shape and HTTP semantics. - **Installer** — `install.sh` now bootstraps `uv` instead of Bundler, runs `uv sync --frozen` to provision `/opt/volumen/.venv`, and points the systemd `ExecStart` at the venv-resident `volumen` console script. The Python runtime is auto-installed on Fedora/openEuler; other distros get a heredoc with the equivalent `apt` / `pacman` / `zypper` / `apk` lines. - **CI** — Forgejo Actions moved to `setup-uv` + Ruff + pytest on a single Python 3.12 job (was: Ruby 3.3/3.4 matrix with Bundler, RuboCop, and `bundle exec rake test`). The release workflow builds wheel + sdist via `python -m build` and uploads them with the Forgejo Releases API. - **Migration guard** — new `.forgejo/workflows/migration-guard.yml` greps the tracked tree for Ruby-only terms (`bundle exec`, `RuboCop`, `Sinatra`, `Puma`, `kramdown`, `gem build`, `Volumen::`, `Rack::Session::Cookie`, `OpenSSL::KDF`, `lib/volumen/`) and fails the build if any of them leak back into production paths. - **Configuration** — `config.toml` gains six new keys surfaced in `config.toml.example`: `[server].env` (`"production"` enables strict startup), `[server].trust_proxy` (honour `X-Forwarded-Proto` from nginx), `[admin].min_password_length` (default `10`), `[admin].max_password_length` (default `1024`, caps scrypt work), `[admin].max_upload_bytes` (default `10485760`, 10 MB), and `[admin].cookie_secure` (default `false` in dev, set `true` behind HTTPS). See `docs/configuration.md` for the full schema. - **Default bind address** — the installer and `config.toml.example` default to `host = "::"` (IPv6 with automatic IPv4 fallback) so the service is reachable on both stacks out of the box. Bind to `"::1"` when running behind a reverse proxy. - **Test client now uses `httpx2`** — dev dependencies replace `httpx>=0.28` with `httpx2>=2.7` (the actively maintained fork by Pydantic). Starlette 1.3.x prefers `httpx2` in `TestClient` and emits `StarletteDeprecationWarning` when only legacy `httpx` is installed; shipping `httpx2` keeps the test suite warning-free. The legacy `httpx` package is still pulled in via `fastapi[standard]` for the `fastapi` CLI and remains API-compatible. The unused `ignore::DeprecationWarning:starlette.testclient` filter is removed from `[tool.pytest.ini_options]` (it targeted the wrong warning class and never matched). ### Removed - `install.sh` — the bash installer is deleted. Use `uv tool install volumen` (or `pipx install volumen`) followed by `volumen init`. - `pkg/` build output and `volumen.gem` (Ruby gem packaging is gone; the project now ships as a wheel + sdist built with `python -m build`). - `Gemfile`, `volumen.gemspec`, `Rakefile`, `.rubocop.yml` and the Sinatra-era `lib/` tree. - `.editorconfig` — Ruff (`pyproject.toml`'s `[tool.ruff]`) is the single source of truth for Python formatting; modern editors default to UTF-8, LF, and a trailing newline. Markdown files in this repo use blank lines between paragraphs (no ` \n` line-break idiom), so trimming trailing whitespace is safe. ### Security - **scrypt password hashing** now uses `hashlib.scrypt` and constant-time verification via `secrets.compare_digest` (was: Ruby's `OpenSSL::KDF`). - **Session cookies** are signed by Starlette's `SessionMiddleware` (`itsdangerous`) with `HttpOnly` and `SameSite=Strict`; `Secure` is added by `SecureCookieMiddleware` when the request was forwarded over HTTPS (only when `[server].trust_proxy = true`). - **CSRF tokens** are generated with `secrets.token_hex(32)` and verified with `secrets.compare_digest` on every state-changing admin POST. - **Markdown sanitisation policy** — the renderer still passes inline HTML through (authors are the trusted admin); document the trust model explicitly in `docs/security.md` so deployments that ingest untrusted content know to add a sanitiser. > Note: refine the Security bullets above once the Python agent confirms the > final cryptographic controls (e.g. whether `image/*` upload signatures get > re-validated server-side and whether the CSRF token is rotated on login). ## [0.3.0] — 2026-07-12 ### Added **Content & authoring** - **Fediverse attribution:** a `fediverse_creator` frontmatter field (e.g. `@user@mastodon.social`) plus a site-wide `[site].fediverse_creator` default in `config.toml`. The value is exposed as `fediverse_creator` on the site and post payloads, rendered as `` in the RSS feed, as `authors[].name` in the JSON feed, and can be consumed by a front-end to emit `` on rendered pages. Per-post values override the site default. - **Cover caption:** a `cover_caption` post field rendered next to the cover image so authors can credit photos or add a short blurb. - **Titled images as `
`:** Markdown images with a title (`![alt](url "caption")`) are wrapped in a `
` with a `
` and a small `i` info icon. Images without a title render exactly as before. **Public API** - `has_next` and `has_prev` boolean flags in the paginated `/api/volumen/posts` response so clients can navigate pages without computing boundaries themselves. - Distinct `"draft"` error code in `/api/volumen/posts/:slug` when the post exists but is a draft, replacing the generic `"not_found"`. **Admin** - **Modern admin UI:** redesigned layout with a sticky top bar, breadcrumbs on every page, and a unified design language across login, post list, post form, and settings. - **Volumen icon:** a dedicated SVG icon shown in the sidebar and on the login page (served at `/admin/icon.svg`). - **Version in sidebar footer** so admins always see which release is running. - **Per-user display name** on the user record and admin header. - **Per-user fediverse handle** editable from the settings page (independent of the post-level `fediverse_creator`). - **Profile photo upload:** each admin user can upload a WebP/AVIF avatar that is stored alongside posts in the content directory and shown in the settings page. - **Tabbed settings page** separating account, profile, and admin sections. - **Restricted uploads:** only WebP (`image/webp`) and AVIF (`image/avif`) are accepted, with a 415 error message that points users to `cwebp` / `avifenc` for conversion. Reflects the engine's WebP/AVIF-only posture end-to-end. - **Polished native inputs** for `` so they match the rest of the admin's design language. **Tooling** - `just lint` recipe for standalone RuboCop checks and `just fmt` for auto-fixing lint issues. - `config.toml.example` template for local development; `config.toml` is now gitignored. ### Changed - Memoize `published_posts` within a single request so repeated calls (posts list, tags, feeds, sitemap) reuse the cached result. - `Store#all` cache now invalidates on individual file mtime changes, not just on the content directory mtime. Manual edits and `git pull` are detected without a restart. - `Cache-Control: public, max-age=60` header on all public API responses for browser and CDN caching. - Extract feed and sitemap generation into a dedicated `FeedHelpers` module, reducing `Server` from ~500 to ~420 lines. ### Fixed - Admin post save/delete now correctly clears the in-memory posts cache, so the post list reflects edits without a manual reload. - The post form's date field defaults to today when the underlying post has no date, preventing the picker from showing an invalid empty value. ### Security - **Content-Security-Policy** header on all `/admin/*` responses: `default-src 'self'`, `script-src 'self' 'unsafe-inline'`, `style-src 'self' 'unsafe-inline'`, `img-src 'self' data:`, `font-src 'self'`, `connect-src 'self'`, `form-action 'self'`, `frame-ancestors 'none'`. - File uploads are rejected (HTTP 413) when exceeding **10 MB** (`MAX_UPLOAD_BYTES`). - File uploads are restricted to `image/webp` and `image/avif` (`ALLOWED_UPLOAD_TYPES`). Anything else is rejected with a 415 and a conversion hint. ## [0.2.0] — 2026-06-25 ### Added - `` in RSS feed items, derived from the post date. - `` in sitemap entries for SEO. - In-memory rate limiting on the login endpoint: 10 attempts per 60 seconds per IP. - `` element in the RSS feed channel. - Periodic cleanup of stale `login_attempts` entries to prevent unbounded memory growth. - Slug format validation (`[a-z0-9._-]`) preventing path traversal and XSS in templates. - Username validation in `change_username` matching the create-user regex. - Escape-on-output for slug and username in admin template attributes. - Memoization of `Store#all` with mtime-based invalidation, eliminating N×read+parse per request. - Atomic file writes (tmp + rename) for posts and `users.toml`. - Post language directory routing: non-default-language posts now land in `content_dir//` instead of overwriting root-level files. - CLI test coverage (version, help, unknown command, option parsing, config bootstrap). - Test coverage for RSS feed and sitemap endpoints. - CORS preflight (`OPTIONS /api/volumen/*`) so browsers can reach the JSON API. - `Users#all` memoization with mtime-based invalidation, matching Store#all. - Session invalidation: deleted users can no longer use existing session cookies. - CSRF protection on `POST /admin/preview`. - Symlink-safe containment in `Store#media_file` via `File.realpath`. ### Added - **Scheduled publishing:** a `publish_at` frontmatter field hides posts from the public API and feeds until their publication date arrives. The admin form includes a Publish at date picker. - **JSON Feed:** `GET /api/volumen/feed.json` serves a jsonfeed.org v1.1 feed with `content_html` and `date_published` per item. - **Fulltext search:** `GET /api/volumen/posts?q=…` filters posts by case-insensitive match in title and body. ### Changed - Split `lib/volumen/server.rb` into `api_routes.rb` and `admin_routes.rb` modules, keeping the server class focused on configuration and shared helpers. ### Fixed - `admin.session_ttl` was defined but never read; it now controls `Rack::Session::Cookie` expiration via `expire_after`. - Derived post excerpts now strip HTML tags before stripping markdown markup, avoiding raw HTML leaks in auto-generated excerpts. - Draft posts no longer leak through the public detail endpoint (`/api/volumen/posts/:slug`). - `Config::DEFAULTS` inner hashes are now frozen and `deep_merge` builds fresh copies, preventing mutation from silently poisoning later `Config.load` calls. - A warning is emitted when `admin.session_key` is configured but shorter than 64 bytes, instead of silently falling back to a random secret. ## [0.1.0] — 2026-06-20 First public release: a small, file-based Markdown blog engine in CRuby. Posts are plain files, the engine exposes them as a JSON API and ships a self-hosted admin — no database, no external services. ### Added **Content & storage** - File-based store: each post is a `.md` file with a TOML frontmatter block delimited by `+++`. No database. - Post fields: `title`, `slug`, `date`, `lang`, `author`, `tags`, `draft`, `excerpt`, `cover`, `all_langs`, and `translations` (a `lang → slug` map). - Drafts (`draft = true`) are hidden from the public API. - Excerpt falls back to the first paragraph (~200 chars) when none is set. - Estimated reading time (~200 words/min) computed per post. - Multilingual posts: per-language slugs via `translations`, plus `all_langs` to surface a single post under every language. **Markdown rendering** - GFM rendering via kramdown: tables, fenced code blocks, task lists, and strikethrough. - Automatic heading IDs for in-page anchors. - Raw HTML in post bodies is passed through (authors are the trusted admin). **Public JSON API (`/api/volumen`)** - Read-only endpoints: `site`, `posts`, `posts/{slug}`, `tags`, `feed.xml` (RSS), and `sitemap.xml`. - `posts` supports pagination (`page`, `limit`, returning `total` and `page_size`) and filtering by `lang` and `tag`. - Permissive CORS so a separate front end can consume the API. **Admin (`/admin`)** - Server-rendered admin with a sidebar layout and full post CRUD. - Dual-mode editor: Markdown source (primary) and a visual WYSIWYG, switchable per post, with a toggleable live preview persisted across sessions. - Image upload from the editor toolbar into `content_dir/media`; any image format is accepted (WebP and AVIF included) and served with the correct `Content-Type`. - Cover image field with upload, URL, clear, and preview. **Multi-user & roles** - Multiple admin users stored in `users.toml`, managed from a settings page. - Two roles: `admin` (full access incl. user management) and `author` (posts only). - Change password and username; last-admin and self-deletion safeguards. **Configuration & CLI** - TOML configuration, auto-generated with a commented template on first run. - Configurable `host`/`port` (defaults to `9090`), `content_dir`, `users_file`, and `site` metadata. - CLI: `serve`, `hash-password`, `version`, `help`, with `--config`, `--content`, `--host`, and `--port` overrides. **Deployment** - `install.sh` installs the Ruby toolchain from distro packages on Fedora and openEuler (and prints guidance on other distros), creates the service user and directories, and installs a systemd unit. - Deployment docs for systemd and FreeBSD (`rc.d`), plus an nginx reverse-proxy setup that runs the admin same-origin, with brute-force rate-limiting and an optional IP allowlist on the login endpoint. **Project & tooling** - Documentation set: architecture, configuration, API reference, deployment, and security. - `just` task set (`install`, `run`, `dev`, `build`, `test`, `uninstall`) and Forgejo Actions CI (RuboCop + tests on Ruby 3.3/3.4, gem build on release). - `CONTRIBUTING.md` and a minitest suite; clean RuboCop. ### Security - Passwords hashed with scrypt and verified in constant time. - Per-form CSRF tokens on every state-changing admin request. - Session cookies are `HttpOnly` and `SameSite=Strict`, and gain the `Secure` attribute automatically on HTTPS requests (detected via `request.ssl?` / `X-Forwarded-Proto`), so the cookie never travels over plain HTTP in production. - Configurable, stable `session_key` so sessions survive restarts. - Generic login error message to avoid username enumeration. [0.1.0]: https://sourcedock.dev/petrbalvin/volumen/releases/tag/v0.1.0 [0.2.0]: https://sourcedock.dev/petrbalvin/volumen/releases/tag/v0.2.0 [0.3.0]: https://sourcedock.dev/petrbalvin/volumen/releases/tag/v0.3.0 [0.4.0]: https://sourcedock.dev/petrbalvin/volumen/releases/tag/v0.4.0