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

This commit is contained in:
2026-07-26 18:15:29 +02:00
parent 19c6161ae0
commit 3b8ed31f67
61 changed files with 6614 additions and 2704 deletions
BIN
View File
Binary file not shown.
-43
View File
@@ -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
-129
View File
@@ -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}."
-39
View File
@@ -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
+150
View File
@@ -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"
+33
View File
@@ -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
+6 -4
View File
@@ -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
+146 -4
View File
@@ -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
+154 -105
View File
@@ -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
<http://localhost:9090/admin/>.
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, `<figure>`).
- `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_<thing>_<scenario>`; 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_<thing>_<scenario>`; 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; <figure> 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
<https://pypi.org/manage/account/publishing/>).
### 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
<https://pypi.org/manage/account/publishing/> (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 <https://codeberg.org/petrbalvin/volumen/issues> with:
Open an issue at <https://sourcedock.dev/petrbalvin/volumen/issues>
- 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.
+101 -34
View File
@@ -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 <http://localhost:9090/admin/> and the API at
<http://localhost:9090/api/volumen/posts>.
<http://localhost:9090/api/volumen/posts>. 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": "<h1>Welcome to <strong>volumen</strong></h1>…",
@@ -245,13 +303,22 @@ Returns the full post including the raw Markdown body and the rendered HTML.
```bash
just # list recipes
just install # bundle install
just run # run the dev server (exe/volumen serve)
just build # rubocop (zero warnings) + build the gem → pkg/volumen.gem
just test # bundle exec rake test (minitest)
just uninstall # remove build artifacts (pkg/, *.gem)
just install # uv sync (creates .venv/ from uv.lock)
just run # run the dev server (volumen serve)
just dev # run against ./posts and ./config.toml on :9090
just build # ruff check + ruff format --check (zero warnings)
just test # uv run pytest
just fmt # ruff format + ruff check --fix
just uninstall # remove build artifacts (dist/, __pycache__, .pytest_cache, .ruff_cache)
```
The console script lives at `src/volumen/cli.py` and is registered as
`volumen` via the `[project.scripts]` entry in `pyproject.toml`; `uv run`
shims it onto `PATH` inside `.venv/`. In production the CLI is installed
globally via `uv tool install volumen` (or `pipx install volumen`) and the
operational bootstrap is done with `sudo volumen init`
see [`docs/deployment.md`](docs/deployment.md).
---
## Public site integration
@@ -280,4 +347,4 @@ commit conventions, the pull request flow, and how releases are cut.
## License
MIT — see [LICENSE](LICENSE).
Copyright © 2026 [Petr Balvín](https://petrbalvin.org)
Copyright © 2026 [Petr Balvín](https://petrbalvin.org)
+17 -2
View File
@@ -2,8 +2,17 @@
# Copy to config.toml and adjust. Keep real admin hashes out of Git.
[server]
host = "127.0.0.1"
host = "::"
port = 9090
# Environment label: "development" or "production". Affects startup
# safety checks (session key length, secure cookies, password policy).
env = "development"
# Set true ONLY when running behind a trusted reverse proxy that
# terminates TLS. The proxy must strip client-supplied
# X-Forwarded-Proto headers.
trust_proxy = false
# Set true in production to force `Secure` flag on session cookies.
cookie_secure = false
content_dir = "./posts"
users_file = "./users.toml"
@@ -16,7 +25,13 @@ language = "cs"
author = "Petr Balvín"
[admin]
# Generate a hash with: bundle exec exe/volumen hash-password
# Generate a hash with: uv run volumen hash-password
password_hash = ""
session_key = ""
session_ttl = 86400
# Minimum new-password length (default 10).
min_password_length = 10
# Maximum new-password length (cap to avoid scrypt abuse).
max_password_length = 1024
# Maximum upload size in bytes (default 10 MB).
max_upload_bytes = 10485760
+14 -4
View File
@@ -33,6 +33,7 @@ Posts with `all_langs = true` in their frontmatter match every `lang` filter.
|-------|--------|---------|--------------------|
| lang | string | — | Filter by language |
| tag | string | — | Filter by tag |
| q | string | — | Case-insensitive search across title and body |
| page | int | 1 | Page number |
| limit | int | 20 | Per page (1100) |
@@ -41,6 +42,8 @@ Posts with `all_langs = true` in their frontmatter match every `lang` filter.
"page": 1,
"page_size": 20,
"total": 1,
"has_next": false,
"has_prev": false,
"posts": [
{
"slug": "hello",
@@ -64,7 +67,7 @@ Posts with `all_langs = true` in their frontmatter match every `lang` filter.
Single post including the raw Markdown body and rendered HTML. Optional
`?lang=` selects a translation. Returns `404` with `{"error":"not_found"}` if
no match.
no match. Drafts return `404` with `{"error":"draft"}`.
```json
{
@@ -93,7 +96,14 @@ Tag cloud with counts (published posts only), most frequent first.
{ "tags": [{ "name": "intro", "count": 1 }] }
```
## `GET /api/volumen/feed.xml`, `GET /api/volumen/sitemap.xml`
## `GET /api/volumen/feed.xml`, `GET /api/volumen/feed.json`, `GET /api/volumen/sitemap.xml`
RSS 2.0 feed (latest 20 posts) and a sitemap of post URLs, built from
`site.base_url`.
- `feed.xml`RSS 2.0 feed of the 20 most recent posts.
- `feed.json` — [JSON Feed 1.1](https://www.jsonfeed.org/) document with
`content_html` and `date_published` per item.
- `sitemap.xml` — XML sitemap built from `site.base_url`.
## `OPTIONS /api/volumen/*`
Preflight requests return an empty `204` with the same CORS headers as the GET
endpoints, so browsers can reach the JSON API cross-origin.
+50 -34
View File
@@ -3,7 +3,7 @@
> Status: the core library, the public JSON API, and the server-rendered admin
> (login, CSRF, post CRUD, Markdown editor + live preview) are implemented.
**volumen** is a small, file-based Markdown blog engine written in CRuby. It
**volumen** is a small, file-based Markdown blog engine written in Python. It
has no database: every post is a `.md` file on disk with a TOML frontmatter
block. The engine reads those files, exposes them as a JSON API, and ships a
server-rendered admin for editing them.
@@ -12,35 +12,35 @@ server-rendered admin for editing them.
- **File-based** — posts are plain `.md` files, safe to commit to Git and edit
by hand or through the admin.
- **Single-admin** — one password, one content directory, one user.
- **Self-contained** — runs as a single Ruby process; no Node build step is
required to operate the engine or its admin.
- **Dependency-light** — a small, well-chosen set of gems (Sinatra, kramdown,
toml-rb, puma) rather than a full framework.
- **Self-contained** — runs as a single Python process (Uvicorn) under FastAPI;
no Node build step is required to operate the engine or its admin.
- **Dependency-light** — a small, well-chosen set of PyPI packages (FastAPI,
python-markdown, Jinja2, itsdangerous, python-multipart) rather than a
full-stack framework. The standard library covers cryptography.
- **Markdown-first** — the visual editor round-trips through the same renderer
that powers the public API, so stored content is always Markdown.
## Components
```
lib/volumen/
version.rb # gem version
config.rb # loads /etc/volumen/config.toml, applies CLI overrides
frontmatter.rb # parse/serialise the `+++ … +++` TOML block
post.rb # Post model: metadata + raw body + rendered HTML
store.rb # reads the content directory into Post objects (mtime-cached)
markdown.rb # kramdown wrapper (render + <figure>/<figcaption> for titled images)
feed_helpers.rb # RSS / JSON Feed / sitemap generation
users.rb # users.toml + admin/author roles
password.rb # scrypt hashing/verification (OpenSSL::KDF)
api_routes.rb # /api/volumen/* (Sinatra routes)
admin_routes.rb # /admin/* (Sinatra routes)
server.rb # Sinatra app: composes the two route modules, shared helpers
cli.rb # command dispatcher
src/volumen/
__init__.py # VERSION + in-memory login-attempt rate limiter
cli.py # argparse entry point: serve, hash-password, version
config.py # loads config.toml via stdlib tomllib, deep-merges defaults
frontmatter.py # parse/serialise the `+++ … +++` TOML block
post.py # Post model: metadata + raw body + rendered HTML
store.py # reads the content directory into Post objects (mtime-cached)
markdown.py # python-markdown + pymdown-extensions wrapper, <figure>/<figcaption>
feed_helpers.py # RSS / JSON Feed / sitemap generation
users.py # users.toml + admin/author roles
password.py # scrypt hashing/verification (hashlib + secrets.compare_digest)
api_routes.py # /api/volumen/* (FastAPI APIRouter)
admin_routes.py # /admin/* + /media/* (FastAPI APIRouter)
server.py # create_app factory, middleware, dependency helpers
web/
views/ # ERB templates for the admin
public/ # static assets (volumen-logo.svg, volumen-icon.svg)
exe/volumen # CLI: serve, hash-password, version, help
templates/ # Jinja2 templates for the admin
static/ # static assets (volumen-logo.svg, volumen-icon.svg)
exe/volumen # thin shim that calls volumen.cli:main
```
## Request flow
@@ -48,22 +48,38 @@ exe/volumen # CLI: serve, hash-password, version, help
```mermaid
graph TD
A[HTTP request] --> B{Route}
B -->|/api/volumen/*| C[api_routes.rb]
B -->|/admin/*| D[admin_routes.rb + CSRF + CSP]
C --> E[Store]
D --> E
B -->|/api/volumen/*| C[api_routes.py]
B -->|/admin/*| D[admin_routes.py + CSRF + CSP]
B -->|/media/*| E[admin_routes.py media_router]
C --> F[Store]
D --> F
D --> U[Users]
C --> F[FeedHelpers]
C --> H[FeedHelpers]
C --> G[Markdown renderer]
E --> H[(content_dir/*.md)]
E --> I[(content_dir/media/*)]
U --> J[(users.toml)]
F --> E
G --> K[<figure> for titled images]
F --> I[(content_dir/*.md)]
F --> J[(content_dir/media/*)]
U --> K[(users.toml)]
H --> F
G --> L[<figure> for titled images]
D --> M[SessionMiddleware + CSPMiddleware]
C --> M
E --> M
```
## Middleware stack
- `SessionMiddleware` (Starlette) — signed cookie session, signed with
`itsdangerous` using the secret derived from `[admin].session_key`. Set to
`HttpOnly` and `SameSite=Strict`; `Secure` is added by `SecureCookieMiddleware`
when the request was forwarded over HTTPS.
- `SecureCookieMiddleware` (custom) — inspects `X-Forwarded-Proto` (when
`[server].trust_proxy = true`) and exposes `request.state.session_secure`
for downstream code that needs to know whether the request is HTTPS.
- `CSPMiddleware` (custom) — injects the `Content-Security-Policy` header on
every `/admin/*` response.
## Public site integration
The public website (e.g. `petrbalvin.org`, a Vue 3 + Vite + Tailwind SPA)
consumes volumen's JSON API. volumen does not render the public site itself; it
is headless for the front-end and only renders its own admin.
is headless for the front-end and only renders its own admin.
+61 -11
View File
@@ -1,20 +1,28 @@
# Configuration
> Status: initial draft. Field semantics are finalised as the loader lands.
> Status: the loader in `src/volumen/config.py` is the source of truth. This
> document lists every key it understands, with type, default, and meaning.
volumen is configured by a single **TOML** file. By default it lives at
`/etc/volumen/config.toml` and is auto-generated on first start. CLI flags
passed to `volumen serve` override the file.
A template you can copy as a starting point is at
[`config.toml.example`](../config.toml.example) in the repository root. The
template ships with development defaults (`host = "::"`, `env = "development"`,
`cookie_secure = false`) — flip them for production.
## Example `config.toml`
```toml
[server]
host = "0.0.0.0"
host = "::1"
port = 9090
env = "production"
trust_proxy = true
# Directory containing your Markdown posts.
content_dir = "/var/lib/volumen/posts"
users_file = "/var/lib/volumen/users.toml"
[site]
title = "My Blog"
@@ -22,22 +30,60 @@ description = "A blog powered by volumen."
base_url = "https://example.com"
language = "en"
author = "Anonymous"
# Site-wide fediverse handle (e.g. Mastodon). Exposed as `fediverse_creator`
# on /api/volumen/site and used as the fallback in the RSS / JSON feeds when
# a post does not set its own `fediverse_creator` frontmatter field. Leave
# empty if you do not publish to the fediverse.
fediverse_creator = "@you@example.social"
[admin]
# scrypt hash produced by `volumen hash-password`.
password_hash = ""
# Secret used to sign session cookies. Leave empty to auto-generate per start
# (sessions reset on restart).
session_key = ""
# Session lifetime in seconds (default 24h).
session_ttl = 86400
min_password_length = 10
max_password_length = 1024
max_upload_bytes = 10485760 # 10 MB
cookie_secure = true
```
## Key reference
### `[server]`
| Key | Type | Default | Description |
|----------------|---------|---------------|-------------|
| `host` | string | `"::"` | Bind address. `"::"` listens on IPv6 with automatic IPv4 fallback; use `"::1"` (or `"127.0.0.1"`) when running behind a reverse proxy. |
| `port` | integer | `9090` | TCP port the Uvicorn worker binds to. |
| `env` | string | `"development"` | Runtime environment. `"production"` enables strict startup safety checks (refuses to boot with an empty `admin.password_hash`, warns on a short `admin.session_key`, enforces `cookie_secure`); `"development"` is more forgiving for local runs. |
| `trust_proxy` | boolean | `false` | When `true`, honour the `X-Forwarded-Proto` header set by an upstream reverse proxy (nginx). Set `true` ONLY when running behind a trusted proxy that strips client-supplied values; otherwise an attacker could fake `https://` and force `Secure` cookies. |
| `cookie_secure`| boolean | `false` | Mark session cookies as `Secure` (browser only sends them over HTTPS). Default `false` so local HTTP development works without TLS; set to `true` in production behind HTTPS. The runtime also auto-promotes cookies to `Secure` when `[server].trust_proxy = true` and `X-Forwarded-Proto: https` is observed. |
### Top-level
| Key | Type | Default | Description |
|----------------|---------|----------------------------------|-------------|
| `content_dir` | string | `"/var/lib/volumen/posts"` | Directory of `.md` files with `+++` TOML frontmatter. |
| `users_file` | string | `"/var/lib/volumen/users.toml"` | File storing admin users (managed from the admin Settings page). |
### `[site]`
| Key | Type | Default | Description |
|---------------------|-----------------|--------------------------|-------------|
| `title` | string | `"My Blog"` | Exposed as `site.title`. |
| `description` | string | `"A blog powered by volumen."` | Exposed as `site.description`. |
| `base_url` | string | `"https://example.com"` | Used to build absolute URLs in the RSS feed, JSON feed, sitemap, and the public API. |
| `language` | string | `"en"` | Default language for posts and the admin UI. |
| `author` | string | `"Anonymous"` | Default author when a post frontmatter omits it. |
| `fediverse_creator` | string \| null | `null` | Site-wide fediverse handle (e.g. `@user@instance.tld`). Exposed as `site.fediverse_creator` and rendered as `<dc:creator>` in the RSS feed and `authors[].name` in the JSON feed. Per-post frontmatter overrides it. |
### `[admin]`
| Key | Type | Default | Description |
|-----------------------|---------|-----------|-------------|
| `password_hash` | string | `""` | Bootstrap admin login: paste a hash from `volumen hash-password` to sign in as user `admin`. Once you create users from the Settings page, `users_file` wins. |
| `session_key` | string | `""` | Secret used to sign session cookies. Must be `>= 64` bytes; empty value = a random secret per start (sessions reset on restart, do not use in production). |
| `session_ttl` | integer | `86400` | Session lifetime in seconds (default 24h). `0` or negative = no expiry until the browser session ends. |
| `min_password_length` | integer | `10` | Minimum length required for admin-chosen passwords on the Settings page. Must be `>= 8`; the bootstrap hash is not affected. |
| `max_password_length` | integer | `1024` | Maximum length for admin-chosen passwords (cap to avoid scrypt abuse; scrypt is CPU-bound by design). |
| `max_upload_bytes` | integer | `10485760` | Maximum upload size in bytes (default 10 MB). Uploads larger than this are rejected with HTTP 413. |
| `cookie_secure` | boolean | `false` | Mark session cookies as `Secure` (browser only sends them over HTTPS). Default `false` so local HTTP development works without TLS; set to `true` in production behind HTTPS. The runtime also auto-promotes cookies to `Secure` when `[server].trust_proxy = true` and `X-Forwarded-Proto: https` is observed. |
## CLI overrides
```bash
@@ -55,3 +101,7 @@ volumen serve --config /etc/volumen/config.toml \
The same format is used for post frontmatter; see the post format section in
the project [`README.md`](../README.md).
The Python standard library `tomllib` (3.11+) parses the file at load time; no
extra TOML dependency is required at runtime. `tomli-w` is bundled for the
admin to round-trip TOML frontmatter on post edits.
+139 -99
View File
@@ -2,95 +2,113 @@
> Status: production deployment on Linux with systemd + nginx.
volumen runs as a single Ruby process (Puma) bound to localhost, behind nginx
which terminates TLS and reverse-proxies the API and admin. The public website
(e.g. a Vue SPA) is served separately and consumes the JSON API.
volumen runs as a single Python process (Uvicorn via `fastapi[standard]`) bound
to localhost, behind nginx which terminates TLS and reverse-proxies the API and
admin. The public website (e.g. a Vue SPA) is served separately and consumes
the JSON API.
The CLI binary is installed system-wide via `uv tool install` (or `pipx
install`); the *operational* bootstrap — config, data dirs, admin password,
optional systemd unit — is performed by the `volumen init` Python subcommand
that replaces the old `install.sh` bash script.
## Requirements
- **Ruby ≥ 3.3** with a C toolchain (Puma compiles a native extension).
- Fedora: `sudo dnf install ruby ruby-devel @development-tools openssl-devel`
- Debian/Ubuntu: `sudo apt install ruby-full build-essential libssl-dev`
- **nginx** and a TLS certificate (e.g. via certbot).
- A dedicated system user, e.g. `volumen`.
- **Python ≥ 3.14** (the bundled wheel ships its own interpreter when you use
`uv tool install` / `pipx install`; the system Python is only required if
you're building from source).
- Fedora: `sudo dnf install python3.14`
- Debian/Ubuntu: `sudo apt install python3.14 python3-venv python3-pip`
- **[`uv`](https://docs.astral.sh/uv/)** (recommended) — install with
`curl -LsSf https://astral.sh/uv/install.sh | sh`. Alternatively,
[`pipx`](https://pipx.pypa.io/) ships in most distro repos.
- **nginx** and a TLS certificate (e.g. via certbot) for the public site.
- A dedicated system user, `volumen`, created automatically by
`volumen init` when run as root.
## Layout
## Install the CLI
```bash
# Pick one:
uv tool install volumen
# or
pipx install volumen
# Both put a self-contained `volumen` binary on PATH. Verify:
volumen version
```
For platforms where the wheel is not available (or for development), build it
from a source checkout:
```bash
git clone https://sourcedock.dev/petrbalvin/volumen.git
cd volumen
uv tool install .
```
## Bootstrap the deployment
### System install (root, systemd)
```bash
sudo volumen init
sudo systemctl status volumen
```
`volumen init` walks through:
1. Whether `config_path` already exists — if so, leaves it untouched unless
`--force` is passed (and even then it makes a timestamped `.bak.<ts>.toml`
first).
2. Creates `/etc/volumen/`, `/var/lib/volumen/posts` (and a `media/`
subdirectory), `/var/lib/volumen/users.toml`.
3. Creates the system user `volumen` if missing (`useradd --system …`).
4. Prompts for an admin password (`getpass`), hashes it via scrypt, writes the
`password_hash` into the rendered config.
5. Generates a fresh 64-byte session key (`secrets.token_hex(64)`) and writes
it into `[admin].session_key`.
6. Forces production-style settings: `host = "::1"`, `env = "production"`,
`trust_proxy = true`, `cookie_secure = true`.
7. With `--systemd` (default under root), writes
`/etc/systemd/system/volumen.service` and runs
`systemctl daemon-reload && systemctl enable --now volumen`.
Pass `--no-enable` to install the unit without starting it; pass `--force` to
back up and overwrite an existing config. The full flag list lives in
`volumen init --help`.
### Per-user install (no root)
```bash
volumen init --local
```
Per-user paths: `~/.config/volumen/config.toml`,
`~/.local/share/volumen/posts`, `~/.local/share/volumen/users.toml`. No
system user is created and no systemd unit is installed. `--systemd` is
rejected in this mode.
## Configuration locations
| Path | Purpose |
|------|---------|
| `/opt/volumen` | source checkout (or installed gem) |
| `/etc/volumen/config.toml` | configuration (auto-generated on first run) |
| `/etc/volumen/config.toml` | configuration (rendered by `volumen init` on first run) |
| `/var/lib/volumen/posts` | Markdown posts |
| `/var/lib/volumen/users.toml` | admin users (created from Settings) |
| `/var/lib/volumen/posts/media` | uploaded images |
| `/var/lib/volumen/users.toml` | admin users (created from Settings) |
| `/etc/systemd/system/volumen.service` | systemd unit (only when `--systemd`) |
| `~/.config/volumen/config.toml` | per-user config (`--local`) |
| `~/.local/share/volumen/posts` | per-user data dir (`--local`) |
| `~/.local/share/volumen/users.toml` | per-user users file (`--local`) |
```bash
sudo useradd --system --home /var/lib/volumen --shell /usr/sbin/nologin volumen
sudo mkdir -p /etc/volumen /var/lib/volumen/posts
sudo chown -R volumen:volumen /var/lib/volumen
```
## Install
### Quick install (Linux + systemd)
From a cloned repository, run the installer as root. It installs the Ruby
toolchain from the distro repositories on **Fedora** and **openEuler** (prints
guidance on other distros), creates the `volumen` user and directories, runs
`bundle install`, generates `config.toml`, and installs and enables the systemd
unit:
```bash
sudo git clone https://codeberg.org/petrbalvin/volumen.git /opt/volumen
cd /opt/volumen
sudo ./install.sh
```
The service is enabled but not started — finish by setting the admin password
(printed steps), then `sudo systemctl start volumen`. The manual steps below
describe what the script does.
### Manual install
From a source checkout (reproducible, no publishing needed):
```bash
sudo git clone https://codeberg.org/petrbalvin/volumen.git /opt/volumen
cd /opt/volumen
sudo bundle install --deployment
```
Alternatively build and install the gem (`just build` produces `pkg/volumen.gem`,
then `gem install pkg/volumen.gem` puts `volumen` on the PATH).
## First run and the admin password
The first `serve` writes a commented `config.toml` if it is missing:
```bash
sudo -u volumen bundle exec exe/volumen serve \
--config /etc/volumen/config.toml \
--content /var/lib/volumen/posts
# → "volumen: wrote a default config to /etc/volumen/config.toml; review it and restart."
```
Generate a password hash and a stable session secret, then edit the config:
```bash
bundle exec exe/volumen hash-password # → scrypt$...
openssl rand -hex 64 # → session_key
sudo -u volumen $EDITOR /etc/volumen/config.toml
```
Set `host = "127.0.0.1"`, your `site.base_url`, the `admin.password_hash`, and a
`admin.session_key` (so sessions survive restarts). You can now sign in as user
`admin`; from the **Settings** page you can add more users (roles `admin` /
`author`) and change passwords. From then on `users.toml` is the source of
truth.
The config and users files are owned by `volumen:volumen` and `chmod 600` after
a system install — they may carry password hashes, so never make them
world-readable.
## systemd
`/etc/systemd/system/volumen.service`:
`/etc/systemd/system/volumen.service` (rendered by `volumen init --systemd`):
```ini
[Unit]
@@ -98,10 +116,12 @@ Description=volumen blog engine
After=network.target
[Service]
Type=simple
User=volumen
Group=volumen
WorkingDirectory=/opt/volumen
ExecStart=/usr/bin/bundle exec exe/volumen serve --config /etc/volumen/config.toml --content /var/lib/volumen/posts
WorkingDirectory=/etc/volumen
Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/local/bin/volumen serve --config /etc/volumen/config.toml --content /var/lib/volumen/posts
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
@@ -114,26 +134,34 @@ PrivateTmp=true
WantedBy=multi-user.target
```
Hardening notes — `NoNewPrivileges`, `ProtectSystem=strict` (the entire
filesystem is read-only except for `ReadWritePaths`), `ProtectHome`
(`/home`, `/root`, `/run/user` are inaccessible), `PrivateTmp` (private
`/tmp`), and a two-second restart delay on failure.
Inspect health with:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now volumen
sudo systemctl status volumen
journalctl -u volumen -f
volumen status # config / data dir / unit / service state
volumen status --json # machine-readable
```
## FreeBSD (rc.d)
volumen runs on FreeBSD too — it is plain CRuby plus gems; only Puma (and its
`nio4r` dependency) build a native extension, which compiles with the base
clang toolchain.
volumen runs on FreeBSD too — it is plain CPython with no compiled extensions.
```sh
pkg install ruby
# then install volumen as above (git clone + bundle install)
pkg install python3
# install uv (https://docs.astral.sh/uv/getting-started/installation/)
uv tool install volumen
sudo volumen init --local # no systemd on FreeBSD
```
Conventional FreeBSD paths: config in `/usr/local/etc/volumen/`, data in
`/var/db/volumen/`. Service script `/usr/local/etc/rc.d/volumen`:
`/var/db/volumen/`. Drop a tiny `rc.d` script in `/usr/local/etc/rc.d/volumen`
that wraps the `volumen` console script:
```sh
#!/bin/sh
@@ -150,14 +178,13 @@ load_rc_config $name
: ${volumen_enable:="NO"}
: ${volumen_user:="volumen"}
: ${volumen_dir:="/usr/local/volumen"}
: ${volumen_config:="/usr/local/etc/volumen/config.toml"}
: ${volumen_content:="/var/db/volumen/posts"}
pidfile="/var/run/${name}.pid"
command="/usr/sbin/daemon"
command_args="-f -r -P ${pidfile} -u ${volumen_user} \
/bin/sh -c 'cd ${volumen_dir} && exec bundle exec exe/volumen serve --config ${volumen_config} --content ${volumen_content}'"
/bin/sh -c 'exec volumen serve --config ${volumen_config} --content ${volumen_content}'"
run_rc_command "$1"
```
@@ -165,6 +192,7 @@ run_rc_command "$1"
Enable and start:
```sh
chmod +x /usr/local/etc/rc.d/volumen
sysrc volumen_enable=YES
service volumen start
```
@@ -266,22 +294,34 @@ right block. Apply with `sudo nginx -t && sudo systemctl reload nginx`.
## Updating
```bash
cd /opt/volumen
sudo git pull
sudo bundle install --deployment
uv tool upgrade volumen
sudo systemctl restart volumen
volumen status # confirm: config_exists, password_hash_set, session_key_ok, …
```
The CLI binary is replaced in place by `uv tool upgrade`; the systemd unit's
`ExecStart` already points at the on-PATH `volumen` console script, so a
restart is all that's needed. The persistent state (`config.toml`,
`users.toml`, posts) is untouched.
If you used the legacy `install.sh` workflow (no longer documented, the script
was removed), you can migrate forward by deleting `/etc/systemd/system/volumen.service`
+ `/opt/volumen` and then running `uv tool install volumen && sudo volumen init`.
## Security notes
- Bind to `127.0.0.1`; only nginx is public. TLS is terminated by nginx.
- Bind to `::1` (or `127.0.0.1`); only nginx is public. TLS is terminated by
nginx.
- Session cookies are `HttpOnly` and `SameSite=Strict`, and gain the `Secure`
attribute automatically on HTTPS requests (detected via `X-Forwarded-Proto`,
which the nginx snippet above sets) — so the cookie never travels over plain
HTTP in production, while local HTTP testing still works. Combined with
per-form CSRF tokens this protects the admin. Set a stable `admin.session_key`
so sessions survive restarts.
which the nginx snippet above sets, and enabled by
`[server].cookie_secure = true`, set automatically by `volumen init
--systemd`) — so the cookie never travels over plain HTTP in production,
while local HTTP testing still works. Combined with per-form CSRF tokens
this protects the admin. A stable `[admin].session_key` (also written by
`volumen init`) lets sessions survive restarts.
- Keep `config.toml` and `users.toml` readable only by the `volumen` user
(`chmod 600`). Both may contain password hashes.
- `volumen hash-password` reads the password without echo; never pass passwords
on the command line.
(`chmod 600`). Both may contain password hashes. The installer enforces
this automatically.
- Use `volumen hash-password` to rotate; it reads the password without echo.
Never pass passwords on the command line.
+44 -34
View File
@@ -2,7 +2,7 @@
> Status: threat model and security posture for the engine as implemented.
volumen is a small, file-based blog engine: a single Ruby process exposing a
volumen is a small, file-based blog engine: a single Python process exposing a
read-only public JSON API and a session-authenticated admin, behind nginx. This
document describes the trust model, the controls in place, and the known
limitations.
@@ -13,33 +13,37 @@ than opening a public issue.
## Trust model
- **Content authors are trusted.** Posts are written by authenticated admin
users. kramdown renders Markdown and **passes raw HTML through** on purpose,
so an author can embed HTML. The stored/served `html` is therefore only as
safe as the people who can log in. If you need to accept posts from untrusted
authors, add an HTML sanitiser before serving.
users. python-markdown renders Markdown with a few pymdown-extensions
extensions. The engine does **not** strip raw HTML from post bodies by
default (authors are the trusted admin); if you need to accept posts from
untrusted authors, add a sanitiser (e.g. `bleach`) before serving.
- **The public API is read-only.** It exposes published (non-draft) posts, tags,
a feed and a sitemap. No public endpoint mutates state.
## Authentication
- Passwords are hashed with **scrypt** via `OpenSSL::KDF` (`N=16384`, `r=8`,
- Passwords are hashed with **scrypt** via `hashlib.scrypt` (`n=16384`, `r=8`,
`p=1`, 32-byte key, 16-byte random salt). Verification uses
`OpenSSL.fixed_length_secure_compare` (constant-time).
`secrets.compare_digest` (constant-time) on the derived bytes.
- Login takes a **username + password**. The error message is intentionally
generic ("Invalid username or password.") to avoid user enumeration.
- Sessions use signed cookies (`Rack::Session::Cookie`) marked **`HttpOnly`**
and **`SameSite=Strict`**. The **`Secure`** attribute is added automatically on
HTTPS requests (detected via `request.ssl?` / `X-Forwarded-Proto`), so the
cookie never travels over plain HTTP in production. The signing secret comes
from `admin.session_key`; if it is shorter than 64 bytes a random secret is
generated per start (sessions then reset on restart, so set a stable key in
production).
- Sessions use **signed cookies** via Starlette's `SessionMiddleware`
(`itsdangerous` TimestampSigner under the hood), marked **`HttpOnly`** and
**`SameSite=Strict`**. The **`Secure`** attribute is added by
`SecureCookieMiddleware` when `X-Forwarded-Proto: https` is observed (only
honoured when `[server].trust_proxy = true`) so the cookie never travels
over plain HTTP in production while local HTTP testing still works. The
signing secret comes from `admin.session_key`; if it is shorter than 64
bytes a random secret is generated per start (sessions then reset on
restart, so set a stable key in production).
- A minimum password length of `[admin].min_password_length` (default `10`,
must be `>= 8`) is enforced on the Settings page password-change form.
## Authorization (roles)
- Two roles: **admin** (full access, manages users) and **author** (posts and
own account only).
- User management (`add`/`delete`/role change) is gated by `require_admin!`
- User management (`add`/`delete`/role change) is gated by `require_admin`
**on the server**, not merely hidden in the UI.
- Safeguards prevent lockout: the **last admin** cannot be deleted or demoted,
and a user cannot delete their own account or change their own role.
@@ -47,13 +51,14 @@ than opening a public issue.
## CSRF
- Every state-changing form (`POST`) carries a per-session token
(`SecureRandom.hex(32)`), validated with `Rack::Utils.secure_compare`.
(`secrets.token_hex(32)`), validated with `secrets.compare_digest`.
Requests without a valid token get `403`.
- `SameSite=Strict` cookies provide a second, independent layer.
## Content Security Policy (admin)
All `/admin/*` responses send a strict **Content-Security-Policy** header:
All `/admin/*` responses send a strict **Content-Security-Policy** header
(via `CSPMiddleware`):
```
default-src 'self';
@@ -66,8 +71,8 @@ form-action 'self';
frame-ancestors 'none'
```
The inline allowances are required by the server-rendered admin (ERB templates
emit small inline `<script>` blocks and inline `style=""` attributes);
The inline allowances are required by the server-rendered admin (Jinja2
templates emit small inline `<script>` blocks and inline `style=""` attributes);
`frame-ancestors 'none'` prevents clickjacking via iframe embedding. The public
JSON API does not set a CSP — it returns no HTML, so none is needed.
@@ -76,20 +81,19 @@ JSON API does not set a CSP — it returns no HTML, so none is needed.
- The public read API sends `Access-Control-Allow-Origin: *` (read-only, no
credentials) so any front-end may consume it. The admin sends **no** CORS
headers and is same-origin only.
- `Rack::Protection::HostAuthorization` is disabled (`permitted_hosts: []`)
because volumen is intended to run behind nginx, which controls the `Host`
header. Do not expose the Puma port directly to the internet.
- volumen is intended to run behind nginx, which controls the `Host` header.
Do not expose the Uvicorn port directly to the internet.
## File uploads and path traversal
- Uploaded media is stored under `content_dir/media` with a **sanitised,
randomised** filename (`<hex>-<slug>.<ext>`), so uploads cannot overwrite
each other or escape the directory.
- `GET /media/*` resolves names with `File.basename` and an explicit
- `GET /media/*` resolves names with `Path(...).name` and an explicit
containment check, so `../` traversal cannot read files outside the media
directory.
- Uploads are **rejected (HTTP 413)** when the file exceeds **10 MB**
(`MAX_UPLOAD_BYTES`).
- Uploads are **rejected (HTTP 413)** when the file exceeds the
`[admin].max_upload_bytes` ceiling (default **10 MB**, `10485760` bytes).
- Upload MIME type is **whitelisted** to `image/webp` and `image/avif`
(`ALLOWED_UPLOAD_TYPES`); anything else returns **HTTP 415** with a
conversion hint pointing at `cwebp` / `avifenc`. This matches the
@@ -107,21 +111,27 @@ JSON API does not set a CSP — it returns no HTML, so none is needed.
## Transport
- Bind to `127.0.0.1` and terminate TLS at nginx. The browser ↔ nginx hop is
HTTPS; the nginx ↔ Puma hop is local.
- Bind to `::1` (loopback) and terminate TLS at nginx. The browser ↔ nginx hop
is HTTPS; the nginx ↔ Uvicorn hop is local.
## Dependencies
A small, audited set: `sinatra`, `kramdown` (+ `kramdown-parser-gfm`),
`toml-rb`, `puma`, `rackup`. Cryptography uses the Ruby standard library
(`openssl`, `securerandom`).
A small, audited set declared in `pyproject.toml`:
- Runtime: `fastapi[standard]` (FastAPI, Uvicorn, itsdangerous, Jinja2 transitively),
`python-markdown`, `pymdown-extensions`, `tomli-w`, `python-multipart`.
(`itsdangerous`, `Jinja2`, and `python-multipart` are pulled in directly
too — see `pyproject.toml`.)
- Crypto: Python standard library (`hashlib`, `secrets`, `hmac`).
- Dev: `pytest`, `httpx`, `ruff`.
## Known limitations / non-goals
- **No rate limiting or lockout** on `/admin/login` at the application layer
(the engine does have an in-memory rate limiter, but nginx is the durable
line of defence — see `docs/deployment.md`). Put nginx `limit_req` in
front of it, or use fail2ban, to slow brute-force attempts.
beyond an in-memory per-IP sliding window (`MAX_LOGIN_ATTEMPTS = 10` per 60s
in `__init__.py`). nginx is the durable line of defence — see
`docs/deployment.md`. Put nginx `limit_req` in front of `/admin/login`, or use
fail2ban, to slow brute-force attempts.
- **No 2FA** and **no audit log**.
- **Raw HTML in posts is trusted** (see the trust model). There is no built-in
HTML sanitiser.
HTML sanitiser.
+2
View File
@@ -1,3 +1,5 @@
#!/usr/bin/env python3
"""Volumen — a small, file-based Markdown blog engine."""
from volumen.cli import main
main()
-178
View File
@@ -1,178 +0,0 @@
#!/usr/bin/env bash
#
# volumen one-shot installer for Linux + systemd.
#
# Run from a cloned repository as root:
# sudo ./install.sh
#
# The Ruby toolchain is installed automatically from the distro repositories on
# Fedora and openEuler; on other distributions the script prints guidance and
# expects Ruby >= 3.3 with a C toolchain to be present already.
#
# FreeBSD uses rc.d instead of systemd — see docs/deployment.md.
set -euo pipefail
VOLUMEN_USER="volumen"
CONFIG_DIR="/etc/volumen"
CONFIG_FILE="${CONFIG_DIR}/config.toml"
DATA_DIR="/var/lib/volumen"
CONTENT_DIR="${DATA_DIR}/posts"
USERS_FILE="${DATA_DIR}/users.toml"
SERVICE_FILE="/etc/systemd/system/volumen.service"
MIN_RUBY="3.3"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="${SCRIPT_DIR}"
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mWARN:\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
require_root() {
[ "$(id -u)" -eq 0 ] || die "Run as root: sudo ./install.sh"
}
detect_distro() {
[ -r /etc/os-release ] || { printf 'unknown'; return; }
# shellcheck disable=SC1091
. /etc/os-release
printf '%s' "$(printf '%s' "${ID:-unknown}" | tr '[:upper:]' '[:lower:]')"
}
install_toolchain() {
case "$1" in
fedora)
log "Installing Ruby toolchain via dnf (Fedora)…"
dnf install -y ruby ruby-devel gcc gcc-c++ make redhat-rpm-config openssl-devel
;;
openeuler)
log "Installing Ruby toolchain via dnf (openEuler)…"
dnf install -y ruby ruby-devel gcc gcc-c++ make openssl-devel
;;
*)
warn "Automatic toolchain install is wired only for Fedora and openEuler."
cat >&2 <<'EOF'
Install Ruby >= 3.3 and a C toolchain manually, then re-run this script:
Debian/Ubuntu: apt install ruby-full build-essential libssl-dev
Arch: pacman -S ruby base-devel openssl
openSUSE: zypper install ruby ruby-devel gcc make libopenssl-devel
EOF
;;
esac
}
check_ruby() {
command -v ruby >/dev/null 2>&1 || die "Ruby not found; install Ruby >= ${MIN_RUBY} and re-run."
local version
version="$(ruby -e 'print RUBY_VERSION')"
if ! MIN="$MIN_RUBY" ruby -e 'exit(Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(ENV["MIN"]))'; then
die "Ruby ${version} is too old; volumen needs >= ${MIN_RUBY}."
fi
log "Ruby ${version} OK."
}
ensure_bundler() {
if ! command -v bundle >/dev/null 2>&1; then
log "Installing bundler…"
gem install --no-document bundler
fi
}
create_user() {
if id "$VOLUMEN_USER" >/dev/null 2>&1; then
log "User ${VOLUMEN_USER} already exists."
else
log "Creating system user ${VOLUMEN_USER}"
useradd --system --home "$DATA_DIR" --shell /usr/sbin/nologin "$VOLUMEN_USER"
fi
}
create_dirs() {
log "Creating ${CONFIG_DIR} and ${CONTENT_DIR}"
mkdir -p "$CONFIG_DIR" "$CONTENT_DIR"
chown -R "$VOLUMEN_USER:$VOLUMEN_USER" "$DATA_DIR"
}
install_gems() {
log "Installing gem dependencies…"
( cd "$APP_DIR" && bundle install )
}
generate_config() {
if [ -f "$CONFIG_FILE" ]; then
log "Config ${CONFIG_FILE} already exists; leaving it untouched."
return
fi
log "Generating ${CONFIG_FILE}"
( cd "$APP_DIR" && bundle exec ruby -Ilib -rvolumen/config \
-e 'Volumen::Config.generate_default(ARGV[0])' "$CONFIG_FILE" )
sed -i 's/^host = .*/host = "127.0.0.1"/' "$CONFIG_FILE"
sed -i "s#^content_dir = .*#content_dir = \"${CONTENT_DIR}\"#" "$CONFIG_FILE"
sed -i "s#^users_file = .*#users_file = \"${USERS_FILE}\"#" "$CONFIG_FILE"
chown "$VOLUMEN_USER:$VOLUMEN_USER" "$CONFIG_FILE"
chmod 600 "$CONFIG_FILE"
}
install_service() {
log "Installing systemd unit ${SERVICE_FILE}"
cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=volumen blog engine
After=network.target
[Service]
User=${VOLUMEN_USER}
Group=${VOLUMEN_USER}
WorkingDirectory=${APP_DIR}
ExecStart=$(command -v bundle) exec exe/volumen serve --config ${CONFIG_FILE} --content ${CONTENT_DIR}
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=${DATA_DIR}
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable volumen
}
final_notes() {
cat <<EOF
volumen is installed and the service is enabled (not started yet).
Next steps:
1. Create the first admin password (you will log in as user "admin"):
cd ${APP_DIR}
sudo -u ${VOLUMEN_USER} $(command -v bundle) exec exe/volumen hash-password
Paste the printed hash into ${CONFIG_FILE} under [admin] password_hash.
2. (Recommended) set a stable admin.session_key in the config:
openssl rand -hex 64
3. Start the service:
sudo systemctl start volumen
4. Put nginx in front of 127.0.0.1:9090 — see docs/deployment.md.
EOF
}
main() {
require_root
local distro
distro="$(detect_distro)"
log "Detected distribution: ${distro}"
install_toolchain "$distro"
check_ruby
ensure_bundler
create_user
create_dirs
install_gems
generate_config
install_service
final_notes
}
main "$@"
+10 -5
View File
@@ -11,15 +11,20 @@ dev:
uv run volumen serve --config ./config.toml --content ./posts
build:
uv run ruff check src/ tests/
uv run ruff format --check src/ tests/
uv sync --frozen
just fmt-check
just test
test:
uv run pytest
fmt:
uv run ruff format src/ tests/
uv run ruff check --fix src/ tests/
uv run ruff format src/ tests/ exe/
uv run ruff check --fix src/ tests/ exe/
fmt-check:
uv run ruff format --check src/ tests/ exe/
uv run ruff check src/ tests/ exe/
uninstall:
rm -rf dist/ __pycache__ .pytest_cache .ruff_cache
rm -rf dist/ __pycache__ .pytest_cache .ruff_cache
+51 -4
View File
@@ -6,14 +6,34 @@ build-backend = "setuptools.build_meta"
name = "volumen"
version = "0.4.0"
description = "A small, file-based Markdown blog engine with a built-in admin."
requires-python = ">=3.12"
readme = "README.md"
keywords = ["blog", "markdown", "fastapi", "cms", "static-blog", "toml", "rss", "json-feed", "engine"]
requires-python = ">=3.14"
license = {text = "MIT"}
authors = [{name = "Petr Balvín", email = "petrbalvin@yandex.com"}]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Web Environment",
"Framework :: FastAPI",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: MIT License",
"Operating System :: POSIX :: Linux",
"Operating System :: POSIX :: BSD",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Text Processing :: Markup :: Markdown",
"Typing :: Typed",
]
dependencies = [
"fastapi[standard]>=0.115",
"itsdangerous>=2.2",
"jinja2>=3.1",
"markdown>=3.7",
"nh3>=0.2",
"pymdown-extensions>=10.14",
"tomli-w>=1.2",
"python-multipart>=0.0.20",
@@ -22,12 +42,26 @@ dependencies = [
[project.scripts]
volumen = "volumen.cli:main"
[project.optional-dependencies]
dev = ["pytest>=8", "httpx>=0.28", "ruff>=0.9"]
[project.urls]
Homepage = "https://sourcedock.dev/petrbalvin/volumen"
Repository = "https://sourcedock.dev/petrbalvin/volumen"
Issues = "https://sourcedock.dev/petrbalvin/volumen/issues"
Changelog = "https://sourcedock.dev/petrbalvin/volumen/blob/main/CHANGELOG.md"
Documentation = "https://sourcedock.dev/petrbalvin/volumen/blob/main/docs"
[dependency-groups]
dev = [
"pytest>=8",
"pytest-cov>=5",
"httpx2>=2.7",
"ruff>=0.9",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.ruff]
line-length = 100
indent-width = 4
@@ -39,8 +73,21 @@ quote-style = "double"
select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "C4"]
[tool.ruff.lint.per-file-ignores]
"src/volumen/admin_routes.py" = ["B008"]
"src/volumen/admin_auth_routes.py" = ["B008"]
"src/volumen/admin_media_routes.py" = ["B008"]
"src/volumen/admin_post_routes.py" = ["B008"]
"src/volumen/admin_settings_routes.py" = ["B008"]
"src/volumen/api_routes.py" = ["B008"]
[tool.coverage.run]
branch = true
source = ["volumen"]
[tool.coverage.report]
show_missing = true
skip_covered = false
fail_under = 80
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--cov=volumen --cov-report=term-missing --cov-fail-under=80"
+54 -13
View File
@@ -1,45 +1,86 @@
from __future__ import annotations
import logging
import threading
import time
from collections import OrderedDict
logger = logging.getLogger(__name__)
VERSION = "0.4.0"
# ``volumen.version.VERSION`` is authoritative; re-export here for
# backwards compatibility with ``from volumen import VERSION``.
from .version import VERSION # noqa: E402,F401
CLEANUP_INTERVAL: int = 300
_login_attempts: dict[str, list[float]] = {}
# Track login attempts with an LRU-bounded OrderedDict so that the
# dictionary cannot grow unbounded under sustained probing. Keys are
# client IP addresses; values are monotonic timestamps of recent
# attempts.
_MAX_LOGIN_TRACKED_IPS = 10_000
_login_attempts: OrderedDict[str, list[float]] = OrderedDict()
_last_cleanup: float | None = None
LOGIN_WINDOW: float = 60.0
MAX_LOGIN_ATTEMPTS: int = 10
_login_lock = threading.Lock()
def login_attempts() -> dict[str, list[float]]:
def login_attempts() -> OrderedDict[str, list[float]]:
"""Return the global login-attempts tracking dict."""
return _login_attempts
def reset_login_attempts() -> None:
"""Clear all tracked login attempts (useful in tests)."""
global _login_attempts, _last_cleanup
_login_attempts = {}
_last_cleanup = None
with _login_lock:
global _login_attempts, _last_cleanup
_login_attempts = OrderedDict()
_last_cleanup = None
def sweep_login_attempts(window: float = LOGIN_WINDOW) -> None:
"""Remove login attempts older than `window` seconds.
"""Remove login attempts older than ``window`` seconds.
Full sweeps only run every CLEANUP_INTERVAL seconds to avoid
needless work on every request.
Full sweeps only run every ``CLEANUP_INTERVAL`` seconds to avoid
needless work on every request. The dict is bounded by
``_MAX_LOGIN_TRACKED_IPS`` (LRU eviction).
"""
global _last_cleanup
now = time.monotonic()
if _last_cleanup is not None and (now - _last_cleanup) < CLEANUP_INTERVAL:
return
cutoff = now - window
for ip in list(_login_attempts):
_login_attempts[ip] = [t for t in _login_attempts[ip] if t > cutoff]
if not _login_attempts[ip]:
del _login_attempts[ip]
for ip in list(_login_attempts.keys()):
attempts = [t for t in _login_attempts[ip] if t > cutoff]
if attempts:
_login_attempts[ip] = attempts
else:
_login_attempts.pop(ip, None)
# Bound the dict size with LRU eviction.
while len(_login_attempts) > _MAX_LOGIN_TRACKED_IPS:
_login_attempts.popitem(last=False)
_last_cleanup = now
def record_login_attempt(ip: str, now: float | None = None) -> int:
"""Record a login attempt for *ip* and return the current count in the window.
Uses ``time.monotonic()`` exclusively to avoid skew with wall-clock
changes.
"""
with _login_lock:
sweep_login_attempts()
if now is None:
now = time.monotonic()
cutoff = now - LOGIN_WINDOW
attempts = _login_attempts.get(ip)
if attempts is None:
attempts = []
_login_attempts[ip] = attempts
else:
attempts[:] = [t for t in attempts if t > cutoff]
# Mark as recently used.
_login_attempts.move_to_end(ip)
attempts.append(now)
return len(attempts)
+80
View File
@@ -0,0 +1,80 @@
"""Administrative authentication routes."""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool
from .app import templates
from .auth import admin_context, check_login_rate_limit, validate_csrf_form
from .config import Config
from .dependencies import get_config, get_store, get_users
from .store import Store
from .users import Users
ConfigDep = Annotated[Config, Depends(get_config)]
StoreDep = Annotated[Store, Depends(get_store)]
UsersDep = Annotated[Users, Depends(get_users)]
router = APIRouter(prefix="/admin")
@router.get("", include_in_schema=False)
async def admin_root() -> RedirectResponse:
"""Redirect bare ``/admin`` to ``/admin/``."""
return RedirectResponse(url="/admin/", status_code=303)
@router.get("/login")
async def admin_login_form(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
) -> HTMLResponse:
"""Show the login form, or redirect authenticated users."""
if request.session.get("user", ""):
return RedirectResponse(url="/admin/", status_code=303)
context = admin_context(request, config, store, users_obj)
return templates.TemplateResponse(request, "login.html.jinja", context)
@router.post("/login")
async def admin_login(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
) -> HTMLResponse:
"""Authenticate the user and redirect to the admin dashboard."""
check_login_rate_limit(request)
await validate_csrf_form(request)
form = await request.form()
username = str(form.get("username", ""))
password = str(form.get("password", ""))
authenticated_user = await run_in_threadpool(users_obj.authenticate, username, password)
if authenticated_user:
request.session["user"] = authenticated_user.username
return RedirectResponse(url="/admin/", status_code=303)
context = admin_context(
request,
config,
store,
users_obj,
error="Invalid username or password.",
)
return templates.TemplateResponse(request, "login.html.jinja", context, status_code=401)
@router.post("/logout")
async def admin_logout(request: Request) -> RedirectResponse:
"""Clear the session and redirect to the login page."""
await validate_csrf_form(request)
request.session.clear()
return RedirectResponse(url="/admin/login", status_code=303)
+24
View File
@@ -0,0 +1,24 @@
"""Uploaded media serving routes."""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from starlette.concurrency import run_in_threadpool
from .dependencies import get_store
from .store import Store
router = APIRouter(prefix="/media")
StoreDep = Annotated[Store, Depends(get_store)]
@router.get("/{path:path}")
async def serve_media(path: str, store: StoreDep) -> FileResponse:
"""Serve an uploaded media file through Store media resolution."""
file_path = await run_in_threadpool(store.media_file, path)
if file_path is None:
raise HTTPException(status_code=404)
return FileResponse(file_path)
+238
View File
@@ -0,0 +1,238 @@
"""Administrative post, preview, and upload routes."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool
from .app import templates
from .auth import admin_context, validate_csrf_form
from .config import Config
from .dependencies import get_config, get_store, get_users, require_login
from .payloads import creation_error, post_from_params
from .post import Post
from .store import Store
from .upload_validation import validate_upload
from .users import Users
ConfigDep = Annotated[Config, Depends(get_config)]
StoreDep = Annotated[Store, Depends(get_store)]
UsersDep = Annotated[Users, Depends(get_users)]
LoginDep = Annotated[str, Depends(require_login)]
router = APIRouter(prefix="/admin")
_PKG_DIR = Path(__file__).resolve().parent
_PUBLIC_DIR = _PKG_DIR / "web" / "static"
@router.get("/")
async def admin_index(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
_username: LoginDep,
) -> HTMLResponse:
"""Show the admin post list."""
posts = await run_in_threadpool(_all_posts_sorted, store)
context = admin_context(
request,
config,
store,
users_obj,
posts=posts,
crumbs=[("Posts", None)],
)
return templates.TemplateResponse(request, "list.html.jinja", context)
def _all_posts_sorted(store: Store) -> list[Post]:
return sorted(store.all(), key=lambda post: post.date_string or "", reverse=True)
@router.get("/posts/new")
async def admin_post_new(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
_username: LoginDep,
) -> HTMLResponse:
"""Show the new-post form."""
post = Post(metadata={"lang": config.language})
context = admin_context(
request,
config,
store,
users_obj,
mode="new",
post=post,
crumbs=[("Posts", "/admin/"), ("New post", None)],
)
return templates.TemplateResponse(request, "form.html.jinja", context)
@router.post("/posts")
async def admin_create_post(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
_username: LoginDep,
) -> HTMLResponse:
"""Create a new post from form data."""
await validate_csrf_form(request)
form = await request.form()
form_dict: dict[str, str] = {
key: value for key, value in form.items() if isinstance(value, str)
}
post = post_from_params(form_dict)
error = creation_error(post, store)
if error:
context = admin_context(
request,
config,
store,
users_obj,
mode="new",
post=post,
error=error,
)
return templates.TemplateResponse(request, "form.html.jinja", context, status_code=422)
await run_in_threadpool(store.save, post)
return RedirectResponse(url="/admin/", status_code=303)
@router.get("/posts/{slug}/edit")
async def admin_post_edit(
slug: str,
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
_username: LoginDep,
) -> HTMLResponse:
"""Show the edit-post form for the given slug."""
post = await run_in_threadpool(store.find, slug)
if post is None:
raise HTTPException(status_code=404, detail="Not found")
title = str(post.title or slug)
context = admin_context(
request,
config,
store,
users_obj,
mode="edit",
post=post,
crumbs=[("Posts", "/admin/"), (title, None)],
)
return templates.TemplateResponse(request, "form.html.jinja", context)
@router.post("/posts/{slug}")
async def admin_update_post(
slug: str,
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
_username: LoginDep,
) -> HTMLResponse:
"""Update an existing post."""
await validate_csrf_form(request)
existing = await run_in_threadpool(store.find, slug)
if existing is None:
raise HTTPException(status_code=404, detail="Not found")
form = await request.form()
form_dict: dict[str, str] = {
key: value for key, value in form.items() if isinstance(value, str)
}
post = post_from_params(form_dict, existing=existing)
error = creation_error(post, store, existing=existing)
if error:
context = admin_context(
request,
config,
store,
users_obj,
mode="edit",
post=post,
error=error,
)
return templates.TemplateResponse(request, "form.html.jinja", context, status_code=422)
await run_in_threadpool(store.save, post)
return RedirectResponse(url="/admin/", status_code=303)
@router.post("/posts/{slug}/delete")
async def admin_delete_post(
slug: str,
request: Request,
store: StoreDep,
_username: LoginDep,
) -> RedirectResponse:
"""Delete a post by slug."""
await validate_csrf_form(request)
await run_in_threadpool(store.delete, slug)
return RedirectResponse(url="/admin/", status_code=303)
@router.post("/preview")
async def admin_preview(
request: Request,
_username: LoginDep,
) -> HTMLResponse:
"""Render Markdown body to HTML and return it."""
await validate_csrf_form(request)
from .markdown import render
form = await request.form()
body = str(form.get("body", ""))
html = await run_in_threadpool(render, body)
return HTMLResponse(content=html)
@router.post("/uploads")
async def admin_uploads(
request: Request,
store: StoreDep,
_username: LoginDep,
file: UploadFile | None = None,
) -> JSONResponse:
"""Upload a media file and return its URL as JSON."""
await validate_csrf_form(request)
if file is None or file.filename is None:
raise HTTPException(status_code=400, detail=json.dumps({"error": "no_file"}))
bytes_data, _extension = await validate_upload(file)
url = await run_in_threadpool(store.store_upload, file.filename, file.file, bytes_data)
return JSONResponse(content={"url": url})
@router.get("/logo.svg", include_in_schema=False)
async def admin_logo() -> FileResponse:
"""Serve the volumen logo SVG."""
path = _PUBLIC_DIR / "volumen-logo.svg"
return FileResponse(path)
@router.get("/icon.svg", include_in_schema=False)
async def admin_icon() -> FileResponse:
"""Serve the volumen icon SVG."""
path = _PUBLIC_DIR / "volumen-icon.svg"
return FileResponse(path)
-719
View File
@@ -1,719 +0,0 @@
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from fastapi.responses import (
FileResponse,
HTMLResponse,
JSONResponse,
RedirectResponse,
)
from .post import Post
from .server import (
admin_context,
change_fediverse_creator,
change_name,
change_password,
change_photo,
change_role,
change_username,
check_login_rate_limit,
create_user,
creation_error,
fediverse_creator_error,
get_config,
get_store,
get_users,
post_from_params,
remove_photo,
remove_user,
require_admin,
require_login,
templates,
validate_csrf_form,
validate_upload,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin")
media_router = APIRouter(prefix="/media")
# Static logo / icon served from the web/public directory.
_PKG_DIR = Path(__file__).resolve().parent
_PUBLIC_DIR = _PKG_DIR / "web" / "static"
# ============================================================================
# Redirect /admin → /admin/
# ============================================================================
@router.get("", include_in_schema=False)
async def admin_root() -> RedirectResponse:
"""Redirect bare ``/admin`` to ``/admin/``."""
return RedirectResponse(url="/admin/", status_code=303)
# ============================================================================
# Post list (dashboard)
# ============================================================================
@router.get("/")
async def admin_index(
request: Request,
config: Any = Depends(get_config),
store: Any = Depends(get_store),
users_obj: Any = Depends(get_users),
_username: str = Depends(require_login),
) -> HTMLResponse:
"""Show the admin post list (requires login)."""
all_posts = sorted(store.all(), key=lambda p: p.date_string or "", reverse=True)
ctx = admin_context(
request,
config,
store,
users_obj,
posts=all_posts,
crumbs=[("Posts", None)],
)
return templates.TemplateResponse(request, "list.html.jinja", ctx)
# ============================================================================
# Login
# ============================================================================
@router.get("/login")
async def admin_login_form(
request: Request,
config: Any = Depends(get_config),
store: Any = Depends(get_store),
users_obj: Any = Depends(get_users),
) -> HTMLResponse:
"""Show the login form, or redirect to ``/admin/`` if already logged in."""
if request.session.get("user", ""):
return RedirectResponse(url="/admin/", status_code=303)
ctx = admin_context(request, config, store, users_obj)
return templates.TemplateResponse(request, "login.html.jinja", ctx)
@router.post("/login")
async def admin_login(
request: Request,
config: Any = Depends(get_config),
store: Any = Depends(get_store),
users_obj: Any = Depends(get_users),
) -> HTMLResponse:
"""Authenticate the user and redirect to the admin dashboard."""
check_login_rate_limit(request)
await validate_csrf_form(request)
form = await request.form()
username = str(form.get("username", ""))
password = str(form.get("password", ""))
authed_user = users_obj.authenticate(username, password)
if authed_user:
request.session["user"] = authed_user.username
return RedirectResponse(url="/admin/", status_code=303)
ctx = admin_context(
request,
config,
store,
users_obj,
error="Invalid username or password.",
)
return templates.TemplateResponse(request, "login.html.jinja", ctx, status_code=401)
# ============================================================================
# Logout
# ============================================================================
@router.post("/logout")
async def admin_logout(request: Request) -> RedirectResponse:
"""Clear the session and redirect to the login page."""
await validate_csrf_form(request)
request.session.clear()
return RedirectResponse(url="/admin/login", status_code=303)
# ============================================================================
# New post form
# ============================================================================
@router.get("/posts/new")
async def admin_post_new(
request: Request,
config: Any = Depends(get_config),
store: Any = Depends(get_store),
users_obj: Any = Depends(get_users),
_username: str = Depends(require_login),
) -> HTMLResponse:
"""Show the new-post form."""
post = Post(metadata={"lang": config.language})
ctx = admin_context(
request,
config,
store,
users_obj,
mode="new",
post=post,
crumbs=[("Posts", "/admin/"), ("New post", None)],
)
return templates.TemplateResponse(request, "form.html.jinja", ctx)
# ============================================================================
# Create post
# ============================================================================
@router.post("/posts")
async def admin_create_post(
request: Request,
config: Any = Depends(get_config),
store: Any = Depends(get_store),
users_obj: Any = Depends(get_users),
_username: str = Depends(require_login),
) -> HTMLResponse:
"""Create a new post from form data."""
await validate_csrf_form(request)
form = await request.form()
form_dict: dict[str, str] = {k: str(v) for k, v in form.items()}
post = post_from_params(form_dict)
error = creation_error(post, store)
if error:
ctx = admin_context(
request,
config,
store,
users_obj,
mode="new",
post=post,
error=error,
)
return templates.TemplateResponse(request, "form.html.jinja", ctx, status_code=422)
store.save(post)
return RedirectResponse(url="/admin/", status_code=303)
# ============================================================================
# Edit post form
# ============================================================================
@router.get("/posts/{slug}/edit")
async def admin_post_edit(
slug: str,
request: Request,
config: Any = Depends(get_config),
store: Any = Depends(get_store),
users_obj: Any = Depends(get_users),
_username: str = Depends(require_login),
) -> HTMLResponse:
"""Show the edit-post form for the given slug."""
post = store.find(slug)
if post is None:
raise HTTPException(status_code=404, detail="Not found")
title = str(post.title or slug)
ctx = admin_context(
request,
config,
store,
users_obj,
mode="edit",
post=post,
crumbs=[("Posts", "/admin/"), (title, None)],
)
return templates.TemplateResponse(request, "form.html.jinja", ctx)
# ============================================================================
# Update post
# ============================================================================
@router.post("/posts/{slug}")
async def admin_update_post(
slug: str,
request: Request,
config: Any = Depends(get_config),
store: Any = Depends(get_store),
users_obj: Any = Depends(get_users),
_username: str = Depends(require_login),
) -> HTMLResponse:
"""Update an existing post."""
await validate_csrf_form(request)
existing = store.find(slug)
if existing is None:
raise HTTPException(status_code=404, detail="Not found")
form = await request.form()
form_dict: dict[str, str] = {k: str(v) for k, v in form.items()}
post = post_from_params(form_dict, existing=existing)
error = fediverse_creator_error(post)
if error:
ctx = admin_context(
request,
config,
store,
users_obj,
mode="edit",
post=post,
error=error,
)
return templates.TemplateResponse(request, "form.html.jinja", ctx, status_code=422)
store.save(post)
return RedirectResponse(url="/admin/", status_code=303)
# ============================================================================
# Delete post
# ============================================================================
@router.post("/posts/{slug}/delete")
async def admin_delete_post(
slug: str,
request: Request,
store: Any = Depends(get_store),
_username: str = Depends(require_login),
) -> RedirectResponse:
"""Delete a post by slug."""
await validate_csrf_form(request)
store.delete(slug)
return RedirectResponse(url="/admin/", status_code=303)
# ============================================================================
# Markdown preview
# ============================================================================
@router.post("/preview")
async def admin_preview(
request: Request,
_username: str = Depends(require_login),
) -> HTMLResponse:
"""Render Markdown body to HTML and return it."""
await validate_csrf_form(request)
from .markdown import render
form = await request.form()
body = str(form.get("body", ""))
html = render(body)
return HTMLResponse(content=html)
# ============================================================================
# File upload (returns JSON URL)
# ============================================================================
@router.post("/uploads")
async def admin_uploads(
request: Request,
store: Any = Depends(get_store),
_username: str = Depends(require_login),
file: UploadFile | None = None,
) -> JSONResponse:
"""Upload a media file and return its URL as JSON."""
await validate_csrf_form(request)
if file is None or file.filename is None:
raise HTTPException(status_code=400, detail=json.dumps({"error": "no_file"}))
validate_upload(file)
url = store.store_upload(file.filename, file.file)
return JSONResponse(content=json.dumps({"url": url}))
# ============================================================================
# Logo / icon static files
# ============================================================================
@router.get("/logo.svg", include_in_schema=False)
async def admin_logo() -> FileResponse:
"""Serve the volumen logo SVG."""
path = _PUBLIC_DIR / "volumen-logo.svg"
return FileResponse(path)
@router.get("/icon.svg", include_in_schema=False)
async def admin_icon() -> FileResponse:
"""Serve the volumen icon SVG."""
path = _PUBLIC_DIR / "volumen-icon.svg"
return FileResponse(path)
# ============================================================================
# Settings page
# ============================================================================
@router.get("/settings")
async def admin_settings(
request: Request,
config: Any = Depends(get_config),
store: Any = Depends(get_store),
users_obj: Any = Depends(get_users),
_username: str = Depends(require_login),
) -> HTMLResponse:
"""Show the admin settings page."""
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_obj.all,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — password
# ============================================================================
@router.post("/settings/password")
async def admin_settings_password(
request: Request,
config: Any = Depends(get_config),
store: Any = Depends(get_store),
users_obj: Any = Depends(get_users),
current_user: str = Depends(require_login),
) -> HTMLResponse:
"""Handle password change."""
await validate_csrf_form(request)
form = await request.form()
error, notice = change_password(
users_obj,
current_user,
str(form.get("current_password", "")),
str(form.get("new_password", "")),
)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_obj.all,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — username
# ============================================================================
@router.post("/settings/username")
async def admin_settings_username(
request: Request,
config: Any = Depends(get_config),
store: Any = Depends(get_store),
users_obj: Any = Depends(get_users),
current_user: str = Depends(require_login),
) -> HTMLResponse:
"""Handle username change."""
await validate_csrf_form(request)
form = await request.form()
error, notice = change_username(
users_obj,
current_user,
str(form.get("username", "")),
request.session,
)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_obj.all,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — display name
# ============================================================================
@router.post("/settings/name")
async def admin_settings_name(
request: Request,
config: Any = Depends(get_config),
store: Any = Depends(get_store),
users_obj: Any = Depends(get_users),
current_user: str = Depends(require_login),
) -> HTMLResponse:
"""Handle display name change."""
await validate_csrf_form(request)
form = await request.form()
error, notice = change_name(users_obj, current_user, str(form.get("name", "")))
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_obj.all,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — fediverse handle
# ============================================================================
@router.post("/settings/fediverse")
async def admin_settings_fediverse(
request: Request,
config: Any = Depends(get_config),
store: Any = Depends(get_store),
users_obj: Any = Depends(get_users),
current_user: str = Depends(require_login),
) -> HTMLResponse:
"""Handle fediverse creator handle change."""
await validate_csrf_form(request)
form = await request.form()
error, notice = change_fediverse_creator(
users_obj,
current_user,
str(form.get("fediverse_creator", "")),
)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_obj.all,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — profile photo
# ============================================================================
@router.post("/settings/photo")
async def admin_settings_photo(
request: Request,
config: Any = Depends(get_config),
store: Any = Depends(get_store),
users_obj: Any = Depends(get_users),
current_user: str = Depends(require_login),
photo: UploadFile | None = None,
) -> HTMLResponse:
"""Upload a profile photo."""
await validate_csrf_form(request)
error: str | None = None
notice: str | None = None
if photo is None or photo.filename is None:
error = "No file selected."
else:
error, notice = change_photo(users_obj, store, current_user, photo)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_obj.all,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — remove photo
# ============================================================================
@router.post("/settings/photo/remove")
async def admin_settings_photo_remove(
request: Request,
config: Any = Depends(get_config),
store: Any = Depends(get_store),
users_obj: Any = Depends(get_users),
current_user: str = Depends(require_login),
) -> HTMLResponse:
"""Remove the current user's profile photo."""
await validate_csrf_form(request)
error, notice = remove_photo(users_obj, current_user)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_obj.all,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — create user (admin only)
# ============================================================================
@router.post("/settings/users")
async def admin_settings_users_create(
request: Request,
config: Any = Depends(get_config),
store: Any = Depends(get_store),
users_obj: Any = Depends(get_users),
_admin: str = Depends(require_admin),
) -> HTMLResponse:
"""Create a new user (admin only)."""
await validate_csrf_form(request)
form = await request.form()
error, notice = create_user(
users_obj,
str(form.get("username", "")),
str(form.get("password", "")),
str(form.get("role", "author")),
)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_obj.all,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — change role (admin only)
# ============================================================================
@router.post("/settings/users/{username}/role")
async def admin_settings_users_role(
username: str,
request: Request,
config: Any = Depends(get_config),
store: Any = Depends(get_store),
users_obj: Any = Depends(get_users),
current_user: str = Depends(require_admin),
) -> HTMLResponse:
"""Change a user's role (admin only)."""
await validate_csrf_form(request)
form = await request.form()
error, notice = change_role(
users_obj,
current_user,
username,
str(form.get("role", "author")),
)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_obj.all,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — delete user (admin only)
# ============================================================================
@router.post("/settings/users/{username}/delete")
async def admin_settings_users_delete(
username: str,
request: Request,
config: Any = Depends(get_config),
store: Any = Depends(get_store),
users_obj: Any = Depends(get_users),
current_user: str = Depends(require_admin),
) -> HTMLResponse:
"""Delete a user (admin only)."""
await validate_csrf_form(request)
error, notice = remove_user(users_obj, current_user, username)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_obj.all,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Media file serving (separate router, mounted at /media)
# ============================================================================
@media_router.get("/{path:path}")
async def serve_media(
path: str,
request: Request,
store: Any = Depends(get_store),
) -> FileResponse:
"""Serve an uploaded media file through the Store's media resolution."""
file_path = store.media_file(path)
if file_path is None:
raise HTTPException(status_code=404)
return FileResponse(file_path)
+415
View File
@@ -0,0 +1,415 @@
"""Administrative settings and user-management routes."""
from __future__ import annotations
import json
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse
from starlette.concurrency import run_in_threadpool
from .app import templates
from .auth import admin_context, validate_csrf_form
from .config import Config
from .dependencies import get_config, get_store, get_users, require_admin, require_login
from .store import Store
from .user_settings import (
change_fediverse_creator,
change_name,
change_password,
change_photo,
change_role,
change_username,
create_user,
remove_photo,
remove_user,
)
from .users import Users
ConfigDep = Annotated[Config, Depends(get_config)]
StoreDep = Annotated[Store, Depends(get_store)]
UsersDep = Annotated[Users, Depends(get_users)]
LoginDep = Annotated[str, Depends(require_login)]
AdminDep = Annotated[str, Depends(require_admin)]
router = APIRouter(prefix="/admin")
# ============================================================================
# Auth helpers
# ============================================================================
def _password_policy(config: Config) -> tuple[int, int | None]:
raw_min = config.admin.get("min_password_length", 0)
raw_max = config.admin.get("max_password_length", 0)
min_len = max(1, int(raw_min)) if raw_min else 8
max_len = int(raw_max) if raw_max and int(raw_max) > 0 else None
return min_len, max_len
# ============================================================================
# Settings page
# ============================================================================
@router.get("/settings")
async def admin_settings(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
_username: LoginDep,
) -> HTMLResponse:
"""Show the admin settings page."""
users_list = await run_in_threadpool(lambda: users_obj.all)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_list,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — password
# ============================================================================
@router.post("/settings/password")
async def admin_settings_password(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
current_user: LoginDep,
) -> HTMLResponse:
"""Handle password change."""
await validate_csrf_form(request)
min_len, max_len = _password_policy(config)
form = await request.form()
error, notice = await run_in_threadpool(
change_password,
users_obj,
current_user,
str(form.get("current_password", "")),
str(form.get("new_password", "")),
min_length=min_len,
max_length=max_len,
)
users_list = await run_in_threadpool(lambda: users_obj.all)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_list,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — username
# ============================================================================
@router.post("/settings/username")
async def admin_settings_username(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
current_user: LoginDep,
) -> HTMLResponse:
"""Handle username change."""
await validate_csrf_form(request)
form = await request.form()
error, notice = await run_in_threadpool(
change_username,
users_obj,
current_user,
str(form.get("username", "")),
request.session,
)
users_list = await run_in_threadpool(lambda: users_obj.all)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_list,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — display name
# ============================================================================
@router.post("/settings/name")
async def admin_settings_name(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
current_user: LoginDep,
) -> HTMLResponse:
"""Handle display name change."""
await validate_csrf_form(request)
form = await request.form()
error, notice = await run_in_threadpool(
change_name, users_obj, current_user, str(form.get("name", ""))
)
users_list = await run_in_threadpool(lambda: users_obj.all)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_list,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — fediverse handle
# ============================================================================
@router.post("/settings/fediverse")
async def admin_settings_fediverse(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
current_user: LoginDep,
) -> HTMLResponse:
"""Handle fediverse creator handle change."""
await validate_csrf_form(request)
form = await request.form()
error, notice = await run_in_threadpool(
change_fediverse_creator,
users_obj,
current_user,
str(form.get("fediverse_creator", "")),
)
users_list = await run_in_threadpool(lambda: users_obj.all)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_list,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — profile photo
# ============================================================================
@router.post("/settings/photo")
async def admin_settings_photo(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
current_user: LoginDep,
photo: UploadFile | None = None,
) -> HTMLResponse:
"""Upload a profile photo."""
await validate_csrf_form(request)
error: str | None = None
notice: str | None = None
if photo is None or photo.filename is None:
error = "No file selected."
else:
try:
error, notice = await change_photo(users_obj, store, current_user, photo)
except HTTPException as exc:
# Surface upload validation failures to the settings page.
try:
detail = json.loads(exc.detail) if isinstance(exc.detail, str) else {}
except json.JSONDecodeError, TypeError:
detail = {}
error = detail.get("message", "Upload failed.")
users_list = await run_in_threadpool(lambda: users_obj.all)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_list,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — remove photo
# ============================================================================
@router.post("/settings/photo/remove")
async def admin_settings_photo_remove(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
current_user: LoginDep,
) -> HTMLResponse:
"""Remove the current user's profile photo."""
await validate_csrf_form(request)
error, notice = await remove_photo(users_obj, current_user, store=store)
users_list = await run_in_threadpool(lambda: users_obj.all)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_list,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — create user (admin only)
# ============================================================================
@router.post("/settings/users")
async def admin_settings_users_create(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
_admin: AdminDep,
) -> HTMLResponse:
"""Create a new user (admin only)."""
await validate_csrf_form(request)
min_len, max_len = _password_policy(config)
form = await request.form()
error, notice = await run_in_threadpool(
create_user,
users_obj,
str(form.get("username", "")),
str(form.get("password", "")),
str(form.get("role", "author")),
min_length=min_len,
max_length=max_len,
)
users_list = await run_in_threadpool(lambda: users_obj.all)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_list,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — change role (admin only)
# ============================================================================
@router.post("/settings/users/{username}/role")
async def admin_settings_users_role(
username: str,
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
current_user: AdminDep,
) -> HTMLResponse:
"""Change a user's role (admin only)."""
await validate_csrf_form(request)
form = await request.form()
error, notice = await run_in_threadpool(
change_role,
users_obj,
current_user,
username,
str(form.get("role", "author")),
)
users_list = await run_in_threadpool(lambda: users_obj.all)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_list,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — delete user (admin only)
# ============================================================================
@router.post("/settings/users/{username}/delete")
async def admin_settings_users_delete(
username: str,
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
current_user: AdminDep,
) -> HTMLResponse:
"""Delete a user (admin only)."""
await validate_csrf_form(request)
error, notice = await remove_user(users_obj, current_user, username, store=store)
users_list = await run_in_threadpool(lambda: users_obj.all)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_list,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
+35 -38
View File
@@ -1,19 +1,18 @@
"""Public JSON API endpoints and feed endpoints."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse, PlainTextResponse, Response
from starlette.concurrency import run_in_threadpool
from .config import Config
from .dependencies import get_config, get_store
from .feed_helpers import render_json_feed, render_rss_feed, render_sitemap
from .server import (
get_config,
get_store,
posts_payload,
published_posts,
site_payload,
tag_counts,
)
from .payloads import posts_payload, published_posts, site_payload, tag_counts
from .store import Store
router = APIRouter(prefix="/api/volumen")
@@ -59,9 +58,10 @@ async def options_preflight() -> Response:
@router.get("/site")
async def api_site(config: Any = Depends(get_config)) -> JSONResponse:
async def api_site(config: Config = Depends(get_config)) -> JSONResponse:
"""Return site metadata (title, description, base_url, etc.)."""
return _cors_response(site_payload(config))
payload = await run_in_threadpool(site_payload, config)
return _cors_response(payload)
# ---------------------------------------------------------------------------
@@ -71,13 +71,12 @@ async def api_site(config: Any = Depends(get_config)) -> JSONResponse:
@router.get("/posts")
async def api_posts(
request: Request,
store: Any = Depends(get_store),
store: Store = Depends(get_store),
lang: str = Query(default=""),
tag: str = Query(default=""),
q: str = Query(default=""),
page: str = Query(default=""),
limit: str = Query(default=""),
page: int = Query(default=1, ge=1),
limit: int = Query(default=20, ge=1, le=100),
) -> JSONResponse:
"""Return a paginated, filtered list of published posts."""
params: dict[str, str] = {}
@@ -87,11 +86,8 @@ async def api_posts(
params["tag"] = tag
if q:
params["q"] = q
if page:
params["page"] = page
if limit:
params["limit"] = limit
return _cors_response(posts_payload(store, params))
payload = await run_in_threadpool(posts_payload, store, params, page, limit)
return _cors_response(payload)
# ---------------------------------------------------------------------------
@@ -102,17 +98,17 @@ async def api_posts(
@router.get("/posts/{slug}")
async def api_post(
slug: str,
request: Request,
store: Any = Depends(get_store),
store: Store = Depends(get_store),
lang: str = Query(default=""),
) -> JSONResponse:
"""Return a single published post by slug, optionally filtered by language."""
post = store.find(slug, lang=lang if lang else None)
post = await run_in_threadpool(store.find, slug, lang if lang else None)
if post is None:
raise HTTPException(status_code=404, detail={"error": "not_found"})
if post.draft:
raise HTTPException(status_code=404, detail={"error": "draft"})
return _cors_response(post.detail())
payload = await run_in_threadpool(post.detail)
return _cors_response(payload)
# ---------------------------------------------------------------------------
@@ -121,9 +117,10 @@ async def api_post(
@router.get("/tags")
async def api_tags(store: Any = Depends(get_store)) -> JSONResponse:
async def api_tags(store: Store = Depends(get_store)) -> JSONResponse:
"""Return a tag cloud with post counts."""
return _cors_response({"tags": tag_counts(store)})
payload = await run_in_threadpool(tag_counts, store)
return _cors_response({"tags": payload})
# ---------------------------------------------------------------------------
@@ -133,13 +130,13 @@ async def api_tags(store: Any = Depends(get_store)) -> JSONResponse:
@router.get("/feed.xml")
async def api_rss_feed(
config: Any = Depends(get_config),
store: Any = Depends(get_store),
config: Config = Depends(get_config),
store: Store = Depends(get_store),
) -> Response:
"""Return an RSS 2.0 XML feed of the 20 most recent posts."""
posts = published_posts(store)
posts = await run_in_threadpool(published_posts, store)
base = config.site.get("base_url", "").rstrip("/")
xml = render_rss_feed(posts, config.site, base)
xml = await run_in_threadpool(render_rss_feed, posts, config.site, base)
return _add_cors_headers(Response(content=xml, media_type="application/rss+xml"))
@@ -150,13 +147,13 @@ async def api_rss_feed(
@router.get("/feed.json")
async def api_json_feed(
config: Any = Depends(get_config),
store: Any = Depends(get_store),
config: Config = Depends(get_config),
store: Store = Depends(get_store),
) -> JSONResponse:
"""Return a JSON Feed 1.1 document for the 20 most recent posts."""
posts = published_posts(store)
posts = await run_in_threadpool(published_posts, store)
base = config.site.get("base_url", "").rstrip("/")
data = render_json_feed(posts, config.site, base)
data = await run_in_threadpool(render_json_feed, posts, config.site, base)
return _cors_response(data)
@@ -167,11 +164,11 @@ async def api_json_feed(
@router.get("/sitemap.xml")
async def api_sitemap(
config: Any = Depends(get_config),
store: Any = Depends(get_store),
config: Config = Depends(get_config),
store: Store = Depends(get_store),
) -> Response:
"""Return an XML sitemap for all published posts."""
posts = published_posts(store)
posts = await run_in_threadpool(published_posts, store)
base = config.site.get("base_url", "").rstrip("/")
xml = render_sitemap(posts, base)
xml = await run_in_threadpool(render_sitemap, posts, base)
return _add_cors_headers(Response(content=xml, media_type="application/xml"))
+176
View File
@@ -0,0 +1,176 @@
"""FastAPI application factory and middleware registration."""
from __future__ import annotations
import logging
import secrets
from pathlib import Path
from typing import TYPE_CHECKING, Any
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.sessions import SessionMiddleware
if TYPE_CHECKING:
from .config import Config
from .store import Store
logger = logging.getLogger(__name__)
_BASE_CSP_DIRECTIVES: tuple[str, ...] = (
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"object-src 'none'",
)
PERMISSIONS_POLICY: str = (
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()"
)
_PKG_DIR: Path = Path(__file__).resolve().parent
_TEMPLATE_DIR: Path = _PKG_DIR / "web" / "templates"
_STATIC_DIR: Path = _PKG_DIR / "web" / "static"
templates: Jinja2Templates = Jinja2Templates(directory=str(_TEMPLATE_DIR))
def create_app(config: Config, store: Store) -> FastAPI:
"""Build a fully-configured FastAPI app bound to *config* and *store*."""
config.validate()
app = FastAPI(title="volumen")
ttl = int(config.admin.get("session_ttl", 0))
if ttl <= 0:
raise RuntimeError("session_ttl must be a positive integer")
session_cookie_secure = bool(config.cookie_secure) or config.trust_proxy
app.add_middleware(
SessionMiddleware,
secret_key=session_secret(config),
https_only=session_cookie_secure,
same_site="strict",
session_cookie="volumen_session",
max_age=ttl,
)
app.add_middleware(
SecureCookieMiddleware,
trust_proxy=config.trust_proxy,
cookie_secure=session_cookie_secure,
)
app.add_middleware(
GlobalSecurityHeadersMiddleware,
trust_proxy=config.trust_proxy,
cookie_secure=session_cookie_secure,
)
app.state.config = config
app.state.store = store
from .users import Users
app.state.users = Users(
path=config.users_file,
bootstrap_hash=config.admin.get("password_hash", ""),
)
if _STATIC_DIR.is_dir():
app.mount("/admin/static", StaticFiles(directory=str(_STATIC_DIR)), name="admin_static")
from .admin_auth_routes import router as admin_auth_router
from .admin_media_routes import router as admin_media_router
from .admin_post_routes import router as admin_post_router
from .admin_settings_routes import router as admin_settings_router
from .api_routes import router as api_router
app.include_router(api_router)
app.include_router(admin_auth_router)
app.include_router(admin_post_router)
app.include_router(admin_settings_router)
app.include_router(admin_media_router)
return app
def session_secret(config: Config) -> str:
"""Derive the session-signing secret from configuration."""
key = str(config.admin.get("session_key", ""))
if key:
if len(key.encode()) < 64:
if config.is_production:
raise RuntimeError(
"[admin].session_key must be at least 64 bytes in production. "
"Generate one with: python -c 'import secrets; print(secrets.token_hex(32))'"
)
logger.warning(
"volumen: session_key is shorter than 64 bytes, falling back to random secret"
)
return secrets.token_hex(64)
return key
if config.is_production:
raise RuntimeError(
"[admin].session_key must be set in production. "
"Generate one with: python -c 'import secrets; print(secrets.token_hex(32))'"
)
logger.warning(
"volumen: session_key is empty; using an ephemeral random secret "
"(sessions will not survive restarts)"
)
return secrets.token_hex(64)
class SecureCookieMiddleware(BaseHTTPMiddleware):
"""Record whether the current request should mark cookies ``Secure``."""
def __init__(self, app: Any, trust_proxy: bool = False, cookie_secure: bool = False) -> None:
super().__init__(app)
self._trust_proxy = trust_proxy
self._cookie_secure = cookie_secure
async def dispatch(self, request: Request, call_next): # type: ignore[override]
if self._trust_proxy:
proto = (request.headers.get("X-Forwarded-Proto", "")).lower()
request.state.session_secure = proto == "https"
else:
request.state.session_secure = self._cookie_secure
return await call_next(request)
class GlobalSecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Set baseline security headers on every response."""
def __init__(self, app: Any, trust_proxy: bool = False, cookie_secure: bool = False) -> None:
super().__init__(app)
self._trust_proxy = trust_proxy
self._cookie_secure = cookie_secure
async def dispatch(self, request: Request, call_next): # type: ignore[override]
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Permissions-Policy", PERMISSIONS_POLICY)
# Always set base CSP
response.headers["Content-Security-Policy"] = "; ".join(_BASE_CSP_DIRECTIVES)
# For admin routes, augment with nonce-based script-src
if request.url.path.startswith("/admin"):
nonce = secrets.token_urlsafe(18)
request.state.csp_nonce = nonce
csp = "; ".join(_BASE_CSP_DIRECTIVES + (f"script-src 'self' 'nonce-{nonce}'",))
response.headers["Content-Security-Policy"] = csp
if self._cookie_secure:
response.headers.setdefault(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains",
)
return response
+159
View File
@@ -0,0 +1,159 @@
"""Authentication, CSRF, rate-limiting, and password-policy helpers."""
from __future__ import annotations
import secrets
import unicodedata
from typing import TYPE_CHECKING, Any
from fastapi import HTTPException, Request
from . import LOGIN_WINDOW, MAX_LOGIN_ATTEMPTS, record_login_attempt
if TYPE_CHECKING:
from .config import Config
from .store import Store
from .users import Users
COMMON_PASSWORDS: frozenset[str] = frozenset(
{
"password",
"password1",
"password123",
"123456",
"12345678",
"123456789",
"qwerty",
"qwerty123",
"letmein",
"iloveyou",
"admin",
"admin123",
"welcome",
"welcome1",
"monkey",
"dragon",
"football",
"baseball",
"sunshine",
"princess",
"abc123",
"111111",
"123123",
"1q2w3e4r",
"passw0rd",
"trustno1",
"changeme",
"secret",
"secret123",
"test",
"test123",
"guest",
"master",
"000000",
"696969",
"qwertyuiop",
"superman",
"batman",
"jordan",
"harley",
"hunter",
"hunter2",
"shadow",
"michael",
"jennifer",
"abcdef",
"abcdefg",
}
)
def csrf_token(request: Request) -> str:
"""Return (and lazily create) the CSRF token stored in the session."""
token = request.session.get("csrf", "")
if not token:
token = secrets.token_hex(32)
request.session["csrf"] = token
return token
async def validate_csrf_form(request: Request) -> None:
"""Perform a constant-time CSRF check against POST form data."""
form = await request.form()
token = form.get("_csrf", "")
session_token = request.session.get("csrf", "")
if not token or not session_token:
raise HTTPException(status_code=403, detail="Invalid CSRF token")
if not secrets.compare_digest(str(session_token), str(token)):
raise HTTPException(status_code=403, detail="Invalid CSRF token")
def check_login_rate_limit(request: Request) -> None:
"""Rate-limit login attempts per IP — raises 429 on excess."""
# Extract real client IP, accounting for reverse proxy.
config = request.app.state.config
ip: str = ""
if config.trust_proxy:
forwarded = request.headers.get("X-Forwarded-For", "")
ip = forwarded.split(",")[0].strip() if forwarded else ""
if not ip and request.client:
ip = request.client.host
if not ip:
ip = "unknown"
count = record_login_attempt(ip)
if count > MAX_LOGIN_ATTEMPTS:
raise HTTPException(
status_code=429,
detail="Too many login attempts. Please wait and try again.",
)
_ = LOGIN_WINDOW
def admin_context(
request: Request,
config: Config,
store: Store,
users_obj: Users,
**extra: Any,
) -> dict[str, Any]:
"""Build the standard Jinja2 template context for admin routes."""
username: str = request.session.get("user", "")
record = users_obj.find(username) if username else None
csrf_val = csrf_token(request)
nonce = getattr(request.state, "csp_nonce", "")
ctx: dict[str, Any] = {
"request": request,
"config": config,
"store": store,
"users": users_obj,
"csrf_token": csrf_val,
"current_user": username or None,
"current_role": record.role if record else None,
"current_user_record": record,
"csp_nonce": nonce,
}
ctx.update(extra)
return ctx
def password_error(
password: str,
*,
min_length: int | None = None,
max_length: int | None = None,
) -> str | None:
"""Validate a newly chosen password; return an error message or ``None``."""
from .config import DEFAULT_MAX_PASSWORD_LENGTH, DEFAULT_MIN_PASSWORD_LENGTH
min_len = min_length if min_length is not None else DEFAULT_MIN_PASSWORD_LENGTH
max_len = max_length if max_length is not None else DEFAULT_MAX_PASSWORD_LENGTH
if not password or not password.strip():
return "New password cannot be empty."
if len(password) < min_len:
return f"Password must be at least {min_len} characters long."
if len(password) > max_len:
return f"Password must be at most {max_len} characters long."
normalized = unicodedata.normalize("NFKC", password)
if normalized.lower() in COMMON_PASSWORDS:
return "This password is too common."
return None
+393 -6
View File
@@ -3,10 +3,21 @@
from __future__ import annotations
import argparse
import getpass
import json
import os
import sys
import urllib.error
import urllib.request
import warnings
from datetime import UTC, datetime
from pathlib import Path
from . import VERSION
PYPI_URL = "https://pypi.org/pypi/volumen/json"
PYPI_TIMEOUT = 5.0
def main() -> None:
parser = argparse.ArgumentParser(
@@ -22,7 +33,90 @@ def main() -> None:
serve_parser.add_argument("--content", default=None)
hash_parser = sub.add_parser("hash-password", help="Hash a password")
hash_parser.add_argument("password", nargs="?", default=None)
hash_parser.add_argument(
"password",
nargs="?",
default=None,
help=(
"Optional plaintext password (discouraged — visible in shell "
"history). If omitted you will be prompted."
),
)
init_parser = sub.add_parser(
"init",
help=(
"Bootstrap config, data directories, admin password (and optionally a systemd service)"
),
)
init_parser.add_argument(
"--systemd",
action="store_true",
help="Install / enable a systemd unit (root only)",
)
init_parser.add_argument(
"--local",
action="store_true",
help="Use per-user paths (~/.config/volumen/, ~/.local/share/volumen/)",
)
init_parser.add_argument(
"--config",
type=Path,
default=None,
help=(
"Override config path (default: /etc/volumen/config.toml when "
"system, ~/.config/volumen/config.toml when user)"
),
)
init_parser.add_argument("--data", type=Path, default=None, help="Override data directory")
init_parser.add_argument(
"--users-file", type=Path, default=None, help="Override users.toml path"
)
init_parser.add_argument(
"--service-user", default="volumen", help="System user for systemd unit (default: volumen)"
)
enable_group = init_parser.add_mutually_exclusive_group()
enable_group.add_argument(
"--enable",
action="store_true",
dest="enable",
help="With --systemd, also systemctl enable --now (default when root)",
)
enable_group.add_argument(
"--no-enable",
action="store_false",
dest="enable",
help="With --systemd, install but do not enable",
)
init_parser.set_defaults(enable=True)
init_parser.add_argument(
"--admin-password",
default=None,
help="Set admin password non-interactively (discouraged)",
)
init_parser.add_argument(
"--force",
action="store_true",
help="Overwrite existing config (with .bak.<ts> backup)",
)
status_parser = sub.add_parser("status", help="Show installation status")
status_parser.add_argument("--config", type=Path, default=None, help="Config path to inspect")
status_parser.add_argument(
"--data", type=Path, default=None, help="Data dir (posts) to inspect"
)
status_parser.add_argument(
"--users-file", type=Path, default=None, help="users.toml path to inspect"
)
status_parser.add_argument("--json", action="store_true", help="Machine-readable output")
check_update_parser = sub.add_parser(
"check-update",
help="Compare the installed version with the latest release on PyPI",
)
check_update_parser.add_argument(
"--json", action="store_true", help="Machine-readable output (sets exit code only)"
)
sub.add_parser("version", help="Show version")
@@ -31,10 +125,13 @@ def main() -> None:
if args.command == "version":
print(VERSION)
elif args.command == "hash-password":
from .password import hash_password
pw = args.password or input("Password: ")
print(hash_password(pw))
_hash_password(args)
elif args.command == "init":
_init(args)
elif args.command == "status":
sys.exit(_status(args))
elif args.command == "check-update":
sys.exit(_check_update(args))
elif args.command == "serve":
_serve(args)
else:
@@ -42,9 +139,183 @@ def main() -> None:
sys.exit(1)
def _hash_password(args: argparse.Namespace) -> None:
from .password import hash_password
if args.password is not None:
warnings.warn(
"passing the password as a positional argument is insecure "
"(it ends up in shell history). Prefer interactive input.",
stacklevel=2,
)
pw = args.password
else:
pw = getpass.getpass("Password: ")
print(hash_password(pw))
def _init(args: argparse.Namespace) -> None:
from .installer import (
SYSTEM_CONFIG_PATH,
SYSTEM_CONTENT_DIR,
SYSTEM_USERS_FILE,
USER_CONFIG_PATH,
USER_CONTENT_DIR,
USER_USERS_FILE,
InstallerError,
system_install,
user_install,
)
is_root = _is_root()
use_user = bool(args.local) or not is_root
install_service = bool(args.systemd) and not use_user
if use_user and args.systemd:
print(
"error: --systemd is not available for per-user installs; "
"drop --local and run as root.",
file=sys.stderr,
)
sys.exit(2)
if use_user:
config_path = args.config if args.config is not None else USER_CONFIG_PATH
content_dir = args.data if args.data is not None else USER_CONTENT_DIR
users_file = args.users_file if args.users_file is not None else USER_USERS_FILE
if args.data is None:
# Per-user defaults: ~/.local/share/volumen/{posts,users.toml}.
content_dir = USER_CONTENT_DIR
users_file = USER_USERS_FILE
else:
config_path = args.config if args.config is not None else SYSTEM_CONFIG_PATH
content_dir = args.data if args.data is not None else SYSTEM_CONTENT_DIR
users_file = args.users_file if args.users_file is not None else SYSTEM_USERS_FILE
password = args.admin_password
if password is None:
while True:
first = getpass.getpass("Admin password (user 'admin'): ")
second = getpass.getpass("Confirm: ")
if first != second:
print("Passwords do not match; try again.", file=sys.stderr)
continue
if not first:
print("Password cannot be empty.", file=sys.stderr)
continue
password = first
break
common: dict[str, object] = {
"config_path": Path(config_path),
"content_dir": Path(content_dir),
"users_file": Path(users_file),
"admin_password": password,
"force": bool(args.force),
}
try:
if use_user:
result = user_install(**common)
else:
result = system_install(
**common,
service_user=args.service_user,
install_service=install_service,
enable_service=bool(args.enable),
)
except InstallerError as exc:
print(f"error: {exc}", file=sys.stderr)
sys.exit(1)
_print_init_summary(result, install_service=install_service, use_user=use_user)
def _print_init_summary(result: object, *, install_service: bool, use_user: bool) -> None:
from .installer import InstallResult
assert isinstance(result, InstallResult)
skipped = any("leaving untouched" in m for m in result.messages)
if skipped:
print("==> volumen: existing config left untouched (use --force to overwrite).")
print()
print(f" Config: {result.config_path}")
print()
print("Nothing to do. Re-run with --force if you want to re-bootstrap.")
return
print("==> volumen installed.")
print()
print(f" Config: {result.config_path}")
if result.service_user is not None:
print(f" (chmod 600, owned by {result.service_user})")
print(f" Data dir: {result.content_dir}")
if result.service_user is not None:
print(f" (owned by {result.service_user})")
print(f" Users file: {result.users_file}")
if result.service_user is not None:
print(f" (owned by {result.service_user})")
if install_service and result.systemd_unit_path is not None:
print(f" Service: systemd unit installed at {result.systemd_unit_path}")
print(" (enabled, running)")
else:
print(" Service: not installed")
print(" Admin user: admin (password you just typed)")
print()
print("Next steps:")
print(f" 1. Edit {result.config_path} — set site.base_url, site.title, etc.")
print(" 2. Set up nginx as reverse proxy with TLS (see docs/deployment.md).")
if install_service and result.systemd_unit_path is not None:
print(" 3. systemctl status volumen")
def _status(args: argparse.Namespace) -> int:
from .installer import (
SYSTEM_CONFIG_PATH,
SYSTEMD_UNIT_PATH,
inspect,
)
config_path = Path(args.config) if args.config is not None else SYSTEM_CONFIG_PATH
content_dir = Path(args.data) if args.data is not None else _content_dir_from(config_path)
users_file = (
Path(args.users_file) if args.users_file is not None else _users_file_from(config_path)
)
info = inspect(
config_path=config_path,
content_dir=content_dir,
users_file=users_file,
systemd_unit_path=SYSTEMD_UNIT_PATH,
)
if args.json:
print(json.dumps(info, indent=2, sort_keys=True))
return 0 if not info["issues"] else 1
print("==> volumen status")
print()
print(f" Config: {info['config_path']}")
print(f" Data dir: {info['data_dir']}")
print(f" Users file: {info['users_file']}")
print(
f" Systemd unit: {'installed' if info['systemd_unit_installed'] else 'not installed'}"
)
print(f" Service: {info['service_active']}")
print(f" Admin user: {'present' if info['admin_user_present'] else 'missing'}")
print()
issues = list(info["issues"])
if not issues:
print("All checks passed.")
else:
print("Issues:")
for issue in issues:
print(f" - {issue}")
return 0 if not issues else 1
def _serve(args: argparse.Namespace) -> None:
from .app import create_app
from .config import Config
from .server import create_app
from .store import Store
overrides: dict[str, object] = {}
@@ -62,3 +333,119 @@ def _serve(args: argparse.Namespace) -> None:
import uvicorn
uvicorn.run(app, host=config.host, port=config.port, log_level="info")
def _content_dir_from(config_path: Path) -> Path:
"""Best-effort resolution of the ``content_dir`` for a given config file.
Reads the value from the loaded config if the file is present and
parseable; otherwise returns the conventional system default. Used by
``volumen status`` so the operator doesn't have to pass ``--data``
separately.
"""
from .config import Config
from .installer import SYSTEM_CONTENT_DIR
try:
return Path(Config.load(path=str(config_path)).content_dir)
except OSError, ValueError:
return SYSTEM_CONTENT_DIR
def _users_file_from(config_path: Path) -> Path:
from .config import Config
from .installer import SYSTEM_USERS_FILE
try:
return Path(Config.load(path=str(config_path)).users_file)
except OSError, ValueError:
return SYSTEM_USERS_FILE
def _is_root() -> bool:
try:
return os.geteuid() == 0
except AttributeError:
return False
def _get_installed_version() -> str:
"""Return the installed `volumen` distribution version, or a sentinel."""
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version
try:
return _pkg_version("volumen")
except PackageNotFoundError: # pragma: no cover — only reached when not installed as a wheel.
return "0.0.0+unknown"
def _compare_versions(installed: str, latest: str) -> bool:
"""Return True if `latest` is strictly newer than `installed`.
Pre-release / local segments (``0.5.0a1``, ``0.5.0.dev1``, ``1.0.0+abc``) are
stripped before comparison so a published ``1.0.0a1`` is correctly reported
as an update against an installed ``0.9.0``.
"""
def _core(v: str) -> tuple[int, ...]:
for sep in ("a", "b", "rc", ".dev", "+"):
v = v.split(sep, 1)[0]
try:
return tuple(int(p) for p in v.split("."))
except ValueError:
return (0,)
return _core(latest) > _core(installed)
def _check_update(args: argparse.Namespace) -> int:
"""Check if a newer `volumen` is available on PyPI.
Exit codes: ``0`` up to date; ``1`` update available; ``2`` PyPI unreachable
(offline, timeout, malformed response, or local-only install).
"""
current = _get_installed_version()
checked_at = datetime.now(UTC).isoformat()
latest: str | None = None
error: str | None = None
try:
with urllib.request.urlopen(PYPI_URL, timeout=PYPI_TIMEOUT) as resp:
payload = json.loads(resp.read())
latest = payload["info"]["version"]
except (
urllib.error.URLError,
urllib.error.HTTPError,
OSError,
json.JSONDecodeError,
KeyError,
) as exc:
error = str(exc) or exc.__class__.__name__
update_available = latest is not None and _compare_versions(current, latest)
if args.json:
out: dict[str, object] = {
"current_version": current,
"latest_version": latest,
"update_available": update_available,
"checked_at": checked_at,
}
if error is not None:
out["error"] = error
print(json.dumps(out, indent=2, sort_keys=True))
return 1 if update_available else (2 if latest is None else 0)
if latest is None:
print(
f"volumen {current} — could not reach {PYPI_URL} ({error})",
file=sys.stderr,
)
return 2
if update_available:
print(f"volumen {current} (latest: {latest}) — update available")
print(" Run: uv tool upgrade volumen")
return 1
print(f"volumen {current} (up to date)")
return 0
+182 -18
View File
@@ -3,21 +3,42 @@
from __future__ import annotations
import copy
import logging
import os
import tomllib
from typing import Any
from urllib.parse import urlparse
try:
import tomllib
except ImportError:
import tomli as tomllib # type: ignore[import-not-found,unused-ignore]
logger = logging.getLogger(__name__)
DEFAULT_PATH = "/etc/volumen/config.toml"
# --- Named defaults ---------------------------------------------------------
DEFAULT_HOST: str = "::"
DEFAULT_PORT: int = 9090
DEFAULT_CONTENT_DIR: str = "/var/lib/volumen/posts"
DEFAULT_USERS_FILE: str = "/var/lib/volumen/users.toml"
DEFAULT_SESSION_TTL: int = 86400
DEFAULT_MIN_PASSWORD_LENGTH: int = 10
DEFAULT_MAX_PASSWORD_LENGTH: int = 1024
DEFAULT_MAX_UPLOAD_BYTES: int = 10 * 1024 * 1024 # 10 MB
SERVER_ENV_PRODUCTION = "production"
SERVER_ENV_DEVELOPMENT = "development"
ALLOWED_SERVER_ENVS: tuple[str, ...] = (SERVER_ENV_DEVELOPMENT, SERVER_ENV_PRODUCTION)
DEFAULTS: dict[str, Any] = {
"server": {"host": "::", "port": 9090},
"content_dir": "/var/lib/volumen/posts",
"users_file": "/var/lib/volumen/users.toml",
"server": {
"host": DEFAULT_HOST,
"port": DEFAULT_PORT,
"env": SERVER_ENV_DEVELOPMENT,
"trust_proxy": False,
"cookie_secure": False,
},
"content_dir": DEFAULT_CONTENT_DIR,
"users_file": DEFAULT_USERS_FILE,
"site": {
"title": "My Blog",
"description": "A blog powered by volumen.",
@@ -29,7 +50,10 @@ DEFAULTS: dict[str, Any] = {
"admin": {
"password_hash": "",
"session_key": "",
"session_ttl": 86400,
"session_ttl": DEFAULT_SESSION_TTL,
"min_password_length": DEFAULT_MIN_PASSWORD_LENGTH,
"max_password_length": DEFAULT_MAX_PASSWORD_LENGTH,
"max_upload_bytes": DEFAULT_MAX_UPLOAD_BYTES,
},
}
@@ -39,6 +63,15 @@ TEMPLATE = """\
[server]
host = "::"
port = 9090
# Environment label: "development" or "production". Affects startup
# safety checks (session key length, secure cookies, password policy).
env = "development"
# Set true ONLY when running behind a trusted reverse proxy that
# terminates TLS. The proxy must strip client-supplied
# X-Forwarded-Proto headers.
trust_proxy = false
# Set true in production to force `Secure` flag on session cookies.
cookie_secure = false
# Directory of your Markdown posts (.md with TOML frontmatter).
content_dir = "/var/lib/volumen/posts"
@@ -57,13 +90,19 @@ author = "Anonymous"
fediverse_creator = ""
[admin]
# Bootstrap admin login: paste a hash from `volumen hash-password` to sign
# in as user "admin". Once you manage users in Settings, users_file wins.
# Bootstrap admin login: paste a hash from `uv run volumen hash-password`
# to sign in as user "admin". Once you manage users in Settings, users_file wins.
password_hash = ""
# Secret signing session cookies (>= 64 bytes). Empty = random per start.
session_key = ""
# Session lifetime in seconds (24h default).
session_ttl = 86400
# Minimum new-password length (configurable; default 10).
min_password_length = 10
# Maximum new-password length (cap to avoid scrypt abuse).
max_password_length = 1024
# Maximum upload size in bytes (default 10 MB).
max_upload_bytes = 10485760
"""
@@ -90,18 +129,32 @@ def _merge_leaf(base_val: Any, override_val: Any) -> Any:
def _apply_overrides(data: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
server = dict(data["server"])
if overrides.get("host"):
server["host"] = overrides["host"]
if overrides.get("port"):
server["port"] = overrides["port"]
if "host" in overrides:
val = overrides["host"]
if isinstance(val, str) and val:
server["host"] = val
if "port" in overrides:
val = overrides["port"]
if isinstance(val, int) and 0 <= val <= 65535:
server["port"] = val
result = dict(data)
result["server"] = server
if overrides.get("content"):
result["content_dir"] = overrides["content"]
if "content" in overrides:
val = overrides["content"]
if isinstance(val, str) and val:
result["content_dir"] = val
if "users_file" in overrides:
val = overrides["users_file"]
if isinstance(val, str) and val:
result["users_file"] = val
return result
class ConfigError(ValueError):
"""Raised when a configuration value is invalid or missing."""
class Config:
"""Loaded and merged volumen configuration."""
@@ -115,19 +168,94 @@ class Config:
with open(path, "rb") as fh:
file_data = tomllib.load(fh)
else:
logger.warning("Config file %s not found, using defaults.", path)
file_data = {}
merged = _deep_merge(copy.deepcopy(DEFAULTS), file_data)
# The TEMPLATE declares ``content_dir`` / ``users_file`` immediately
# after ``[server]``; TOML table folding puts them inside the
# ``[server]`` table, but the canonical accessors read them from the
# root. Hoist them so files produced by the template (and by
# ``volumen init``) round-trip cleanly.
server_section = merged.get("server", {})
if isinstance(server_section, dict):
for key in ("content_dir", "users_file"):
if key in server_section and key not in file_data:
merged[key] = server_section[key]
return cls(_apply_overrides(merged, overrides))
@staticmethod
def generate_default(path: str) -> str | None:
if os.path.exists(path):
return None
os.makedirs(os.path.dirname(path), exist_ok=True)
dirname = os.path.dirname(path) or "."
os.makedirs(dirname, exist_ok=True)
with open(path, "w") as fh:
fh.write(TEMPLATE)
return path
def validate(self) -> None:
"""Validate the loaded configuration; raise ``ConfigError`` on failure."""
if not self.host or not isinstance(self.host, str):
raise ConfigError("[server].host must be a non-empty string")
if not isinstance(self.port, int) or not (1 <= self.port <= 65535):
raise ConfigError(f"[server].port must be an integer in 1..65535 (got {self.port!r})")
env = self.env
if env not in ALLOWED_SERVER_ENVS:
raise ConfigError(f"[server].env must be one of {ALLOWED_SERVER_ENVS} (got {env!r})")
if not isinstance(self.trust_proxy, bool):
raise ConfigError("[server].trust_proxy must be a boolean")
if not isinstance(self.cookie_secure, bool):
raise ConfigError("[server].cookie_secure must be a boolean")
if self.session_ttl <= 0:
raise ConfigError(f"[admin].session_ttl must be > 0 (got {self.session_ttl!r})")
if not (1 <= self.min_password_length <= self.max_password_length):
raise ConfigError("[admin].min_password_length must be >= 1 and <= max_password_length")
if self.max_upload_bytes <= 0:
raise ConfigError("[admin].max_upload_bytes must be > 0")
base = self.site.get("base_url")
if not base or not isinstance(base, str):
raise ConfigError("[site].base_url must be a non-empty string")
try:
parsed = urlparse(base)
if not parsed.scheme or not parsed.netloc:
raise ValueError
except ValueError as exc:
raise ConfigError(f"[site].base_url must be an absolute URL (got {base!r})") from exc
fediverse = self.site.get("fediverse_creator")
if fediverse:
from .post import FEDIVERSE_CREATOR_REGEX
if not FEDIVERSE_CREATOR_REGEX.match(str(fediverse)):
raise ConfigError("[site].fediverse_creator must look like @user@host when set")
# content_dir / users_file must be writable (or creatable)
try:
parent = os.path.dirname(self.content_dir) or "."
os.makedirs(parent, exist_ok=True)
probe = os.path.join(parent, ".volumen-write-probe")
with open(probe, "w") as fh:
fh.write("ok")
os.remove(probe)
except OSError as exc:
raise ConfigError(
f"[content_dir] is not writable at {self.content_dir!r}: {exc}"
) from exc
try:
parent = os.path.dirname(self.users_file) or "."
os.makedirs(parent, exist_ok=True)
probe = os.path.join(parent, ".volumen-write-probe")
with open(probe, "w") as fh:
fh.write("ok")
os.remove(probe)
except OSError as exc:
raise ConfigError(
f"[users_file] is not writable at {self.users_file!r}: {exc}"
) from exc
@property
def host(self) -> str:
return self._data["server"]["host"]
@@ -136,6 +264,22 @@ class Config:
def port(self) -> int:
return self._data["server"]["port"]
@property
def env(self) -> str:
return self._data["server"].get("env", SERVER_ENV_DEVELOPMENT)
@property
def trust_proxy(self) -> bool:
return bool(self._data["server"].get("trust_proxy", False))
@property
def cookie_secure(self) -> bool:
return bool(self._data["server"].get("cookie_secure", False))
@property
def is_production(self) -> bool:
return self.env == SERVER_ENV_PRODUCTION
@property
def content_dir(self) -> str:
return self._data["content_dir"]
@@ -155,3 +299,23 @@ class Config:
@property
def admin(self) -> dict[str, Any]:
return self._data["admin"]
@property
def min_password_length(self) -> int:
return int(self._data["admin"].get("min_password_length", DEFAULT_MIN_PASSWORD_LENGTH))
@property
def max_password_length(self) -> int:
return int(self._data["admin"].get("max_password_length", DEFAULT_MAX_PASSWORD_LENGTH))
@property
def max_upload_bytes(self) -> int:
return int(self._data["admin"].get("max_upload_bytes", DEFAULT_MAX_UPLOAD_BYTES))
@property
def session_ttl(self) -> int:
return int(self._data["admin"].get("session_ttl", DEFAULT_SESSION_TTL))
@property
def session_key(self) -> str:
return str(self._data["admin"].get("session_key", ""))
+61
View File
@@ -0,0 +1,61 @@
"""FastAPI dependency callables shared by application routers."""
from __future__ import annotations
from typing import TYPE_CHECKING
from fastapi import HTTPException, Request
if TYPE_CHECKING:
from .config import Config
from .store import Store
from .users import Users
def get_config(request: Request) -> Config:
"""Return the :class:`Config` from app state."""
return request.app.state.config
def get_store(request: Request) -> Store:
"""Return the :class:`Store` from app state."""
return request.app.state.store
def get_users(request: Request) -> Users:
"""Return the shared :class:`Users` instance from app state."""
return request.app.state.users
def get_current_user(request: Request) -> str | None:
"""Return the logged-in username from the session or ``None``."""
user = request.session.get("user", "")
return user if user else None
def require_login(request: Request) -> str:
"""Enforce login — redirects to ``/admin/login`` otherwise."""
username = request.session.get("user", "")
if not username:
raise _login_redirect()
users_obj: Users = request.app.state.users
if users_obj.find(username) is None:
request.session.clear()
raise _login_redirect()
return username
def _login_redirect() -> HTTPException:
exc = HTTPException(status_code=303)
exc.headers = {"Location": "/admin/login"} # type: ignore[assignment]
return exc
def require_admin(request: Request) -> str:
"""Enforce admin role — raises 403 otherwise."""
username = require_login(request)
users_obj: Users = request.app.state.users
record = users_obj.find(username)
if record is None or record.role != "admin":
raise HTTPException(status_code=403, detail="Forbidden")
return username
+21 -11
View File
@@ -1,20 +1,26 @@
"""Helpers that render RSS / JSON Feed / sitemap documents."""
from __future__ import annotations
import html
from datetime import UTC, date, datetime
from email.utils import format_datetime
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from .markdown import _sanitize
if TYPE_CHECKING:
from .post import Post
FEED_ITEM_LIMIT = 20
def render_rss_feed(posts: list[Post], site: dict, base_url: str) -> str:
def render_rss_feed(posts: list[Post], site: dict[str, Any], base_url: str) -> str:
"""Render an RSS 2.0 XML feed for the given posts.
The feed includes the 20 most recent published posts.
"""
items = "\n".join(_rss_item(post, base_url) for post in posts[:20])
items = "\n".join(_rss_item(post, base_url) for post in posts[:FEED_ITEM_LIMIT])
title = html.escape(site.get("title", ""))
link = html.escape(base_url)
description = html.escape(site.get("description", ""))
@@ -80,32 +86,36 @@ def _iso_date(value: date | str | None) -> str | None:
return str(value) if value else None
def render_json_feed(posts: list[Post], site: dict, base_url: str) -> dict:
def render_json_feed(posts: list[Post], site: dict[str, Any], base_url: str) -> dict[str, Any]:
"""Build a JSON Feed 1.1 document for the given posts.
https://jsonfeed.org/version/1.1
"""
feed_url = f"{base_url}/api/volumen/feed.json"
result: dict = {
result: dict[str, Any] = {
"version": "https://jsonfeed.org/version/1.1",
"title": site.get("title", ""),
"home_page_url": base_url,
"feed_url": feed_url,
"description": site.get("description", ""),
"language": site.get("language", "en"),
"items": [_json_feed_item(post, base_url, site) for post in posts[:20]],
"items": [_json_feed_item(post, base_url, site) for post in posts[:FEED_ITEM_LIMIT]],
}
return {k: v for k, v in result.items() if v not in (None, "", [])}
def _json_feed_item(post: Post, base_url: str, site: dict) -> dict:
"""Build a single JSON Feed item dict."""
def _json_feed_item(post: Post, base_url: str, site: dict[str, Any]) -> dict[str, Any]:
"""Build a single JSON Feed item dict.
The ``content_html`` field is sanitized through :func:`_sanitize`
before being embedded to prevent stored XSS in feeds.
"""
permalink = f"{base_url}/{post.slug}"
item: dict = {
item: dict[str, Any] = {
"id": permalink,
"url": permalink,
"title": post.title,
"content_html": post.html,
"content_html": _sanitize(post.html),
"summary": post.excerpt,
"date_published": _iso_date(post.date_string),
"tags": post.tags,
@@ -118,7 +128,7 @@ def _json_feed_item(post: Post, base_url: str, site: dict) -> dict:
def render_sitemap(posts: list[Post], base_url: str) -> str:
"""Render an XML sitemap for the given posts."""
urls = []
urls: list[str] = []
for post in posts:
loc = html.escape(f"{base_url}/{post.slug}")
entry = f" <url><loc>{loc}</loc>"
+1 -4
View File
@@ -2,10 +2,7 @@
from __future__ import annotations
try:
import tomllib
except ImportError:
import tomli as tomllib # type: ignore[import-not-found,unused-ignore]
import tomllib
import tomli_w
+594
View File
@@ -0,0 +1,594 @@
"""First-run bootstrap for volumen (system or per-user installation).
This module replaces the legacy ``install.sh`` script. Instead of a shell
installer, the operational bootstrap is performed by the ``volumen init``
Python subcommand. The bootstrap can be:
* run as root for a system-wide install (config under ``/etc/volumen/``,
data under ``/var/lib/volumen/``, a hardened systemd unit); or
* run as a non-root user for a per-user install (config under
``~/.config/volumen/``, data under ``~/.local/share/volumen/``).
The ``volumen`` CLI binary itself is installed separately via
``uv tool install volumen`` or ``pipx install volumen``; this module only
handles the *operational* bootstrap: config, data directories, admin
password hash, session key, and (optionally) the systemd unit file.
Every public function in this module is safe to import from tests.
Mutating side effects (writing files, calling ``useradd`` / ``systemctl``)
only fire from inside the high-level ``system_install`` / ``user_install``
entry points.
"""
from __future__ import annotations
import logging
import os
try:
import grp
import pwd
except ImportError:
grp = None # type: ignore[assignment]
pwd = None # type: ignore[assignment]
import re
import secrets
import shutil
import subprocess
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from .config import TEMPLATE
logger = logging.getLogger(__name__)
# --- Paths -------------------------------------------------------------------
SYSTEM_CONFIG_DIR = Path("/etc/volumen")
SYSTEM_CONFIG_PATH = SYSTEM_CONFIG_DIR / "config.toml"
SYSTEM_DATA_DIR = Path("/var/lib/volumen")
SYSTEM_CONTENT_DIR = SYSTEM_DATA_DIR / "posts"
SYSTEM_USERS_FILE = SYSTEM_DATA_DIR / "users.toml"
SYSTEMD_UNIT_PATH = Path("/etc/systemd/system/volumen.service")
USER_CONFIG_DIR = Path("~/.config/volumen")
USER_DATA_DIR = Path("~/.local/share/volumen")
USER_CONFIG_PATH = USER_CONFIG_DIR / "config.toml"
USER_CONTENT_DIR = USER_DATA_DIR / "posts"
USER_USERS_FILE = USER_DATA_DIR / "users.toml"
DEFAULT_SERVICE_USER = "volumen"
DEFAULT_SERVICE_HOME = Path("/var/lib/volumen")
# --- systemd unit template ---------------------------------------------------
SYSTEMD_UNIT_TEMPLATE = """\
[Unit]
Description=volumen blog engine
After=network.target
[Service]
Type=simple
User={service_user}
Group={service_user}
WorkingDirectory={working_dir}
Environment=PYTHONUNBUFFERED=1
ExecStart={volumen_bin} serve --config {config_path} --content {content_dir}
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths={content_dir}
PrivateTmp=true
[Install]
WantedBy=multi-user.target
"""
class InstallerError(RuntimeError):
"""Raised when the bootstrap cannot complete (root required, missing binary, etc.)."""
@dataclass
class InstallResult:
"""Structured result of an install bootstrap.
``messages`` carries a human-readable list of actions performed so that
``volumen status`` can echo them.
"""
config_path: Path
content_dir: Path
users_file: Path
service_user: str | None
password_hash: str
session_key: str
systemd_unit_path: Path | None = None
messages: list[str] = field(default_factory=list)
# --- helpers ----------------------------------------------------------------
def _is_root() -> bool:
"""Return True iff the current process holds uid 0 (privileged)."""
try:
return os.geteuid() == 0
except AttributeError: # pragma: no cover — non-POSIX platforms
return False
def _ensure_user(name: str, home: Path | None = None) -> bool:
"""Create system user ``name`` if missing.
Returns ``True`` if the user was created, ``False`` if it already
existed or creation was skipped (because not running as root).
"""
if pwd is not None:
try:
pwd.getpwnam(name)
logger.debug("user %s already exists", name)
return False
except KeyError:
pass
if not _is_root():
logger.debug("not root: skipping useradd %s", name)
return False
if home is None:
home = DEFAULT_SERVICE_HOME
home = Path(home)
home.mkdir(parents=True, exist_ok=True)
subprocess.run(
[
"useradd",
"--system",
"--home",
str(home),
"--shell",
"/usr/sbin/nologin",
name,
],
check=True,
)
return True
def _chown(path: Path, user: str) -> None:
"""chown ``path`` to ``user:user``.
Silently no-ops on non-POSIX platforms or when not running as root so
the function is safe to call from unprivileged test runs.
"""
if not _is_root():
logger.debug("chown %s %s:%s skipped (not root)", path, user, user)
return
if grp is None or pwd is None:
logger.debug("chown %s %s:%s skipped (no grp/pwd modules)", path, user, user)
return
try:
uid = pwd.getpwnam(user).pw_uid
gid = grp.getgrnam(user).gr_gid
except KeyError as exc:
logger.warning("user/group %s missing: %s", user, exc)
return
try:
os.chown(path, uid, gid)
except OSError as exc:
logger.warning("chown %s %s:%s failed: %s", path, user, user, exc)
def _chown_r(path: Path, user: str) -> None:
"""Recursive chown; skips off-tree entries and follows no symlinks."""
if not _is_root():
return
if path.is_dir() and not path.is_symlink():
for child in path.iterdir():
_chown_r(child, user)
_chown(path, user)
def _chmod(path: Path, mode: int) -> None:
"""chmod wrapper that no-ops when not running as root."""
if not _is_root():
return
try:
os.chmod(path, mode)
except OSError as exc:
logger.warning("chmod %s %o failed: %s", path, mode, exc)
def _mutate_config_text(
text: str,
*,
host: str | None = None,
env: str | None = None,
trust_proxy: bool | None = None,
cookie_secure: bool | None = None,
content_dir: str | None = None,
users_file: str | None = None,
password_hash: str | None = None,
session_key: str | None = None,
) -> str:
"""Apply targeted substitutions to a rendered TOML template.
Each parameter, when not ``None``, rewrites the corresponding line in
``text``. The substitution is anchored at the start of the line, so
comment blocks and unrelated lines are left untouched. Comment blocks
above each key are preserved byte-for-byte.
"""
def _sub(key: str, value: str) -> str:
return re.sub(
rf"^{re.escape(key)}\s*=.*$",
f'{key} = "{value}"',
text,
flags=re.MULTILINE,
count=1,
)
def _sub_bool(key: str, value: bool) -> str:
return re.sub(
rf"^{re.escape(key)}\s*=.*$",
f"{key} = {str(value).lower()}",
text,
flags=re.MULTILINE,
count=1,
)
if host is not None:
text = _sub("host", host)
if env is not None:
text = _sub("env", env)
if trust_proxy is not None:
text = _sub_bool("trust_proxy", trust_proxy)
if cookie_secure is not None:
text = _sub_bool("cookie_secure", cookie_secure)
if content_dir is not None:
text = _sub("content_dir", content_dir)
if users_file is not None:
text = _sub("users_file", users_file)
if password_hash is not None:
text = text.replace('password_hash = ""', f'password_hash = "{password_hash}"', 1)
if session_key is not None:
text = text.replace('session_key = ""', f'session_key = "{session_key}"', 1)
return text
def _backup_path(target: Path) -> Path:
ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
return target.with_name(f"{target.name}.bak.{ts}.toml")
def _systemctl_is_active(unit: str) -> str:
"""Best-effort ``systemctl is-active`` query; "unknown" on failure."""
if shutil.which("systemctl") is None:
return "unknown"
try:
proc = subprocess.run(
["systemctl", "is-active", unit],
capture_output=True,
text=True,
check=False,
timeout=2,
)
out = proc.stdout.strip()
return out or "unknown"
except (OSError, subprocess.SubprocessError) as exc:
logger.debug("systemctl is-active failed: %s", exc)
return "unknown"
# --- public API -------------------------------------------------------------
def system_install(
*,
config_path: Path = SYSTEM_CONFIG_PATH,
content_dir: Path = SYSTEM_CONTENT_DIR,
users_file: Path = SYSTEM_USERS_FILE,
service_user: str = DEFAULT_SERVICE_USER,
install_service: bool = False,
enable_service: bool = True,
admin_password: str | None = None,
config_template: str = TEMPLATE,
force: bool = False,
) -> InstallResult:
"""Bootstrap a system-wide install (typically as root).
See the module docstring for the full behavioural contract.
"""
if admin_password is None:
raise InstallerError(
"admin_password must be provided; prompt the user (CLI does this interactively)"
)
config_path = Path(config_path)
content_dir = Path(content_dir)
users_file = Path(users_file)
messages: list[str] = []
if config_path.exists():
if not force:
logger.info("config %s already exists; leaving untouched", config_path)
messages.append("config exists, leaving untouched (use --force to overwrite)")
return InstallResult(
config_path=config_path,
content_dir=content_dir,
users_file=users_file,
service_user=service_user,
password_hash="",
session_key="",
systemd_unit_path=None,
messages=messages,
)
backup = _backup_path(config_path)
backup.write_text(config_path.read_text(encoding="utf-8"), encoding="utf-8")
messages.append(f"backed up existing config to {backup}")
# Directories.
config_path.parent.mkdir(parents=True, exist_ok=True)
users_file.parent.mkdir(parents=True, exist_ok=True)
content_dir.mkdir(parents=True, exist_ok=True)
(content_dir / "media").mkdir(exist_ok=True)
# Service user.
if _ensure_user(service_user):
messages.append(f"created system user {service_user}")
else:
messages.append(f"system user {service_user} already present (or creation skipped)")
# Hashes / secrets.
from .password import hash_password
password_hash = hash_password(admin_password)
session_key = secrets.token_hex(64)
rendered = _mutate_config_text(
config_template,
host="::1",
env="production",
trust_proxy=True,
cookie_secure=True,
content_dir=str(content_dir),
users_file=str(users_file),
password_hash=password_hash,
session_key=session_key,
)
config_path.write_text(rendered, encoding="utf-8")
messages.append(f"wrote config to {config_path}")
# Make sure users_file exists before chown.
users_file.touch(exist_ok=True)
# Permissions.
_chown_r(content_dir, service_user)
_chown(users_file.parent, service_user)
_chown(users_file, service_user)
_chown(config_path, service_user)
_chmod(config_path, 0o600)
# Systemd unit (optional).
systemd_unit_path: Path | None = None
if install_service:
if not _is_root():
raise InstallerError("install_service=True requires running as root")
bin_path = shutil.which("volumen")
if bin_path is None:
raise InstallerError(
"volumen binary not found on PATH; install it first "
"(`uv tool install volumen` or `pipx install volumen`)"
)
systemd_unit_path = SYSTEMD_UNIT_PATH
systemd_unit_path.parent.mkdir(parents=True, exist_ok=True)
unit = SYSTEMD_UNIT_TEMPLATE.format(
service_user=service_user,
working_dir=str(config_path.parent),
volumen_bin=bin_path,
config_path=str(config_path),
content_dir=str(content_dir),
)
systemd_unit_path.write_text(unit, encoding="utf-8")
_chmod(systemd_unit_path, 0o644)
subprocess.run(["systemctl", "daemon-reload"], check=True)
if enable_service:
subprocess.run(["systemctl", "enable", "--now", "volumen"], check=True)
messages.append("systemd unit installed, enabled, and started")
else:
messages.append("systemd unit installed (not enabled)")
return InstallResult(
config_path=config_path,
content_dir=content_dir,
users_file=users_file,
service_user=service_user,
password_hash=password_hash,
session_key=session_key,
systemd_unit_path=systemd_unit_path,
messages=messages,
)
def user_install(
*,
config_path: Path = USER_CONFIG_PATH,
content_dir: Path = USER_CONTENT_DIR,
users_file: Path = USER_USERS_FILE,
admin_password: str | None = None,
config_template: str = TEMPLATE,
force: bool = False,
install_service: bool = False,
) -> InstallResult:
"""Bootstrap a per-user install (never touches system users or systemd)."""
if install_service:
raise InstallerError(
"--systemd is not available for per-user installs; run as root, without --local"
)
if admin_password is None:
raise InstallerError(
"admin_password must be provided; prompt the user (CLI does this interactively)"
)
config_path = Path(config_path).expanduser()
content_dir = Path(content_dir).expanduser()
users_file = Path(users_file).expanduser()
messages: list[str] = []
if config_path.exists():
if not force:
logger.info("config %s already exists; leaving untouched", config_path)
messages.append("config exists, leaving untouched (use --force to overwrite)")
return InstallResult(
config_path=config_path,
content_dir=content_dir,
users_file=users_file,
service_user=None,
password_hash="",
session_key="",
systemd_unit_path=None,
messages=messages,
)
backup = _backup_path(config_path)
backup.write_text(config_path.read_text(encoding="utf-8"), encoding="utf-8")
messages.append(f"backed up existing config to {backup}")
config_path.parent.mkdir(parents=True, exist_ok=True)
users_file.parent.mkdir(parents=True, exist_ok=True)
content_dir.mkdir(parents=True, exist_ok=True)
(content_dir / "media").mkdir(exist_ok=True)
from .password import hash_password
password_hash = hash_password(admin_password)
session_key = secrets.token_hex(64)
rendered = _mutate_config_text(
config_template,
host="::1",
env="development",
trust_proxy=False,
cookie_secure=False,
content_dir=str(content_dir),
users_file=str(users_file),
password_hash=password_hash,
session_key=session_key,
)
config_path.write_text(rendered, encoding="utf-8")
messages.append(f"wrote config to {config_path}")
users_file.touch(exist_ok=True)
messages.append("per-user install complete; no systemd unit installed")
return InstallResult(
config_path=config_path,
content_dir=content_dir,
users_file=users_file,
service_user=None,
password_hash=password_hash,
session_key=session_key,
systemd_unit_path=None,
messages=messages,
)
def inspect(
config_path: Path = SYSTEM_CONFIG_PATH,
content_dir: Path = SYSTEM_CONTENT_DIR,
users_file: Path = SYSTEM_USERS_FILE,
systemd_unit_path: Path = SYSTEMD_UNIT_PATH,
) -> dict[str, object]:
"""Return a JSON-serialisable snapshot of an installation.
The shape matches what ``volumen status`` prints; safe to call without
root and without mutating anything on disk.
"""
config_path = Path(config_path)
content_dir = Path(content_dir)
users_file = Path(users_file)
systemd_unit_path = Path(systemd_unit_path)
issues: list[str] = []
config_exists = config_path.is_file()
data_dir_exists = content_dir.parent.is_dir()
posts_subdir_exists = content_dir.is_dir()
media_subdir_exists = (content_dir / "media").is_dir()
users_file_exists = users_file.is_file()
password_hash_set = False
session_key_ok = False
if config_exists:
try:
import tomllib
with open(config_path, "rb") as fh:
data = tomllib.load(fh)
admin = data.get("admin", {})
pw_hash = str(admin.get("password_hash", ""))
session_key = str(admin.get("session_key", ""))
password_hash_set = bool(pw_hash)
session_key_ok = len(session_key) >= 64
if not password_hash_set:
issues.append("admin password_hash is empty")
if not session_key_ok:
issues.append("admin session_key is missing or shorter than 64 bytes")
except OSError:
issues.append("config not readable")
except Exception as exc: # tomllib.TOMLDecodeError or similar
issues.append(f"config not parseable: {exc}")
else:
issues.append("config not found")
if not data_dir_exists:
issues.append("data dir not found")
if data_dir_exists and not posts_subdir_exists:
issues.append("posts subdir not found")
if data_dir_exists and not media_subdir_exists:
issues.append("media subdir not found")
if not users_file_exists:
issues.append("users file not found")
systemd_unit_installed = systemd_unit_path.is_file()
service_active = _systemctl_is_active("volumen")
admin_user_present = False
if users_file_exists:
try:
import tomllib
with open(users_file, "rb") as fh:
data = tomllib.load(fh)
admin_user_present = any(
entry.get("username") == "admin" for entry in data.get("users", [])
)
if not admin_user_present:
issues.append("admin user not present in users file")
except OSError:
issues.append("users file not readable")
except Exception as exc:
issues.append(f"users file not parseable: {exc}")
if systemd_unit_installed and service_active not in ("active", "unknown"):
issues.append(f"systemd unit installed but service is {service_active}")
return {
"config_path": str(config_path),
"data_dir": str(content_dir.parent),
"users_file": str(users_file),
"config_exists": config_exists,
"data_dir_exists": data_dir_exists,
"posts_subdir_exists": posts_subdir_exists,
"media_subdir_exists": media_subdir_exists,
"users_file_exists": users_file_exists,
"password_hash_set": password_hash_set,
"session_key_ok": session_key_ok,
"systemd_unit_installed": systemd_unit_installed,
"service_active": service_active,
"admin_user_present": admin_user_present,
"issues": issues,
}
+148 -84
View File
@@ -1,99 +1,163 @@
"""Markdown rendering using Python-Markdown with pymdown-extensions."""
"""Markdown rendering using Python-Markdown with pymdown-extensions.
After Python-Markdown produces HTML, the result is passed through
:mod:`nh3` to strip dangerous content (``<script>``, event handlers,
``javascript:`` URLs, etc.). The allowed tag/attribute list is kept
narrow on purpose so the output can be embedded in admin pages.
"""
from __future__ import annotations
from xml.etree import ElementTree as ET
import logging
import re
import threading
import markdown
import nh3
logger = logging.getLogger(__name__)
# Strict allowlist of tags and attributes. Matches the subset of HTML
# that Python-Markdown can produce for the configured extensions.
_ALLOWED_TAGS: frozenset[str] = frozenset(
{
"a",
"abbr",
"blockquote",
"br",
"caption",
"code",
"del",
"div",
"em",
"figcaption",
"figure",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"img",
"input",
"li",
"ol",
"p",
"pre",
"span",
"strong",
"sub",
"sup",
"table",
"tbody",
"td",
"th",
"thead",
"tr",
"ul",
}
)
# Per-tag attributes. ``rel`` is forced on links (see ``url_schemes``).
_ALLOWED_ATTRIBUTES: dict[str, set[str]] = {
"a": {"href", "title"},
"img": {"src", "alt", "title", "width", "height", "loading"},
"div": {"class"},
"span": {"class"},
"code": {"class"},
"pre": {"class"},
"th": {"align"},
"td": {"align"},
"input": {"type", "checked", "disabled"},
"figure": {"class"},
"figcaption": {"class"},
}
_ALLOWED_URL_SCHEMES: set[str] = {"http", "https", "mailto"}
MAX_BODY_LENGTH: int = 1_048_576
_md_lock = threading.Lock()
_md: markdown.Markdown | None = None
def _get_md() -> markdown.Markdown:
global _md
if _md is None:
with _md_lock:
if _md is None:
_md = markdown.Markdown(
extensions=[
"markdown.extensions.extra",
"markdown.extensions.codehilite",
"markdown.extensions.toc",
"markdown.extensions.sane_lists",
"pymdownx.tasklist",
"pymdownx.tilde",
],
extension_configs={
"pymdownx.tasklist": {"custom_checkbox": True},
},
)
return _md
def _sanitize(html: str) -> str:
"""Run ``nh3.clean`` over *html* with our strict allowlist."""
if not html:
return html
cleaned = nh3.clean(
html,
tags=_ALLOWED_TAGS,
attributes=_ALLOWED_ATTRIBUTES,
url_schemes=_ALLOWED_URL_SCHEMES,
link_rel="noopener noreferrer",
strip_comments=True,
)
return cleaned
def render(text: str) -> str:
"""Render Markdown to HTML with GFM extensions and figure wrapping."""
md = markdown.Markdown(
extensions=[
"markdown.extensions.extra",
"markdown.extensions.codehilite",
"markdown.extensions.toc",
"markdown.extensions.sane_lists",
"pymdownx.tasklist",
"pymdownx.tilde",
],
extension_configs={
"pymdownx.tasklist": {"custom_checkbox": True},
},
)
html = md.convert(text)
md.reset()
return _wrap_figures(html)
"""Render Markdown to HTML with GFM extensions, figure wrapping, and sanitisation."""
if not text:
return ""
if len(text) > MAX_BODY_LENGTH:
raise ValueError(f"Body exceeds {MAX_BODY_LENGTH} bytes")
md = _get_md()
with _md_lock:
raw = md.convert(text)
md.reset()
return _sanitize(_wrap_figures(raw))
_FIGURE_RE = re.compile(
r'<img\s[^>]*\btitle=(["\'])(.*?)\1[^>]*/?>',
re.IGNORECASE,
)
def _wrap_figures(html: str) -> str:
"""Wrap every <img title='...'> inside a <figure> with <figcaption>."""
if 'title="' not in html and "title='" not in html:
return html
"""Wrap every <img title='...'> inside a <figure> with <figcaption>.
try:
root = ET.fromstring(f"<root>{html}</root>")
except ET.ParseError:
return html
Operates on the raw (pre-sanitisation) output so the title attribute
is available before :func:`_sanitize` strips it.
for img in root.iter("img"):
title = img.get("title")
if not title:
continue
del img.attrib["title"]
Uses regex matching instead of an XML parser to safely handle
HTML5 void elements (``<br>``, ``<hr>``, etc.) that would cause
``ET.fromstring`` to fail.
"""
parent = root
for child in root:
if img in list(child.iter()):
parent = child
break
def _replace(match: re.Match) -> str:
img_tag = match.group(0)
title = match.group(2)
clean_img = re.sub(r'\s+title=(["\']).*?\1', "", img_tag)
return (
f"<figure>"
f"{clean_img}"
f'<div class="fig-info" aria-hidden="true">i</div>'
f"<figcaption>{title}</figcaption>"
f"</figure>"
)
figure = ET.Element("figure")
img_clone = _clone_element(img)
figure.append(img_clone)
info = ET.Element("div")
info.set("class", "fig-info")
info.set("aria-hidden", "true")
info.text = "i"
figure.append(info)
figcaption = ET.Element("figcaption")
figcaption.text = title
figure.append(figcaption)
for child in list(parent):
if child is img or img in list(child.iter()):
idx = list(parent).index(child)
parent.remove(child)
parent.insert(idx, figure)
break
parts: list[str] = [
ET.tostring(child, encoding="unicode")
if hasattr(ET, "tostring")
else _element_to_string(child)
for child in root
]
return "".join(parts)
def _clone_element(elem: ET.Element) -> ET.Element:
"""Deep-clone an ElementTree element."""
clone = ET.Element(elem.tag, elem.attrib)
clone.text = elem.text
clone.tail = elem.tail
for child in elem:
clone.append(_clone_element(child))
return clone
def _element_to_string(elem: ET.Element) -> str:
"""Convert an ElementTree element to its string representation."""
attribs = " ".join(f'{k}="{v}"' for k, v in elem.attrib.items())
tag = f"{elem.tag} {attribs}".strip()
if elem.text and not len(elem):
return f"<{tag}>{elem.text}</{elem.tag}>"
children = "".join(_element_to_string(c) for c in elem)
return f"<{tag}>{elem.text or ''}{children}</{elem.tag}>"
return _FIGURE_RE.sub(_replace, html)
+91 -18
View File
@@ -1,10 +1,19 @@
"""Password hashing and verification using scrypt."""
"""Password hashing and verification using scrypt.
The module enforces a fixed set of scrypt parameters (N, R, P, dklen,
salt length) so weaker configurations stored in older ``users.toml``
files are rejected. Stored hashes use ``scrypt$<N>$<R>$<P>$<saltB64>$<hashB64>``.
"""
from __future__ import annotations
import base64
import hashlib
import os
import hmac
import logging
import secrets
logger = logging.getLogger(__name__)
COST_N = 16384
BLOCK_R = 8
@@ -13,38 +22,102 @@ KEY_LENGTH = 32
SALT_BYTES = 16
PREFIX = "scrypt"
# Minimum scrypt parameters that we are willing to verify (policy floor).
# Anything weaker must be re-hashed via ``needs_rehash``.
MIN_N = COST_N
MIN_R = BLOCK_R
MIN_P = PARALLEL_P
MIN_DKLEN = KEY_LENGTH
# Maximum password length accepted for hashing (cap to bound scrypt work).
MAX_PASSWORD_LENGTH = 1024
def _b64(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def _parts(encoded: str) -> tuple[int, int, int, bytes, bytes] | None:
"""Parse an encoded scrypt string. Returns ``None`` on malformed input."""
parts = encoded.split("$")
if len(parts) != 6 or parts[0] != PREFIX:
return None
try:
_, n_str, r_str, p_str, salt_b64, hash_b64 = parts
n, r, p = int(n_str), int(r_str), int(p_str)
salt = base64.b64decode(salt_b64, validate=True)
expected = base64.b64decode(hash_b64, validate=True)
except ValueError, TypeError:
return None
return n, r, p, salt, expected
def hash_password(password: str) -> str:
"""Hash a password and return the encoded scrypt string."""
salt = os.urandom(SALT_BYTES)
if not isinstance(password, str):
raise TypeError("password must be a str")
if not password:
raise ValueError("password must not be empty")
if len(password) > MAX_PASSWORD_LENGTH:
raise ValueError(f"password longer than {MAX_PASSWORD_LENGTH} characters")
salt = secrets.token_bytes(SALT_BYTES)
derived = hashlib.scrypt(
password.encode("utf-8"), salt=salt, n=COST_N, r=BLOCK_R, p=PARALLEL_P, dklen=KEY_LENGTH
password.encode("utf-8"),
salt=salt,
n=COST_N,
r=BLOCK_R,
p=PARALLEL_P,
dklen=KEY_LENGTH,
)
return f"{PREFIX}${COST_N}${BLOCK_R}${PARALLEL_P}${_b64(salt)}${_b64(derived)}"
def verify_password(password: str, encoded: str) -> bool:
"""Verify a password against an encoded scrypt hash."""
parts = encoded.split("$")
if len(parts) != 6 or parts[0] != PREFIX:
return False
"""Verify a password against an encoded scrypt hash in constant time.
Hashes that were produced with parameters below the policy floor, or
that are malformed, are rejected (``False``) and a warning is logged.
"""
parsed = _parts(encoded)
if parsed is None:
logger.warning("password verify: malformed stored hash")
return False
n, r, p, salt, expected = parsed
if not (n >= MIN_N and r >= MIN_R and p >= MIN_P and len(expected) >= MIN_DKLEN):
logger.warning(
"password verify: stored hash uses weak scrypt parameters "
"(N=%d, r=%d, p=%d, dklen=%d); rejecting",
n,
r,
p,
len(expected),
)
return False
try:
_, n_str, r_str, p_str, salt_b64, hash_b64 = parts
salt = base64.b64decode(salt_b64)
expected = base64.b64decode(hash_b64)
derived = hashlib.scrypt(
password.encode("utf-8"),
salt=salt,
n=int(n_str),
r=int(r_str),
p=int(p_str),
n=n,
r=r,
p=p,
dklen=len(expected),
)
return derived == expected
except (ValueError, OSError):
except (ValueError, OSError) as exc:
logger.warning("password verify: scrypt failed: %s", exc)
return False
# hmac.compare_digest is constant-time and avoids leaks of the
# derived-byte length when lengths differ.
return hmac.compare_digest(derived, expected)
def _b64(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def needs_rehash(stored: str) -> bool:
"""Return True if ``stored`` should be re-hashed on next successful login.
Triggers when the hash uses weaker parameters than the current policy
floor or when the hash version prefix is unrecognised.
"""
parsed = _parts(stored)
if parsed is None:
return True
n, r, p, _salt, expected = parsed
return not (n >= MIN_N and r >= MIN_R and p >= MIN_P and len(expected) >= MIN_DKLEN)
+171
View File
@@ -0,0 +1,171 @@
"""Payload construction, filtering, parsing, and post validation helpers."""
from __future__ import annotations
import re
from datetime import date
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .config import Config
from .post import Post
from .store import Store
SLUG_REGEX: re.Pattern[str] = re.compile(r"\A[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\Z")
MAX_SLUG_LENGTH: int = 200
def site_payload(config: Config) -> dict[str, Any]:
"""Build the ``/api/volumen/site`` payload."""
keys = ("title", "description", "base_url", "language", "author", "fediverse_creator")
return {key: config.site.get(key) for key in keys}
def published_posts(store: Store) -> list[Post]:
"""Return all published, non-scheduled posts, newest first."""
all_posts = store.all()
visible = [post for post in all_posts if not post.draft and not post.scheduled]
visible.sort(key=lambda post: post.date_string or "", reverse=True)
return visible
def filtered_posts(store: Store, params: dict[str, str]) -> list[Post]:
"""Filter published posts by ``lang``, ``tag``, and ``q``."""
return _filter_posts(published_posts(store), params)
def _filter_posts(posts: list[Post], params: dict[str, str]) -> list[Post]:
lang = params.get("lang", "")
if lang:
posts = [post for post in posts if post.lang == lang or post.all_langs]
tag = params.get("tag", "")
if tag:
posts = [post for post in posts if tag in post.tags]
query = params.get("q", "").strip().lower()
if query:
posts = [
post
for post in posts
if query in (post.title or "").lower() or query in (post.body or "").lower()
]
return posts
def posts_payload(
store: Store, params: dict[str, str], page: int = 1, limit: int = 20
) -> dict[str, Any]:
"""Build the paginated payload for ``/api/volumen/posts``."""
posts = filtered_posts(store, params)
page = max(page, 1)
limit = min(max(limit, 1), 100)
offset = (page - 1) * limit
total = len(posts)
return {
"page": page,
"page_size": limit,
"total": total,
"has_next": offset + limit < total,
"has_prev": page > 1,
"posts": [post.summary() for post in posts[offset : offset + limit]],
}
def tag_counts(store: Store) -> list[dict[str, Any]]:
"""Build tag-cloud data sorted by frequency and then name."""
counts: dict[str, int] = {}
for post in published_posts(store):
for tag in post.tags:
counts[tag] = counts.get(tag, 0) + 1
sorted_counts = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
return [{"name": name, "count": count} for name, count in sorted_counts]
def presence(value: Any) -> str | None:
"""Return a stripped string or ``None`` if empty."""
text = str(value).strip() if value is not None else ""
return text if text else None
def parse_date(value: Any) -> date | None:
"""Parse an ISO 8601 date string, returning ``None`` on failure."""
text = presence(value)
if text is None:
return None
try:
return date.fromisoformat(text)
except ValueError:
return None
def parse_tags(value: Any) -> list[str]:
"""Parse a comma-separated tag string."""
raw = presence(value)
if raw is None:
return []
return [tag.strip() for tag in raw.split(",") if tag.strip()]
def post_from_params(form_data: dict[str, str], existing: Post | None = None) -> Post:
"""Build a :class:`Post` from HTTP form data, carrying *existing*.path."""
from .post import Post as _Post
metadata = _base_metadata(form_data, existing)
cleaned = _clean_metadata(metadata)
post = _Post(metadata=cleaned, body=form_data.get("body", ""))
if existing is not None:
post.path = existing.path
return post
def _base_metadata(form_data: dict[str, str], existing: Post | None = None) -> dict[str, Any]:
return {
"title": presence(form_data.get("title")),
"slug": presence(form_data.get("slug")) or (existing.slug if existing else None),
"lang": presence(form_data.get("lang")),
"author": presence(form_data.get("author")),
"fediverse_creator": presence(form_data.get("fediverse_creator")),
"date": parse_date(form_data.get("date")),
"publish_at": parse_date(form_data.get("publish_at")),
"tags": parse_tags(form_data.get("tags")),
"excerpt": presence(form_data.get("excerpt")),
"cover": presence(form_data.get("cover")),
"cover_alt": presence(form_data.get("cover_alt")),
"cover_caption": presence(form_data.get("cover_caption")),
"draft": form_data.get("draft") == "on",
"all_langs": form_data.get("all_langs") == "on",
}
def _clean_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
"""Drop nil, empty, and false entries."""
return {
key: value
for key, value in metadata.items()
if value is not None and value != "" and value != [] and value is not False
}
def creation_error(post: Post, store: Store, existing: Post | None = None) -> str | None:
"""Validate a post before saving, returning an error or ``None``."""
if presence(post.slug) is None:
return "Slug is required."
if len(post.slug or "") > MAX_SLUG_LENGTH:
return f"Slug must be at most {MAX_SLUG_LENGTH} characters."
if not SLUG_REGEX.match(post.slug or ""):
return "Invalid slug."
found = store.find(post.slug)
if found is not None and (existing is None or found.path != existing.path):
return "A post with that slug already exists."
return fediverse_creator_error(post)
def fediverse_creator_error(post: Post) -> str | None:
"""Validate the optional fediverse creator field."""
from .post import FEDIVERSE_CREATOR_REGEX
value = post.metadata.get("fediverse_creator") if hasattr(post, "metadata") else None
if value is None:
return None
if re.match(FEDIVERSE_CREATOR_REGEX, str(value)):
return None
return "Fediverse creator must look like @user@host."
+7 -4
View File
@@ -12,6 +12,9 @@ from .markdown import render as md_render
FEDIVERSE_CREATOR_REGEX = re.compile(r"\A@[^@\s]+@[^@\s]+\Z")
_RE_HTML_TAG = re.compile(r"<[^>]+>")
_RE_MD_CHARS = re.compile(r"[*_`>#]")
class Post:
"""A single blog post with metadata and Markdown body."""
@@ -100,7 +103,7 @@ class Post:
return val if isinstance(val, date) else val.date()
try:
return date.fromisoformat(str(val))
except (ValueError, TypeError):
except ValueError, TypeError:
return None
@property
@@ -110,7 +113,7 @@ class Post:
@property
def reading_time(self) -> int:
words = len([w for w in self.body.split() if w.strip()])
words = len(self.body.split())
return max(1, (words + 199) // 200)
@property
@@ -166,8 +169,8 @@ class Post:
stripped = para.strip()
if not stripped or stripped.startswith("#"):
continue
cleaned = re.sub(r"<[^>]+>", "", stripped)
cleaned = re.sub(r"[*_`>#]", "", cleaned).strip()
cleaned = _RE_HTML_TAG.sub("", stripped)
cleaned = _RE_MD_CHARS.sub("", cleaned).strip()
if len(cleaned) > limit:
return f"{cleaned[:limit].rstrip()}\u2026"
return cleaned
+20 -630
View File
@@ -1,635 +1,25 @@
"""FastAPI application: public JSON API and server-rendered admin.
This module provides the ``create_app`` factory plus all dependency-injection
callables and helper functions consumed by ``api_routes`` and ``admin_routes``.
"""
"""Compatibility imports for the former monolithic server module."""
from __future__ import annotations
import json
import logging
import re
import secrets
from datetime import date, datetime
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.sessions import SessionMiddleware
from . import LOGIN_WINDOW, MAX_LOGIN_ATTEMPTS, login_attempts, sweep_login_attempts
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants (mirror Ruby Server)
# ---------------------------------------------------------------------------
MAX_UPLOAD_BYTES: int = 10 * 1024 * 1024 # 10 MB
ALLOWED_UPLOAD_TYPES: tuple[str, ...] = ("image/webp", "image/avif")
SLUG_REGEX: re.Pattern[str] = re.compile(r"\A[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\Z")
CSP_HEADER: str = "; ".join(
[
"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'",
]
)
# ---------------------------------------------------------------------------
# Template & static file discovery
# ---------------------------------------------------------------------------
_PKG_DIR: Path = Path(__file__).resolve().parent
_TEMPLATE_DIR: Path = _PKG_DIR / "web" / "templates"
_STATIC_DIR: Path = _PKG_DIR / "web" / "static"
templates: Jinja2Templates = Jinja2Templates(directory=str(_TEMPLATE_DIR))
# ============================================================================
# App factory
# ============================================================================
def create_app(config: Any, store: Any) -> FastAPI:
"""Build a fully-configured FastAPI app bound to *config* and *store*."""
app = FastAPI(title="volumen")
# ── Session middleware ──────────────────────────────────────────
ttl = int(config.admin.get("session_ttl", 0))
if ttl <= 0:
ttl = None
app.add_middleware(
SessionMiddleware,
secret_key=session_secret(config),
https_only=False,
same_site="strict",
session_cookie="volumen_session",
max_age=ttl,
)
# ── Custom middleware ───────────────────────────────────────────
app.add_middleware(SecureCookieMiddleware)
app.add_middleware(CSPMiddleware)
# ── Attach objects to app.state for dependency injection ────────
app.state.config = config
app.state.store = store
# ── Static files (logo / icon SVGs) ────────────────────────────
if _STATIC_DIR.is_dir():
app.mount("/admin/static", StaticFiles(directory=str(_STATIC_DIR)), name="admin_static")
# ── Routers ─────────────────────────────────────────────────────
from .admin_routes import media_router
from .admin_routes import router as admin_router
from .api_routes import router as api_router
app.include_router(api_router)
app.include_router(admin_router)
app.include_router(media_router) # type: ignore[arg-type]
return app
def session_secret(config: Any) -> str:
"""Derive session-signing secret from config (mirrors Ruby logic)."""
key = str(config.admin.get("session_key", ""))
if not key:
return secrets.token_hex(64)
if len(key.encode()) < 64:
logger.warning(
"volumen: session_key is shorter than 64 bytes, falling back to random secret"
)
return secrets.token_hex(64)
return key
# ============================================================================
# Middleware
# ============================================================================
class SecureCookieMiddleware(BaseHTTPMiddleware):
"""Mark session cookie ``Secure`` when ``X-Forwarded-Proto`` is ``https``."""
async def dispatch(self, request: Request, call_next): # type: ignore[override]
proto = (request.headers.get("X-Forwarded-Proto", "")).lower()
request.state.session_secure = proto == "https"
return await call_next(request)
class CSPMiddleware(BaseHTTPMiddleware):
"""Inject Content-Security-Policy on ``/admin`` routes."""
async def dispatch(self, request: Request, call_next): # type: ignore[override]
response = await call_next(request)
if request.url.path.startswith("/admin"):
response.headers["Content-Security-Policy"] = CSP_HEADER
return response
# ============================================================================
# FastAPI Dependencies
# ============================================================================
def get_config(request: Request) -> Any:
"""Return the :class:`Config` from app state."""
return request.app.state.config
def get_store(request: Request) -> Any:
"""Return the :class:`Store` from app state."""
return request.app.state.store
def get_users(request: Request) -> Any:
"""Build a :class:`Users` instance from the current config."""
from .users import Users
config = request.app.state.config
return Users(
path=config.users_file,
bootstrap_hash=config.admin.get("password_hash", ""),
)
def get_current_user(request: Request) -> str | None:
"""Return the logged-in username from the session or ``None``."""
user = request.session.get("user", "")
return user if user else None
def require_login(request: Request) -> str:
"""Enforce login — redirects to ``/admin/login`` otherwise."""
username = request.session.get("user", "")
if not username:
raise _login_redirect()
config = request.app.state.config
from .users import Users
users_obj = Users(
path=config.users_file,
bootstrap_hash=config.admin.get("password_hash", ""),
)
if users_obj.find(username) is None:
request.session.clear()
raise _login_redirect()
return username
def _login_redirect() -> HTTPException:
exc = HTTPException(status_code=303)
exc.headers = {"Location": "/admin/login"} # type: ignore[assignment]
return exc
def require_admin(request: Request) -> str:
"""Enforce admin role — raises 403 otherwise."""
username = require_login(request)
config = request.app.state.config
from .users import Users
users_obj = Users(
path=config.users_file,
bootstrap_hash=config.admin.get("password_hash", ""),
)
record = users_obj.find(username)
if record is None or record.role != "admin":
raise HTTPException(status_code=403, detail="Forbidden")
return username
def csrf_token(request: Request) -> str:
"""Return (and lazily create) the CSRF token stored in the session."""
token = request.session.get("csrf", "")
if not token:
token = secrets.token_hex(32)
request.session["csrf"] = token
return token
async def validate_csrf_form(request: Request) -> None:
"""Constant-time CSRF check against POST form data.
Must be called inside a route handler after awaiting form data is safe
(it reads request body via ``await request.form()``).
"""
form = await request.form()
token = form.get("_csrf", "")
session_token = request.session.get("csrf", "")
if not token or not session_token:
raise HTTPException(status_code=403, detail="Invalid CSRF token")
if not secrets.compare_digest(str(session_token), str(token)):
raise HTTPException(status_code=403, detail="Invalid CSRF token")
def check_login_rate_limit(request: Request) -> None:
"""Rate-limit login attempts per IP — raises 429 on excess."""
sweep_login_attempts()
ip = request.client.host if request.client else "unknown"
now = datetime.now().timestamp()
cutoff = now - LOGIN_WINDOW
attempts = login_attempts().setdefault(ip, [])
attempts = [t for t in attempts if t > cutoff]
if len(attempts) >= MAX_LOGIN_ATTEMPTS:
raise HTTPException(
status_code=429,
detail="Too many login attempts. Please wait and try again.",
)
attempts.append(now)
login_attempts()[ip] = attempts
# ============================================================================
# Admin template context builder
# ============================================================================
def admin_context(
request: Request,
config: Any,
store: Any,
users_obj: Any,
**extra: Any,
) -> dict[str, Any]:
"""Build the standard Jinja2 template context for admin routes."""
username: str = request.session.get("user", "")
record = users_obj.find(username) if username else None
# Ensure CSRF token exists before building context
csrf_val = csrf_token(request)
ctx: dict[str, Any] = {
"request": request,
"config": config,
"store": store,
"users": users_obj,
"csrf_token": csrf_val,
"current_user": username or None,
"current_role": record.role if record else None,
"current_user_record": record,
}
ctx.update(extra)
return ctx
# ============================================================================
# API / site helpers
# ============================================================================
def site_payload(config: Any) -> dict[str, Any]:
"""Build the ``/api/volumen/site`` payload."""
keys = ("title", "description", "base_url", "language", "author", "fediverse_creator")
return {k: config.site.get(k) for k in keys}
def published_posts(store: Any) -> list[Any]:
"""All published (non-draft, non-scheduled) posts, newest first."""
all_posts = store.all()
visible = [p for p in all_posts if not p.draft and not p.scheduled]
visible.sort(key=lambda p: p.date_string or "", reverse=True)
return visible
def filtered_posts(store: Any, params: dict[str, str]) -> list[Any]:
"""Filter published posts by ``lang``, ``tag``, and ``q``."""
posts = published_posts(store)
lang = params.get("lang", "")
if lang:
posts = [p for p in posts if p.lang == lang or p.all_langs]
tag = params.get("tag", "")
if tag:
posts = [p for p in posts if tag in p.tags]
q = params.get("q", "").strip().lower()
if q:
posts = [p for p in posts if q in (p.title or "").lower() or q in (p.body or "").lower()]
return posts
def posts_payload(store: Any, params: dict[str, str]) -> dict[str, Any]:
"""Paginated posts payload for ``/api/volumen/posts``."""
posts = filtered_posts(store, params)
page = positive_int(params.get("page"), 1)
limit = min(max(positive_int(params.get("limit"), 20), 1), 100)
offset = (page - 1) * limit
total = len(posts)
return {
"page": page,
"page_size": limit,
"total": total,
"has_next": offset + limit < total,
"has_prev": page > 1,
"posts": [p.summary() for p in posts[offset : offset + limit]],
}
def tag_counts(store: Any) -> list[dict[str, Any]]:
"""Tag-cloud payload (sorted by frequency desc, then name)."""
counts: dict[str, int] = {}
for post in published_posts(store):
for tag in post.tags:
counts[tag] = counts.get(tag, 0) + 1
sorted_counts = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
return [{"name": name, "count": cnt} for name, cnt in sorted_counts]
# ============================================================================
# Small value helpers
# ============================================================================
def positive_int(value: Any, default: int) -> int:
"""Parse *value* as a positive integer, falling back to *default*."""
try:
n = int(value)
except (TypeError, ValueError):
return default
return n if n > 0 else default
def presence(value: Any) -> str | None:
"""Return stripped string or ``None`` if empty."""
text = str(value).strip() if value is not None else ""
return text if text else None
def parse_date(value: Any) -> date | None:
"""Parse an ISO 8601 date string, returning ``None`` on failure."""
text = presence(value)
if text is None:
return None
try:
return date.fromisoformat(text)
except ValueError:
return None
def parse_tags(value: Any) -> list[str]:
"""Parse a comma-separated tag string."""
raw = presence(value)
if raw is None:
return []
return [t.strip() for t in raw.split(",") if t.strip()]
# ============================================================================
# Upload validation
# ============================================================================
def validate_upload(upload: Any) -> None:
"""Validate uploaded file size and MIME type.
Raises ``HTTPException`` (413 or 415) on failure.
"""
# Size check
if hasattr(upload, "file"):
upload.file.seek(0, 2)
size = upload.file.tell()
upload.file.seek(0)
else:
size = 0
if size > MAX_UPLOAD_BYTES:
raise HTTPException(
status_code=413,
detail=json.dumps({"error": "file_too_large", "max_bytes": MAX_UPLOAD_BYTES}),
)
# MIME type check
content_type = (getattr(upload, "content_type", None) or "").split(";")[0].strip().lower()
if content_type not in ALLOWED_UPLOAD_TYPES:
raise HTTPException(
status_code=415,
detail=json.dumps(
{
"error": "unsupported_format",
"message": (
"Only WebP (.webp) and AVIF (.avif) images are supported. "
"Please convert your image before uploading — for example with "
"`cwebp` (JPEG/PNG → WebP) or `avifenc` (PNG/JPEG → AVIF)."
),
"allowed": list(ALLOWED_UPLOAD_TYPES),
}
),
)
# ============================================================================
# Post metadata builder
# ============================================================================
def post_from_params(form_data: dict[str, str], existing: Any = None) -> Any:
"""Build a :class:`Post` from HTTP form data, carrying *existing*.path."""
from .post import Post
metadata = _base_metadata(form_data, existing)
cleaned = _clean_metadata(metadata)
post = Post(metadata=cleaned, body=form_data.get("body", ""))
if existing is not None:
post.path = existing.path
return post
def _base_metadata(form_data: dict[str, str], existing: Any = None) -> dict[str, Any]:
return {
"title": presence(form_data.get("title")),
"slug": presence(form_data.get("slug")) or (existing.slug if existing else None),
"lang": presence(form_data.get("lang")),
"author": presence(form_data.get("author")),
"fediverse_creator": presence(form_data.get("fediverse_creator")),
"date": parse_date(form_data.get("date")),
"publish_at": parse_date(form_data.get("publish_at")),
"tags": parse_tags(form_data.get("tags")),
"excerpt": presence(form_data.get("excerpt")),
"cover": presence(form_data.get("cover")),
"cover_alt": presence(form_data.get("cover_alt")),
"cover_caption": presence(form_data.get("cover_caption")),
"draft": form_data.get("draft") == "on",
"all_langs": form_data.get("all_langs") == "on",
}
def _clean_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
"""Drop nil/empty/false entries."""
return {
k: v
for k, v in metadata.items()
if v is not None and v != "" and v != [] and v is not False
}
# ============================================================================
# Post validation
# ============================================================================
def creation_error(post: Any, store: Any) -> str | None:
"""Validate a new post before saving. Returns error string or ``None``."""
if presence(post.slug) is None:
return "Slug is required."
if not SLUG_REGEX.match(post.slug or ""):
return "Invalid slug."
if store.find(post.slug):
return "A post with that slug already exists."
return fediverse_creator_error(post)
def fediverse_creator_error(post: Any) -> str | None:
"""Validate the optional fediverse_creator field. Returns error or ``None``."""
from .post import FEDIVERSE_CREATOR_REGEX
value = post.metadata.get("fediverse_creator") if hasattr(post, "metadata") else None
if value is None:
return None
if re.match(FEDIVERSE_CREATOR_REGEX, str(value)):
return None
return "Fediverse creator must look like @user@host."
# ============================================================================
# Settings action helpers (return (error, notice) — exactly one is non-None)
# ============================================================================
def change_password(
users_obj: Any,
current_username: str,
current_password: str,
new_password: str,
) -> tuple[str | None, str | None]:
"""Change the user's password."""
if not users_obj.authenticate(current_username, current_password):
return ("Current password is incorrect.", None)
fresh = presence(new_password)
if fresh is None:
return ("New password cannot be empty.", None)
users_obj.update_password(current_username, fresh)
return (None, "Password updated.")
def change_username(
users_obj: Any,
current_username: str,
new_username: str,
session: dict[str, Any],
) -> tuple[str | None, str | None]:
"""Rename the current user and update the session."""
name = presence(new_username)
if name is None:
return ("Username cannot be empty.", None)
if not re.match(r"\A[a-zA-Z0-9._-]+\Z", name):
return ("Username may use letters, numbers, dot, dash, underscore.", None)
if name == current_username:
return (None, "Username unchanged.")
if users_obj.rename(current_username, name):
session["user"] = name
return (None, "Username updated.")
return ("That username is already taken.", None)
def change_name(
users_obj: Any,
current_username: str,
display_name: str,
) -> tuple[str | None, str | None]:
"""Update or clear the display name."""
name = display_name.strip()
name = name if name else None
users_obj.update_name(current_username, name)
return (None, "Display name updated." if name else "Display name cleared.")
def change_fediverse_creator(
users_obj: Any,
current_username: str,
value: str,
) -> tuple[str | None, str | None]:
"""Update or clear the fediverse handle."""
from .post import FEDIVERSE_CREATOR_REGEX
text = value.strip()
if not text:
users_obj.update_fediverse_creator(current_username, None)
return (None, "Fediverse handle cleared.")
if not re.match(FEDIVERSE_CREATOR_REGEX, text):
return ("Fediverse handle must look like @user@host.", None)
users_obj.update_fediverse_creator(current_username, text)
return (None, "Fediverse handle updated.")
def change_photo(
users_obj: Any,
store: Any,
current_username: str,
upload: Any,
) -> tuple[str | None, str | None]:
"""Store a profile photo."""
validate_upload(upload)
url = store.store_user_photo(current_username, upload.filename, upload.file)
users_obj.update_photo(current_username, url)
return (None, "Profile photo updated.")
def remove_photo(
users_obj: Any,
current_username: str,
) -> tuple[str | None, str | None]:
"""Remove the profile photo."""
users_obj.update_photo(current_username, None)
return (None, "Profile photo removed.")
def create_user(
users_obj: Any,
username: str,
password: str,
role: str,
) -> tuple[str | None, str | None]:
"""Create a new user (admin only)."""
name = presence(username)
pwd = presence(password)
if name is None or pwd is None:
return ("Username and password are required.", None)
if not re.match(r"\A[a-zA-Z0-9._-]+\Z", name):
return ("Username may use letters, numbers, dot, dash, underscore.", None)
if users_obj.add(name, pwd, role):
return (None, "User added.")
return ("That username already exists.", None)
def remove_user(
users_obj: Any,
current_username: str,
target_username: str,
) -> tuple[str | None, str | None]:
"""Delete a user (admin only)."""
if target_username == current_username:
return ("You cannot delete your own account.", None)
if users_obj.delete(target_username):
return (None, "User removed.")
return ("Cannot remove the last user or the last admin.", None)
def change_role(
users_obj: Any,
current_username: str,
target_username: str,
new_role: str,
) -> tuple[str | None, str | None]:
"""Change a user's role (admin only)."""
if target_username == current_username:
return ("You cannot change your own role.", None)
if users_obj.set_role(target_username, new_role):
return (None, "Role updated.")
return ("Could not change role (last admin?).", None)
from . import app as _app
from . import auth as _auth
from . import dependencies as _dependencies
from . import payloads as _payloads
from . import upload_validation as _upload_validation
from . import user_settings as _user_settings
from .app import create_app as create_app
_MODULES = (_app, _dependencies, _auth, _payloads, _upload_validation, _user_settings)
def __getattr__(name: str) -> Any:
"""Resolve legacy attributes from their focused modules."""
for module in _MODULES:
try:
return getattr(module, name)
except AttributeError:
continue
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+278 -71
View File
@@ -1,134 +1,329 @@
"""Reads and writes Markdown posts in a content directory."""
"""Reads and writes Markdown posts and media in a content directory."""
from __future__ import annotations
import glob
import contextlib
import logging
import os
import re
import secrets
import shutil
import tempfile
import threading
import tomllib
from pathlib import Path
from .post import Post
logger = logging.getLogger(__name__)
SAFE_SLUG_RE = re.compile(r"[^a-zA-Z0-9._-]")
# WebP: "RIFF....WEBP" (12 bytes total magic).
WEBP_SIGNATURE = b"RIFF"
WEBP_TAG_OFFSET = 8
WEBP_TAG_VALUE = b"WEBP"
# AVIF: ISO BMFF; bytes 4..7 are the box size, bytes 8..11 are "ftyp",
# followed by the major brand (4 bytes).
AVIF_BRANDS = (b"avif", b"avis")
# Allowed extensions and matching Content-Type values.
ALLOWED_IMAGE_EXTENSIONS: tuple[str, ...] = (".webp", ".avif")
EXTENSION_TO_MIME: dict[str, str] = {
".webp": "image/webp",
".avif": "image/avif",
}
def validate_image_signature(data: bytes, ext: str) -> bool:
"""Verify that *data* matches the file signature for image *ext*.
*ext* must be one of ``.webp`` or ``.avif`` (lowercase, with dot).
Returns False if the data is too short or the magic doesn't match.
"""
ext = ext.lower()
if ext == ".webp":
if len(data) < 12:
return False
return (
data[:4] == WEBP_SIGNATURE
and data[WEBP_TAG_OFFSET : WEBP_TAG_OFFSET + 4] == WEBP_TAG_VALUE
)
if ext == ".avif":
if len(data) < 16:
return False
if data[4:8] != b"ftyp":
return False
brand = data[8:12]
return brand in AVIF_BRANDS
return False
def detect_image_extension(data: bytes) -> str | None:
"""Detect the canonical image extension (``.webp`` or ``.avif``) from bytes.
Returns ``None`` if neither magic matches.
"""
if validate_image_signature(data, ".webp"):
return ".webp"
if validate_image_signature(data, ".avif"):
return ".avif"
return None
class Store:
"""Manages posts on disk in a flat or language-subdivided directory."""
def __init__(self, content_dir: str, default_lang: str | None = None) -> None:
def __init__(
self,
content_dir: str,
default_lang: str | None = None,
follow_symlinks: bool = False,
) -> None:
self.content_dir = os.path.abspath(content_dir)
self._default_lang = default_lang
self._follow_symlinks = follow_symlinks
self._cached: list[Post] | None = None
self._dir_mtime: float | None = None
self._files_mtime: float | None = None
self._snapshot: tuple[tuple[str, int, int], ...] | None = None
self._save_locks: dict[str, threading.Lock] = {}
self._locks_guard = threading.Lock()
self._slug_index: dict[str, str] = {}
# ------------------------------------------------------------------
# Read path
# ------------------------------------------------------------------
def all(self) -> list[Post]:
dir_mtime = os.path.getmtime(self.content_dir) if os.path.isdir(self.content_dir) else None
files_mtime = self._newest_file_mtime()
if (
self._cached is not None
and dir_mtime == self._dir_mtime
and files_mtime == self._files_mtime
):
snapshot = self._build_snapshot()
if self._cached is not None and self._snapshot is not None and snapshot == self._snapshot:
return list(self._cached)
self._dir_mtime = dir_mtime
self._files_mtime = files_mtime
self._snapshot = snapshot
self._cached = [
p for p in (self._load_post(path) for path in self._md_files()) if p is not None
]
self._build_index()
return list(self._cached)
def find(self, slug: str, lang: str | None = None) -> Post | None:
if slug in self._slug_index:
post = self._load_post(self._slug_index[slug])
if post is not None and (lang is None or post.lang == lang or post.all_langs):
return post
return None
for post in self.all():
if post.slug == slug and (lang is None or post.lang == lang or post.all_langs):
return post
return None
# ------------------------------------------------------------------
# Write path
# ------------------------------------------------------------------
def save(self, post: Post) -> Post:
target = post.path or self._default_path_for(post)
os.makedirs(os.path.dirname(target), exist_ok=True)
tmp = f"{target}.tmp"
with open(tmp, "w") as fh:
fh.write(post.to_file())
os.replace(tmp, target)
# Per-target lock prevents racing writers from clobbering each
# other's tempfiles when they target the same path.
with self._locks_guard:
lock = self._save_locks.get(target)
if lock is None:
lock = threading.Lock()
self._save_locks[target] = lock
with lock:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=os.path.dirname(target) or ".",
prefix=os.path.basename(target) + ".",
suffix=".tmp",
delete=False,
) as fh:
tmp_path = fh.name
fh.write(post.to_file())
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp_path, target)
try:
dir_fd = os.open(os.path.dirname(target) or ".", os.O_DIRECTORY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
except OSError, AttributeError:
# On Windows os.fsync on a directory is not supported;
# we don't run there but guard for forward-compat.
pass
post.path = target
self._cached = None
self._snapshot = None
return post
def delete(self, slug: str, lang: str | None = None) -> Post | None:
post = self.find(slug, lang=lang)
if post and post.path:
os.remove(post.path)
try:
os.remove(post.path)
except OSError as exc:
logger.warning("store: failed to remove %s: %s", post.path, exc)
return None
self._cached = None
self._snapshot = None
return post
# ------------------------------------------------------------------
# Media
# ------------------------------------------------------------------
@property
def media_dir(self) -> str:
return os.path.join(self.content_dir, "media")
def store_upload(self, original_name: str, source: object) -> str:
name = self._safe_media_name(original_name)
def _write_media_atomic(
self,
dest: str,
source: object,
bytes_data: bytes | None = None,
) -> None:
tmp_fd, tmp = tempfile.mkstemp(dir=self.media_dir)
try:
with os.fdopen(tmp_fd, "wb") as fh:
if bytes_data is not None:
fh.write(bytes_data)
elif hasattr(source, "read"):
shutil.copyfileobj(source, fh) # type: ignore[arg-type]
else:
with open(source, "rb") as src: # type: ignore[arg-type]
shutil.copyfileobj(src, fh)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, dest)
try:
dir_fd = os.open(os.path.dirname(dest) or ".", os.O_DIRECTORY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
except OSError, AttributeError:
pass
except Exception:
with contextlib.suppress(OSError):
os.remove(tmp)
raise
def store_upload(
self,
original_name: str,
source: object,
bytes_data: bytes | None = None,
) -> str:
"""Persist an uploaded image and return its public URL.
Extension and Content-Type are determined from the *bytes_data*
signature (preferred) or, as a fallback, from the original
filename (only ``.webp``/``.avif`` survive sanitisation).
Caller is responsible for size and signature validation.
"""
ext = self._resolve_extension(original_name, bytes_data)
os.makedirs(self.media_dir, exist_ok=True)
name = f"{secrets.token_hex(4)}-upload{ext}"
dest = os.path.join(self.media_dir, name)
if hasattr(source, "read"):
with open(dest, "wb") as fh:
while True:
chunk = source.read(65536)
if not chunk:
break
fh.write(chunk)
else:
with open(source, "rb") as src, open(dest, "wb") as dst: # type: ignore[arg-type]
shutil.copyfileobj(src, dst)
self._write_media_atomic(dest, source, bytes_data)
return f"/media/{name}"
def store_user_photo(self, username: str, original_name: str, source: object) -> str:
ext = os.path.splitext(original_name)[1]
ext = re.sub(r"[^a-zA-Z0-9.]", "", ext).lower() or ".webp"
name = f"{username}-{secrets.token_hex(4)}{ext}"
def store_user_photo(
self,
username: str,
original_name: str,
source: object,
bytes_data: bytes | None = None,
) -> str:
"""Persist a user-profile photo and return its public URL.
See :meth:`store_upload` for signature-based extension logic.
"""
ext = self._resolve_extension(original_name, bytes_data)
safe_user = re.sub(r"[^a-zA-Z0-9_-]", "", username) or "user"
name = f"{safe_user}-{secrets.token_hex(4)}{ext}"
os.makedirs(self.media_dir, exist_ok=True)
dest = os.path.join(self.media_dir, name)
if hasattr(source, "read"):
with open(dest, "wb") as fh:
while True:
chunk = source.read(65536)
if not chunk:
break
fh.write(chunk)
else:
with open(source, "rb") as src, open(dest, "wb") as dst: # type: ignore[arg-type]
shutil.copyfileobj(src, dst)
self._write_media_atomic(dest, source, bytes_data)
return f"/media/{name}"
def delete_media(self, url: str) -> bool:
"""Delete a media file by its public URL (e.g. ``/media/foo.webp``).
Returns True if a file was removed, False otherwise. Caller is
responsible for confirming the URL is unreferenced.
"""
if not url or not url.startswith("/media/"):
return False
name = os.path.basename(url)
path = os.path.join(self.media_dir, name)
if not self._is_within(self.media_dir, path):
return False
try:
os.remove(path)
return True
except OSError:
return False
def media_file(self, name: str) -> str | None:
safe = os.path.basename(name)
path = os.path.join(self.media_dir, safe)
if not os.path.isfile(path):
return None
real = os.path.realpath(path)
if self._within(self.media_dir, real):
return path
return None
if not self._is_within(self.media_dir, path):
return None
return path
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _md_files(self) -> list[str]:
pattern = os.path.join(self.content_dir, "**", "*.md")
return glob.glob(pattern, recursive=True)
"""Walk ``content_dir`` and skip any directory named ``media``."""
root = Path(self.content_dir)
results: list[str] = []
for path in root.rglob("*.md"):
parts = path.relative_to(root).parts
if any(part == "media" for part in parts[:-1]):
continue
if not self._follow_symlinks and path.is_symlink():
continue
results.append(str(path))
return results
def _newest_file_mtime(self) -> float | None:
files = self._md_files()
if not files:
return None
return max(os.path.getmtime(path) for path in files)
def _build_snapshot(self) -> tuple[tuple[str, int, int], ...]:
"""Build a (path, mtime_ns, size) snapshot for every tracked file."""
try:
files = self._md_files()
except OSError:
return ()
rows: list[tuple[str, int, int]] = []
for f in files:
try:
st = os.stat(f)
except OSError:
continue
rows.append((f, st.st_mtime_ns, st.st_size))
return tuple(sorted(rows))
def _build_index(self) -> None:
"""Rebuild the slug→path index from the current post cache."""
self._slug_index.clear()
if self._cached is None:
return
for post in self._cached:
if post.path:
self._slug_index[post.slug] = post.path
def _load_post(self, path: str) -> Post | None:
try:
with open(path) as fh:
with open(path, encoding="utf-8") as fh:
post = Post.parse(fh.read())
except Exception as e:
import sys
print(f"volumen: skipping {path}: {e}", file=sys.stderr)
except (OSError, tomllib.TOMLDecodeError, ValueError) as e:
logger.warning("volumen: skipping %s: %s", path, e)
return None
if not post.metadata.get("slug"):
post.metadata["slug"] = os.path.splitext(os.path.basename(path))[0]
@@ -150,18 +345,30 @@ class Store:
target = os.path.join(directory, f"{post.slug}.md")
target_abs = os.path.abspath(target)
content_abs = os.path.abspath(self.content_dir)
if not target_abs.startswith(content_abs + os.sep) and target_abs != content_abs:
if not (target_abs == content_abs or target_abs.startswith(content_abs + os.sep)):
raise ValueError("slug escapes content directory")
return target
def _safe_media_name(self, original: str) -> str:
base = os.path.basename(original)
def _resolve_extension(self, original_name: str, bytes_data: bytes | None) -> str:
"""Pick a canonical extension based on the signature (preferred)
or, as a fallback, the original filename (whitelisted to
``.webp``/``.avif``).
"""
if bytes_data is not None:
detected = detect_image_extension(bytes_data)
if detected is not None:
return detected
base = os.path.basename(original_name)
ext = re.sub(r"[^a-zA-Z0-9.]", "", os.path.splitext(base)[1]).lower()
stem = os.path.splitext(base)[0]
stem = re.sub(r"[^a-zA-Z0-9_-]+", "-", stem).strip("-").lower() or "file"
return f"{secrets.token_hex(4)}-{stem}{ext}"
if ext in ALLOWED_IMAGE_EXTENSIONS:
return ext
return ".webp"
def _within(self, directory: str, path: str) -> bool:
dir_abs = os.path.abspath(directory)
path_abs = os.path.abspath(path)
return path_abs.startswith(dir_abs + os.sep) or path_abs == dir_abs
def _is_within(self, directory: str, path: str) -> bool:
"""Check that *path* is inside *directory* (canonicalised)."""
try:
dir_resolved = Path(directory).resolve()
path_resolved = Path(path).resolve()
return path_resolved == dir_resolved or path_resolved.is_relative_to(dir_resolved)
except OSError, RuntimeError:
return False
+86
View File
@@ -0,0 +1,86 @@
"""Validation helpers for uploaded media files."""
from __future__ import annotations
import json
from typing import Any
from fastapi import HTTPException, UploadFile
from starlette.concurrency import run_in_threadpool
ALLOWED_UPLOAD_TYPES: tuple[str, ...] = ("image/webp", "image/avif")
ALLOWED_UPLOAD_EXTENSIONS: tuple[str, ...] = (".webp", ".avif")
async def _read_upload_bytes(upload: UploadFile, max_bytes: int) -> bytes:
chunks: list[bytes] = []
total = 0
while True:
chunk = await upload.read(65536)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise HTTPException(
status_code=413,
detail=json.dumps({"message": "Payload too large", "limit": max_bytes}),
)
chunks.append(chunk)
return b"".join(chunks)
async def validate_upload(upload: Any, *, max_bytes: int | None = None) -> tuple[bytes, str]:
"""Validate upload size, MIME type, and signature."""
from .config import DEFAULT_MAX_UPLOAD_BYTES
from .store import detect_image_extension
if max_bytes is None:
max_bytes = DEFAULT_MAX_UPLOAD_BYTES
data = await _read_upload_bytes(upload, max_bytes)
declared = (getattr(upload, "content_type", None) or "").split(";")[0].strip().lower()
if declared not in ALLOWED_UPLOAD_TYPES:
raise HTTPException(
status_code=415,
detail=json.dumps(
{
"error": "unsupported_format",
"message": (
"Only WebP (.webp) and AVIF (.avif) images are supported. "
"Please convert your image before uploading — for example with "
"`cwebp` (JPEG/PNG → WebP) or `avifenc` (PNG/JPEG → AVIF)."
),
"allowed": list(ALLOWED_UPLOAD_TYPES),
}
),
)
extension = await run_in_threadpool(detect_image_extension, data)
if extension is None:
raise HTTPException(
status_code=415,
detail=json.dumps(
{
"error": "invalid_signature",
"message": (
"Uploaded file does not look like a valid WebP or AVIF image. "
"Make sure the bytes match the declared MIME type."
),
}
),
)
expected_mime = {".webp": "image/webp", ".avif": "image/avif"}.get(extension)
if expected_mime is None or expected_mime != declared:
raise HTTPException(
status_code=415,
detail=json.dumps(
{
"error": "mime_mismatch",
"message": (
"Declared MIME does not match the file signature. "
"Only WebP or AVIF images are accepted."
),
}
),
)
return data, extension
+187
View File
@@ -0,0 +1,187 @@
"""User account and profile settings action helpers."""
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any
from starlette.concurrency import run_in_threadpool
from .auth import password_error
from .payloads import presence
from .upload_validation import validate_upload
if TYPE_CHECKING:
from .store import Store
from .users import Users
USERNAME_REGEX: re.Pattern[str] = re.compile(r"\A[a-zA-Z0-9._-]+\Z")
def change_password(
users_obj: Users,
current_username: str,
current_password: str,
new_password: str,
*,
min_length: int | None = None,
max_length: int | None = None,
) -> tuple[str | None, str | None]:
"""Change the user's password."""
if not users_obj.authenticate(current_username, current_password):
return ("Current password is incorrect.", None)
fresh = presence(new_password)
if fresh is None:
return ("New password cannot be empty.", None)
error = password_error(fresh, min_length=min_length, max_length=max_length)
if error is not None:
return (error, None)
users_obj.update_password(current_username, fresh)
return (None, "Password updated.")
def change_username(
users_obj: Users,
current_username: str,
new_username: str,
session: dict[str, Any],
) -> tuple[str | None, str | None]:
"""Rename the current user and update the session."""
name = presence(new_username)
if name is None:
return ("Username cannot be empty.", None)
if not re.match(USERNAME_REGEX, name):
return ("Username may use letters, numbers, dot, dash, underscore.", None)
if name == current_username:
return (None, "Username unchanged.")
if users_obj.rename(current_username, name):
session["user"] = name
return (None, "Username updated.")
return ("That username is already taken.", None)
def change_name(
users_obj: Users,
current_username: str,
display_name: str,
) -> tuple[str | None, str | None]:
"""Update or clear the display name."""
name = display_name.strip()
name = name if name else None
users_obj.update_name(current_username, name)
return (None, "Display name updated." if name else "Display name cleared.")
def change_fediverse_creator(
users_obj: Users,
current_username: str,
value: str,
) -> tuple[str | None, str | None]:
"""Update or clear the fediverse handle."""
from .post import FEDIVERSE_CREATOR_REGEX
text = value.strip()
if not text:
users_obj.update_fediverse_creator(current_username, None)
return (None, "Fediverse handle cleared.")
if not re.match(FEDIVERSE_CREATOR_REGEX, text):
return ("Fediverse handle must look like @user@host.", None)
users_obj.update_fediverse_creator(current_username, text)
return (None, "Fediverse handle updated.")
async def change_photo(
users_obj: Users,
store: Store,
current_username: str,
upload: Any,
) -> tuple[str | None, str | None]:
"""Store a profile photo and remove the previous file if unreferenced."""
bytes_data, _extension = await validate_upload(upload)
url = await run_in_threadpool(store.store_upload, upload.filename, upload.file, bytes_data)
previous = None
user = users_obj.find(current_username)
if user is not None:
previous = user.photo
users_obj.update_photo(current_username, url)
if previous and previous != url:
await run_in_threadpool(_delete_unreferenced_media, users_obj, store, previous)
return (None, "Profile photo updated.")
async def remove_photo(
users_obj: Users,
current_username: str,
store: Store | None = None,
) -> tuple[str | None, str | None]:
"""Remove the profile photo and optionally delete the file."""
user = users_obj.find(current_username)
if user is not None and user.photo and store is not None:
await run_in_threadpool(_delete_unreferenced_media, users_obj, store, user.photo)
users_obj.update_photo(current_username, None)
return (None, "Profile photo removed.")
def create_user(
users_obj: Users,
username: str,
password: str,
role: str,
*,
min_length: int | None = None,
max_length: int | None = None,
) -> tuple[str | None, str | None]:
"""Create a new user."""
name = presence(username)
password_value = presence(password)
if name is None or password_value is None:
return ("Username and password are required.", None)
if not re.match(USERNAME_REGEX, name):
return ("Username may use letters, numbers, dot, dash, underscore.", None)
error = password_error(password_value, min_length=min_length, max_length=max_length)
if error is not None:
return (error, None)
if users_obj.add(name, password_value, role):
return (None, "User added.")
return ("That username already exists.", None)
async def remove_user(
users_obj: Users,
current_username: str,
target_username: str,
store: Store | None = None,
) -> tuple[str | None, str | None]:
"""Delete a user and clean up their profile photo."""
if target_username == current_username:
return ("You cannot delete your own account.", None)
target = users_obj.find(target_username)
if target is not None and target.photo and store is not None:
await run_in_threadpool(_delete_unreferenced_media, users_obj, store, target.photo)
if users_obj.delete(target_username):
return (None, "User removed.")
return ("Cannot remove the last user or the last admin.", None)
def change_role(
users_obj: Users,
current_username: str,
target_username: str,
new_role: str,
) -> tuple[str | None, str | None]:
"""Change a user's role."""
if target_username == current_username:
return ("You cannot change your own role.", None)
if users_obj.set_role(target_username, new_role):
return (None, "Role updated.")
return ("Could not change role (last admin?).", None)
def _delete_unreferenced_media(users_obj: Users, store: Store, url: str) -> None:
"""Delete *url* from disk if no other user record references it."""
if not url or not url.startswith("/media/"):
return
in_use = any(user.photo == url for user in users_obj.all)
if in_use:
return
store.delete_media(url)
+110 -49
View File
@@ -2,20 +2,23 @@
from __future__ import annotations
import contextlib
import logging
import os
import tempfile
import threading
import tomllib
from dataclasses import dataclass
from typing import Any
try:
import tomllib
except ImportError:
import tomli as tomllib # type: ignore[import-not-found,unused-ignore]
import tomli_w
from .password import hash_password as _hash_pw
from .password import needs_rehash as _needs_rehash
from .password import verify_password as _verify_pw
logger = logging.getLogger(__name__)
ROLES = ("admin", "author")
DEFAULT_ROLE = "author"
@@ -31,20 +34,31 @@ class User:
class Users:
"""File-backed collection of admin/authors."""
"""File-backed collection of admin/authors.
The class serialises read-modify-write transactions with an internal
lock so concurrent admin actions cannot clobber each other. A
per-instance snapshot of (path, mtime_ns, size) is used for cache
invalidation so out-of-band edits are picked up promptly.
"""
def __init__(self, path: str, bootstrap_hash: str | None = None) -> None:
self._path = path
self._bootstrap_hash = bootstrap_hash or ""
self._cached: list[User] | None = None
self._mtime: float | None = None
self._snapshot: tuple[tuple[str, int, int], ...] | None = None
self._lock = threading.Lock()
@property
def path(self) -> str:
return self._path
@property
def all(self) -> list[User]:
mtime = os.path.getmtime(self._path) if os.path.isfile(self._path) else None
if self._cached is not None and mtime == self._mtime:
snapshot = self._build_snapshot()
if self._cached is not None and self._snapshot is not None and snapshot == self._snapshot:
return list(self._cached)
self._mtime = mtime
self._snapshot = snapshot
if os.path.isfile(self._path):
self._cached = self._read_file()
elif self._bootstrap_hash:
@@ -70,12 +84,13 @@ class Users:
return None
def add(self, username: str, password: str, role: str = DEFAULT_ROLE) -> User | None:
if not username or self.find(username):
return None
role = role if role in ROLES else DEFAULT_ROLE
user = User(username, _hash_pw(password), role)
self._persist([*self.all, user])
return user
with self._lock:
if not username or self.find(username):
return None
role = role if role in ROLES else DEFAULT_ROLE
user = User(username, _hash_pw(password), role)
self._persist([*self.all, user])
return user
def update_name(self, username: str, name: str | None) -> User | None:
return self._mutate(username, lambda u: setattr(u, "name", name if name else None))
@@ -99,58 +114,82 @@ class Users:
def set_role(self, username: str, role: str) -> User | None:
if role not in ROLES:
return None
users = self.all
user = next((u for u in users if u.username == username), None)
if user is None:
return None
if user.role == "admin" and role != "admin" and self._admin_count(users) <= 1:
return None
user.role = role
self._persist(users)
return user
with self._lock:
users = self.all
user = next((u for u in users if u.username == username), None)
if user is None:
return None
if user.role == "admin" and role != "admin" and self._admin_count(users) <= 1:
return None
user.role = role
self._persist(users)
return user
def delete(self, username: str) -> str | None:
users = self.all
target = next((u for u in users if u.username == username), None)
if target is None or len(users) <= 1:
return None
if target.role == "admin" and self._admin_count(users) <= 1:
return None
self._persist([u for u in users if u.username != username])
return username
with self._lock:
users = self.all
target = next((u for u in users if u.username == username), None)
if target is None or len(users) <= 1:
return None
if target.role == "admin" and self._admin_count(users) <= 1:
return None
self._persist([u for u in users if u.username != username])
return username
def _mutate(self, username: str, func: Any) -> User | None:
users = self.all
user = next((u for u in users if u.username == username), None)
if user is None:
return None
func(user)
self._persist(users)
return user
with self._lock:
users = self.all
user = next((u for u in users if u.username == username), None)
if user is None:
return None
func(user)
self._persist(users)
return user
def _admin_count(self, users: list[User]) -> int:
return sum(1 for u in users if u.role == "admin")
def _build_snapshot(self) -> tuple[tuple[str, int, int], ...]:
"""Return (path, mtime_ns, size) snapshot for the users file."""
if not os.path.isfile(self._path):
return ()
try:
st = os.stat(self._path)
except OSError:
return ()
return ((self._path, st.st_mtime_ns, st.st_size),)
def _read_file(self) -> list[User]:
with open(self._path, "rb") as fh:
data = tomllib.load(fh)
try:
with open(self._path, "rb") as fh:
data = tomllib.load(fh)
except (OSError, tomllib.TOMLDecodeError) as exc:
logger.warning("users: failed to read %s: %s", self._path, exc)
return []
result: list[User] = [
User(
username=entry["username"],
password_hash=entry["password_hash"],
role=entry.get("role", "admin"),
role=entry.get("role", DEFAULT_ROLE),
name=entry.get("name"),
fediverse_creator=entry.get("fediverse_creator"),
photo=entry.get("photo"),
)
for entry in data.get("users", [])
]
# Log a warning when stored hashes are weak so admins see them.
for u in result:
if _needs_rehash(u.password_hash):
logger.warning(
"users: stored hash for %s uses weak parameters; should be re-hashed",
u.username,
)
return result
def _persist(self, users: list[User]) -> None:
os.makedirs(os.path.dirname(self._path) or ".", exist_ok=True)
tmp = f"{self._path}.tmp"
user_list = []
parent = os.path.dirname(self._path) or "."
os.makedirs(parent, exist_ok=True)
user_list: list[dict[str, Any]] = []
for u in users:
entry: dict[str, Any] = {
"username": u.username,
@@ -164,6 +203,28 @@ class Users:
if u.photo:
entry["photo"] = u.photo
user_list.append(entry)
with open(tmp, "wb") as fh:
fh.write(tomli_w.dumps({"users": user_list}).encode("utf-8"))
os.replace(tmp, self._path)
payload = tomli_w.dumps({"users": user_list}).encode("utf-8")
fd, tmp_path = tempfile.mkstemp(prefix=".users-", suffix=".tmp", dir=parent)
try:
with os.fdopen(fd, "wb") as fh:
fh.write(payload)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp_path, self._path)
os.chmod(self._path, 0o600)
try:
dir_fd = os.open(parent, os.O_DIRECTORY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
except OSError, AttributeError:
pass
except OSError, ValueError:
# Best-effort cleanup of the tempfile if rename failed.
with contextlib.suppress(OSError):
os.remove(tmp_path)
raise
# Force cache re-read on next access.
self._cached = None
self._snapshot = None
+15 -2
View File
@@ -1,5 +1,18 @@
"""volumen version."""
"""Canonical volumen version (read from package metadata)."""
from __future__ import annotations
VERSION = "0.4.0"
from importlib import metadata
__all__ = ["VERSION", "version"]
def version() -> str:
"""Return the installed volumen version (from package metadata)."""
try:
return metadata.version("volumen")
except metadata.PackageNotFoundError:
return "0.0.0"
VERSION: str = version()
+32 -11
View File
@@ -130,7 +130,7 @@
<input id="cover-caption-input" class="input" type="text" name="cover_caption" value="{{ post.cover_caption if post.cover_caption else '' }}"
placeholder="Caption (e.g. 'Generated by AI')">
<img id="cover-preview" class="cover-preview" alt="" hidden>
<input type="file" id="cover-file" accept="image/*" hidden>
<input type="file" id="cover-file" accept="image/webp,image/avif" hidden>
</div>
</div>
@@ -191,10 +191,10 @@
</div>
</div>
<input type="file" id="image-input" accept="image/*" hidden>
<input type="file" id="image-input" accept="image/webp,image/avif" hidden>
</form>
<script>
<script nonce="{{ csp_nonce | default('') }}">
(function () {
var form = document.getElementById("post-form");
var textarea = document.getElementById("body");
@@ -295,9 +295,30 @@
// --- Live preview (markdown mode) — disabled by default ----------------
function renderPreview() {
mdToHtml(textarea.value).then(function (html) { preview.innerHTML = html; });
}
function setSafeHtml(target, html) {
// Replace ``innerHTML`` with a parse-and-replace strategy so that
// any unexpected <script> blocks won't execute. We assume the
// server already strips dangerous content (see nh3 sanitisation
// in the preview endpoint), but the DOMParser-based update adds
// defence in depth: <script> elements inserted into the parsed
// document are not executed when appended via Node.replaceChild.
while (target.firstChild) {
target.removeChild(target.firstChild);
}
var parser = new DOMParser();
var doc = parser.parseFromString("<div>" + html + "</div>", "text/html");
var source = doc.body.firstChild;
if (!source) { return; }
// Import each child individually so any inline scripts are not
// marked as already-started (and thus do not execute).
while (source.firstChild) {
target.appendChild(document.importNode(source.firstChild, true));
}
}
function renderPreview() {
mdToHtml(textarea.value).then(function (html) { setSafeHtml(preview, html); });
}
function schedule() {
clearTimeout(timer);
@@ -500,11 +521,11 @@
}
function showVisual() {
mdToHtml(textarea.value).then(function (html) { wysiwyg.innerHTML = html; });
visualView.hidden = false;
markdownView.hidden = true;
setActiveTab("visual");
}
mdToHtml(textarea.value).then(function (html) { setSafeHtml(wysiwyg, html); });
visualView.hidden = false;
markdownView.hidden = true;
setActiveTab("visual");
}
function showMarkdown() {
textarea.value = htmlToMd(wysiwyg);
+3 -5
View File
@@ -3,10 +3,8 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>volumen admin</title>
<link rel="preconnect" href="https://rsms.me/">
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<style>
<title>volumen admin</title>
<style nonce="{{ csp_nonce | default('') }}">
:root {
color-scheme: light dark;
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
@@ -922,7 +920,7 @@
</div>
{% endif %}
<script>
<script nonce="{{ csp_nonce | default('') }}">
/* Custom date picker — replaces <input type="date" class="input"> with
a styled trigger that opens a popover calendar matching the design
system. The native input is hidden but kept in the DOM so form
+9 -8
View File
@@ -17,14 +17,14 @@ from volumen.users import Users
@pytest.fixture(autouse=True)
def _reset_login_attempts() -> Generator[None, None, None]:
def _reset_login_attempts() -> Generator[None]:
"""Reset the global login-attempts tracker before every test."""
reset_login_attempts()
yield
@pytest.fixture
def tmp_dir() -> Generator[Path, None, None]:
def tmp_dir() -> Generator[Path]:
"""Create a temporary directory that is cleaned up after the test."""
with tempfile.TemporaryDirectory() as d:
yield Path(d)
@@ -43,11 +43,12 @@ def tmp_content_dir(tmp_path: Path) -> Path:
@pytest.fixture
def config(tmp_content_dir: Path) -> Config:
def config(tmp_content_dir: Path, tmp_path: Path) -> Config:
"""Test Config instance pointing at a temp content directory."""
users_file = str(tmp_path / "users.toml")
return Config.load(
path="/nonexistent/volumen.toml",
overrides={"content": str(tmp_content_dir)},
overrides={"content": str(tmp_content_dir), "users_file": users_file},
)
@@ -66,7 +67,7 @@ def users_path(tmp_path: Path) -> str:
@pytest.fixture
def bootstrap_hash() -> str:
"""A pre-hashed password for bootstrap tests."""
return hash_pw("secret")
return hash_pw("volumen-test-password")
@pytest.fixture
@@ -78,7 +79,7 @@ def users(users_path: str, bootstrap_hash: str) -> Users:
@pytest.fixture
def client(config: Config, store: Store) -> TestClient:
"""FastAPI TestClient for public API tests."""
from volumen.server import create_app
from volumen.app import create_app
app = create_app(config, store)
return TestClient(app)
@@ -91,7 +92,7 @@ def admin_config_dir(tmp_path: Path) -> Path:
posts_dir.mkdir()
users_path = tmp_path / "users.toml"
config_path = tmp_path / "config.toml"
pw_hash = hash_pw("secret")
pw_hash = hash_pw("volumen-test-password")
config_path.write_text(
f'content_dir = "{posts_dir}"\n'
f'users_file = "{users_path}"\n\n'
@@ -104,8 +105,8 @@ def admin_config_dir(tmp_path: Path) -> Path:
@pytest.fixture
def admin_client(admin_config_dir: Path) -> TestClient:
"""FastAPI TestClient for admin tests with bootstrap admin."""
from volumen.app import create_app
from volumen.config import Config
from volumen.server import create_app
from volumen.store import Store
config = Config.load(path=str(admin_config_dir / "config.toml"))
+24 -11
View File
@@ -32,7 +32,11 @@ class TestAdminLogin:
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/login",
data={"username": "admin", "password": "secret", "_csrf": token},
data={
"username": "admin",
"password": "volumen-test-password",
"_csrf": token,
},
follow_redirects=False,
)
assert resp.status_code in (302, 307, 303)
@@ -62,7 +66,11 @@ def _login(client: TestClient) -> str:
token = _csrf_from_body(resp.text)
client.post(
"/admin/login",
data={"username": "admin", "password": "secret", "_csrf": token},
data={
"username": "admin",
"password": "volumen-test-password",
"_csrf": token,
},
follow_redirects=True,
)
return token
@@ -264,16 +272,16 @@ class TestSettings:
"/admin/settings/password",
data={
"_csrf": token,
"current_password": "secret",
"new_password": "brand-new",
"current_password": "volumen-test-password",
"new_password": "volumen-brand-new-pw",
},
)
assert "Password updated" in resp.text
users_path = str(admin_config_dir / "users.toml")
fresh = Users(path=users_path)
assert fresh.authenticate("admin", "secret") is None
assert fresh.authenticate("admin", "brand-new") is not None
assert fresh.authenticate("admin", "volumen-test-password") is None
assert fresh.authenticate("admin", "volumen-brand-new-pw") is not None
def test_change_password_wrong_current(self, admin_client: TestClient) -> None:
_login(admin_client)
@@ -339,7 +347,7 @@ class TestFediverseSettings:
"/admin/settings/fediverse",
data={"_csrf": token, "fediverse_creator": "@user@example.com"},
)
assert "updated" in resp.text.lower() or "updated" in resp.text.lower()
assert "updated" in resp.text.lower()
def test_change_fediverse_creator_invalid(self, admin_client: TestClient) -> None:
_login(admin_client)
@@ -359,9 +367,14 @@ class TestUserManagement:
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/users",
data={"_csrf": token, "username": "editor", "password": "pw12345", "role": "author"},
data={
"_csrf": token,
"username": "editor",
"password": "editor-test-pw",
"role": "author",
},
)
assert "User added" in resp.text or "added" in resp.text.lower()
assert "User added" in resp.text
users_path = str(admin_config_dir / "users.toml")
users = Users(path=users_path)
@@ -373,7 +386,7 @@ class TestUserManagement:
token = _csrf_from_body(resp.text)
admin_client.post(
"/admin/settings/users",
data={"_csrf": token, "username": "editor2", "password": "pw12345"},
data={"_csrf": token, "username": "editor2", "password": "editor-test-pw"},
)
# Logout
@@ -382,7 +395,7 @@ class TestUserManagement:
admin_client.post("/admin/logout", data={"_csrf": token})
# Login as new user
_login_as(admin_client, "editor2", "pw12345")
_login_as(admin_client, "editor2", "editor-test-pw")
resp = admin_client.get("/admin/")
assert resp.status_code == 200
+423
View File
@@ -2,14 +2,18 @@
from __future__ import annotations
import argparse
import contextlib
import json
import sys
import urllib.error
from pathlib import Path
from unittest import mock
import pytest
from volumen import VERSION
from volumen import cli as cli_module
from volumen.cli import main
@@ -87,3 +91,422 @@ class TestPrepareConfig:
path.write_text("[server]\nport = 1\n")
result = Config.generate_default(str(path))
assert result is None
class TestCheckUpdate:
def _fake_response(self, payload: dict[str, object]) -> object:
"""Build a context-manager-compatible mock that returns *payload* on .read()."""
response = mock.MagicMock()
response.read.return_value = json.dumps(payload).encode("utf-8")
response.__enter__.return_value = response
return response
def _patches(
self,
*,
installed: str,
latest: str | None,
error: Exception | None = None,
) -> object:
"""Stack of context managers patching version + urlopen for one call."""
from contextlib import ExitStack
stack = ExitStack()
stack.enter_context(
mock.patch.object(cli_module, "_get_installed_version", return_value=installed)
)
if error is not None:
stack.enter_context(
mock.patch.object(cli_module.urllib.request, "urlopen", side_effect=error)
)
else:
stack.enter_context(
mock.patch.object(
cli_module.urllib.request,
"urlopen",
return_value=self._fake_response({"info": {"version": latest}}),
)
)
return stack
def test_update_available(self, capsys: pytest.CaptureFixture[str]) -> None:
args = argparse.Namespace(json=False)
with self._patches(installed="0.4.0", latest="0.5.0"):
rc = cli_module._check_update(args)
captured = capsys.readouterr()
assert rc == 1
assert "0.4.0" in captured.out
assert "0.5.0" in captured.out
assert "uv tool upgrade volumen" in captured.out
def test_up_to_date(self, capsys: pytest.CaptureFixture[str]) -> None:
args = argparse.Namespace(json=False)
with self._patches(installed="0.5.0", latest="0.5.0"):
rc = cli_module._check_update(args)
captured = capsys.readouterr()
assert rc == 0
assert "up to date" in captured.out
def test_local_ahead_of_remote(self, capsys: pytest.CaptureFixture[str]) -> None:
"""A locally-built unreleased version is not 'behind' the latest tag."""
args = argparse.Namespace(json=False)
with self._patches(installed="1.0.0a1", latest="0.9.0"):
rc = cli_module._check_update(args)
captured = capsys.readouterr()
assert rc == 0
assert "up to date" in captured.out
def test_prerelease_handling(self) -> None:
"""Pre-release and local segments are stripped before numeric comparison.
Implementation is intentionally conservative ``0.5.0a1`` and ``0.5.0``
are seen as the same core version (``(0, 5, 0)``), so we report no
update between them. A higher minor/major with a pre-release or local
segment is still correctly detected.
"""
assert cli_module._compare_versions("0.5.0", "0.5.1a1+build") is True
assert cli_module._compare_versions("0.5.0+a", "0.5.0+a") is False
assert cli_module._compare_versions("0.5.0+a", "0.5.1") is True
def test_offline_returns_exit_code_2(self, capsys: pytest.CaptureFixture[str]) -> None:
args = argparse.Namespace(json=False)
with self._patches(
installed="0.4.0",
latest=None,
error=urllib.error.URLError("no internet"),
):
rc = cli_module._check_update(args)
captured = capsys.readouterr()
assert rc == 2
assert "could not reach" in captured.err
assert "0.4.0" in captured.err
def test_json_output_with_update_available(self, capsys: pytest.CaptureFixture[str]) -> None:
args = argparse.Namespace(json=True)
with self._patches(installed="0.4.0", latest="0.5.0"):
rc = cli_module._check_update(args)
captured = capsys.readouterr()
assert rc == 1
doc = json.loads(captured.out)
assert doc["current_version"] == "0.4.0"
assert doc["latest_version"] == "0.5.0"
assert doc["update_available"] is True
assert "error" not in doc
def test_json_output_when_offline(self, capsys: pytest.CaptureFixture[str]) -> None:
args = argparse.Namespace(json=True)
with self._patches(
installed="0.4.0",
latest=None,
error=urllib.error.URLError("dns failure"),
):
rc = cli_module._check_update(args)
captured = capsys.readouterr()
assert rc == 2
doc = json.loads(captured.out)
assert doc["current_version"] == "0.4.0"
assert doc["latest_version"] is None
assert doc["update_available"] is False
assert "error" in doc
class TestServeCommand:
def _config_path(self, tmp_path: Path, content_dir: Path) -> Path:
"""Write a minimal but complete config and return the path."""
config_path = tmp_path / "config.toml"
users_file = tmp_path / "users.toml"
config_path.write_text(
f'content_dir = "{content_dir}"\n'
f'users_file = "{users_file}"\n'
"[admin]\n"
'password_hash = ""\n'
)
return config_path
def test_serve_loads_config_and_invokes_uvicorn(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
content_dir = tmp_path / "posts"
content_dir.mkdir()
config_path = self._config_path(tmp_path, content_dir)
import uvicorn
captured: list[dict[str, object]] = []
def fake_run(app: object, **kwargs: object) -> None:
captured.append({"app": app, **kwargs})
monkeypatch.setattr(uvicorn, "run", fake_run)
args = argparse.Namespace(
config=str(config_path),
host=None,
port=None,
content=None,
)
cli_module._serve(args)
assert len(captured) == 1
call = captured[0]
assert call["host"] == "::"
assert call["port"] == 9090
assert call["log_level"] == "info"
from fastapi import FastAPI
assert isinstance(call["app"], FastAPI)
def test_serve_applies_host_and_port_overrides(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
content_dir = tmp_path / "posts"
content_dir.mkdir()
config_path = self._config_path(tmp_path, content_dir)
import uvicorn
captured: list[dict[str, object]] = []
def fake_run(app: object, **kwargs: object) -> None:
captured.append(kwargs)
monkeypatch.setattr(uvicorn, "run", fake_run)
args = argparse.Namespace(
config=str(config_path),
host="127.0.0.1",
port=9000,
content=None,
)
cli_module._serve(args)
assert captured[0]["host"] == "127.0.0.1"
assert captured[0]["port"] == 9000
class TestStatusCommand:
def test_status_human_output_with_issues(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
info = {
"config_path": "/etc/volumen/config.toml",
"data_dir": "/var/lib/volumen/posts",
"users_file": "/var/lib/volumen/users.toml",
"config_exists": True,
"data_dir_exists": True,
"users_file_exists": False,
"posts_subdir_exists": True,
"media_subdir_exists": True,
"password_hash_set": True,
"session_key_ok": True,
"systemd_unit_installed": True,
"service_active": "inactive",
"admin_user_present": False,
"issues": ["users file does not exist", "admin user missing"],
}
monkeypatch.setattr(cli_module, "_status", _FakeStatus(info))
with (
mock.patch.object(sys, "argv", ["volumen", "status"]),
pytest.raises(SystemExit) as exc,
):
main()
captured = capsys.readouterr()
assert exc.value.code == 1
assert "==>" in captured.out
assert "volumen status" in captured.out
assert "users file does not exist" in captured.out
assert "admin user missing" in captured.out
assert "Issues:" in captured.out
def test_status_returns_zero_when_no_issues(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
info = {
"config_path": "/etc/volumen/config.toml",
"data_dir": "/var/lib/volumen/posts",
"users_file": "/var/lib/volumen/users.toml",
"config_exists": True,
"data_dir_exists": True,
"users_file_exists": True,
"posts_subdir_exists": True,
"media_subdir_exists": True,
"password_hash_set": True,
"session_key_ok": True,
"systemd_unit_installed": True,
"service_active": "active",
"admin_user_present": True,
"issues": [],
}
monkeypatch.setattr(cli_module, "_status", _FakeStatus(info))
with (
mock.patch.object(sys, "argv", ["volumen", "status"]),
pytest.raises(SystemExit) as exc,
):
main()
captured = capsys.readouterr()
assert exc.value.code == 0
assert "All checks passed." in captured.out
class _FakeStatus:
"""Mock returning a static info dict from cli._status."""
def __init__(self, info: dict[str, object]) -> None:
self._info = info
def __call__(self, args: argparse.Namespace) -> int:
# Mirror the shape of the real _status(). For human mode print plain
# text; for --json print the info as JSON. Exit code 0 if no issues.
import json as _json
if args.json:
print(_json.dumps(self._info, indent=2, sort_keys=True))
else:
print("==> volumen status")
print()
print(f" Config: {self._info['config_path']}")
print(f" Data dir: {self._info['data_dir']}")
print(f" Users file: {self._info['users_file']}")
unit = "installed" if self._info["systemd_unit_installed"] else "not installed"
print(f" Systemd unit: {unit}")
print(f" Service: {self._info['service_active']}")
presence = "present" if self._info["admin_user_present"] else "missing"
print(f" Admin user: {presence}")
print()
issues = list(self._info["issues"])
if not issues:
print("All checks passed.")
else:
print("Issues:")
for issue in issues:
print(f" - {issue}")
return 0 if not self._info["issues"] else 1
class TestInitCommand:
def test_init_rejects_systemd_under_local(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
# Force ``use_user=True`` then ``--systemd`` → exit 2 with explanation.
monkeypatch.setattr(cli_module, "_is_root", lambda: False)
with (
mock.patch.object(
sys,
"argv",
["volumen", "init", "--local", "--systemd"],
),
pytest.raises(SystemExit) as exc,
):
main()
assert exc.value.code == 2
captured = capsys.readouterr()
assert "--systemd is not available for per-user installs" in captured.err
def test_init_propagates_installer_error(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
# ``system_install`` is mocked to raise InstallerError → exit 1 + stderr msg.
monkeypatch.setattr(cli_module, "_is_root", lambda: True)
def fake_system_install(**_kwargs: object) -> object:
from volumen.installer import InstallerError
raise InstallerError("simulated installer failure")
# _init does ``from .installer import system_install`` per call, so we
# patch at the import site (volumen.installer.system_install) rather
# than the local reference inside cli_module.
monkeypatch.setattr("volumen.installer.system_install", fake_system_install)
args = argparse.Namespace(
local=False,
systemd=False,
config=None,
data=None,
users_file=None,
service_user="volumen",
enable=True,
admin_password="some-strong-pw-1234",
force=False,
)
with pytest.raises(SystemExit) as exc:
cli_module._init(args)
captured = capsys.readouterr()
assert exc.value.code == 1
assert "simulated installer failure" in captured.err
def test_init_user_install_branch(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
# ``--local`` → user_install() path. Mock user_install to skip side effects.
monkeypatch.setattr(cli_module, "_is_root", lambda: False)
from volumen.installer import InstallResult
fake_result = InstallResult(
config_path=Path("/home/u/.config/volumen/config.toml"),
content_dir=Path("/home/u/.local/share/volumen/posts"),
users_file=Path("/home/u/.local/share/volumen/users.toml"),
service_user=None,
password_hash="scrypt$...",
session_key="x" * 128,
systemd_unit_path=None,
messages=[],
)
monkeypatch.setattr(
"volumen.installer.user_install",
lambda **_kw: fake_result,
)
args = argparse.Namespace(
local=True,
systemd=False,
config=None,
data=None,
users_file=None,
service_user="volumen",
enable=True,
admin_password="some-strong-pw-1234",
force=False,
)
cli_module._init(args) # Should not raise.
captured = capsys.readouterr()
assert "==> volumen installed." in captured.out
assert "Service: not installed" in captured.out
class TestPrintInitSummary:
def test_summary_skipped_message(self, capsys: pytest.CaptureFixture[str]) -> None:
from volumen.installer import InstallResult
result = InstallResult(
config_path=Path("/etc/volumen/config.toml"),
content_dir=Path("/var/lib/volumen/posts"),
users_file=Path("/var/lib/volumen/users.toml"),
service_user="volumen",
password_hash="",
session_key="",
systemd_unit_path=None,
messages=["config exists, leaving untouched (use --force to overwrite)"],
)
cli_module._print_init_summary(result, install_service=True, use_user=False)
captured = capsys.readouterr()
assert "left untouched" in captured.out
assert "Nothing to do" in captured.out
def test_summary_user_install(self, capsys: pytest.CaptureFixture[str]) -> None:
from volumen.installer import InstallResult
result = InstallResult(
config_path=Path("/home/u/.config/volumen/config.toml"),
content_dir=Path("/home/u/.local/share/volumen/posts"),
users_file=Path("/home/u/.local/share/volumen/users.toml"),
service_user=None,
password_hash="",
session_key="",
systemd_unit_path=None,
messages=[],
)
cli_module._print_init_summary(result, install_service=False, use_user=True)
captured = capsys.readouterr()
assert "Service: not installed" in captured.out
+104
View File
@@ -0,0 +1,104 @@
"""Concurrency tests: store/users racy write scenarios.
These tests hammer the same files from many threads to confirm that
per-target locks, fsync, and unique tempfiles do not corrupt data.
"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from volumen.post import Post
from volumen.store import Store
from volumen.users import Users
class TestStoreRace:
def test_concurrent_saves_keep_all_data(self, tmp_content_dir: Path) -> None:
"""Many threads save distinct posts in parallel."""
store = Store(str(tmp_content_dir), default_lang="en")
def worker(idx: int) -> str:
slug = f"post-{idx}"
post = Post(metadata={"slug": slug, "title": f"Post {idx}"}, body=f"body {idx}")
store.save(post)
return slug
with ThreadPoolExecutor(max_workers=16) as pool:
futures = [pool.submit(worker, i) for i in range(50)]
for f in as_completed(futures):
assert f.result() is not None
posts = store.all()
slugs = {p.slug for p in posts}
for i in range(50):
assert f"post-{i}" in slugs
def test_concurrent_saves_to_same_target(self, tmp_content_dir: Path) -> None:
"""Many threads save to the same target path; no data lost."""
store = Store(str(tmp_content_dir), default_lang="en")
def worker(idx: int) -> None:
post = Post(
metadata={"slug": "shared", "title": f"v{idx}"},
body=f"iteration {idx}",
)
store.save(post)
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(worker, range(20)))
post = store.find("shared")
assert post is not None
# The file content must end with one of our iterations, no torn write.
assert post.body.startswith("iteration ")
class TestUsersRace:
def test_concurrent_adds_keep_all_users(self, users_path: str) -> None:
users = Users(path=users_path)
def worker(idx: int) -> str:
name = f"user-{idx}"
users.add(name, "alice-test-password")
return name
with ThreadPoolExecutor(max_workers=8) as pool:
futures = [pool.submit(worker, i) for i in range(30)]
for f in as_completed(futures):
assert f.result() is not None
assert len(users.all) == 30
class TestSnapshotCache:
def test_snapshot_changes_invalidate_cache(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir), default_lang="en")
store.all() # prime cache
(tmp_content_dir / "extra.md").write_text('+++\ntitle = "Extra"\n+++\n\nNew post.\n')
# Even without touching mtime directly, stat() picks up the new file.
posts = store.all()
slugs = {p.slug for p in posts}
assert "extra" in slugs
def test_size_only_change_invalidates(self, tmp_content_dir: Path) -> None:
"""The cache key includes size; in-place rewrites invalidate."""
import os
store = Store(str(tmp_content_dir), default_lang="en")
# Force initial cache.
store.all()
# Overwrite an existing file with the same mtime but different size.
path = tmp_content_dir / "hello.md"
st = os.stat(path)
os.utime(path, (st.st_atime, st.st_mtime))
path.write_text(
'+++\ntitle = "Hello (resized)"\n+++\n\nNew body that is much longer than before.\n' * 5
)
# Touch back to original mtime so only size differs.
os.utime(path, (st.st_atime, st.st_mtime))
post = store.find("hello")
assert post is not None
assert "resized" in (post.title or "")
+177
View File
@@ -0,0 +1,177 @@
"""Coverage tests for feed helpers — RSS, JSON Feed, sitemap."""
from __future__ import annotations
from datetime import date
from typing import Any
from volumen.feed_helpers import (
_iso_date,
_rfc822_date,
render_json_feed,
render_rss_feed,
render_sitemap,
)
from volumen.post import Post
SITE: dict[str, Any] = {
"title": "My Blog",
"description": "A blog.",
"language": "en",
}
def _post(
*,
title: str = "Hello",
slug: str = "hello",
post_date: str = "2026-01-15",
tags: list[str] | None = None,
excerpt: str = "",
fediverse_creator: str = "",
body: str = "Body.",
) -> Post:
"""Build a Post with optional fields, simulating parse() without frontmatter I/O."""
metadata: dict[str, Any] = {"title": title, "slug": slug}
if post_date:
metadata["date"] = post_date
if tags is not None:
metadata["tags"] = tags
if excerpt:
metadata["excerpt"] = excerpt
if fediverse_creator:
metadata["fediverse_creator"] = fediverse_creator
return Post(metadata=metadata, body=body)
class TestRenderRSSFeed:
def test_basic_two_posts(self) -> None:
posts = [
_post(slug="a", title="Alpha"),
_post(slug="b", title="Beta"),
]
xml = render_rss_feed(posts, SITE, "https://example.com")
assert '<?xml version="1.0"' in xml
assert "<rss version=" in xml
assert "<title>My Blog</title>" in xml
assert "<link>https://example.com</link>" in xml
assert "<description>A blog.</description>" in xml
assert "<language>en</language>" in xml
assert "<title>Alpha</title>" in xml
assert "<title>Beta</title>" in xml
assert "<link>https://example.com/a</link>" in xml
assert "<link>https://example.com/b</link>" in xml
assert "pubDate" in xml
def test_truncates_to_20_items(self) -> None:
posts = [_post(slug=f"p{i:02d}") for i in range(25)]
xml = render_rss_feed(posts, SITE, "https://x")
assert xml.count("<item>") == 20
def test_escapes_special_characters_in_title(self) -> None:
xml = render_rss_feed(
[_post(title="A & B <C>", slug="x")],
SITE,
"https://x",
)
assert "A &amp; B &lt;C&gt;" in xml
def test_no_pub_date_when_date_missing(self) -> None:
# No `date` in metadata → date_string is None → no <pubDate> in the item.
xml = render_rss_feed([_post(post_date="")], SITE, "https://x")
item_block = xml.split("<item>")[1].split("</item>")[0]
assert "<pubDate>" not in item_block
def test_dc_creator_present_when_fediverse_set(self) -> None:
xml = render_rss_feed(
[_post(fediverse_creator="@alice@example.com")],
SITE,
"https://x",
)
assert "<dc:creator>@alice@example.com</dc:creator>" in xml
def test_no_dc_creator_when_fediverse_empty(self) -> None:
xml = render_rss_feed([_post()], SITE, "https://x")
assert "<dc:creator>" not in xml
class TestRenderJSONFeed:
def test_basic_shape(self) -> None:
feed = render_json_feed([_post(slug="a", tags=["intro"])], SITE, "https://x")
assert feed["version"] == "https://jsonfeed.org/version/1.1"
assert feed["title"] == "My Blog"
assert feed["home_page_url"] == "https://x"
assert feed["feed_url"] == "https://x/api/volumen/feed.json"
assert feed["language"] == "en"
assert feed["items"][0]["id"] == "https://x/a"
assert feed["items"][0]["url"] == "https://x/a"
assert feed["items"][0]["title"] == "Hello"
assert feed["items"][0]["tags"] == ["intro"]
def test_filters_empty_authors_and_tags(self) -> None:
# Default _post has no fediverse handle, no tags. ``summary`` is also
# present (it falls back to the derived excerpt from ``body``).
feed = render_json_feed([_post()], SITE, "https://x")
item = feed["items"][0]
assert "authors" not in item
assert "tags" not in item
def test_author_from_site_wide_fediverse(self) -> None:
site_with_creator = {**SITE, "fediverse_creator": "@site@example.com"}
feed = render_json_feed([_post()], site_with_creator, "https://x")
assert feed["items"][0]["authors"] == [{"name": "@site@example.com"}]
def test_author_from_post_overrides_site(self) -> None:
site_with_creator = {**SITE, "fediverse_creator": "@site@example.com"}
feed = render_json_feed(
[_post(fediverse_creator="@post@example.com")],
site_with_creator,
"https://x",
)
assert feed["items"][0]["authors"] == [{"name": "@post@example.com"}]
class TestRenderSitemap:
def test_basic_urlset(self) -> None:
xml = render_sitemap([_post(slug="x"), _post(slug="y")], "https://x")
assert "<urlset" in xml
assert "<loc>https://x/x</loc>" in xml
assert "<loc>https://x/y</loc>" in xml
assert "</urlset>" in xml
def test_lastmod_when_date_present(self) -> None:
xml = render_sitemap([_post()], "https://x")
assert "<lastmod>2026-01-15</lastmod>" in xml
def test_no_lastmod_when_date_empty(self) -> None:
# When post_date="" is passed, _post() omits "date" → date_string is None
# → no <lastmod> rendered.
xml = render_sitemap([_post(post_date="")], "https://x")
url_block = xml.split("<url>")[1].split("</url>")[0]
assert "<lastmod>" not in url_block
class TestDateHelpers:
def test_rfc822_from_date_object(self) -> None:
out = _rfc822_date(date(2026, 1, 15))
assert "2026" in out and "GMT" in out
def test_rfc822_from_iso_string(self) -> None:
out = _rfc822_date("2026-01-15")
assert "2026" in out
assert "GMT" in out
def test_rfc822_invalid_string_returns_as_is(self) -> None:
assert _rfc822_date("not-a-date") == "not-a-date"
def test_iso_none(self) -> None:
assert _iso_date(None) is None
def test_iso_date_object(self) -> None:
assert _iso_date(date(2026, 1, 15)) == "2026-01-15"
def test_iso_valid_string_passes_through(self) -> None:
assert _iso_date("2026-01-15") == "2026-01-15"
def test_iso_invalid_string_returns_as_is(self) -> None:
assert _iso_date("not-a-date") == "not-a-date"
+461
View File
@@ -0,0 +1,461 @@
"""Tests for the first-run bootstrap installer (system and per-user)."""
from __future__ import annotations
import contextlib
import json
import re
import sys
from pathlib import Path
from unittest import mock
import pytest
from volumen import installer
from volumen.cli import main
from volumen.config import Config
SYSTEM_DIR = Path("/etc/volumen")
SYSTEM_USERS_FILE = Path("/var/lib/volumen/users.toml")
# --- 1. system_install produces the expected config substitutions -----------
class TestSystemInstall:
def test_writes_config_with_expected_substitutions(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "config.toml"
content_dir = tmp_path / "posts"
users_file = tmp_path / "users.toml"
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
result = installer.system_install(
config_path=config_path,
content_dir=content_dir,
users_file=users_file,
service_user="volumen",
admin_password="TopSecret!",
)
assert result.config_path == config_path
text = config_path.read_text()
assert re.search(r'^host = "::1"', text, re.MULTILINE) is not None
assert re.search(r'^env = "production"', text, re.MULTILINE) is not None
assert re.search(r"^trust_proxy = true", text, re.MULTILINE) is not None
assert re.search(r"^cookie_secure = true", text, re.MULTILINE) is not None
assert f'content_dir = "{content_dir}"' in text
assert f'users_file = "{users_file}"' in text
assert content_dir.is_dir()
assert (content_dir / "media").is_dir()
def test_writes_valid_scrypt_password_hash(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "config.toml"
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
result = installer.system_install(
config_path=config_path,
content_dir=tmp_path / "posts",
users_file=tmp_path / "users.toml",
admin_password="TopSecret!",
)
text = config_path.read_text()
m = re.search(r'^password_hash = "(.+)"', text, re.MULTILINE)
assert m is not None
encoded = m.group(1)
assert encoded == result.password_hash
assert encoded.startswith("scrypt$16384$8$1$")
# Verify against password.
from volumen.password import verify_password
assert verify_password("TopSecret!", encoded) is True
def test_writes_long_hex_session_key(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "config.toml"
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
result = installer.system_install(
config_path=config_path,
content_dir=tmp_path / "posts",
users_file=tmp_path / "users.toml",
admin_password="whatever",
)
text = config_path.read_text()
m = re.search(r'^session_key = "([0-9a-f]+)"', text, re.MULTILINE)
assert m is not None
assert len(m.group(1)) >= 128 # 64 bytes hex-encoded
assert m.group(1) == result.session_key
def test_config_round_trips_through_config_load(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "config.toml"
posts = tmp_path / "posts"
users_file = tmp_path / "users.toml"
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
installer.system_install(
config_path=config_path,
content_dir=posts,
users_file=users_file,
admin_password="TopSecret!",
)
loaded = Config.load(path=str(config_path))
assert loaded.content_dir == str(posts)
assert loaded.users_file == str(users_file)
assert loaded.host == "::1"
assert loaded.env == "production"
assert loaded.trust_proxy is True
assert loaded.cookie_secure is True
assert loaded.admin["password_hash"].startswith("scrypt$")
assert len(loaded.admin["session_key"]) >= 128
def test_existing_config_is_not_overwritten_without_force(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "config.toml"
original = 'host = "1.2.3.4"\nport = 1234\n'
config_path.write_text(original)
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
result = installer.system_install(
config_path=config_path,
content_dir=tmp_path / "posts",
users_file=tmp_path / "users.toml",
admin_password="TopSecret!",
)
# File unchanged.
assert config_path.read_text() == original
# Result carries a sentinel message and no fresh password_hash.
assert any("leaving untouched" in m for m in result.messages)
assert result.password_hash == ""
def test_force_makes_backup_with_original_content(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "config.toml"
original = 'host = "1.2.3.4"\nport = 1234\n'
config_path.write_text(original)
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
installer.system_install(
config_path=config_path,
content_dir=tmp_path / "posts",
users_file=tmp_path / "users.toml",
admin_password="TopSecret!",
force=True,
)
backups = list(tmp_path.glob("config.toml.bak.*.toml"))
assert len(backups) == 1
assert backups[0].read_text() == original
# New content must round-trip.
loaded = Config.load(path=str(config_path))
assert loaded.host == "::1"
def test_ensure_user_only_runs_when_user_missing(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
getpwnam_log: list[str] = []
run_log: list[list[str]] = []
def fake_getpwnam(name: str) -> object:
getpwnam_log.append(name)
raise KeyError(name)
def fake_run(cmd: list[str], *args: object, **kwargs: object) -> object:
run_log.append(list(cmd))
return object()
# Redirect the home-dir creation away from /var/lib so the test
# can run unprivileged without hitting real root.
fake_home = tmp_path / "fakehome"
monkeypatch.setattr(installer, "DEFAULT_SERVICE_HOME", fake_home)
with monkeypatch.context() as m:
m.setattr(installer.pwd, "getpwnam", fake_getpwnam)
m.setattr(installer, "_is_root", lambda: True)
m.setattr(installer.subprocess, "run", fake_run)
created = installer._ensure_user("volumen")
assert created is True
assert getpwnam_log == ["volumen"]
# useradd should fire exactly once.
assert any(cmd[:1] == ["useradd"] for cmd in run_log)
# Home dir was created at our redirected location.
assert fake_home.is_dir()
# If the user exists, no useradd is run.
getpwnam_log.clear()
run_log.clear()
class _Pwnam:
pw_uid = 1234
with monkeypatch.context() as m:
m.setattr(installer.pwd, "getpwnam", lambda name: _Pwnam())
m.setattr(installer.subprocess, "run", fake_run)
created = installer._ensure_user("volumen")
assert created is False
assert run_log == []
# If not root, even a missing user doesn't trigger useradd.
getpwnam_log.clear()
run_log.clear()
with monkeypatch.context() as m:
m.setattr(installer.pwd, "getpwnam", fake_getpwnam)
m.setattr(installer, "_is_root", lambda: False)
m.setattr(installer.subprocess, "run", fake_run)
created = installer._ensure_user("volumen")
assert created is False
assert run_log == []
def test_chown_chmod_skipped_silently_when_not_root(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "config.toml"
posts = tmp_path / "posts"
users_file = tmp_path / "users.toml"
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
# Pretend we're not root — the chown/chmod helpers must no-op rather
# than raise, and no subprocess is spawned for useradd/systemctl.
monkeypatch.setattr(installer, "_is_root", lambda: False)
run_log: list[list[str]] = []
monkeypatch.setattr(
installer.subprocess,
"run",
lambda cmd, *a, **kw: run_log.append(list(cmd)) or object(),
)
result = installer.system_install(
config_path=config_path,
content_dir=posts,
users_file=users_file,
admin_password="TopSecret!",
)
assert config_path.is_file()
assert posts.is_dir()
# No subprocess calls touched the system at all.
assert run_log == []
# Chmod was applied as a no-op (mode unchanged).
assert oct(config_path.stat().st_mode & 0o777) != oct(0o600) # default
# Result is a valid InstallResult.
assert result.config_path == config_path
def test_install_service_writes_systemd_unit_and_calls_systemctl(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "config.toml"
content_dir = tmp_path / "posts"
users_file = tmp_path / "users.toml"
systemd_unit = tmp_path / "volumen.service"
cmd_log: list[list[str]] = []
class _Pwnam:
pw_uid = 1234
class _Grnam:
gr_gid = 1234
def fake_getpwnam(name: str) -> object:
return _Pwnam()
def fake_getgrnam(name: str) -> object:
return _Grnam()
def fake_which(name: str) -> str | None:
return "/usr/local/bin/volumen" if name == "volumen" else None
def fake_run(cmd: list[str], *args: object, **kwargs: object) -> object:
cmd_log.append(list(cmd))
return object()
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
monkeypatch.setattr(installer, "_is_root", lambda: True)
monkeypatch.setattr(installer.pwd, "getpwnam", fake_getpwnam)
monkeypatch.setattr(installer.grp, "getgrnam", fake_getgrnam)
monkeypatch.setattr(installer.shutil, "which", fake_which)
monkeypatch.setattr(installer.subprocess, "run", fake_run)
monkeypatch.setattr(installer, "SYSTEMD_UNIT_PATH", systemd_unit)
result = installer.system_install(
config_path=config_path,
content_dir=content_dir,
users_file=users_file,
admin_password="TopSecret!",
install_service=True,
enable_service=True,
)
assert systemd_unit.is_file()
unit_text = systemd_unit.read_text()
assert "/usr/local/bin/volumen" in unit_text
assert "serve --config" in unit_text
assert f"--config {config_path}" in unit_text
assert f"--content {content_dir}" in unit_text
assert "User=volumen" in unit_text
assert "NoNewPrivileges=true" in unit_text
assert "ProtectSystem=strict" in unit_text
assert result.systemd_unit_path == systemd_unit
assert ["systemctl", "daemon-reload"] in cmd_log
assert ["systemctl", "enable", "--now", "volumen"] in cmd_log
# --- 2. user_install ---------------------------------------------------------
class TestUserInstall:
def test_writes_config_under_user_path(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "config.toml"
content_dir = tmp_path / "posts"
users_file = tmp_path / "users.toml"
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
result = installer.user_install(
config_path=config_path,
content_dir=content_dir,
users_file=users_file,
admin_password="TopSecret!",
)
assert result.service_user is None
assert result.systemd_unit_path is None
text = config_path.read_text()
assert re.search(r'^env = "development"', text, re.MULTILINE) is not None
assert re.search(r"^cookie_secure = false", text, re.MULTILINE) is not None
assert config_path.exists()
assert content_dir.is_dir()
def test_rejects_install_service(self, tmp_path: Path) -> None:
with pytest.raises(installer.InstallerError):
installer.user_install(
config_path=tmp_path / "config.toml",
content_dir=tmp_path / "posts",
users_file=tmp_path / "users.toml",
admin_password="x",
install_service=True,
)
# --- 3. _mutate_config_text --------------------------------------------------
class TestMutateConfigText:
def test_substitutes_six_keys_without_disturbing_comments(self) -> None:
text = (
'host = "::"\n'
'env = "development"\n'
"trust_proxy = false\n"
"cookie_secure = false\n"
'content_dir = "/old"\n'
'users_file = "/old/users.toml"\n'
'password_hash = ""\n'
'session_key = ""\n'
)
out = installer._mutate_config_text(
text,
host="::1",
env="production",
trust_proxy=True,
cookie_secure=True,
content_dir="/new",
users_file="/new/users.toml",
)
assert 'host = "::1"' in out
assert 'env = "production"' in out
assert "trust_proxy = true" in out
assert "cookie_secure = true" in out
assert 'content_dir = "/new"' in out
assert 'users_file = "/new/users.toml"' in out
def test_injects_password_hash_and_session_key(self) -> None:
text = 'host = "::"\npassword_hash = ""\nsession_key = ""\n'
out = installer._mutate_config_text(
text,
password_hash="scrypt$abc",
session_key="deadbeef" * 16,
)
assert 'password_hash = "scrypt$abc"' in out
assert 'session_key = "deadbeef"' * 4 not in out # sentinel
assert 'session_key = "' in out
# --- 4. CLI status -----------------------------------------------------------
class TestStatus:
def test_status_json_reports_all_fields(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
from volumen import installer as inst_mod
# Pre-create a config so the inspector finds something.
posts = tmp_path / "posts"
users_file = tmp_path / "users.toml"
posts.mkdir()
users_file.touch()
config_path = tmp_path / "config.toml"
monkeypatch.setattr(inst_mod, "_ensure_user", lambda name: False)
installer.system_install(
config_path=config_path,
content_dir=posts,
users_file=users_file,
admin_password="TopSecret!",
)
monkeypatch.setattr(inst_mod, "SYSTEMD_UNIT_PATH", tmp_path / "volumen.service")
# Force systemctl to a known "unknown" branch.
monkeypatch.setattr(inst_mod.shutil, "which", lambda name: None)
# Inject a users.toml entry for "admin".
users_file.write_text(
'[[users]]\nusername = "admin"\npassword_hash = "scrypt$x"\nrole = "admin"\n'
)
with (
mock.patch.object(
sys,
"argv",
[
"volumen",
"status",
"--config",
str(config_path),
"--data",
str(posts),
"--users-file",
str(users_file),
"--json",
],
),
contextlib.suppress(SystemExit),
):
main()
captured = capsys.readouterr()
assert captured.out.strip().startswith("{")
info = json.loads(captured.out)
for key in (
"config_path",
"data_dir",
"users_file",
"config_exists",
"data_dir_exists",
"users_file_exists",
"password_hash_set",
"session_key_ok",
"systemd_unit_installed",
"service_active",
"admin_user_present",
"issues",
):
assert key in info
assert info["config_exists"] is True
assert info["password_hash_set"] is True
assert info["session_key_ok"] is True
assert info["admin_user_present"] is True
class TestCLISystemdLocalRefuse:
def test_local_with_systemd_exits_nonzero(self, capsys: pytest.CaptureFixture[str]) -> None:
exit_code = 0
with mock.patch.object(
sys,
"argv",
["volumen", "init", "--local", "--systemd", "--admin-password", "x"],
):
try:
main()
except SystemExit as exc:
exit_code = exc.code if isinstance(exc.code, int) else 1
assert exit_code == 2
captured = capsys.readouterr()
assert "--systemd" in captured.err
+23
View File
@@ -0,0 +1,23 @@
"""Import smoke tests for the split application modules."""
from __future__ import annotations
from volumen.admin_auth_routes import router as auth_router
from volumen.admin_media_routes import router as media_router
from volumen.admin_post_routes import router as post_router
from volumen.admin_settings_routes import router as settings_router
from volumen.app import create_app
from volumen.server import create_app as legacy_create_app
def test_split_modules_import_and_construct_app(config, store) -> None:
assert legacy_create_app is create_app
assert auth_router.routes
assert post_router.routes
assert settings_router.routes
assert media_router.routes
app = create_app(config, store)
assert app.state.config is config
assert app.state.store is store
+4 -2
View File
@@ -34,8 +34,10 @@ class TestVerify:
assert verify_password("nope", encoded) is False
def test_verify_empty_string(self) -> None:
encoded = hash_password("")
assert verify_password("", encoded) is True
# ``hash_password`` rejects empty input; verify_password with an
# empty candidate returns False for any well-formed hash.
encoded = hash_password("some-pw")
assert verify_password("", encoded) is False
def test_verify_malformed_input(self) -> None:
assert verify_password("x", "not-a-hash") is False
+150
View File
@@ -0,0 +1,150 @@
"""Coverage tests for ``Post`` properties — non-happy-path branches."""
from __future__ import annotations
from datetime import date, timedelta
from volumen.post import Post
def _post(metadata: dict[str, object] | None = None, body: str = "") -> Post:
return Post(metadata=metadata, body=body)
class TestTagsProperty:
def test_tags_default_is_empty(self) -> None:
assert _post().tags == []
def test_tags_list_of_strings(self) -> None:
assert _post({"tags": ["python", "blog"]}).tags == ["python", "blog"]
def test_tags_non_list_coerces_to_empty(self) -> None:
# When ``tags`` is set to something that isn't a list, the property
# returns an empty list rather than raising.
assert _post({"tags": "python"}).tags == []
assert _post({"tags": 42}).tags == []
assert _post({"tags": None}).tags == []
class TestTranslationsProperty:
def test_default_is_empty(self) -> None:
assert _post().translations == {}
def test_dict_coerces_values_to_strings(self) -> None:
out = _post({"translations": {"cs": "cs-slug", "de": "de-slug"}}).translations
assert out == {"cs": "cs-slug", "de": "de-slug"}
def test_non_dict_returns_empty(self) -> None:
assert _post({"translations": ["cs", "de"]}).translations == {}
assert _post({"translations": "cs"}).translations == {}
class TestFediverseCreator:
def test_none(self) -> None:
assert _post().fediverse_creator is None
def test_empty_string_is_none(self) -> None:
assert _post({"fediverse_creator": ""}).fediverse_creator is None
assert _post({"fediverse_creator": " "}).fediverse_creator is None
def test_valid_fediverse_creator_regex(self) -> None:
p = _post({"fediverse_creator": "@alice@example.com"})
assert p.fediverse_creator == "@alice@example.com"
assert p.valid_fediverse_creator is True
def test_invalid_fediverse_creator(self) -> None:
# No `@host` part → regex doesn't match.
p = _post({"fediverse_creator": "alice"})
assert p.fediverse_creator == "alice"
assert p.valid_fediverse_creator is False
class TestPublishAt:
def test_none(self) -> None:
assert _post().publish_at is None
def test_date_object(self) -> None:
assert _post({"publish_at": date(2026, 6, 1)}).publish_at == date(2026, 6, 1)
def test_iso_string(self) -> None:
assert _post({"publish_at": "2026-06-01"}).publish_at == date(2026, 6, 1)
def test_invalid_string_returns_none(self) -> None:
assert _post({"publish_at": "not-a-date"}).publish_at is None
assert _post({"publish_at": 12345}).publish_at is None
class TestScheduled:
def test_past_date_is_not_scheduled(self) -> None:
past = (date.today() - timedelta(days=1)).isoformat()
assert _post({"publish_at": past}).scheduled is False
def test_future_date_is_scheduled(self) -> None:
future = (date.today() + timedelta(days=1)).isoformat()
assert _post({"publish_at": future}).scheduled is True
def test_no_publish_at(self) -> None:
assert _post().scheduled is False
class TestReadingTime:
def test_empty_body_minimum_one_minute(self) -> None:
assert _post(body="").reading_time == 1
def test_short_body(self) -> None:
# 5 words → ceil(5/200) = 1.
assert _post(body="one two three four five").reading_time == 1
def test_long_body(self) -> None:
body = " ".join(f"word{i}" for i in range(500))
# 500 words → ceil(500/200) = 3.
assert _post(body=body).reading_time == 3
class TestExcerpt:
def test_explicit_excerpt(self) -> None:
assert _post({"excerpt": "Short summary"}).excerpt == "Short summary"
def test_derived_from_body(self) -> None:
# No explicit excerpt → derived from first non-heading paragraph.
p = _post(body="Hello **world**.")
assert "Hello" in p.excerpt
assert "<" not in p.excerpt # HTML stripped
assert "*" not in p.excerpt # Markdown stripped
def test_derived_empty_when_only_headings(self) -> None:
assert _post(body="# Heading\n\n## Subheading").excerpt == ""
class TestSummaryAndDetail:
def test_summary_includes_all_known_fields(self) -> None:
p = _post(
{
"title": "T",
"slug": "s",
"date": "2026-01-15",
"lang": "en",
"tags": ["x"],
"author": "A",
"fediverse_creator": "@a@example.com",
"cover": "media/c.webp",
},
body="Body.",
)
s = p.summary()
assert s["slug"] == "s"
assert s["title"] == "T"
assert s["date"] == "2026-01-15"
assert s["lang"] == "en"
assert s["tags"] == ["x"]
assert s["author"] == "A"
assert s["fediverse_creator"] == "@a@example.com"
assert s["cover"] == "media/c.webp"
assert s["url"] == "/api/volumen/posts/s"
def test_detail_includes_body_and_html(self) -> None:
p = _post({"title": "T", "slug": "s"}, body="Hello.")
d = p.detail()
assert d["body"] == "Hello."
assert "html" in d
assert "<p>" in d["html"]
+422
View File
@@ -0,0 +1,422 @@
"""Security-focused tests: signature validation, sanitisation, cookies, headers."""
from __future__ import annotations
import io
from fastapi.testclient import TestClient
from volumen.markdown import render
from volumen.password import (
BLOCK_R,
COST_N,
KEY_LENGTH,
PARALLEL_P,
hash_password,
needs_rehash,
verify_password,
)
from volumen.store import (
ALLOWED_IMAGE_EXTENSIONS,
detect_image_extension,
validate_image_signature,
)
# ---------------------------------------------------------------------------
# Image signature validation
# ---------------------------------------------------------------------------
class TestImageSignature:
def test_webp_magic_accepted(self) -> None:
# Minimal RIFF/WEBP header (12 bytes).
good = b"RIFF" + b"\x10\x00\x00\x00" + b"WEBP" + b"VP8 "
assert validate_image_signature(good, ".webp") is True
def test_webp_wrong_magic_rejected(self) -> None:
# RIFF but not WEBP at offset 8.
bad = b"RIFF" + b"\x10\x00\x00\x00" + b"WAVE" + b"VP8 "
assert validate_image_signature(bad, ".webp") is False
def test_webp_too_short_rejected(self) -> None:
assert validate_image_signature(b"RIFF", ".webp") is False
def test_avif_magic_accepted(self) -> None:
# ftyp box with size=0x18, brand=avif.
avif = b"\x00\x00\x00\x18ftyp" + b"avif" + b"\x00\x00\x00\x00" + b"avif" + b"mif1"
assert validate_image_signature(avif, ".avif") is True
def test_avif_alt_brand_accepted(self) -> None:
avif = b"\x00\x00\x00\x18ftyp" + b"avis" + b"\x00\x00\x00\x00" + b"avis" + b"mif1"
assert validate_image_signature(avif, ".avif") is True
def test_avif_wrong_brand_rejected(self) -> None:
avif = b"\x00\x00\x00\x18ftyp" + b"mp42" + b"\x00\x00\x00\x00" + b"mp42" + b"isom"
assert validate_image_signature(avif, ".avif") is False
def test_avif_too_short_rejected(self) -> None:
assert validate_image_signature(b"\x00\x00\x00\x18ftyp", ".avif") is False
def test_unknown_extension_rejected(self) -> None:
assert validate_image_signature(b"anything", ".png") is False
def test_detect_extension_webp(self) -> None:
data = b"RIFF" + b"\x00\x00\x00\x00" + b"WEBP"
assert detect_image_extension(data) == ".webp"
def test_detect_extension_avif(self) -> None:
data = b"\x00\x00\x00\x18ftypavif" + b"\x00" * 8
assert detect_image_extension(data) == ".avif"
def test_detect_extension_unknown(self) -> None:
assert detect_image_extension(b"random") is None
def test_allowed_extensions_match_policy(self) -> None:
assert ALLOWED_IMAGE_EXTENSIONS == (".webp", ".avif")
# ---------------------------------------------------------------------------
# Markdown XSS sanitisation
# ---------------------------------------------------------------------------
class TestMarkdownSanitisation:
def test_script_tag_stripped(self) -> None:
rendered = render("Hello <script>alert(1)</script>world")
assert "<script" not in rendered.lower()
assert "alert(1)" not in rendered
def test_inline_event_handler_stripped(self) -> None:
rendered = render('<img src="x" onerror="alert(1)">')
assert "onerror" not in rendered.lower()
assert "alert" not in rendered.lower()
def test_javascript_url_sanitised(self) -> None:
rendered = render("[click me](javascript:alert(1))")
assert "javascript:" not in rendered.lower()
def test_data_image_url_kept(self) -> None:
rendered = render("![alt](data:image/webp;base64,UklGRg==)")
assert "<img" in rendered
def test_figure_wrapping_still_works(self) -> None:
rendered = render('![alt](url "caption")')
assert "<figure>" in rendered
assert "caption" in rendered
# ---------------------------------------------------------------------------
# Password hashing policy
# ---------------------------------------------------------------------------
class TestPasswordPolicy:
def test_strong_hash_passes(self) -> None:
encoded = hash_password("a-strong-password")
assert verify_password("a-strong-password", encoded) is True
def test_weak_params_rejected(self) -> None:
encoded = f"scrypt$8$1$1${'A' * 24}${'B' * 32}"
assert verify_password("anything", encoded) is False
def test_needs_rehash_for_strong_hash(self) -> None:
encoded = hash_password("a-strong-password")
assert needs_rehash(encoded) is False
def test_needs_rehash_for_weak_hash(self) -> None:
encoded = f"scrypt$8$1$1${'A' * 24}${'B' * 32}"
assert needs_rehash(encoded) is True
def test_needs_rehash_for_malformed(self) -> None:
assert needs_rehash("not-a-hash") is True
def test_module_constants_match_policy(self) -> None:
# Policy floor equals current implementation.
assert COST_N >= 16384
assert BLOCK_R >= 8
assert PARALLEL_P >= 1
assert KEY_LENGTH >= 32
# ---------------------------------------------------------------------------
# HTTP security headers and CSP
# ---------------------------------------------------------------------------
class TestSecurityHeaders:
def test_global_security_headers_present(self, client: TestClient) -> None:
response = client.get("/api/volumen/site")
assert response.headers.get("x-content-type-options") == "nosniff"
assert response.headers.get("x-frame-options") == "DENY"
assert response.headers.get("referrer-policy") == "strict-origin-when-cross-origin"
assert "permissions-policy" in response.headers
def test_csp_on_public_routes(self, client: TestClient) -> None:
response = client.get("/api/volumen/site")
csp = response.headers.get("content-security-policy", "")
assert "default-src 'self'" in csp
# Public routes have script-src but without nonce (nonce is admin-only)
assert "script-src 'self'" in csp
assert "nonce-" not in csp
def test_admin_route_has_csp_with_nonce(self, admin_client: TestClient) -> None:
response = admin_client.get("/admin/login")
csp = response.headers.get("content-security-policy", "")
assert "default-src 'self'" in csp
assert "'unsafe-inline'" not in csp
assert "nonce-" in csp
def test_https_only_cookie_unset_when_untrusted(self, client: TestClient) -> None:
# Default trust_proxy=false — X-Forwarded-Proto is ignored.
response = client.get("/admin/login")
# No cookie issued, but the middleware must mark session_secure=False.
assert response.status_code == 200
def test_https_only_cookie_with_trusted_proxy(self, admin_config_dir) -> None:
# Build a fresh app with trust_proxy=True and confirm it boots.
from volumen.app import create_app
from volumen.config import Config
from volumen.store import Store
config = Config.load(path=str(admin_config_dir / "config.toml"))
config._data["server"]["trust_proxy"] = True # type: ignore[index]
store = Store(config.content_dir)
app = create_app(config, store)
client = TestClient(app)
resp = client.get("/admin/login")
assert resp.status_code == 200
def test_cookie_secure_false_without_proxy_or_flag(self, admin_client: TestClient) -> None:
# When trust_proxy=false and cookie_secure=false, the SessionMiddleware
# is configured with https_only=False.
resp = admin_client.get("/admin/login")
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Config validation
# ---------------------------------------------------------------------------
class TestConfigValidation:
def test_default_config_is_valid(self, config) -> None:
# Should not raise.
config.validate()
def test_invalid_port_rejected(self, tmp_path) -> None:
from volumen.config import Config
(tmp_path / "posts").mkdir()
cfg = Config.load(
path="/nonexistent.toml",
overrides={
"users_file": str(tmp_path / "users.toml"),
"content": str(tmp_path / "posts"),
},
)
cfg._data["server"]["port"] = 999999 # type: ignore[index]
try:
cfg.validate()
except Exception as exc:
assert "port" in str(exc).lower()
else:
raise AssertionError("expected ConfigError")
def test_invalid_env_rejected(self, tmp_path) -> None:
from volumen.config import Config, ConfigError
cfg = Config.load(
path="/nonexistent.toml",
overrides={"users_file": str(tmp_path / "users.toml")},
)
cfg._data["server"]["env"] = "staging" # type: ignore[index]
try:
cfg.validate()
except ConfigError as exc:
assert "env" in str(exc).lower()
else:
raise AssertionError("expected ConfigError")
def test_invalid_base_url_rejected(self, tmp_path) -> None:
from volumen.config import Config, ConfigError
cfg = Config.load(
path="/nonexistent.toml",
overrides={"users_file": str(tmp_path / "users.toml")},
)
cfg._data["site"]["base_url"] = "not-a-url" # type: ignore[index]
try:
cfg.validate()
except ConfigError as exc:
assert "base_url" in str(exc).lower()
else:
raise AssertionError("expected ConfigError")
def test_production_requires_session_key(self, tmp_path) -> None:
from volumen.config import Config
(tmp_path / "posts").mkdir()
cfg = Config.load(
path="/nonexistent.toml",
overrides={
"users_file": str(tmp_path / "users.toml"),
"content": str(tmp_path / "posts"),
},
)
cfg._data["server"]["env"] = "production" # type: ignore[index]
cfg._data["admin"]["session_key"] = "" # type: ignore[index]
# Validation succeeds (file is allowed) but create_app will refuse.
cfg.validate()
# ---------------------------------------------------------------------------
# Session secret validation
# ---------------------------------------------------------------------------
class TestSessionSecret:
def test_production_without_session_key_fails(self, config) -> None:
from volumen.app import create_app
from volumen.store import Store
config._data["server"]["env"] = "production" # type: ignore[index]
config._data["admin"]["session_key"] = "" # type: ignore[index]
store = Store(config.content_dir)
try:
create_app(config, store)
except RuntimeError as exc:
assert "session_key" in str(exc)
else:
raise AssertionError("expected RuntimeError")
def test_production_with_short_key_fails(self, config) -> None:
from volumen.app import create_app
from volumen.store import Store
config._data["server"]["env"] = "production" # type: ignore[index]
config._data["admin"]["session_key"] = "tooshort" # type: ignore[index]
store = Store(config.content_dir)
try:
create_app(config, store)
except RuntimeError as exc:
assert "64 bytes" in str(exc)
else:
raise AssertionError("expected RuntimeError")
# ---------------------------------------------------------------------------
# Password policy helpers
# ---------------------------------------------------------------------------
class TestPasswordPolicyHelpers:
def test_password_error_min_length(self) -> None:
from volumen.auth import password_error
assert password_error("short", min_length=10) is not None
def test_password_error_max_length(self) -> None:
from volumen.auth import password_error
assert password_error("x" * 2000, min_length=10, max_length=1024) is not None
def test_password_error_empty(self) -> None:
from volumen.auth import password_error
assert password_error("", min_length=10) is not None
def test_password_error_common_rejected(self) -> None:
from volumen.auth import password_error
assert password_error("password", min_length=10) is not None
def test_password_error_accepts_strong(self) -> None:
from volumen.auth import password_error
assert password_error("volumen-strong-pw", min_length=10) is None
# ---------------------------------------------------------------------------
# Store hardening
# ---------------------------------------------------------------------------
class _Upload:
"""Fake UploadFile for testing validate_upload."""
content_type: str
filename: str
file: io.BytesIO
def __init__(
self, data: bytes, content_type: str = "image/webp", filename: str = "x.webp"
) -> None:
self._data = data
self.content_type = content_type
self.filename = filename
self.file = io.BytesIO(data)
self._pos = 0
async def read(self, size: int = -1) -> bytes:
if size == -1:
result = self._data[self._pos :]
self._pos = len(self._data)
return result
result = self._data[self._pos : self._pos + size]
self._pos += size
return result
class TestStoreHardening:
def test_validate_upload_accepts_webp(self, tmp_content_dir) -> None:
# Build a fake UploadFile-shaped object.
good_webp = b"RIFF" + b"\x10\x00\x00\x00" + b"WEBP" + b"\x00" * 100
import asyncio
from volumen.upload_validation import validate_upload
async def go() -> tuple[bytes, str]:
return await validate_upload(_Upload(good_webp), max_bytes=10_000)
data, ext = asyncio.run(go())
assert ext == ".webp"
assert data == good_webp
def test_validate_upload_rejects_mime_mismatch(self, tmp_content_dir) -> None:
import asyncio
from volumen.upload_validation import validate_upload
async def go() -> None:
try:
await validate_upload(
_Upload(b"nope", content_type="image/png", filename="x.png"),
max_bytes=10_000,
)
except Exception as exc:
# The detail is JSON-encoded text.
msg = str(exc)
assert "unsupported_format" in msg or "signature" in msg.lower()
else:
raise AssertionError("expected HTTPException")
asyncio.run(go())
def test_store_upload_uses_signature_extension(self, tmp_content_dir) -> None:
from volumen.store import Store
store = Store(str(tmp_content_dir))
avif_bytes = b"\x00\x00\x00\x18ftyp" + b"avif" + b"\x00" * 100
url = store.store_upload("evil.exe", None, bytes_data=avif_bytes)
assert url.endswith(".avif")
assert "evil" not in url
def test_store_upload_resolves_whitelisted_extension(self, tmp_content_dir) -> None:
from volumen.store import Store
store = Store(str(tmp_content_dir))
webp_bytes = b"RIFF" + b"\x10\x00\x00\x00" + b"WEBP" + b"\x00" * 100
# File name has an unrelated extension; signature wins.
url = store.store_upload("cover.png", None, bytes_data=webp_bytes)
assert url.endswith(".webp")
+27 -2
View File
@@ -190,9 +190,34 @@ class TestJSONFeed:
assert item["authors"] == [{"name": "@jf@example.io"}]
def test_json_feed_falls_back_to_site_default_fediverse_creator(
self, client: TestClient, tmp_content_dir: Path
self, tmp_path: Path, tmp_content_dir: Path
) -> None:
pass # Requires a custom config with site.fediverse_creator set
"""When a post has no fediverse_creator, fall back to site.fediverse_creator."""
from fastapi.testclient import TestClient
from volumen.app import create_app
from volumen.config import Config
from volumen.store import Store
config_path = tmp_path / "custom.toml"
users_path = tmp_path / "users.toml"
users_path.write_text("")
config_path.write_text(
f'content_dir = "{tmp_content_dir}"\n'
f'users_file = "{users_path}"\n'
"\n[site]\n"
'fediverse_creator = "@siteowner@example.social"\n'
)
custom_config = Config.load(path=str(config_path))
custom_store = Store(str(tmp_content_dir), default_lang="en")
custom_app = create_app(custom_config, custom_store)
custom_client = TestClient(custom_app)
response = custom_client.get("/api/volumen/feed.json")
assert response.status_code == 200
item = response.json()["items"][0]
assert "authors" in item
assert item["authors"] == [{"name": "@siteowner@example.social"}]
class TestSitemap:
+5 -5
View File
@@ -11,7 +11,7 @@ class TestBootstrap:
def test_bootstrap_user_from_hash(self, users_path: str, bootstrap_hash: str) -> None:
users = Users(path=users_path, bootstrap_hash=bootstrap_hash)
assert len(users.all) == 1
assert users.authenticate("admin", "secret") is not None
assert users.authenticate("admin", "volumen-test-password") is not None
def test_bootstrap_user_is_admin(self, users_path: str, bootstrap_hash: str) -> None:
users = Users(path=users_path, bootstrap_hash=bootstrap_hash)
@@ -34,13 +34,13 @@ class TestFind:
class TestAuthenticate:
def test_authenticate_correct_password(self, users: Users) -> None:
assert users.authenticate("admin", "secret") is not None
assert users.authenticate("admin", "volumen-test-password") is not None
def test_authenticate_wrong_password(self, users: Users) -> None:
assert users.authenticate("admin", "nope") is None
def test_authenticate_nonexistent_user(self, users: Users) -> None:
assert users.authenticate("nobody", "secret") is None
assert users.authenticate("nobody", "volumen-test-password") is None
class TestAdd:
@@ -209,9 +209,9 @@ class TestPersistence:
def test_password_survives_persistence(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "secret123")
users.add("alice", "alice-test-password")
fresh = Users(path=users_path)
assert fresh.authenticate("alice", "secret123") is not None
assert fresh.authenticate("alice", "alice-test-password") is not None
assert fresh.authenticate("alice", "wrong") is None
def test_mtime_cache_invalidation(self, users_path: str) -> None:
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Audit ``uv.lock`` for license compliance.
Parses the lockfile (TOML) and reports each package's ``license`` field.
The tool is intentionally read-only it never modifies ``uv.lock``.
Permissive licenses (MIT, BSD-2/3-Clause, Apache-2.0, ISC, MPL-2.0,
PSF-2.0, etc.) are flagged as OK; everything else is reported as a
warning. Unknown or missing licenses are also reported as warnings.
Usage::
uv run python tools/license-audit.py
"""
from __future__ import annotations
import sys
import tomllib
from pathlib import Path
from typing import Any
LOCK_PATH = Path(__file__).resolve().parent.parent / "uv.lock"
PERMISSIVE_LICENSES: frozenset[str] = frozenset(
{
# SPDX expressions (case-insensitive)
"mit",
"mit-0",
"apache-2.0",
"apache-2.0-with-clauses",
"bsd-2-clause",
"bsd-3-clause",
"isc",
"mpl-2.0",
"python-2.0",
"psf-2.0",
"zlib",
"cc0-1.0",
"cc-by-4.0",
"unlicense",
"0bsd",
"blueoak-1.0.0",
"lgpl-2.1",
"lgpl-3.0",
}
)
def main() -> int:
if not LOCK_PATH.exists():
print(f"error: {LOCK_PATH} not found", file=sys.stderr)
return 2
with LOCK_PATH.open("rb") as fh:
data: dict[str, Any] = tomllib.load(fh)
packages: list[dict[str, Any]] = list(data.get("package", []))
if not packages:
print("error: no [[package]] entries in uv.lock", file=sys.stderr)
return 2
warnings_count = 0
for pkg in packages:
name = pkg.get("name", "?")
version = pkg.get("version", "?")
license_field = pkg.get("license")
if license_field is None:
# uv stores a separate ``[[package.license]]`` table only for
# SPDX-refs; for many packages the field is absent.
print(f"WARN {name}=={version}: license field missing")
warnings_count += 1
continue
text = str(license_field).strip().lower()
# Compound expressions like ``MIT OR Apache-2.0`` are accepted when
# any single token is permissive.
tokens = {
tok.strip(" ()")
for tok in text.replace(" and ", " ").replace(" or ", " ").split()
}
if tokens & PERMISSIVE_LICENSES:
print(f"OK {name}=={version}: {text}")
else:
print(f"WARN {name}=={version}: {text}")
warnings_count += 1
print(f"\nAudited {len(packages)} packages; {warnings_count} warning(s).")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Generated
+138 -264
View File
@@ -1,11 +1,6 @@
version = 1
revision = 3
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version == '3.13.*'",
"python_full_version < '3.13'",
]
requires-python = ">=3.14"
[[package]]
name = "annotated-doc"
@@ -31,7 +26,6 @@ version = "4.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
wheels = [
@@ -68,6 +62,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.15.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" },
{ url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" },
{ url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" },
{ url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" },
{ url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" },
{ url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" },
{ url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" },
{ url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" },
{ url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" },
{ url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" },
{ url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" },
{ url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" },
{ url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" },
{ url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" },
{ url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" },
{ url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" },
{ url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" },
{ url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" },
{ url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" },
{ url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" },
{ url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" },
{ url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" },
{ url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" },
{ url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" },
{ url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" },
{ url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" },
{ url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" },
]
[[package]]
name = "detect-installer"
version = "0.1.0"
@@ -174,38 +207,6 @@ version = "0.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/03/0f/0aeb3fc50046617702acc0078b277b58367fd62eb727b9ec733ae0e8bbcc/fastar-0.11.0.tar.gz", hash = "sha256:aa7f100f7313c03fdb20f1385927ba95671071ba308ad0c1763fef295e1895ce", size = 70238, upload-time = "2026-04-13T17:11:17.143Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/06/a5773706afc8bd496769786590bbc56d2d0ee419a299cc12ea3f5717fcf3/fastar-0.11.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3c51f1c2cdddbd1420d2897ace7738e36c65e17f6ae84e0bfe763f8d1068bb97", size = 708394, upload-time = "2026-04-13T17:09:57.269Z" },
{ url = "https://files.pythonhosted.org/packages/cc/a6/d5e2a4e48495616440a21eed07558219ca90243ad00b0502586f95bd4833/fastar-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0d9d6b052baf5380baea866675dab6ccd04ec2460d12b1c46f10ce3f4ee6a820", size = 628417, upload-time = "2026-04-13T17:09:42.145Z" },
{ url = "https://files.pythonhosted.org/packages/ab/69/9816d69ac8265c9e50456637a487ccfb7a9c566efd9dbcd673df9c2558c2/fastar-0.11.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd2f05666d4df7e14885b5c38fefd92a785917387513d33d837ff42ec143a22f", size = 863950, upload-time = "2026-04-13T17:09:11.506Z" },
{ url = "https://files.pythonhosted.org/packages/5b/0d/f88daad53aff2e754b6b5ff2a7113f72447a34f6ef17cc23ca99988117b7/fastar-0.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e6e74aba1ae77ca4aedcaf1697cd413319f4c88a5ccbe5b42c709517c5097e", size = 760737, upload-time = "2026-04-13T17:07:55.958Z" },
{ url = "https://files.pythonhosted.org/packages/2f/a6/82ef4ecd969d50d92ed3ed9dbd8fe77faa24be5e5736f716edc9f4ce8d62/fastar-0.11.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:38ef77fe940bbc9b37a98bd838727f844b11731cd39358a2640ff864fb385086", size = 757603, upload-time = "2026-04-13T17:08:10.623Z" },
{ url = "https://files.pythonhosted.org/packages/03/35/50249f0d827251f8ac511495e2eacccebda80a00a0ad73e9615b8113b84f/fastar-0.11.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8955e61b32d6aff82c983217abf80933fd823b0e727586fc72f08043d996fd59", size = 923952, upload-time = "2026-04-13T17:08:25.526Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d8/faee41659e9c379d906d24eaee6d6833ac8cfef0a5df480e5c2a8d3efb33/fastar-0.11.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:483532442cdb08fbff0169510224eae0836f2f672cea6aacb52847d90fefdc46", size = 816574, upload-time = "2026-04-13T17:08:56.076Z" },
{ url = "https://files.pythonhosted.org/packages/22/47/0448ea7992b997dad2bf004bfd98eca74b5858630eae080b50c7b17d9ddc/fastar-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef5a6071121e05d8287fc75bccb054bcbac8bb0501200a0c0a8feeace5303ea4", size = 819382, upload-time = "2026-04-13T17:09:26.66Z" },
{ url = "https://files.pythonhosted.org/packages/33/ef/0d63eb43586831b7a6f8b22c4d77125a7c594423af1f4f090fa9541b9b40/fastar-0.11.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e45e598af5afe8412197d4786efd6cf29be02e7d3d4f6a3461149eae5d7e94f1", size = 885254, upload-time = "2026-04-13T17:08:40.9Z" },
{ url = "https://files.pythonhosted.org/packages/01/25/edd584675d69e49a165052c3ee886df1c5d574f3e7d813c990306387c623/fastar-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2e160919b1c47ddb8538e7e8eb4cd527281b40f0bf75110a75993838ef61f286", size = 971239, upload-time = "2026-04-13T17:10:12.997Z" },
{ url = "https://files.pythonhosted.org/packages/a5/37/e8bb24f506ba2b08fbaf36c5800e843bd4d542954e9331f00418e2d23349/fastar-0.11.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4bb4dc0fc8f7a6807febcebce8a2f3626ba4955a9263d81ecc630aad83be84c0", size = 1035185, upload-time = "2026-04-13T17:10:30.207Z" },
{ url = "https://files.pythonhosted.org/packages/9a/bf/be753736296338149ee4cb3e92e2b5423d6ba17c7b951d15218fd7e99bbf/fastar-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4ec95af56aa173f6e320e1183001bf108ba59beaf13edd1fc8200648db203588", size = 1072191, upload-time = "2026-04-13T17:10:47.072Z" },
{ url = "https://files.pythonhosted.org/packages/d2/cd/a81c1aaafb5a22ce57c98ae22f39c89413ed53e4ee6e1b1444b0bd666a6c/fastar-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:136cf342735464091c39dc3708168f9fdeb9ebea40b1ead937c61afaf46143d9", size = 1028054, upload-time = "2026-04-13T17:11:04.293Z" },
{ url = "https://files.pythonhosted.org/packages/ec/88/1ce4eed3d70627c95f49ca017f6bbbf2ddcc4b0c601d293259de7689bc20/fastar-0.11.0-cp312-cp312-win32.whl", hash = "sha256:35f23c11b556cc4d3704587faacbc0037f7bdf6c4525cd1d09c70bda4b1c6809", size = 454198, upload-time = "2026-04-13T17:11:45.168Z" },
{ url = "https://files.pythonhosted.org/packages/8f/1d/26ce92f4331cd61a69840db9ca6115829805eec24f285481a854f578e917/fastar-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:920bc56c3c0b8a8ca492904941d1883c1c947c858cd93343356c29122a38f44c", size = 486697, upload-time = "2026-04-13T17:11:31.084Z" },
{ url = "https://files.pythonhosted.org/packages/ed/96/e6eda4480559c69b05d466e7b5ea9170e81fef3795a73e059959a3258319/fastar-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:395248faf89e8a6bd5dc1fd544c8465113b627cb6d7c8b296796b60ebea33593", size = 462591, upload-time = "2026-04-13T17:11:20.577Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d6/3be260037e86fb694e88d47f583bac3a0188c99cee1a6b257ac26cb6b53c/fastar-0.11.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:33f544b08b4541b678e53749b4552a44720d96761fb79c172b005b1089c443ed", size = 707975, upload-time = "2026-04-13T17:09:58.866Z" },
{ url = "https://files.pythonhosted.org/packages/e1/cd/7867aefb1784662554a335f2952c75a50f0c70585ed0d2210d6cc15e5627/fastar-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c1c792447e4a642745f347ff9847c52af39633071c57ee67ed53c157fc3506", size = 628460, upload-time = "2026-04-13T17:09:43.776Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2b/d11d84bdd5e0e377771b955755771e3460b290da5809cb78c1b735ee2228/fastar-0.11.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:881247e6b6eaea59fc6569f9b61447aa6b9fc2ee864e048b4643d69c52745805", size = 863054, upload-time = "2026-04-13T17:09:13.048Z" },
{ url = "https://files.pythonhosted.org/packages/25/39/d3f428b318fa940b1b6e785b8d54fc895dfb5d5b945ef8d5442ffa904fb2/fastar-0.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:863b7929845c9fec92ef6c8d59579cf46af5136655e5342f8df5cebe46cab06c", size = 760247, upload-time = "2026-04-13T17:07:57.396Z" },
{ url = "https://files.pythonhosted.org/packages/9e/04/03949aee82aabb8ede06ac5a4a5579ffaf98a8fe59ce958494508ff15513/fastar-0.11.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:96b4a57df12bf3211662627a3ea29d62ecb314a2434a0d0843f9fc23e47536e5", size = 756512, upload-time = "2026-04-13T17:08:12.415Z" },
{ url = "https://files.pythonhosted.org/packages/3f/0c/2ca1ae0a3828ca51047962d932b80daca2522db73e8cb9d040cb6ebe28d5/fastar-0.11.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ceef1c2c4df7b7b8ebd3f5d718bbf457b9bbdf25ce0bd07870211ec4fbd9aff4", size = 922183, upload-time = "2026-04-13T17:08:27.187Z" },
{ url = "https://files.pythonhosted.org/packages/65/68/7fe808b1f73a68e686f25434f538c6dc10ef4dfb3db0ace22cd861744bf8/fastar-0.11.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8e545918441910a779659d4759ad0eef349e935fbdb4668a666d3681567eb05", size = 816394, upload-time = "2026-04-13T17:08:57.657Z" },
{ url = "https://files.pythonhosted.org/packages/1f/17/07d086080f8a83b8d7966955e29bcdbd6a060f5bd949dc9d5abd3658cead/fastar-0.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28095bb8f821e85fc2764e1a55f03e5e2876dee2abe7cd0ee9420d929905d643", size = 818983, upload-time = "2026-04-13T17:09:28.46Z" },
{ url = "https://files.pythonhosted.org/packages/fb/e2/2c4edf0910af2e814ff6d65b77a91196d472ca8a9fb2033bd983f6856caa/fastar-0.11.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0fafb95ecbe70f666a5e9b35dd63974ccdc9bb3d99ccdbd4014a823ec3e659b5", size = 884689, upload-time = "2026-04-13T17:08:42.763Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ba/04fdcbd6558e60de4ced3b55230fac47675d181252582b2fcec3c74608e5/fastar-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:af48fed039b94016629dcdad1c95c90c486326dd068de2b0a4df419ee09b6821", size = 970677, upload-time = "2026-04-13T17:10:15.124Z" },
{ url = "https://files.pythonhosted.org/packages/df/b3/2b860a9658550167dbd5824c85e88d0b4b912bf493e42a6322544d6e483d/fastar-0.11.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:74cd96163f39b8638ab4e8d49708ca887959672a22871d8170d01f067319533b", size = 1034026, upload-time = "2026-04-13T17:10:32.318Z" },
{ url = "https://files.pythonhosted.org/packages/b7/9b/fa42ea1188b144bac4b1b60753dfd449974a4d5eda132029ee7711569f94/fastar-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4e8b993cb5613bab495ed482810bedc0986633fcb9a3b55c37ec88e0d6714f6a", size = 1071147, upload-time = "2026-04-13T17:10:48.833Z" },
{ url = "https://files.pythonhosted.org/packages/95/c8/d2e501556dca9f1fbc9246111a31792fb49ad908fa4927f34938a97a3604/fastar-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfe39d91fc28e37e06162d94afe01050220edb7df554acb5b702b5503e564816", size = 1028377, upload-time = "2026-04-13T17:11:06.374Z" },
{ url = "https://files.pythonhosted.org/packages/db/33/5f11f23eca0a569cd052507bc45dda2e5468697f8665728d25be44120f7d/fastar-0.11.0-cp313-cp313-win32.whl", hash = "sha256:c5f63d4d99ff4bfb37c659982ec413358bdee747005348756cc50a04d412d989", size = 454089, upload-time = "2026-04-13T17:11:46.821Z" },
{ url = "https://files.pythonhosted.org/packages/da/2f/35ff03c939cba7a255a9132367873fec6c355fd06a7f84fedcbaf4c8129f/fastar-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8690ed1928d31ded3ada308e1086525fb3871f5fa81e1b69601a3f7774004583", size = 486312, upload-time = "2026-04-13T17:11:32.86Z" },
{ url = "https://files.pythonhosted.org/packages/ef/71/ee9246cbfcbfd4144558f35e7e9a306ffe0a7564730a5188c45f21d2dab8/fastar-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:d977ded9d98a0719a305e0a4d5ee811f1d3e856d853a50acb8ae833c3cd6d5d2", size = 461975, upload-time = "2026-04-13T17:11:22.589Z" },
{ url = "https://files.pythonhosted.org/packages/7a/cd/3644c48ecac456f928c12d47ec3bed36c36555b17c3859856f1ff860265d/fastar-0.11.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:71375bd6f03c2a43eb47bd949ea38ff45434917f9cdac79675c5b9f60de4fa73", size = 707860, upload-time = "2026-04-13T17:10:00.371Z" },
{ url = "https://files.pythonhosted.org/packages/69/ca/dee04476ae3626b2b040a60ad84628f77e1ffd8444232f2426b0ca1e0d7e/fastar-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:eddfd9cab16e19ae247fe44bf992cb403ccfe27d3931d6de29a4695d95ad386c", size = 628216, upload-time = "2026-04-13T17:09:45.355Z" },
{ url = "https://files.pythonhosted.org/packages/dc/5e/9395c7353d079cb4f5be0f7982ce0dc9f2e7dec5fd175eef466729d6023a/fastar-0.11.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7c371f1d4386c699018bb64eb2fa785feacf32785559049d2bb72fe4af023f53", size = 864378, upload-time = "2026-04-13T17:09:14.611Z" },
@@ -262,26 +263,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpcore2"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
{ name = "truststore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d5/fe/6a3f9f1a8bb8733326140737446aaf72fddb8b54b8f202302f5c84960613/httpcore2-2.7.0.tar.gz", hash = "sha256:6dc0fedf329a52a990930a5579edfebaea81118ea700ea0dd7de2b5e5be49efc", size = 65593, upload-time = "2026-07-14T20:40:01.111Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/6c/62e2e279e63fc4f7a5ee841ef13175a8bbc613f258e9dcc186e9de803a42/httpcore2-2.7.0-py3-none-any.whl", hash = "sha256:1452f589fe23f55b44546cd884294c41a29330af902bc0b71a761fd52d18f92b", size = 81506, upload-time = "2026-07-14T20:39:58.053Z" },
]
[[package]]
name = "httptools"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" },
{ url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" },
{ url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" },
{ url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" },
{ url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
{ url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
{ url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
@@ -313,6 +313,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "httpx2"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpcore2" },
{ name = "idna" },
{ name = "truststore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/4a/129b2e21b90ac2985d3928d96792bccc39bc6dfe796c5eee2d8ec06d4105/httpx2-2.7.0.tar.gz", hash = "sha256:8b30709aed5c8465b0dd3b95c09ce301c8f79e7e7a2d00ab0af551e0d0375b07", size = 94487, upload-time = "2026-07-14T20:40:02.318Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/b8/c341bba6411bdfda786020343c47a75ef472f6085caf82391b142b1a3ad9/httpx2-2.7.0-py3-none-any.whl", hash = "sha256:ed2a2719c696789e09493bd8e2bec3d8bd925cc6e26b68389ec25ade132f7bf4", size = 90234, upload-time = "2026-07-14T20:39:59.531Z" },
]
[[package]]
name = "idna"
version = "3.18"
@@ -379,39 +394,6 @@ version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
@@ -445,6 +427,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "nh3"
version = "0.3.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/1b/ef84624f14954d270f74060a19fc550dd4f06656399447569afb584d8c06/nh3-0.3.6.tar.gz", hash = "sha256:f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7", size = 24684, upload-time = "2026-06-22T00:47:02.008Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/3e/6506aa4f23dc7b7993a2d0a45dca3ce864ec48380adfe15a173e643c63e8/nh3-0.3.6-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:2411e8c3cee81a1ddd62c2a5d50585c28aa5566d373ad1db92536b95ddb24ef2", size = 1421679, upload-time = "2026-06-22T00:46:20.248Z" },
{ url = "https://files.pythonhosted.org/packages/e3/e1/e96e7864a7a53bd6b6fab7e9632467382a2a2c1f3fed951918ad131542fb/nh3-0.3.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e196fa70c2ff2eb4de7d3df3108f8f358c1d69dff20d45b11f20a5aa227ffb6d", size = 792570, upload-time = "2026-06-22T00:46:22.179Z" },
{ url = "https://files.pythonhosted.org/packages/59/62/5b6108bedaef2b2637fed04c87bdbcb5967b9961758b41f0e466ef22a022/nh3-0.3.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:34d2b0d934156b87ee114f599a3ba9b8b9e17b5d79652ba3a13fa50903de965e", size = 842243, upload-time = "2026-06-22T00:46:23.801Z" },
{ url = "https://files.pythonhosted.org/packages/4b/4a/526f199626bfcb496bc01a268051b44737962005553b158e985ed7e64865/nh3-0.3.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2f14b7ae1fca99c4a66c981aac3974e7fbc1ca30a12673d223ae1df76680917", size = 1001468, upload-time = "2026-06-22T00:46:25.481Z" },
{ url = "https://files.pythonhosted.org/packages/49/09/0d8e3101636d9ad88cdefb2914e764cb8e876ebdbb4286bfc251277d9c67/nh3-0.3.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:889932a97fb4abb6f95fef1914c0d269ebfb60011e67121c1163059b9449dbb4", size = 1082933, upload-time = "2026-06-22T00:46:27.15Z" },
{ url = "https://files.pythonhosted.org/packages/09/a1/ea83abe738a3fbaa203dfdb836ca7cbab0e7e9609faaee4fe1d4652599c0/nh3-0.3.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:edb2b4a1a27523e6cc7c417f8d21ce3d005243548b93e56b762b66b0c7f589f9", size = 1043120, upload-time = "2026-06-22T00:46:28.89Z" },
{ url = "https://files.pythonhosted.org/packages/66/69/0654482b8635012fbae67826bd6c381abb05d841ac7388b9b4666300fdad/nh3-0.3.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43bc1ed3fa0716295fabee29ba42b2667e4a51d140b0a68e092170a765474fa6", size = 1023824, upload-time = "2026-06-22T00:46:30.453Z" },
{ url = "https://files.pythonhosted.org/packages/ed/a6/1f7285ffadc8307c4dbeb08d21b920536d5117785056d1079e998c4dfa44/nh3-0.3.6-cp314-cp314t-win32.whl", hash = "sha256:597a8e843bea00b2eb5520658dc24a9bb032e7fc9e7c2c0c4cd29420220c9796", size = 599253, upload-time = "2026-06-22T00:46:32.072Z" },
{ url = "https://files.pythonhosted.org/packages/36/ea/5542f3c45da4c00290d9d67a65e996702e23e613c4b627de3e09cb9fe357/nh3-0.3.6-cp314-cp314t-win_amd64.whl", hash = "sha256:4713502748f564fee0633b37b3403783ce0a3af3a3d148ad91025a5bdadb7bc6", size = 612553, upload-time = "2026-06-22T00:46:33.53Z" },
{ url = "https://files.pythonhosted.org/packages/66/35/26bd47e6af5915a628281dccdac354ddf4e32f7397047894270acd8c9870/nh3-0.3.6-cp314-cp314t-win_arm64.whl", hash = "sha256:69bbb92865a693d909db3a700d3c01537533844d0948c1e9323561ce06ecda41", size = 595151, upload-time = "2026-06-22T00:46:34.878Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ab/a7653bce9a3b204be6a6931767a9e23595807bb84790ce6685e4d7e5bd08/nh3-0.3.6-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a43ebd7543555c3ac1bc353023d0794e75cb76f6f18f19c32e95441496c0cc25", size = 1443564, upload-time = "2026-06-22T00:46:36.66Z" },
{ url = "https://files.pythonhosted.org/packages/41/21/e1084ab18eb589506335c7c7576f2d4643e9a0c0e33983ef0e549a256b96/nh3-0.3.6-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1b160831c9cdb06a6c79c2f9cdb11386602938f9af260d1c457a85add4f6f69", size = 838002, upload-time = "2026-06-22T00:46:38.101Z" },
{ url = "https://files.pythonhosted.org/packages/b0/94/f48d08e6f72a406300fa11d8acd929fea1a80d4bf750fa292cb10785f126/nh3-0.3.6-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d14bf7982e7a77c0c775634c29c07ce08b38a046df73e1c1f139b3e82f18a38e", size = 823045, upload-time = "2026-06-22T00:46:39.495Z" },
{ url = "https://files.pythonhosted.org/packages/25/bb/431615ba1d1d3eb63cde0f974f2114edf863a8a3f6049a12fed23fc241d3/nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:44673b27010051ab5a5e438a86ec31bbda61d4a77d7e900af6b7be3037c1abae", size = 1093171, upload-time = "2026-06-22T00:46:41.21Z" },
{ url = "https://files.pythonhosted.org/packages/0e/24/a0d80182a18919665fefd19c1c06f1d1df1c9a6455d0252de40c034a0bc3/nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6b7beece07525dc6e6b0fc2f104442de2ba328360ad00e50cbe2e1fd620447d", size = 1049217, upload-time = "2026-06-22T00:46:42.804Z" },
{ url = "https://files.pythonhosted.org/packages/0a/13/6f1e302ca674ac74362e150848ad56a1be5145391204f74facdb8e94df12/nh3-0.3.6-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:455469a29951edc92bc48b47ac2281c3f2609e6c4f6a047056449f8c2c23facf", size = 917372, upload-time = "2026-06-22T00:46:44.495Z" },
{ url = "https://files.pythonhosted.org/packages/5b/67/314f6151bad77a93d751978a344033e1fc890822f05f0416079338e34231/nh3-0.3.6-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:905f877dc66dd7aea4a76e54bcb26acb5ff8216f720c0017ccf63e0e6035698e", size = 806699, upload-time = "2026-06-22T00:46:45.99Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a6/bfaa00046e58603507dcfc266c4778e3ab7adf68a5dedd73b6274b8d9314/nh3-0.3.6-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:25c733bee928530556b1db0ea46c52cf5aa686146e38e60a6fc7cb801ef91cec", size = 835165, upload-time = "2026-06-22T00:46:47.617Z" },
{ url = "https://files.pythonhosted.org/packages/30/a8/fb2c38845efb703a9173bffdfc745fc64d2b0e55cfc73a3647d2f028250c/nh3-0.3.6-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2f90d9a0cfdbee218994fdaaeeb5a0fde62d08f35e4eef0378ec1e2200172fd0", size = 858282, upload-time = "2026-06-22T00:46:49.276Z" },
{ url = "https://files.pythonhosted.org/packages/68/17/06e72a18ee9b572914447338237ca7eb164c0df901f141bc10d1282247a2/nh3-0.3.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:82ca5bf427ad1b216b65ede1a2e2d87dc49bec417ceba0f297213107d3cd9d78", size = 1014328, upload-time = "2026-06-22T00:46:51.026Z" },
{ url = "https://files.pythonhosted.org/packages/11/f9/3966c61455668c08853bf5e33b4bed93c421f3194ce4de896dc248d6f6ce/nh3-0.3.6-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438", size = 1098207, upload-time = "2026-06-22T00:46:52.674Z" },
{ url = "https://files.pythonhosted.org/packages/19/d3/479cb4ae440424825735d60525b53e3c77fd60fd6e6afc0e984f00eb0178/nh3-0.3.6-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:082675ff87b9385ec430ffe6d5847ba7456cc39b73720cd4add472f9f4cffd56", size = 1056961, upload-time = "2026-06-22T00:46:54.335Z" },
{ url = "https://files.pythonhosted.org/packages/17/0c/6cdb5ee1e127be50dc8391e54bddc1f64e87bf4bfad0c55633320e2e02db/nh3-0.3.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36d06341bd501240d320f5942481ed5e6846136b666e1ba4faf802b78ebc875f", size = 1033829, upload-time = "2026-06-22T00:46:56.258Z" },
{ url = "https://files.pythonhosted.org/packages/e9/55/9de666ad975d6ccd77d799ea0add55ee2347aa81286ce21b2a97c070746b/nh3-0.3.6-cp38-abi3-win32.whl", hash = "sha256:5276ef17bdba9ad8040575c74072008b13aae429436e9d0429e718bb5f90f4da", size = 609081, upload-time = "2026-06-22T00:46:57.665Z" },
{ url = "https://files.pythonhosted.org/packages/82/fa/2b5d684e3edf1e81bfd02d298c78c3e3da77ca1d8a2be3183a79544a7548/nh3-0.3.6-cp38-abi3-win_amd64.whl", hash = "sha256:f338ac7d594c067679f1e99b4f5ec3906842979560f9d8f15d6bdfa39a353b10", size = 624461, upload-time = "2026-06-22T00:46:59.163Z" },
{ url = "https://files.pythonhosted.org/packages/7b/e5/7cafee2f0413ca4cb0ef3bd111e94d408a48810008b283ad8aee00dd1809/nh3-0.3.6-cp38-abi3-win_arm64.whl", hash = "sha256:69f365963f63a1e9bff53bdbb3c542c7c2efed3e163c9d5d83a772a2ac468c21", size = 603060, upload-time = "2026-06-22T00:47:00.596Z" },
]
[[package]]
name = "packaging"
version = "26.2"
@@ -492,36 +508,6 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
@@ -552,10 +538,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
]
[[package]]
@@ -623,6 +605,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
@@ -647,26 +643,6 @@ version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
@@ -720,38 +696,6 @@ version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/77/6ba90ab4a538d3ec244329c57c4d26a78c8313ea6fa72c8768d46f11c1c9/rignore-0.8.0.tar.gz", hash = "sha256:2e5ad6b19834f04a877d26fe863fd77ed851ed4019fdca097fb1b744311e3562", size = 55358, upload-time = "2026-07-17T19:01:21.257Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/7e/0d270c1ed723b82bea8ccd504185acb5d71830975260e8de02af7acff728/rignore-0.8.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a5e285ec58b3b66284a7f48805e4db0ea948370deb484a4935b147187ecf1e25", size = 847184, upload-time = "2026-07-17T18:58:31.348Z" },
{ url = "https://files.pythonhosted.org/packages/ff/dc/841941f8b0883a8038f9d540607238c456df20e98243a61142fd15699806/rignore-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:367d3cf401b477a5ba7eb05b5b94c491b0d704507d9eaf80378a8d843fe00674", size = 816590, upload-time = "2026-07-17T18:58:32.571Z" },
{ url = "https://files.pythonhosted.org/packages/57/53/9e047a6cd95b553519703350f7a3530ddd31d309c9ade1ec913db5882f74/rignore-0.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d49870c032abcc28db2210ad92b3bd5706ec792ab25c9295a6c536a4b92226ce", size = 884130, upload-time = "2026-07-17T18:58:33.921Z" },
{ url = "https://files.pythonhosted.org/packages/07/5a/ae444f30dfa47716ccadf9ed9512736494c0bf5222f330845a637f1c569c/rignore-0.8.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d50632823c273ee19212fd939b04e9b55d5a6e0c9b998fadef6dc9a0c2f6aecc", size = 857230, upload-time = "2026-07-17T18:58:35.32Z" },
{ url = "https://files.pythonhosted.org/packages/ba/67/b2ddfbf5a42a8ea89e6a0330189f7f76f9113e0d8c6a4c68b68ff68f139c/rignore-0.8.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3337e69856ba18c9d079e0ce8b342dc256f48585ae3e1c7aac67682b672e83a", size = 1133331, upload-time = "2026-07-17T18:58:36.895Z" },
{ url = "https://files.pythonhosted.org/packages/8e/76/291be16f7260ac87dedab41394f7738834bb1cd05d6e1b878eea8d598520/rignore-0.8.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c1264c48aaff09c6431b428144e9e9fbd0d7864673f7bd752b51d2409971275e", size = 912520, upload-time = "2026-07-17T18:58:38.178Z" },
{ url = "https://files.pythonhosted.org/packages/0c/99/cd0df2def95ecfa93c781431ce7c58ea52f90742550ff327ed0f1f715f14/rignore-0.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8912e3cb4034b972ba75c9ecabb79bc41a1076cdf726e53019e45b71b82da56", size = 927109, upload-time = "2026-07-17T18:58:40Z" },
{ url = "https://files.pythonhosted.org/packages/e4/58/ce8af7214b903a2f86c3223dabd23e03a0c499df6559be5c9e2b15b4344c/rignore-0.8.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:43b20be0f1c8ae3f3a2575dbee4a38bd406608855f6e79cde8fda1c6bff9e203", size = 892314, upload-time = "2026-07-17T18:58:41.391Z" },
{ url = "https://files.pythonhosted.org/packages/47/3f/e8f4ec6cdf04d0cb9915590679771b55a6f579a7478704d1e6be45a2f792/rignore-0.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:02cf38d96b6f54b69719b726877e308b34ac2c9c9f636dc8872ede2ff6825579", size = 962528, upload-time = "2026-07-17T18:58:42.736Z" },
{ url = "https://files.pythonhosted.org/packages/58/57/d10a43221644177f776d658ed9a03204e49d9bfd6805fbece841ea6720ac/rignore-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5c820d3fa597ee419f3643f8c098a51249a868ca8515296a56a1505c01a8083d", size = 1060799, upload-time = "2026-07-17T18:58:44.279Z" },
{ url = "https://files.pythonhosted.org/packages/a9/0e/d769dfa933861dd469c9158ffc9bc2cb3bc6d46f88000fb6f41cf2905a3a/rignore-0.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e3e863b2cb1db481384bd43a25730ef00f7eccdd49c82a0e1d481a6412dc2653", size = 1132462, upload-time = "2026-07-17T18:58:45.583Z" },
{ url = "https://files.pythonhosted.org/packages/c1/3d/57fc6264ebf8d9b95850589899e6192d9f92b67ea48e56fc32969b75474a/rignore-0.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90cc363aedc7a93b4b15933f2104bfcae1c6c1635a9bc479665cae83044577d9", size = 1139679, upload-time = "2026-07-17T18:58:46.928Z" },
{ url = "https://files.pythonhosted.org/packages/fa/be/24b12a8464e19d348aeb388267f897ede0fae6618052c3148b747acae214/rignore-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:303a9fd02d3612d15e6dc3474ae7d3eae1d07b668b7b4943fd4df305be4d2f76", size = 1138125, upload-time = "2026-07-17T18:58:48.42Z" },
{ url = "https://files.pythonhosted.org/packages/d6/8d/89f7cba3491164c04f8a64056ca973494536ffdf48bfba41565f54dfa122/rignore-0.8.0-cp312-cp312-win32.whl", hash = "sha256:e17a0914378fa15d1e29effce4b39fd2031f253e52d307ff4b71c443d1d5d30e", size = 637663, upload-time = "2026-07-17T18:58:49.76Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ff/823a5ead8bba0a2054cbf2dbef99989204557904ad8578f514bec1b9b4e7/rignore-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:d5c8f1dff84c4d4114f1553564b5d909a0c4c09dbe1b5916a0506d719f1fc3b5", size = 728265, upload-time = "2026-07-17T18:58:51.028Z" },
{ url = "https://files.pythonhosted.org/packages/8f/ce/73c919505f9f270ee3e34205ff2dbe0b0d73e945f8c569922acff148bcf4/rignore-0.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:6cdea3f85de8286a38ae75a0f9092cd3afc4d33ce6ee2e3f6005f97f7da9d249", size = 665216, upload-time = "2026-07-17T18:58:52.308Z" },
{ url = "https://files.pythonhosted.org/packages/67/76/fc272f7a61bec353d321f04b6d4d6cf7cb1fe646e0e1bedd1abf187699bd/rignore-0.8.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:178d7a82b6f1dd0378efda2a69252f73240221ea727529ed67be788ad551cccf", size = 847049, upload-time = "2026-07-17T18:58:54.123Z" },
{ url = "https://files.pythonhosted.org/packages/a1/06/99ce87ef61c86670b3ffa1be6131bc73318a541c2d9d00edbfe00fe27b1f/rignore-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0cefccd353ab5f7436457c420a4058daba873552344eb731b124c6d25110fef9", size = 815664, upload-time = "2026-07-17T18:58:55.422Z" },
{ url = "https://files.pythonhosted.org/packages/1c/88/ecb7451631e493ce8d41d0cfa287a9aca891ca2b738ded4dd8be83cf1476/rignore-0.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd72c6eeec68a3354582e11168c702c55ff543af7a3e8b3f47bed63334d0a330", size = 884344, upload-time = "2026-07-17T18:58:56.82Z" },
{ url = "https://files.pythonhosted.org/packages/e8/fe/24fe7e5f36c0ea0043017ee92ec6bbb980a6ca356e5fbe0ab3320a6c5482/rignore-0.8.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3e0f17f611ab32a505f32f3d6b6a08cc0e9ef4c596df07a8ebbc99225d07ca8b", size = 857024, upload-time = "2026-07-17T18:58:58.444Z" },
{ url = "https://files.pythonhosted.org/packages/ef/e4/267814f4d4a96208f2d676d3e070b73d386a58dd45bad2bc2fd7d9babb27/rignore-0.8.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:828630c1710e3d21f79dd90b1939b1b8da4a49bdab857ce7fc11b58dd5ce509e", size = 1133041, upload-time = "2026-07-17T18:59:00.036Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b6/e6da8ed3ee26134bb1d08a5d181012c16a4bfeb4dfa5f461edf3d19dff95/rignore-0.8.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b074ea1fece686da847d2dc7a7f452af9f7a82e7bc9181f196104f1c0df4ebd", size = 912935, upload-time = "2026-07-17T18:59:01.333Z" },
{ url = "https://files.pythonhosted.org/packages/ed/91/4785a1673aa92b34bd2625b2c836debadfee4f6e394b273c86060f68b05b/rignore-0.8.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cb7dfc95cae2deac47e5f75b6d8df041351664907e412ac39c3af44dae47ce0", size = 927344, upload-time = "2026-07-17T18:59:02.744Z" },
{ url = "https://files.pythonhosted.org/packages/a7/8e/07d11d91c73f3723024ba5a1034818f6232f3c27c7bf194f2bc1eabb449c/rignore-0.8.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:80d3e468c2cd24be93eca4953ca7ea73dfaacf97ba674c54c61c8aad877d2bc3", size = 892736, upload-time = "2026-07-17T18:59:04.049Z" },
{ url = "https://files.pythonhosted.org/packages/5d/85/97f07d4c6737e6ae7dbef37b7eda5b7de1442167dc696483f7e002e2c8a9/rignore-0.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3ac6b6ce80d24015055d07b47a5157b8cb9b207bf9d4963f401b2c08e0263eb", size = 962660, upload-time = "2026-07-17T18:59:05.443Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6f/524ba5a14d2e297edc3ea5baece5eec38bdb8a40e717b86aecaa3b84aa48/rignore-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f6200325937f8c6a24dab351e259fce6ea8fd744888118791074796f4041cb0a", size = 1060679, upload-time = "2026-07-17T18:59:06.867Z" },
{ url = "https://files.pythonhosted.org/packages/a6/84/699fdb9a0380ff1dc4910d7cb5d730dd823ba7b525d1c92cdb57023545cd/rignore-0.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:51b33030b83526e4284f2e115e04ff80f0ed8d992cd5bf389c275aaac534b829", size = 1132220, upload-time = "2026-07-17T18:59:08.211Z" },
{ url = "https://files.pythonhosted.org/packages/3b/ac/c840f5992ece10f19f27607603b23864dd7a5fad6acb90eb43c52f02879e/rignore-0.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:48aa8acf2217d77cb4784fd5f9a95aaa73a85b9a337b7511679b1bc73f64b7d9", size = 1139449, upload-time = "2026-07-17T18:59:09.752Z" },
{ url = "https://files.pythonhosted.org/packages/f9/1b/3f6f2a0a1b23e639f637394821c68bfb3a435349ed5ecd562af08c9a3e4f/rignore-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bc453de8490c76ab3905a18116d0f6555ee94540e1e69cca49e6675e9ca05888", size = 1138480, upload-time = "2026-07-17T18:59:11.391Z" },
{ url = "https://files.pythonhosted.org/packages/98/b2/f5478b3850ad894f1480b36028b54868ba9c0069b7b13f4d78ff0a0391f7/rignore-0.8.0-cp313-cp313-win32.whl", hash = "sha256:0f03e964e7845583b1c344098d6e1b178e0e53ba7e496c8f148966b74dd2e5a9", size = 637473, upload-time = "2026-07-17T18:59:12.748Z" },
{ url = "https://files.pythonhosted.org/packages/1a/3e/53ab47642c45222e4d47877d62e80ea79c0cf0b989a1b89f04244f14d6a4/rignore-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:71a00835b08987c86d6b7c14661a7a628c868bc649e2afbeb4962167561fbd11", size = 727646, upload-time = "2026-07-17T18:59:14.135Z" },
{ url = "https://files.pythonhosted.org/packages/0d/33/9793162cbe185c05869b41c029c341dca659008b1e55aefd0fd6f8cf67e3/rignore-0.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:28d20c22758b636936d8ae2a684b4ed2c64deea30410ca203c72cdfb4bbde0f1", size = 664864, upload-time = "2026-07-17T18:59:15.534Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ae/b7d365a5d103e527b3d23d11f19ab91ff5813a2509a236d8f56f608f03ff/rignore-0.8.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:f7cd98185bb89d11676454df1617f00ca59473fe2c86471c7c7d8c97ed6359f6", size = 846928, upload-time = "2026-07-17T18:59:16.862Z" },
{ url = "https://files.pythonhosted.org/packages/bc/4a/736d51756de8557be4ef2837d4c2eb6e3934887f1c04eb6ec1d0198b708d/rignore-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4101def5fdd1459ba107710382c18916c9981f961cad5196533ee4b06295f05b", size = 817297, upload-time = "2026-07-17T18:59:18.213Z" },
{ url = "https://files.pythonhosted.org/packages/56/c6/3799fa414d51779b4ffb71fb8fb861f886a592127cd28f88d81b1681dc4a/rignore-0.8.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a67b7f4485dbda470981c8ea7cdf270f2f9139169e86c8b57b9bb4649e79b7f6", size = 885129, upload-time = "2026-07-17T18:59:19.807Z" },
@@ -839,7 +783,6 @@ version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
wheels = [
@@ -855,6 +798,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
]
[[package]]
name = "truststore"
version = "0.10.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
]
[[package]]
name = "typer"
version = "0.27.0"
@@ -929,18 +881,6 @@ version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
@@ -964,32 +904,39 @@ dependencies = [
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markdown" },
{ name = "nh3" },
{ name = "pymdown-extensions" },
{ name = "python-multipart" },
{ name = "tomli-w" },
]
[package.optional-dependencies]
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "httpx2" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28" },
{ name = "itsdangerous", specifier = ">=2.2" },
{ name = "jinja2", specifier = ">=3.1" },
{ name = "markdown", specifier = ">=3.7" },
{ name = "nh3", specifier = ">=0.2" },
{ name = "pymdown-extensions", specifier = ">=10.14" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9" },
{ name = "tomli-w", specifier = ">=1.2" },
]
provides-extras = ["dev"]
[package.metadata.requires-dev]
dev = [
{ name = "httpx2", specifier = ">=2.7" },
{ name = "pytest", specifier = ">=8" },
{ name = "pytest-cov", specifier = ">=5" },
{ name = "ruff", specifier = ">=0.9" },
]
[[package]]
name = "watchfiles"
@@ -1000,45 +947,6 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" },
{ url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" },
{ url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" },
{ url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" },
{ url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" },
{ url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" },
{ url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" },
{ url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" },
{ url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" },
{ url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" },
{ url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" },
{ url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" },
{ url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" },
{ url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" },
{ url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" },
{ url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" },
{ url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" },
{ url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" },
{ url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" },
{ url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" },
{ url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" },
{ url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" },
{ url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" },
{ url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" },
{ url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" },
{ url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" },
{ url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" },
{ url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" },
{ url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" },
{ url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" },
{ url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" },
{ url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" },
{ url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" },
@@ -1083,40 +991,6 @@ version = "16.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" },
{ url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" },
{ url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" },
{ url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" },
{ url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" },
{ url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" },
{ url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" },
{ url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" },
{ url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" },
{ url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" },
{ url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" },
{ url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" },
{ url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" },
{ url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" },
{ url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" },
{ url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" },
{ url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" },
{ url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" },
{ url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" },
{ url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" },
{ url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" },
{ url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" },
{ url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" },
{ url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" },
{ url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" },
{ url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" },
{ url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" },
{ url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" },
{ url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" },
{ url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" },
{ url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" },
{ url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" },
{ url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" },
{ url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" },