Files
volumen/CHANGELOG.md
petrbalvin a66a36abbc
Test / test (push) Successful in 59s
chore: prepare release v0.4.2
2026-07-27 10:36:44 +02:00

404 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.2] — 2026-07-27
### Fixed
- **`web/templates/*.jinja` missing from distribution wheel** — added `web/templates/*.jinja` to `[tool.setuptools.package-data]` in `pyproject.toml`. After a fresh install from PyPI the admin panel returned 500 because `Jinja2Templates` could not find any templates.
- **Duplicate `script-src` / `style-src` in admin CSP** — the nonce-augmented directives used to be *appended* after the base ones, so the browser (which honours the first occurrence) ignored the nonce and blocked every inline JS/CSS. They are now *replaced*: `script-src` and `style-src` are filtered out of the base CSP and the nonce versions are appended as the only occurrence.
- **CSP nonce generated after template rendering** — `request.state.csp_nonce` is now set in `GlobalSecurityHeadersMiddleware.dispatch()` *before* `call_next(request)` so templates see it during rendering. Previously it was generated afterwards, so `csp_nonce` was an empty string in the template context and every inline script was blocked.
- **Missing `nonce` attribute on `<script>` in `list.html.jinja` and `settings.html.jinja`** — inline scripts in these templates now carry `nonce="{{ csp_nonce | default('') }}"` so they match the nonce in the CSP header.
- **Bootstrap `admin.password_hash` ignored when `users.toml` is empty** — `Users.all` now falls back to the bootstrap admin even when `users.toml` exists but is empty (e.g. right after `volumen init`, which creates the file via `Path.touch()`). Previously the first login after a clean install failed.
- **`admin_context` did not pass `users_exist`** — the login page showed the “No users configured” warning even when authentication worked. Added `"users_exist": users_obj.any` to the admin template context.
- **Inline `style="..."` blocked by strict admin CSP** — the admin templates used inline `style="..."` attributes (and one `card.style.display = ...` JS assignment) which the new strict `style-src 'self' 'nonce-…'` directive blocks. Replaced every offending attribute with a utility class in the `<style>` block (`page-head-actions`, `form-options`, `form-inline`, `form-hidden`, `form-spaced`, `flex-spacer`, `btn-static`, `btn-danger-text`, `text-fg-strong`, `badge-inline`, `h3-section`, `mt-3`, `is-hidden`); the dynamic `background-image: url(...)` on `.post-cover` now flows through a `--cover-url` custom property (`style="--cover-url: …"`) which CSP3 does not treat as an inline style application.
- **`/favicon.ico` returned 404** — browsers auto-request `/favicon.ico` even when the page declares an explicit `<link rel="icon">`. Added a tiny route in `create_app()` that serves the packaged `web/static/volumen-icon.svg` with `Content-Type: image/svg+xml` and a 1-day `Cache-Control`; added the matching `<link rel="icon" type="image/svg+xml" href="/favicon.ico">` to the admin layout so the tab gets the volumen icon instead of a generic placeholder.
- **Sidebar version label rendered as bare `v`** — `admin_context` did not include the package `VERSION`, so the footer `<div class="sidebar-version">v{{ version }}</div>` rendered as just `v` with no number behind it. Added `"version": VERSION` to the context.
## [0.4.1] — 2026-07-27
### Fixed
- **Missing CSP nonce for `style-src` on admin routes** — added `style-src 'self' 'nonce-{nonce}'` to the CSP header on `/admin` routes. Without a nonce the browser blocked every inline `<style>` tag in the admin templates.
- **`web/static/` SVG assets missing from distribution wheel** — added a `[tool.setuptools.package-data]` section to `pyproject.toml` so `volumen-icon.svg` and `volumen-logo.svg` ship inside the installed wheel. The admin panel previously returned 500 on `/admin/icon.svg` and `/admin/logo.svg`.
## [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 `<dc:creator>` in the RSS feed, as
`authors[].name` in the JSON feed, and can be consumed by a front-end to
emit `<meta name="fediverse:creator">` 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 `<figure>`:** Markdown images with a title
(`![alt](url "caption")`) are wrapped in a `<figure>` with a `<figcaption>`
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 `<input type="date|time|file">` 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
- `<pubDate>` in RSS feed items, derived from the post date.
- `<lastmod>` in sitemap entries for SEO.
- In-memory rate limiting on the login endpoint: 10 attempts per 60 seconds per IP.
- `<language>` 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/<lang>/` 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.