diff --git a/.coverage b/.coverage
new file mode 100644
index 0000000..abcf0a6
Binary files /dev/null and b/.coverage differ
diff --git a/.editorconfig b/.editorconfig
deleted file mode 100644
index 8310d60..0000000
--- a/.editorconfig
+++ /dev/null
@@ -1,43 +0,0 @@
-# EditorConfig: https://editorconfig.org
-
-root = true
-
-[*]
-charset = utf-8
-end_of_line = lf
-insert_final_newline = true
-trim_trailing_whitespace = true
-indent_style = space
-indent_size = 2
-
-[*.{rb,erb,gemspec}]
-indent_style = space
-indent_size = 2
-
-[{Gemfile,Rakefile}]
-indent_style = space
-indent_size = 2
-
-[*.{js,html,css,vue}]
-indent_style = space
-indent_size = 2
-
-[*.{yml,yaml,json,toml}]
-indent_style = space
-indent_size = 2
-
-[*.{rs,cj}]
-indent_style = space
-indent_size = 4
-
-[*.go]
-indent_style = tab
-indent_size = 4
-
-[justfile]
-indent_style = tab
-
-[*.md]
-indent_style = space
-indent_size = 2
-trim_trailing_whitespace = false
diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml
deleted file mode 100644
index 98a36ac..0000000
--- a/.forgejo/workflows/release.yml
+++ /dev/null
@@ -1,129 +0,0 @@
-name: Release
-
-on:
- push:
- tags: ["v*"]
-
-jobs:
- release:
- runs-on: codeberg-small
- steps:
- - uses: https://data.forgejo.org/actions/checkout@v4
-
- - uses: https://github.com/ruby/setup-ruby@v1
- with:
- ruby-version: "3.4"
- bundler-cache: true
-
- - name: Resolve and verify version
- id: version
- env:
- REF: ${{ forgejo.ref }}
- run: |
- set -euo pipefail
-
- VERSION="${REF##*/}"
- if [[ ! "$VERSION" =~ ^v[0-9]+(\.[0-9]+){0,2}([-+].*)?$ ]]; then
- echo "ERROR: expected a semver tag like v1.2.3, got: '$VERSION' (ref: '$REF')"
- exit 1
- fi
-
- VERSION_NO_V="${VERSION#v}"
- GEM_VERSION="$(ruby -Ilib -rvolumen/version -e 'print Volumen::VERSION')"
- if [ "$VERSION_NO_V" != "$GEM_VERSION" ]; then
- echo "ERROR: tag ${VERSION} does not match Volumen::VERSION (${GEM_VERSION})"
- exit 1
- fi
-
- echo "version_no_v=${VERSION_NO_V}" >> "$FORGEJO_OUTPUT"
-
- - name: Quality gate
- run: |
- set -euo pipefail
- bundle exec rubocop
- bundle exec rake test
-
- - name: Build gem
- env:
- VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
- run: |
- set -euo pipefail
- gem build volumen.gemspec --output "volumen-${VERSION_NO_V}.gem"
- ls -lh "volumen-${VERSION_NO_V}.gem"
-
- - name: Extract CHANGELOG section
- env:
- VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
- run: |
- set -euo pipefail
- sed -n "/^## \[${VERSION_NO_V}\] /,/^## \[/p" CHANGELOG.md \
- | sed '$d' \
- | tail -n +2 \
- > release-body.md
-
- if [ ! -s release-body.md ]; then
- echo "ERROR: no CHANGELOG section found for ${VERSION_NO_V}"
- echo "Expected a heading like: ## [${VERSION_NO_V}] — YYYY-MM-DD"
- exit 1
- fi
-
- echo "Release body preview:"
- head -n 20 release-body.md
-
- - name: Create release
- id: create
- env:
- FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
- run: |
- set -euo pipefail
- BODY="$(ruby -rjson -e 'print JSON.generate($stdin.read)' < release-body.md)"
-
- response="$(curl -sS -w '\n%{http_code}' \
- -H "Authorization: token ${FORGEJO_TOKEN}" \
- -H "Content-Type: application/json" \
- -X POST \
- "${FORGEJO_SERVER_URL}/api/v1/repos/${FORGEJO_REPOSITORY}/releases" \
- -d "{
- \"tag_name\": \"${FORGEJO_REF_NAME}\",
- \"name\": \"${FORGEJO_REF_NAME}\",
- \"body\": ${BODY},
- \"draft\": false,
- \"prerelease\": false
- }")"
-
- http_code="$(echo "$response" | tail -1)"
- payload="$(echo "$response" | sed '$d')"
- echo "HTTP ${http_code}"
-
- if [ "$http_code" != "201" ]; then
- echo "Failed to create release: ${payload}"
- exit 1
- fi
-
- RELEASE_ID="$(echo "$payload" | ruby -rjson -e 'print JSON.parse($stdin.read)["id"]')"
- echo "Created release ID=${RELEASE_ID}"
- echo "release_id=${RELEASE_ID}" >> "$FORGEJO_OUTPUT"
-
- - name: Upload gem asset
- env:
- FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
- VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
- RELEASE_ID: ${{ steps.create.outputs.release_id }}
- run: |
- set -euo pipefail
- GEM="volumen-${VERSION_NO_V}.gem"
-
- http_code="$(curl -sS -o /dev/null -w '%{http_code}' \
- -H "Authorization: token ${FORGEJO_TOKEN}" \
- -H "Content-Type: application/octet-stream" \
- -X POST \
- --data-binary "@${GEM}" \
- "${FORGEJO_SERVER_URL}/api/v1/repos/${FORGEJO_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${GEM}")"
-
- echo "HTTP ${http_code}"
- if [ "$http_code" != "201" ]; then
- echo "Failed to upload ${GEM}"
- exit 1
- fi
-
- echo "Release ${FORGEJO_REF_NAME} is live with ${GEM}."
diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml
deleted file mode 100644
index 3c65669..0000000
--- a/.forgejo/workflows/test.yml
+++ /dev/null
@@ -1,39 +0,0 @@
-name: Test
-
-on:
- push:
- branches: [development]
- pull_request:
- branches: [development]
-
-jobs:
- lint:
- runs-on: codeberg-small
- steps:
- - uses: https://data.forgejo.org/actions/checkout@v4
-
- - uses: https://github.com/ruby/setup-ruby@v1
- with:
- ruby-version: "3.4"
- bundler-cache: true
-
- - name: RuboCop
- run: bundle exec rubocop
-
- test:
- runs-on: codeberg-small
- needs: lint
- strategy:
- fail-fast: false
- matrix:
- ruby-version: ["3.3", "3.4"]
- steps:
- - uses: https://data.forgejo.org/actions/checkout@v4
-
- - uses: https://github.com/ruby/setup-ruby@v1
- with:
- ruby-version: ${{ matrix.ruby-version }}
- bundler-cache: true
-
- - name: Tests
- run: bundle exec rake test
diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
new file mode 100644
index 0000000..da0ec21
--- /dev/null
+++ b/.gitea/workflows/release.yml
@@ -0,0 +1,150 @@
+name: Release
+
+on:
+ push:
+ tags: ["v*"]
+
+jobs:
+ release:
+ runs-on: fedora
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python and uv
+ uses: astral-sh/setup-uv@v5
+ with:
+ python-version: "3.14"
+ enable-cache: true
+ cache-dependency-glob: "uv.lock"
+
+ - name: Resolve and verify version
+ id: version
+ env:
+ REF: ${{ gitea.ref }}
+ run: |
+ set -euo pipefail
+
+ VERSION="${REF##*/}"
+ if [[ ! "$VERSION" =~ ^v[0-9]+(\.[0-9]+){0,2}([-+].*)?$ ]]; then
+ echo "ERROR: expected a semver tag like v1.2.3, got: '$VERSION' (ref: '$REF')"
+ exit 1
+ fi
+
+ VERSION_NO_V="${VERSION#v}"
+ PYPROJECT_VERSION="$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")"
+ if [ "$VERSION_NO_V" != "$PYPROJECT_VERSION" ]; then
+ echo "ERROR: tag ${VERSION} does not match pyproject.toml version (${PYPROJECT_VERSION})"
+ exit 1
+ fi
+
+ echo "version_no_v=${VERSION_NO_V}" >> "$GITEA_OUTPUT"
+
+ - name: Quality gate
+ run: |
+ set -euo pipefail
+ uv sync --frozen --group dev
+ uv run ruff check src/ tests/
+ uv run ruff format --check src/ tests/
+ uv run pytest
+
+ - name: Build wheel and sdist
+ env:
+ VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
+ run: |
+ set -euo pipefail
+ uv pip install --upgrade build
+ uv run python -m build
+ ls -lh "dist/volumen-${VERSION_NO_V}"*
+
+ - name: Extract CHANGELOG section
+ env:
+ VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
+ run: |
+ set -euo pipefail
+ sed -n "/^## \[${VERSION_NO_V}\] /,/^## \[/p" CHANGELOG.md \
+ | sed '$d' \
+ | tail -n +2 \
+ > release-body.md
+
+ if [ ! -s release-body.md ]; then
+ echo "ERROR: no CHANGELOG section found for ${VERSION_NO_V}"
+ echo "Expected a heading like: ## [${VERSION_NO_V}] — YYYY-MM-DD"
+ exit 1
+ fi
+
+ echo "Release body preview:"
+ head -n 20 release-body.md
+
+ - name: Create release
+ id: create
+ env:
+ GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
+ GITEA_SERVER_URL: ${{ gitea.server_url }}
+ GITEA_REPOSITORY: ${{ gitea.repository }}
+ run: |
+ set -euo pipefail
+ BODY="$(python -c 'import json,sys; print(json.dumps(sys.stdin.read()))' < release-body.md)"
+
+ response="$(curl -sS -w '\n%{http_code}' \
+ -H "Authorization: token ${GITEA_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -X POST \
+ "${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \
+ -d "{
+ \"tag_name\": \"$GITHUB_REF_NAME\",
+ \"name\": \"$GITHUB_REF_NAME\",
+ \"body\": ${BODY},
+ \"draft\": false,
+ \"prerelease\": false
+ }")"
+
+ http_code="$(echo "$response" | tail -1)"
+ payload="$(echo "$response" | sed '$d')"
+ echo "HTTP ${http_code}"
+
+ if [ "$http_code" != "201" ]; then
+ echo "Failed to create release: ${payload}"
+ exit 1
+ fi
+
+ RELEASE_ID="$(echo "$payload" | python -c 'import json,sys; print(json.loads(sys.stdin.read())["id"])')"
+ echo "Created release ID=${RELEASE_ID}"
+ echo "release_id=${RELEASE_ID}" >> "$GITEA_OUTPUT"
+
+ - name: Upload wheel and sdist assets
+ env:
+ GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
+ VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
+ RELEASE_ID: ${{ steps.create.outputs.release_id }}
+ GITEA_SERVER_URL: ${{ gitea.server_url }}
+ GITEA_REPOSITORY: ${{ gitea.repository }}
+ run: |
+ set -euo pipefail
+ for ASSET in "volumen-${VERSION_NO_V}-py3-none-any.whl" \
+ "volumen-${VERSION_NO_V}.tar.gz"; do
+ if [ ! -f "dist/${ASSET}" ]; then
+ echo "Missing build artefact: dist/${ASSET}"
+ exit 1
+ fi
+ http_code="$(curl -sS -o /dev/null -w '%{http_code}' \
+ -H "Authorization: token ${GITEA_TOKEN}" \
+ -H "Content-Type: application/octet-stream" \
+ -X POST \
+ --data-binary "@dist/${ASSET}" \
+ "${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${ASSET}")"
+
+ echo "Uploaded ${ASSET}: HTTP ${http_code}"
+ if [ "$http_code" != "201" ]; then
+ echo "Failed to upload ${ASSET}"
+ exit 1
+ fi
+ done
+
+ echo "Release $GITHUB_REF_NAME is live with wheel + sdist."
+
+ # Publish to PyPI (only on tag push). Requires `PYPI_TOKEN` in repo
+ # secrets (project-scoped upload token from https://pypi.org/manage/account/publishing/).
+ - name: Publish to PyPI
+ if: startsWith(env.GITEA_REF_NAME, 'v')
+ run: |
+ uv publish --token "$PYPI_TOKEN"
diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml
new file mode 100644
index 0000000..3372506
--- /dev/null
+++ b/.gitea/workflows/test.yml
@@ -0,0 +1,33 @@
+name: Test
+
+on:
+ push:
+ branches: [development]
+ pull_request:
+ branches: [development]
+ merge_group:
+
+jobs:
+ test:
+ runs-on: fedora
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python and uv
+ uses: astral-sh/setup-uv@v5
+ with:
+ python-version: "3.14"
+ enable-cache: true
+ cache-dependency-glob: "uv.lock"
+
+ - name: Install dependencies
+ run: uv sync --frozen --group dev
+
+ - name: Lint (ruff check)
+ run: uv run ruff check src/ tests/
+
+ - name: Format (ruff format --check)
+ run: uv run ruff format --check src/ tests/
+
+ - name: Tests (pytest)
+ run: uv run pytest
diff --git a/.gitignore b/.gitignore
index f9e34fc..b1276f5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,19 +1,21 @@
# Python
__pycache__/
*.pyc
+*.pyo
*.egg-info/
.venv/
.pytest_cache/
.ruff_cache/
dist/
-# Ruby leftovers (from the old build)
-/pkg/
-
# Test / coverage
/coverage/
/tmp/
+# mkdocs / Sphinx build output
+/site/
+/var/
+
# Local development sandbox (posts, generated config)
/.volumen/
@@ -28,4 +30,4 @@ dist/
# Editor / OS
*.swp
-.DS_Store
+.DS_Store
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b8cee1b..16aab50 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,11 +5,152 @@ 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
@@ -240,6 +381,7 @@ admin — no database, no external services.
- Configurable, stable `session_key` so sessions survive restarts.
- Generic login error message to avoid username enumeration.
-[0.1.0]: https://codeberg.org/petrbalvin/volumen/releases/tag/v0.1.0
-[0.2.0]: https://codeberg.org/petrbalvin/volumen/releases/tag/v0.2.0
-[0.3.0]: https://codeberg.org/petrbalvin/volumen/releases/tag/v0.3.0
+[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
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7391096..f773276 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,16 +1,20 @@
# Contributing
Thank you for considering contributing to **volumen** — a small, file-based
-Markdown blog engine written in CRuby, with a built-in admin and a public JSON
-API.
+Markdown blog engine written in Python, with a built-in admin and a public
+JSON API.
## Development Setup
### 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`
+- **Python ≥ 3.14** with the `venv` module (the venv itself is managed by
+ `uv`).
+ - Fedora: `sudo dnf install python3.14 python3-virtualenv`
+ - Debian/Ubuntu: `sudo apt install python3.14 python3-venv python3-pip`
+- **[`uv`](https://docs.astral.sh/uv/)** — the package manager. The project
+ pins every dependency (including transitive ones) in `uv.lock`; install with
+ `curl -LsSf https://astral.sh/uv/install.sh | sh` (or via your distro).
- [`just`](https://github.com/casey/just) — the `justfile` is the source of
truth for install / run / build / test / uninstall.
- Linux, macOS, or FreeBSD.
@@ -18,11 +22,11 @@ API.
### Quick Start
```bash
-git clone https://codeberg.org/petrbalvin/volumen.git
+git clone https://sourcedock.dev/petrbalvin/volumen.git
cd volumen
-just install # bundle install
-just test # bundle exec rake test (minitest)
-just build # rubocop (zero warnings) + gem build → pkg/volumen.gem
+just install # uv sync
+just test # uv run pytest
+just build # ruff check + ruff format --check (zero warnings)
just dev # run against ./posts and ./config.toml on :9090
```
@@ -31,7 +35,9 @@ admin password is `admin` (the hash lives in `./config.toml`). Open
.
For a fresh `serve` (without `--config`), the first run writes a commented
-template to `/etc/volumen/config.toml`; see [`docs/deployment.md`](docs/deployment.md).
+template to `/etc/volumen/config.toml`; see [`docs/deployment.md`](docs/deployment.md)
+for the full production bootstrap (`uv tool install volumen && sudo volumen
+init`).
### Running Tests
@@ -39,49 +45,51 @@ template to `/etc/volumen/config.toml`; see [`docs/deployment.md`](docs/deployme
just test
```
-The minitest suite (`test/`) covers:
+The pytest suite (`tests/`) covers:
- `frontmatter` — `+++` TOML block parse/serialise round-trips.
- `post` / `store` — the Post model, reading/writing posts and media.
-- `markdown` — GFM rendering (tables, fenced code).
+- `markdown` — GFM-ish rendering (tables, fenced code, ``).
- `config` — defaults, overrides, template generation.
- `password` / `users` — scrypt hashing, roles, last-admin safeguards.
-- `server` / `admin` — the JSON API and the admin (auth, CSRF, CRUD, uploads,
- roles) via `rack-test`.
+- `server` / `admin` — the JSON API and the admin (auth, CSRF, CRUD,
+ uploads, roles) via `httpx.AsyncClient` against the FastAPI app.
### Linting
-RuboCop is the source of truth for style (`.rubocop.yml`). The project enforces
-**zero offenses**:
+Ruff is the source of truth for style (settings in `pyproject.toml`,
+`[tool.ruff]`, `[tool.ruff.lint]`). The project enforces **zero warnings**:
```bash
-bundle exec rubocop # check (also run as part of `just build`)
-bundle exec rubocop -A # autocorrect
+just build # ruff check + ruff format --check
+just fmt # ruff format + ruff check --fix (autocorrect)
```
+`just build` is what CI runs; both steps must exit 0 with no diff in
+`--check` mode.
+
## Code Style
-- Idiomatic Ruby; let RuboCop guide you. CI fails on any offense.
-- **Two-space** indentation, **double quotes**, and `# frozen_string_literal: true`
- at the top of every Ruby file.
-- Document every class/module with a short comment (`Style/Documentation` is on).
-- Keep lines ≤ 100 characters (`Layout/LineLength`).
-- Prefer guard clauses; return `nil`/objects for expected "not found" cases
- rather than raising.
-- Keep the dependency set small and deliberate — runtime gems are `sinatra`,
- `kramdown` (+ `kramdown-parser-gfm`), `toml-rb`, `puma`, `rackup`. Crypto uses
- the standard library (`openssl`, `securerandom`). New runtime dependencies
- should be discussed in an issue first.
+- Idiomatic Python 3.14+; let Ruff guide you. CI fails on any warning.
+- **4-space** indentation, **double quotes**, lines `≤ 100` characters (set
+ in `[tool.ruff]`).
+- Type hints on every public function signature; `from __future__ import
+ annotations` at the top of every module.
+- Prefer the standard library. New runtime dependencies belong in
+ `pyproject.toml`'s `dependencies` array and should be discussed in an
+ issue first.
+- Prefer `httpx` for HTTP tests; never start a real network server in tests.
- Admin content is authored by trusted users; see [`docs/security.md`](docs/security.md)
for the trust model before touching rendering or uploads.
### Testing
-- Put tests in `test/*_test.rb`, each starting with `require "test_helper"`.
-- Use `Dir.mktmpdir` for anything touching the filesystem; never assume a clean
- working directory.
-- Exercise the HTTP layer with `Rack::Test` (`Volumen::Server.configured(...)`).
-- Name tests `test__`; cover both the happy and the error path.
+- Put tests in `tests/test_*.py`; use `pytest` fixtures (see
+ `tests/conftest.py`) for the temp content directory and the FastAPI app.
+- Exercise the HTTP layer with `httpx.AsyncClient` driving the FastAPI app
+ via `app=` in the `AsyncClient` constructor.
+- Name tests `test__`; cover both the happy and the error
+ path.
## Commit Conventions
@@ -101,11 +109,11 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/).
```
feat: add author role with last-admin safeguards
-fix: rename Server.build to .configured to avoid a Sinatra clash
+fix: rename create_app to factory to avoid a FastAPI clash
refactor: extract frontmatter parsing into its own module
docs: document the dual-mode editor
test: cover GFM table and fenced code rendering
-chore: pin gem versions to latest
+chore: pin Python dependencies to latest
```
- English, lowercase subject, imperative mood (`add`, not `added`).
@@ -118,10 +126,11 @@ chore: pin gem versions to latest
2. Make your changes with Conventional-Commits messages.
3. Record your changes under the `## [development]` section at the top of
`CHANGELOG.md`, using the Keep a Changelog categories (`Added`, `Changed`,
- `Fixed`, etc.).
-4. Ensure `bundle exec rubocop` reports zero offenses and `just test` passes.
+ `Fixed`, `Removed`, `Security`).
+4. Ensure `just build` reports zero warnings and `just test` passes.
5. Add or update tests for your change.
-6. Update documentation if the public API, configuration, or behaviour changes:
+6. Update documentation if the public API, configuration, or behaviour
+ changes:
- `README.md` — high-level overview.
- `docs/configuration.md` — config keys.
- `docs/api-reference.md` — JSON API behaviour.
@@ -136,32 +145,32 @@ chore: pin gem versions to latest
```
volumen/
├── exe/
-│ └── volumen # CLI: serve, hash-password, version, help
-├── lib/
-│ ├── volumen.rb # library entry (requires the core modules)
+│ └── volumen # thin shim: from volumen.cli import main
+├── src/
│ └── volumen/
-│ ├── version.rb
-│ ├── frontmatter.rb # parse/serialise the +++ TOML frontmatter block
-│ ├── markdown.rb # kramdown (GFM) → HTML; for titled images
-│ ├── post.rb # Post model (metadata + body + rendered HTML)
-│ ├── store.rb # read/write posts and media on disk (mtime-cached)
-│ ├── config.rb # load/merge config.toml, generate template
-│ ├── password.rb # scrypt hashing/verification (OpenSSL::KDF)
-│ ├── users.rb # users.toml, admin/author roles, per-user profile
-│ ├── feed_helpers.rb # RSS / JSON Feed / sitemap generation
-│ ├── api_routes.rb # /api/volumen/* (Sinatra routes)
-│ ├── admin_routes.rb # /admin/* (Sinatra routes)
-│ ├── server.rb # Sinatra app: composes the two route modules
-│ ├── cli.rb # command dispatcher
+│ ├── __init__.py # VERSION + login-attempt limiter
+│ ├── cli.py # argparse entry point: serve, hash-password, version
+│ ├── config.py # tomllib loader + deep-merge defaults
+│ ├── frontmatter.py # parse/serialise the +++ TOML frontmatter block
+│ ├── markdown.py # python-markdown + pymdown-extensions
+│ ├── post.py # Post model (metadata + body + rendered HTML)
+│ ├── store.py # read/write posts and media on disk (mtime-cached)
+│ ├── password.py # scrypt hashing/verification (hashlib + secrets)
+│ ├── users.py # users.toml + admin/author roles
+│ ├── feed_helpers.py # RSS / JSON Feed / sitemap generation
+│ ├── api_routes.py # /api/volumen/* (FastAPI router)
+│ ├── admin_routes.py # /admin/* + /media/* (FastAPI router)
+│ ├── server.py # FastAPI app factory, middleware, dependencies
│ └── web/
-│ ├── views/ # ERB templates (layout, login, list, form, settings)
-│ └── public/ # static assets (volumen-logo.svg, volumen-icon.svg)
-├── test/ # minitest suite
+│ ├── templates/ # Jinja2 templates (layout, login, list, form, settings)
+│ └── static/ # static assets (volumen-logo.svg, volumen-icon.svg)
+├── tests/ # pytest suite
├── docs/ # architecture, configuration, api-reference, deployment, security
-├── .forgejo/workflows/ # test.yml, release.yml (Forgejo Actions)
+- `.gitea/workflows/` # test.yml, release.yml, migration-guard.yml (Gitea Actions)
├── justfile # install, run, dev, lint, fmt, build, test, uninstall
-├── volumen.gemspec
-├── Gemfile
+├── pyproject.toml # PEP 621 metadata + dependencies + tool config
+├── uv.lock # locked dependency graph
+├── config.toml.example # bootstrap config template
├── CHANGELOG.md
└── LICENSE
```
@@ -172,19 +181,24 @@ volumen/
graph TD
Browser[Browser / Vue SPA]
Nginx[nginx reverse proxy]
- Server[Volumen::Server -- Sinatra]
- Store[Volumen::Store]
- Markdown[Volumen::Markdown -- kramdown GFM]
- Users[Volumen::Users]
+ App[FastAPI app -- Uvicorn]
+ ApiR[api_routes.py]
+ AdminR[admin_routes.py + CSRF + CSP]
+ Store[Store]
+ Markdown[Markdown renderer -- python-markdown]
+ Users[Users]
Posts[(content_dir/*.md)]
UsersFile[(users.toml)]
Browser -->|GET /api/volumen/*| Nginx
Browser -->|/admin| Nginx
- Nginx --> Server
- Server -->|read/write posts| Store
- Server -->|render Markdown| Markdown
- Server -->|auth + roles| Users
+ Nginx --> App
+ App --> ApiR
+ App --> AdminR
+ ApiR --> Store
+ ApiR --> Markdown
+ AdminR --> Store
+ AdminR --> Users
Store --> Posts
Users --> UsersFile
```
@@ -192,73 +206,108 @@ graph TD
### Key Design Decisions
- **File-based, no database** — every post is a `.md` file with TOML
- frontmatter; users live in a `users.toml`. Everything is plain text, safe to
- commit and back up.
-- **Headless for the front-end** — volumen serves a read-only JSON API and its
- own server-rendered admin; the public site is a separate consumer.
-- **Single universal gem** — volumen is pure Ruby, so `gem build` produces one
- platform-independent gem. Only the `puma` dependency has a native extension,
- compiled at install time on the host.
-- **Trusted authors** — kramdown passes raw HTML through on purpose; content is
- only as safe as who can log in (see `docs/security.md`).
-- **Roles, server-enforced** — `admin` manages users, `author` manages posts
- and their own account; `require_admin!` gates user management on the server,
- with last-admin safeguards.
+ frontmatter; users live in a `users.toml`. Everything is plain text,
+ safe to commit and back up.
+- **Headless for the front-end** — volumen serves a read-only JSON API
+ and its own server-rendered admin; the public site is a separate
+ consumer.
+- **Standard library first** — TOML parsing uses `tomllib` (3.11+), scrypt
+ uses `hashlib.scrypt`, constant-time compares use `secrets.compare_digest`.
+ No runtime TOML or crypto dependency is required.
+- **Trusted authors** — python-markdown passes raw HTML through on
+ purpose; content is only as safe as who can log in (see
+ `docs/security.md`).
+- **Roles, server-enforced** — `admin` manages users, `author` manages
+ posts and their own account; `require_admin` (a FastAPI dependency)
+ gates user management on the server, with last-admin safeguards.
+- **IPv6-first** — the default bind address is `::`; bind to `::1` when
+ behind nginx.
## CI
-volumen uses **Forgejo Actions**; both workflows live under `.forgejo/workflows/`
-and run on the `codeberg-small` runner.
+volumen uses **Gitea Actions**; the workflows live under
+`.gitea/workflows/` and run on the `fedora:latest` runner.
-### Test — `.forgejo/workflows/test.yml`
+### Test — `.gitea/workflows/test.yml`
Triggered on push and pull requests targeting `development`.
-| Job | What it does |
-|--------|-----------------------------------------------|
-| `lint` | `bundle exec rubocop` (must report zero offenses) |
-| `test` | `bundle exec rake test` on a Ruby `3.3` / `3.4` matrix |
+| Step | What it does |
+|------|--------------|
+| Set up Python and uv | `astral-sh/setup-uv` with Python 3.14 |
+| Install dependencies | `uv sync --frozen --group dev` |
+| Lint | `uv run ruff check src/ tests/` |
+| Format | `uv run ruff format --check src/ tests/` |
+| Tests | `uv run pytest` |
Run the same locally before opening a PR:
```bash
-bundle exec rubocop
+just build
just test
```
-### Release — `.forgejo/workflows/release.yml`
+### Release — `.gitea/workflows/release.yml`
-Triggered by pushing a `v*` tag. It verifies the tag matches `Volumen::VERSION`,
-runs the quality gate (rubocop + tests), builds `volumen-X.Y.Z.gem`, extracts
-the matching `CHANGELOG.md` section, creates a Forgejo release via the REST API,
-and attaches the gem. It does **not** publish to RubyGems.
+Triggered by pushing a `v*` tag. It verifies the tag matches the version
+in `pyproject.toml`, runs the quality gate, builds the wheel and sdist
+with `python -m build`, extracts the matching `CHANGELOG.md` section,
+creates a Gitea release via the REST API, uploads both artefacts, and
+publishes them to PyPI via `uv publish`.
-Requires a `FORGEJO_TOKEN` secret with permission to create releases.
+The auto-generated `${{ secrets.GITEA_TOKEN }}` is used to create the release
+(via the REST API). Only one secret is required: a `PYPI_TOKEN`
+(project-scoped upload token from
+).
+
+### Migration guard — `.gitea/workflows/migration-guard.yml`
+
+A grep guard that fails the build if any of the Ruby-only strings
+(`bundle exec`, `RuboCop`, `Sinatra`, `Puma`, `kramdown`, `gem build`,
+`Volumen::`, `Rack::Session::Cookie`, `OpenSSL::KDF`, `lib/volumen/`)
+appear in tracked files other than the explicitly allow-listed
+`CHANGELOG.md` and the guard workflow itself.
### Cutting a release
-1. Bump `VERSION` in `lib/volumen/version.rb`.
+1. Bump `version` under `[project]` in `pyproject.toml`.
2. Rename `## [development]` to `## [X.Y.Z] — YYYY-MM-DD` at the top of
- `CHANGELOG.md`, and add a fresh empty `## [development]` section above it.
+ `CHANGELOG.md`, and add a fresh empty `## [development]` section
+ above it.
3. Ensure CI is green on `development`.
-4. Merge `development` into `main` (the only time `main` changes outside a tag).
+4. Merge `development` into `main` (the only time `main` changes outside
+ a tag).
5. Tag and push:
```bash
git tag -a vX.Y.Z -m "volumen vX.Y.Z"
git push origin main
git push origin vX.Y.Z
```
-6. The release workflow builds the gem and publishes the release from the
- CHANGELOG section.
+6. The release workflow builds the wheel + sdist, publishes them to
+ sourcedock.dev Releases **and** to PyPI (`uv publish --token "$PYPI_TOKEN"`).
+
+#### PyPI publishing token
+
+A PyPI API token must be configured in
+`Settings → Secrets and Variables → Actions` of the sourcedock.dev repository
+under the name `PYPI_TOKEN`. Create a **project-scoped token** at
+ (scoped to `volumen`)
+rather than a global account token. Rotate the token at least yearly.
+The token must have the `upload` scope for the project.
+
+For local dry-runs and ad-hoc uploads, use the same token via the
+`UV_PUBLISH_TOKEN` env var or `uv publish --token "$PYPI_TOKEN"`. Never
+commit it.
## Report a Bug
-Open an issue at with:
+Open an issue at
-- volumen version (`volumen version`).
+- volumen version (`uv run volumen version`).
- Operating system and architecture.
- Exact steps (request or admin action) that reproduce it.
-- Expected vs actual behaviour, and the relevant slice of `journalctl -u volumen`.
+- Expected vs actual behaviour, and the relevant slice of
+ `journalctl -u volumen`.
-For **security issues**, please email **opensource@petrbalvin.org** rather than
-opening a public issue.
+For **security issues**, please email **opensource@petrbalvin.org** rather
+than opening a public issue.
\ No newline at end of file
diff --git a/README.md b/README.md
index 015dcd8..417d11f 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# volumen — file-based Markdown blog engine
-**volumen** is a small, dependency-light blog engine written in **Ruby**. It
+**volumen** is a small, dependency-light blog engine written in **Python**. It
serves your Markdown posts as a JSON API and includes a built-in, server-
rendered admin with both a Markdown source editor and a visual editor — so any
front-end (Vue, React, Svelte, plain HTML) can consume your content without
@@ -10,26 +10,33 @@ It is designed to be:
- **File-based** — every post is a plain `.md` file with TOML frontmatter, safe
to commit to Git and edit in your favourite editor.
-- **Self-contained** — runs as a single Ruby process; no Node build step is
- required to operate the engine or its admin.
+- **Self-contained** — runs as a single Python process (Uvicorn); no Node
+ build step is required to operate the engine or its admin.
- **Multi-user with roles** — username + password login; **admin** (full
access, manages users) and **author** (posts and own account) roles.
- **Multi-site friendly** — one instance per blog, configurable per instance.
- **Markdown-first** — the visual editor round-trips through the same renderer
that powers the public API, so what you store is always Markdown.
+- **IPv6-first** — the default bind address is `::` (IPv6 with automatic IPv4
+ fallback); bind to `::1` when running behind nginx so the admin port is not
+ exposed to the network.
---
## Stack
-| Concern | Choice |
-|----------------|------------------------------------------|
-| Language | CRuby (≥ 3.2) |
-| Web framework | [Sinatra](https://sinatrarb.com) |
-| Markdown | [kramdown](https://kramdown.gettalong.org) |
-| Frontmatter | TOML via [`toml-rb`](https://github.com/emancu/toml-rb) |
-| App server | [Puma](https://puma.io) |
-| Packaging | a Ruby gem with an `exe/volumen` CLI |
+| Concern | Choice |
+|----------------|------------------------------------------------------------------------|
+| Language | Python 3.14+ |
+| Web framework | [FastAPI](https://fastapi.tiangolo.com/) |
+| App server | [Uvicorn](https://www.uvicorn.org/) (via `fastapi[standard]`) |
+| Markdown | [python-markdown](https://python-markdown.github.io/) + [pymdown-extensions](https://facelessuser.github.io/pymdown-extensions/) |
+| Frontmatter | TOML via stdlib [`tomllib`](https://docs.python.org/3/library/tomllib.html) + [`tomli-w`](https://github.com/camillescott/tomli-w) |
+| Templating | [Jinja2](https://jinja.palletsprojects.com/) |
+| Sessions | Starlette `SessionMiddleware` (signed cookies via [`itsdangerous`](https://itsdangerous.palletsprojects.com/)) |
+| Packaging | Wheel + sdist via [`setuptools`](https://setuptools.pypa.io/); [`uv`](https://docs.astral.sh/uv/) for dependency management |
+| Lint / format | [Ruff](https://docs.astral.sh/ruff/) |
+| Tests | [pytest](https://docs.pytest.org/) + [httpx](https://www.python-httpx.org/) |
---
@@ -40,19 +47,22 @@ It is designed to be:
- Multi-language posts with a translations map
- Clean JSON API at `/api/volumen/`:
- `GET /site` — site metadata
- - `GET /posts` — paginated list with `lang`, `tag`, `page`, `limit` filters
+ - `GET /posts` — paginated list with `lang`, `tag`, `q`, `page`, `limit`
+ filters
- `GET /posts/{slug}` — single post (raw Markdown + rendered HTML)
- `GET /tags` — tag cloud with counts
- `GET /feed.xml` — RSS 2.0 feed
+ - `GET /feed.json` — JSON Feed 1.1
- `GET /sitemap.xml` — sitemap
- Server-rendered admin at `/admin/`:
- - Username + password login (scrypt-hashed via Ruby's `OpenSSL::KDF`)
+ - Username + password login (scrypt-hashed via `hashlib.scrypt`,
+ verified in constant time with `secrets.compare_digest`)
- Settings page: change password, change username, manage users and roles
(admin only)
- Session cookies (HttpOnly, SameSite=Strict) + CSRF tokens
- List, create, edit, delete posts
- - **Markdown source** editor and a **visual** editor with live preview, both
- storing Markdown
+ - **Markdown source** editor and a **visual** editor with live preview,
+ both storing Markdown
- "Reload from disk" to pick up out-of-band edits
- Permissive CORS for the public API, same-origin only for the admin
@@ -64,16 +74,44 @@ It is designed to be:
## Quick start
+### Production / first install
+
```bash
-# 1. Install dependencies (Ruby ≥ 3.2)
+# 1. Install the volumen CLI (once, on the machine):
+uv tool install volumen # or: pipx install volumen
+
+# 2. Bootstrap (once, typically as root — generates config, prompts for
+# an admin password, and installs a hardened systemd unit):
+sudo volumen init
+sudo systemctl status volumen
+
+# 3. Reverse-proxy with nginx and you're done — see docs/deployment.md.
+```
+
+`uv tool install` (or `pipx install`) drops a self-contained `volumen`
+executable on `PATH`; `volumen init` is the operational bootstrap that
+renders the config, creates the data dirs, generates a session key, and
+(with `--systemd`, on by default under root) installs a hardened unit
+file. See `volumen init --help` and `docs/deployment.md` for the full
+flow.
+
+### Development (this checkout)
+
+```bash
+# 1. Install dependencies (Python 3.14+ and uv required)
just install
# 2. Run
just run
```
+`just install` runs `uv sync` (creates `.venv/` and installs the locked
+dependency set from `uv.lock`). `just run` launches the `volumen` console
+script from the venv with the bundled sample posts.
+
The admin will live at and the API at
-.
+. The dev admin password is `admin`
+(the hash lives in `./config.toml`).
---
@@ -89,7 +127,7 @@ date = 2026-01-15 # first-class TOML date
lang = "en" # language code
author = "Petr Balvín" # optional
fediverse_creator = "@petrbalvin@mastodon.social" # optional, for Mastodon attribution
-tags = ["ruby", "web"] # optional
+tags = ["python", "web"] # optional
draft = false # optional
excerpt = "Short summary" # optional, auto-derived from body if missing
all_langs = false # optional, show this post in every language
@@ -100,7 +138,7 @@ cs = "muj-prispevek"
# Heading
-Body in **Markdown**, rendered by kramdown.
+Body in **Markdown**, rendered by python-markdown.
```
Posts are organised on disk as either:
@@ -156,20 +194,28 @@ per-post value takes precedence.
becoming `false`).
- **No whitespace pitfalls** — indentation is not significant.
+The engine parses it with the Python 3.11+ standard library `tomllib`; no
+extra TOML dependency is needed at runtime.
+
---
## Configuration
-A single TOML file, auto-generated at `/etc/volumen/config.toml` on first
-start. See [`docs/configuration.md`](docs/configuration.md) for the full
+A single TOML file, auto-generated at `/etc/volumen/config.toml` when you
+run `volumen init` (or, for local development, by `volumen serve` on first
+start). See [`docs/configuration.md`](docs/configuration.md) for the full
schema.
```toml
[server]
-host = "0.0.0.0"
+host = "::" # IPv6 + IPv4 fallback; use "::1" behind nginx
port = 9090
+env = "production"
+trust_proxy = true
+cookie_secure = true # required in production behind HTTPS
content_dir = "/var/lib/volumen/posts"
+users_file = "/var/lib/volumen/users.toml"
[site]
title = "My Blog"
@@ -179,17 +225,26 @@ language = "en"
author = "Anonymous"
[admin]
-password_hash = ""
-session_key = ""
+password_hash = "" # populated by `volumen init`
+session_key = "" # populated by `volumen init`
session_ttl = 86400
+min_password_length = 10
+cookie_secure = false # set to true in production behind HTTPS
+```
+
+`volumen init` writes the bootstrap admin hash and a fresh session key for
+you. To rotate the admin password later:
+
+```bash
+sudo volumen hash-password # returns a scrypt$… string; paste into [admin].password_hash
```
---
## Public API
-The API is at `/api/volumen/`. CORS is open (`*`) for read endpoints; admin routes
-are same-origin only.
+The API is at `/api/volumen/`. CORS is open (`*`) for read endpoints; admin
+routes are same-origin only.
### `GET /api/volumen/posts`
@@ -197,6 +252,7 @@ are same-origin only.
|-------|--------|---------|----------------------|
| lang | string | — | Filter by language |
| tag | string | — | Filter by tag |
+| q | string | — | Search in title/body |
| page | int | 1 | Page number |
| limit | int | 20 | Posts per page |
@@ -205,6 +261,8 @@ are same-origin only.
"page": 1,
"page_size": 20,
"total": 42,
+ "has_next": true,
+ "has_prev": false,
"posts": [
{
"slug": "welcome",
@@ -212,7 +270,7 @@ are same-origin only.
"excerpt": "A short introduction…",
"date": "2026-01-15",
"lang": "en",
- "tags": ["intro", "ruby"],
+ "tags": ["intro", "python"],
"author": "Petr Balvín",
"translations": { "cs": "vitame-vas" },
"url": "/api/volumen/posts/welcome"
@@ -231,7 +289,7 @@ Returns the full post including the raw Markdown body and the rendered HTML.
"title": "Welcome to volumen",
"date": "2026-01-15",
"lang": "en",
- "tags": ["intro", "ruby"],
+ "tags": ["intro", "python"],
"excerpt": "…",
"body": "# Welcome to **volumen**\n\n…",
"html": "