chore: prepare release v0.4.0
This commit is contained in:
@@ -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
|
||||
@@ -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}."
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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
@@ -33,6 +33,7 @@ Posts with `all_langs = true` in their frontmatter match every `lang` filter.
|
||||
|-------|--------|---------|--------------------|
|
||||
| lang | string | — | Filter by language |
|
||||
| tag | string | — | Filter by tag |
|
||||
| q | string | — | Case-insensitive search across title and body |
|
||||
| page | int | 1 | Page number |
|
||||
| limit | int | 20 | Per page (1–100) |
|
||||
|
||||
@@ -41,6 +42,8 @@ Posts with `all_langs = true` in their frontmatter match every `lang` filter.
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"total": 1,
|
||||
"has_next": false,
|
||||
"has_prev": false,
|
||||
"posts": [
|
||||
{
|
||||
"slug": "hello",
|
||||
@@ -64,7 +67,7 @@ Posts with `all_langs = true` in their frontmatter match every `lang` filter.
|
||||
|
||||
Single post including the raw Markdown body and rendered HTML. Optional
|
||||
`?lang=` selects a translation. Returns `404` with `{"error":"not_found"}` if
|
||||
no match.
|
||||
no match. Drafts return `404` with `{"error":"draft"}`.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -93,7 +96,14 @@ Tag cloud with counts (published posts only), most frequent first.
|
||||
{ "tags": [{ "name": "intro", "count": 1 }] }
|
||||
```
|
||||
|
||||
## `GET /api/volumen/feed.xml`, `GET /api/volumen/sitemap.xml`
|
||||
## `GET /api/volumen/feed.xml`, `GET /api/volumen/feed.json`, `GET /api/volumen/sitemap.xml`
|
||||
|
||||
RSS 2.0 feed (latest 20 posts) and a sitemap of post URLs, built from
|
||||
`site.base_url`.
|
||||
- `feed.xml` — RSS 2.0 feed of the 20 most recent posts.
|
||||
- `feed.json` — [JSON Feed 1.1](https://www.jsonfeed.org/) document with
|
||||
`content_html` and `date_published` per item.
|
||||
- `sitemap.xml` — XML sitemap built from `site.base_url`.
|
||||
|
||||
## `OPTIONS /api/volumen/*`
|
||||
|
||||
Preflight requests return an empty `204` with the same CORS headers as the GET
|
||||
endpoints, so browsers can reach the JSON API cross-origin.
|
||||
+50
-34
@@ -3,7 +3,7 @@
|
||||
> Status: the core library, the public JSON API, and the server-rendered admin
|
||||
> (login, CSRF, post CRUD, Markdown editor + live preview) are implemented.
|
||||
|
||||
**volumen** is a small, file-based Markdown blog engine written in CRuby. It
|
||||
**volumen** is a small, file-based Markdown blog engine written in Python. It
|
||||
has no database: every post is a `.md` file on disk with a TOML frontmatter
|
||||
block. The engine reads those files, exposes them as a JSON API, and ships a
|
||||
server-rendered admin for editing them.
|
||||
@@ -12,35 +12,35 @@ server-rendered admin for editing them.
|
||||
|
||||
- **File-based** — posts are plain `.md` files, safe to commit to Git and edit
|
||||
by hand or through the admin.
|
||||
- **Single-admin** — one password, one content directory, one user.
|
||||
- **Self-contained** — runs as a single Ruby process; no Node build step is
|
||||
required to operate the engine or its admin.
|
||||
- **Dependency-light** — a small, well-chosen set of gems (Sinatra, kramdown,
|
||||
toml-rb, puma) rather than a full framework.
|
||||
- **Self-contained** — runs as a single Python process (Uvicorn) under FastAPI;
|
||||
no Node build step is required to operate the engine or its admin.
|
||||
- **Dependency-light** — a small, well-chosen set of PyPI packages (FastAPI,
|
||||
python-markdown, Jinja2, itsdangerous, python-multipart) rather than a
|
||||
full-stack framework. The standard library covers cryptography.
|
||||
- **Markdown-first** — the visual editor round-trips through the same renderer
|
||||
that powers the public API, so stored content is always Markdown.
|
||||
|
||||
## Components
|
||||
|
||||
```
|
||||
lib/volumen/
|
||||
version.rb # gem version
|
||||
config.rb # loads /etc/volumen/config.toml, applies CLI overrides
|
||||
frontmatter.rb # parse/serialise the `+++ … +++` TOML block
|
||||
post.rb # Post model: metadata + raw body + rendered HTML
|
||||
store.rb # reads the content directory into Post objects (mtime-cached)
|
||||
markdown.rb # kramdown wrapper (render + <figure>/<figcaption> for titled images)
|
||||
feed_helpers.rb # RSS / JSON Feed / sitemap generation
|
||||
users.rb # users.toml + admin/author roles
|
||||
password.rb # scrypt hashing/verification (OpenSSL::KDF)
|
||||
api_routes.rb # /api/volumen/* (Sinatra routes)
|
||||
admin_routes.rb # /admin/* (Sinatra routes)
|
||||
server.rb # Sinatra app: composes the two route modules, shared helpers
|
||||
cli.rb # command dispatcher
|
||||
src/volumen/
|
||||
__init__.py # VERSION + in-memory login-attempt rate limiter
|
||||
cli.py # argparse entry point: serve, hash-password, version
|
||||
config.py # loads config.toml via stdlib tomllib, deep-merges defaults
|
||||
frontmatter.py # parse/serialise the `+++ … +++` TOML block
|
||||
post.py # Post model: metadata + raw body + rendered HTML
|
||||
store.py # reads the content directory into Post objects (mtime-cached)
|
||||
markdown.py # python-markdown + pymdown-extensions wrapper, <figure>/<figcaption>
|
||||
feed_helpers.py # RSS / JSON Feed / sitemap generation
|
||||
users.py # users.toml + admin/author roles
|
||||
password.py # scrypt hashing/verification (hashlib + secrets.compare_digest)
|
||||
api_routes.py # /api/volumen/* (FastAPI APIRouter)
|
||||
admin_routes.py # /admin/* + /media/* (FastAPI APIRouter)
|
||||
server.py # create_app factory, middleware, dependency helpers
|
||||
web/
|
||||
views/ # ERB templates for the admin
|
||||
public/ # static assets (volumen-logo.svg, volumen-icon.svg)
|
||||
exe/volumen # CLI: serve, hash-password, version, help
|
||||
templates/ # Jinja2 templates for the admin
|
||||
static/ # static assets (volumen-logo.svg, volumen-icon.svg)
|
||||
exe/volumen # thin shim that calls volumen.cli:main
|
||||
```
|
||||
|
||||
## Request flow
|
||||
@@ -48,22 +48,38 @@ exe/volumen # CLI: serve, hash-password, version, help
|
||||
```mermaid
|
||||
graph TD
|
||||
A[HTTP request] --> B{Route}
|
||||
B -->|/api/volumen/*| C[api_routes.rb]
|
||||
B -->|/admin/*| D[admin_routes.rb + CSRF + CSP]
|
||||
C --> E[Store]
|
||||
D --> E
|
||||
B -->|/api/volumen/*| C[api_routes.py]
|
||||
B -->|/admin/*| D[admin_routes.py + CSRF + CSP]
|
||||
B -->|/media/*| E[admin_routes.py media_router]
|
||||
C --> F[Store]
|
||||
D --> F
|
||||
D --> U[Users]
|
||||
C --> F[FeedHelpers]
|
||||
C --> H[FeedHelpers]
|
||||
C --> G[Markdown renderer]
|
||||
E --> H[(content_dir/*.md)]
|
||||
E --> I[(content_dir/media/*)]
|
||||
U --> J[(users.toml)]
|
||||
F --> E
|
||||
G --> K[<figure> for titled images]
|
||||
F --> I[(content_dir/*.md)]
|
||||
F --> J[(content_dir/media/*)]
|
||||
U --> K[(users.toml)]
|
||||
H --> F
|
||||
G --> L[<figure> for titled images]
|
||||
D --> M[SessionMiddleware + CSPMiddleware]
|
||||
C --> M
|
||||
E --> M
|
||||
```
|
||||
|
||||
## Middleware stack
|
||||
|
||||
- `SessionMiddleware` (Starlette) — signed cookie session, signed with
|
||||
`itsdangerous` using the secret derived from `[admin].session_key`. Set to
|
||||
`HttpOnly` and `SameSite=Strict`; `Secure` is added by `SecureCookieMiddleware`
|
||||
when the request was forwarded over HTTPS.
|
||||
- `SecureCookieMiddleware` (custom) — inspects `X-Forwarded-Proto` (when
|
||||
`[server].trust_proxy = true`) and exposes `request.state.session_secure`
|
||||
for downstream code that needs to know whether the request is HTTPS.
|
||||
- `CSPMiddleware` (custom) — injects the `Content-Security-Policy` header on
|
||||
every `/admin/*` response.
|
||||
|
||||
## Public site integration
|
||||
|
||||
The public website (e.g. `petrbalvin.org`, a Vue 3 + Vite + Tailwind SPA)
|
||||
consumes volumen's JSON API. volumen does not render the public site itself; it
|
||||
is headless for the front-end and only renders its own admin.
|
||||
is headless for the front-end and only renders its own admin.
|
||||
+61
-11
@@ -1,20 +1,28 @@
|
||||
# Configuration
|
||||
|
||||
> Status: initial draft. Field semantics are finalised as the loader lands.
|
||||
> Status: the loader in `src/volumen/config.py` is the source of truth. This
|
||||
> document lists every key it understands, with type, default, and meaning.
|
||||
|
||||
volumen is configured by a single **TOML** file. By default it lives at
|
||||
`/etc/volumen/config.toml` and is auto-generated on first start. CLI flags
|
||||
passed to `volumen serve` override the file.
|
||||
|
||||
A template you can copy as a starting point is at
|
||||
[`config.toml.example`](../config.toml.example) in the repository root. The
|
||||
template ships with development defaults (`host = "::"`, `env = "development"`,
|
||||
`cookie_secure = false`) — flip them for production.
|
||||
|
||||
## Example `config.toml`
|
||||
|
||||
```toml
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
host = "::1"
|
||||
port = 9090
|
||||
env = "production"
|
||||
trust_proxy = true
|
||||
|
||||
# Directory containing your Markdown posts.
|
||||
content_dir = "/var/lib/volumen/posts"
|
||||
users_file = "/var/lib/volumen/users.toml"
|
||||
|
||||
[site]
|
||||
title = "My Blog"
|
||||
@@ -22,22 +30,60 @@ description = "A blog powered by volumen."
|
||||
base_url = "https://example.com"
|
||||
language = "en"
|
||||
author = "Anonymous"
|
||||
# Site-wide fediverse handle (e.g. Mastodon). Exposed as `fediverse_creator`
|
||||
# on /api/volumen/site and used as the fallback in the RSS / JSON feeds when
|
||||
# a post does not set its own `fediverse_creator` frontmatter field. Leave
|
||||
# empty if you do not publish to the fediverse.
|
||||
fediverse_creator = "@you@example.social"
|
||||
|
||||
[admin]
|
||||
# scrypt hash produced by `volumen hash-password`.
|
||||
password_hash = ""
|
||||
# Secret used to sign session cookies. Leave empty to auto-generate per start
|
||||
# (sessions reset on restart).
|
||||
session_key = ""
|
||||
# Session lifetime in seconds (default 24h).
|
||||
session_ttl = 86400
|
||||
min_password_length = 10
|
||||
max_password_length = 1024
|
||||
max_upload_bytes = 10485760 # 10 MB
|
||||
cookie_secure = true
|
||||
```
|
||||
|
||||
## Key reference
|
||||
|
||||
### `[server]`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|----------------|---------|---------------|-------------|
|
||||
| `host` | string | `"::"` | Bind address. `"::"` listens on IPv6 with automatic IPv4 fallback; use `"::1"` (or `"127.0.0.1"`) when running behind a reverse proxy. |
|
||||
| `port` | integer | `9090` | TCP port the Uvicorn worker binds to. |
|
||||
| `env` | string | `"development"` | Runtime environment. `"production"` enables strict startup safety checks (refuses to boot with an empty `admin.password_hash`, warns on a short `admin.session_key`, enforces `cookie_secure`); `"development"` is more forgiving for local runs. |
|
||||
| `trust_proxy` | boolean | `false` | When `true`, honour the `X-Forwarded-Proto` header set by an upstream reverse proxy (nginx). Set `true` ONLY when running behind a trusted proxy that strips client-supplied values; otherwise an attacker could fake `https://` and force `Secure` cookies. |
|
||||
| `cookie_secure`| boolean | `false` | Mark session cookies as `Secure` (browser only sends them over HTTPS). Default `false` so local HTTP development works without TLS; set to `true` in production behind HTTPS. The runtime also auto-promotes cookies to `Secure` when `[server].trust_proxy = true` and `X-Forwarded-Proto: https` is observed. |
|
||||
|
||||
### Top-level
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|----------------|---------|----------------------------------|-------------|
|
||||
| `content_dir` | string | `"/var/lib/volumen/posts"` | Directory of `.md` files with `+++` TOML frontmatter. |
|
||||
| `users_file` | string | `"/var/lib/volumen/users.toml"` | File storing admin users (managed from the admin Settings page). |
|
||||
|
||||
### `[site]`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---------------------|-----------------|--------------------------|-------------|
|
||||
| `title` | string | `"My Blog"` | Exposed as `site.title`. |
|
||||
| `description` | string | `"A blog powered by volumen."` | Exposed as `site.description`. |
|
||||
| `base_url` | string | `"https://example.com"` | Used to build absolute URLs in the RSS feed, JSON feed, sitemap, and the public API. |
|
||||
| `language` | string | `"en"` | Default language for posts and the admin UI. |
|
||||
| `author` | string | `"Anonymous"` | Default author when a post frontmatter omits it. |
|
||||
| `fediverse_creator` | string \| null | `null` | Site-wide fediverse handle (e.g. `@user@instance.tld`). Exposed as `site.fediverse_creator` and rendered as `<dc:creator>` in the RSS feed and `authors[].name` in the JSON feed. Per-post frontmatter overrides it. |
|
||||
|
||||
### `[admin]`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----------------------|---------|-----------|-------------|
|
||||
| `password_hash` | string | `""` | Bootstrap admin login: paste a hash from `volumen hash-password` to sign in as user `admin`. Once you create users from the Settings page, `users_file` wins. |
|
||||
| `session_key` | string | `""` | Secret used to sign session cookies. Must be `>= 64` bytes; empty value = a random secret per start (sessions reset on restart, do not use in production). |
|
||||
| `session_ttl` | integer | `86400` | Session lifetime in seconds (default 24h). `0` or negative = no expiry until the browser session ends. |
|
||||
| `min_password_length` | integer | `10` | Minimum length required for admin-chosen passwords on the Settings page. Must be `>= 8`; the bootstrap hash is not affected. |
|
||||
| `max_password_length` | integer | `1024` | Maximum length for admin-chosen passwords (cap to avoid scrypt abuse; scrypt is CPU-bound by design). |
|
||||
| `max_upload_bytes` | integer | `10485760` | Maximum upload size in bytes (default 10 MB). Uploads larger than this are rejected with HTTP 413. |
|
||||
| `cookie_secure` | boolean | `false` | Mark session cookies as `Secure` (browser only sends them over HTTPS). Default `false` so local HTTP development works without TLS; set to `true` in production behind HTTPS. The runtime also auto-promotes cookies to `Secure` when `[server].trust_proxy = true` and `X-Forwarded-Proto: https` is observed. |
|
||||
|
||||
## CLI overrides
|
||||
|
||||
```bash
|
||||
@@ -55,3 +101,7 @@ volumen serve --config /etc/volumen/config.toml \
|
||||
|
||||
The same format is used for post frontmatter; see the post format section in
|
||||
the project [`README.md`](../README.md).
|
||||
|
||||
The Python standard library `tomllib` (3.11+) parses the file at load time; no
|
||||
extra TOML dependency is required at runtime. `tomli-w` is bundled for the
|
||||
admin to round-trip TOML frontmatter on post edits.
|
||||
+139
-99
@@ -2,95 +2,113 @@
|
||||
|
||||
> Status: production deployment on Linux with systemd + nginx.
|
||||
|
||||
volumen runs as a single Ruby process (Puma) bound to localhost, behind nginx
|
||||
which terminates TLS and reverse-proxies the API and admin. The public website
|
||||
(e.g. a Vue SPA) is served separately and consumes the JSON API.
|
||||
volumen runs as a single Python process (Uvicorn via `fastapi[standard]`) bound
|
||||
to localhost, behind nginx which terminates TLS and reverse-proxies the API and
|
||||
admin. The public website (e.g. a Vue SPA) is served separately and consumes
|
||||
the JSON API.
|
||||
|
||||
The CLI binary is installed system-wide via `uv tool install` (or `pipx
|
||||
install`); the *operational* bootstrap — config, data dirs, admin password,
|
||||
optional systemd unit — is performed by the `volumen init` Python subcommand
|
||||
that replaces the old `install.sh` bash script.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Ruby ≥ 3.3** with a C toolchain (Puma compiles a native extension).
|
||||
- Fedora: `sudo dnf install ruby ruby-devel @development-tools openssl-devel`
|
||||
- Debian/Ubuntu: `sudo apt install ruby-full build-essential libssl-dev`
|
||||
- **nginx** and a TLS certificate (e.g. via certbot).
|
||||
- A dedicated system user, e.g. `volumen`.
|
||||
- **Python ≥ 3.14** (the bundled wheel ships its own interpreter when you use
|
||||
`uv tool install` / `pipx install`; the system Python is only required if
|
||||
you're building from source).
|
||||
- Fedora: `sudo dnf install python3.14`
|
||||
- Debian/Ubuntu: `sudo apt install python3.14 python3-venv python3-pip`
|
||||
- **[`uv`](https://docs.astral.sh/uv/)** (recommended) — install with
|
||||
`curl -LsSf https://astral.sh/uv/install.sh | sh`. Alternatively,
|
||||
[`pipx`](https://pipx.pypa.io/) ships in most distro repos.
|
||||
- **nginx** and a TLS certificate (e.g. via certbot) for the public site.
|
||||
- A dedicated system user, `volumen`, created automatically by
|
||||
`volumen init` when run as root.
|
||||
|
||||
## Layout
|
||||
## Install the CLI
|
||||
|
||||
```bash
|
||||
# Pick one:
|
||||
uv tool install volumen
|
||||
# or
|
||||
pipx install volumen
|
||||
|
||||
# Both put a self-contained `volumen` binary on PATH. Verify:
|
||||
volumen version
|
||||
```
|
||||
|
||||
For platforms where the wheel is not available (or for development), build it
|
||||
from a source checkout:
|
||||
|
||||
```bash
|
||||
git clone https://sourcedock.dev/petrbalvin/volumen.git
|
||||
cd volumen
|
||||
uv tool install .
|
||||
```
|
||||
|
||||
## Bootstrap the deployment
|
||||
|
||||
### System install (root, systemd)
|
||||
|
||||
```bash
|
||||
sudo volumen init
|
||||
sudo systemctl status volumen
|
||||
```
|
||||
|
||||
`volumen init` walks through:
|
||||
|
||||
1. Whether `config_path` already exists — if so, leaves it untouched unless
|
||||
`--force` is passed (and even then it makes a timestamped `.bak.<ts>.toml`
|
||||
first).
|
||||
2. Creates `/etc/volumen/`, `/var/lib/volumen/posts` (and a `media/`
|
||||
subdirectory), `/var/lib/volumen/users.toml`.
|
||||
3. Creates the system user `volumen` if missing (`useradd --system …`).
|
||||
4. Prompts for an admin password (`getpass`), hashes it via scrypt, writes the
|
||||
`password_hash` into the rendered config.
|
||||
5. Generates a fresh 64-byte session key (`secrets.token_hex(64)`) and writes
|
||||
it into `[admin].session_key`.
|
||||
6. Forces production-style settings: `host = "::1"`, `env = "production"`,
|
||||
`trust_proxy = true`, `cookie_secure = true`.
|
||||
7. With `--systemd` (default under root), writes
|
||||
`/etc/systemd/system/volumen.service` and runs
|
||||
`systemctl daemon-reload && systemctl enable --now volumen`.
|
||||
|
||||
Pass `--no-enable` to install the unit without starting it; pass `--force` to
|
||||
back up and overwrite an existing config. The full flag list lives in
|
||||
`volumen init --help`.
|
||||
|
||||
### Per-user install (no root)
|
||||
|
||||
```bash
|
||||
volumen init --local
|
||||
```
|
||||
|
||||
Per-user paths: `~/.config/volumen/config.toml`,
|
||||
`~/.local/share/volumen/posts`, `~/.local/share/volumen/users.toml`. No
|
||||
system user is created and no systemd unit is installed. `--systemd` is
|
||||
rejected in this mode.
|
||||
|
||||
## Configuration locations
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `/opt/volumen` | source checkout (or installed gem) |
|
||||
| `/etc/volumen/config.toml` | configuration (auto-generated on first run) |
|
||||
| `/etc/volumen/config.toml` | configuration (rendered by `volumen init` on first run) |
|
||||
| `/var/lib/volumen/posts` | Markdown posts |
|
||||
| `/var/lib/volumen/users.toml` | admin users (created from Settings) |
|
||||
| `/var/lib/volumen/posts/media` | uploaded images |
|
||||
| `/var/lib/volumen/users.toml` | admin users (created from Settings) |
|
||||
| `/etc/systemd/system/volumen.service` | systemd unit (only when `--systemd`) |
|
||||
| `~/.config/volumen/config.toml` | per-user config (`--local`) |
|
||||
| `~/.local/share/volumen/posts` | per-user data dir (`--local`) |
|
||||
| `~/.local/share/volumen/users.toml` | per-user users file (`--local`) |
|
||||
|
||||
```bash
|
||||
sudo useradd --system --home /var/lib/volumen --shell /usr/sbin/nologin volumen
|
||||
sudo mkdir -p /etc/volumen /var/lib/volumen/posts
|
||||
sudo chown -R volumen:volumen /var/lib/volumen
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
### Quick install (Linux + systemd)
|
||||
|
||||
From a cloned repository, run the installer as root. It installs the Ruby
|
||||
toolchain from the distro repositories on **Fedora** and **openEuler** (prints
|
||||
guidance on other distros), creates the `volumen` user and directories, runs
|
||||
`bundle install`, generates `config.toml`, and installs and enables the systemd
|
||||
unit:
|
||||
|
||||
```bash
|
||||
sudo git clone https://codeberg.org/petrbalvin/volumen.git /opt/volumen
|
||||
cd /opt/volumen
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
The service is enabled but not started — finish by setting the admin password
|
||||
(printed steps), then `sudo systemctl start volumen`. The manual steps below
|
||||
describe what the script does.
|
||||
|
||||
### Manual install
|
||||
|
||||
From a source checkout (reproducible, no publishing needed):
|
||||
|
||||
```bash
|
||||
sudo git clone https://codeberg.org/petrbalvin/volumen.git /opt/volumen
|
||||
cd /opt/volumen
|
||||
sudo bundle install --deployment
|
||||
```
|
||||
|
||||
Alternatively build and install the gem (`just build` produces `pkg/volumen.gem`,
|
||||
then `gem install pkg/volumen.gem` puts `volumen` on the PATH).
|
||||
|
||||
## First run and the admin password
|
||||
|
||||
The first `serve` writes a commented `config.toml` if it is missing:
|
||||
|
||||
```bash
|
||||
sudo -u volumen bundle exec exe/volumen serve \
|
||||
--config /etc/volumen/config.toml \
|
||||
--content /var/lib/volumen/posts
|
||||
# → "volumen: wrote a default config to /etc/volumen/config.toml; review it and restart."
|
||||
```
|
||||
|
||||
Generate a password hash and a stable session secret, then edit the config:
|
||||
|
||||
```bash
|
||||
bundle exec exe/volumen hash-password # → scrypt$...
|
||||
openssl rand -hex 64 # → session_key
|
||||
sudo -u volumen $EDITOR /etc/volumen/config.toml
|
||||
```
|
||||
|
||||
Set `host = "127.0.0.1"`, your `site.base_url`, the `admin.password_hash`, and a
|
||||
`admin.session_key` (so sessions survive restarts). You can now sign in as user
|
||||
`admin`; from the **Settings** page you can add more users (roles `admin` /
|
||||
`author`) and change passwords. From then on `users.toml` is the source of
|
||||
truth.
|
||||
The config and users files are owned by `volumen:volumen` and `chmod 600` after
|
||||
a system install — they may carry password hashes, so never make them
|
||||
world-readable.
|
||||
|
||||
## systemd
|
||||
|
||||
`/etc/systemd/system/volumen.service`:
|
||||
`/etc/systemd/system/volumen.service` (rendered by `volumen init --systemd`):
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
@@ -98,10 +116,12 @@ Description=volumen blog engine
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=volumen
|
||||
Group=volumen
|
||||
WorkingDirectory=/opt/volumen
|
||||
ExecStart=/usr/bin/bundle exec exe/volumen serve --config /etc/volumen/config.toml --content /var/lib/volumen/posts
|
||||
WorkingDirectory=/etc/volumen
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=/usr/local/bin/volumen serve --config /etc/volumen/config.toml --content /var/lib/volumen/posts
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=true
|
||||
@@ -114,26 +134,34 @@ PrivateTmp=true
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Hardening notes — `NoNewPrivileges`, `ProtectSystem=strict` (the entire
|
||||
filesystem is read-only except for `ReadWritePaths`), `ProtectHome`
|
||||
(`/home`, `/root`, `/run/user` are inaccessible), `PrivateTmp` (private
|
||||
`/tmp`), and a two-second restart delay on failure.
|
||||
|
||||
Inspect health with:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now volumen
|
||||
sudo systemctl status volumen
|
||||
journalctl -u volumen -f
|
||||
volumen status # config / data dir / unit / service state
|
||||
volumen status --json # machine-readable
|
||||
```
|
||||
|
||||
## FreeBSD (rc.d)
|
||||
|
||||
volumen runs on FreeBSD too — it is plain CRuby plus gems; only Puma (and its
|
||||
`nio4r` dependency) build a native extension, which compiles with the base
|
||||
clang toolchain.
|
||||
volumen runs on FreeBSD too — it is plain CPython with no compiled extensions.
|
||||
|
||||
```sh
|
||||
pkg install ruby
|
||||
# then install volumen as above (git clone + bundle install)
|
||||
pkg install python3
|
||||
# install uv (https://docs.astral.sh/uv/getting-started/installation/)
|
||||
uv tool install volumen
|
||||
sudo volumen init --local # no systemd on FreeBSD
|
||||
```
|
||||
|
||||
Conventional FreeBSD paths: config in `/usr/local/etc/volumen/`, data in
|
||||
`/var/db/volumen/`. Service script `/usr/local/etc/rc.d/volumen`:
|
||||
`/var/db/volumen/`. Drop a tiny `rc.d` script in `/usr/local/etc/rc.d/volumen`
|
||||
that wraps the `volumen` console script:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
@@ -150,14 +178,13 @@ load_rc_config $name
|
||||
|
||||
: ${volumen_enable:="NO"}
|
||||
: ${volumen_user:="volumen"}
|
||||
: ${volumen_dir:="/usr/local/volumen"}
|
||||
: ${volumen_config:="/usr/local/etc/volumen/config.toml"}
|
||||
: ${volumen_content:="/var/db/volumen/posts"}
|
||||
|
||||
pidfile="/var/run/${name}.pid"
|
||||
command="/usr/sbin/daemon"
|
||||
command_args="-f -r -P ${pidfile} -u ${volumen_user} \
|
||||
/bin/sh -c 'cd ${volumen_dir} && exec bundle exec exe/volumen serve --config ${volumen_config} --content ${volumen_content}'"
|
||||
/bin/sh -c 'exec volumen serve --config ${volumen_config} --content ${volumen_content}'"
|
||||
|
||||
run_rc_command "$1"
|
||||
```
|
||||
@@ -165,6 +192,7 @@ run_rc_command "$1"
|
||||
Enable and start:
|
||||
|
||||
```sh
|
||||
chmod +x /usr/local/etc/rc.d/volumen
|
||||
sysrc volumen_enable=YES
|
||||
service volumen start
|
||||
```
|
||||
@@ -266,22 +294,34 @@ right block. Apply with `sudo nginx -t && sudo systemctl reload nginx`.
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
cd /opt/volumen
|
||||
sudo git pull
|
||||
sudo bundle install --deployment
|
||||
uv tool upgrade volumen
|
||||
sudo systemctl restart volumen
|
||||
volumen status # confirm: config_exists, password_hash_set, session_key_ok, …
|
||||
```
|
||||
|
||||
The CLI binary is replaced in place by `uv tool upgrade`; the systemd unit's
|
||||
`ExecStart` already points at the on-PATH `volumen` console script, so a
|
||||
restart is all that's needed. The persistent state (`config.toml`,
|
||||
`users.toml`, posts) is untouched.
|
||||
|
||||
If you used the legacy `install.sh` workflow (no longer documented, the script
|
||||
was removed), you can migrate forward by deleting `/etc/systemd/system/volumen.service`
|
||||
+ `/opt/volumen` and then running `uv tool install volumen && sudo volumen init`.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Bind to `127.0.0.1`; only nginx is public. TLS is terminated by nginx.
|
||||
- Bind to `::1` (or `127.0.0.1`); only nginx is public. TLS is terminated by
|
||||
nginx.
|
||||
- Session cookies are `HttpOnly` and `SameSite=Strict`, and gain the `Secure`
|
||||
attribute automatically on HTTPS requests (detected via `X-Forwarded-Proto`,
|
||||
which the nginx snippet above sets) — so the cookie never travels over plain
|
||||
HTTP in production, while local HTTP testing still works. Combined with
|
||||
per-form CSRF tokens this protects the admin. Set a stable `admin.session_key`
|
||||
so sessions survive restarts.
|
||||
which the nginx snippet above sets, and enabled by
|
||||
`[server].cookie_secure = true`, set automatically by `volumen init
|
||||
--systemd`) — so the cookie never travels over plain HTTP in production,
|
||||
while local HTTP testing still works. Combined with per-form CSRF tokens
|
||||
this protects the admin. A stable `[admin].session_key` (also written by
|
||||
`volumen init`) lets sessions survive restarts.
|
||||
- Keep `config.toml` and `users.toml` readable only by the `volumen` user
|
||||
(`chmod 600`). Both may contain password hashes.
|
||||
- `volumen hash-password` reads the password without echo; never pass passwords
|
||||
on the command line.
|
||||
(`chmod 600`). Both may contain password hashes. The installer enforces
|
||||
this automatically.
|
||||
- Use `volumen hash-password` to rotate; it reads the password without echo.
|
||||
Never pass passwords on the command line.
|
||||
|
||||
+44
-34
@@ -2,7 +2,7 @@
|
||||
|
||||
> Status: threat model and security posture for the engine as implemented.
|
||||
|
||||
volumen is a small, file-based blog engine: a single Ruby process exposing a
|
||||
volumen is a small, file-based blog engine: a single Python process exposing a
|
||||
read-only public JSON API and a session-authenticated admin, behind nginx. This
|
||||
document describes the trust model, the controls in place, and the known
|
||||
limitations.
|
||||
@@ -13,33 +13,37 @@ than opening a public issue.
|
||||
## Trust model
|
||||
|
||||
- **Content authors are trusted.** Posts are written by authenticated admin
|
||||
users. kramdown renders Markdown and **passes raw HTML through** on purpose,
|
||||
so an author can embed HTML. The stored/served `html` is therefore only as
|
||||
safe as the people who can log in. If you need to accept posts from untrusted
|
||||
authors, add an HTML sanitiser before serving.
|
||||
users. python-markdown renders Markdown with a few pymdown-extensions
|
||||
extensions. The engine does **not** strip raw HTML from post bodies by
|
||||
default (authors are the trusted admin); if you need to accept posts from
|
||||
untrusted authors, add a sanitiser (e.g. `bleach`) before serving.
|
||||
- **The public API is read-only.** It exposes published (non-draft) posts, tags,
|
||||
a feed and a sitemap. No public endpoint mutates state.
|
||||
|
||||
## Authentication
|
||||
|
||||
- Passwords are hashed with **scrypt** via `OpenSSL::KDF` (`N=16384`, `r=8`,
|
||||
- Passwords are hashed with **scrypt** via `hashlib.scrypt` (`n=16384`, `r=8`,
|
||||
`p=1`, 32-byte key, 16-byte random salt). Verification uses
|
||||
`OpenSSL.fixed_length_secure_compare` (constant-time).
|
||||
`secrets.compare_digest` (constant-time) on the derived bytes.
|
||||
- Login takes a **username + password**. The error message is intentionally
|
||||
generic ("Invalid username or password.") to avoid user enumeration.
|
||||
- Sessions use signed cookies (`Rack::Session::Cookie`) marked **`HttpOnly`**
|
||||
and **`SameSite=Strict`**. The **`Secure`** attribute is added automatically on
|
||||
HTTPS requests (detected via `request.ssl?` / `X-Forwarded-Proto`), so the
|
||||
cookie never travels over plain HTTP in production. The signing secret comes
|
||||
from `admin.session_key`; if it is shorter than 64 bytes a random secret is
|
||||
generated per start (sessions then reset on restart, so set a stable key in
|
||||
production).
|
||||
- Sessions use **signed cookies** via Starlette's `SessionMiddleware`
|
||||
(`itsdangerous` TimestampSigner under the hood), marked **`HttpOnly`** and
|
||||
**`SameSite=Strict`**. The **`Secure`** attribute is added by
|
||||
`SecureCookieMiddleware` when `X-Forwarded-Proto: https` is observed (only
|
||||
honoured when `[server].trust_proxy = true`) so the cookie never travels
|
||||
over plain HTTP in production while local HTTP testing still works. The
|
||||
signing secret comes from `admin.session_key`; if it is shorter than 64
|
||||
bytes a random secret is generated per start (sessions then reset on
|
||||
restart, so set a stable key in production).
|
||||
- A minimum password length of `[admin].min_password_length` (default `10`,
|
||||
must be `>= 8`) is enforced on the Settings page password-change form.
|
||||
|
||||
## Authorization (roles)
|
||||
|
||||
- Two roles: **admin** (full access, manages users) and **author** (posts and
|
||||
own account only).
|
||||
- User management (`add`/`delete`/role change) is gated by `require_admin!`
|
||||
- User management (`add`/`delete`/role change) is gated by `require_admin`
|
||||
**on the server**, not merely hidden in the UI.
|
||||
- Safeguards prevent lockout: the **last admin** cannot be deleted or demoted,
|
||||
and a user cannot delete their own account or change their own role.
|
||||
@@ -47,13 +51,14 @@ than opening a public issue.
|
||||
## CSRF
|
||||
|
||||
- Every state-changing form (`POST`) carries a per-session token
|
||||
(`SecureRandom.hex(32)`), validated with `Rack::Utils.secure_compare`.
|
||||
(`secrets.token_hex(32)`), validated with `secrets.compare_digest`.
|
||||
Requests without a valid token get `403`.
|
||||
- `SameSite=Strict` cookies provide a second, independent layer.
|
||||
|
||||
## Content Security Policy (admin)
|
||||
|
||||
All `/admin/*` responses send a strict **Content-Security-Policy** header:
|
||||
All `/admin/*` responses send a strict **Content-Security-Policy** header
|
||||
(via `CSPMiddleware`):
|
||||
|
||||
```
|
||||
default-src 'self';
|
||||
@@ -66,8 +71,8 @@ form-action 'self';
|
||||
frame-ancestors 'none'
|
||||
```
|
||||
|
||||
The inline allowances are required by the server-rendered admin (ERB templates
|
||||
emit small inline `<script>` blocks and inline `style=""` attributes);
|
||||
The inline allowances are required by the server-rendered admin (Jinja2
|
||||
templates emit small inline `<script>` blocks and inline `style=""` attributes);
|
||||
`frame-ancestors 'none'` prevents clickjacking via iframe embedding. The public
|
||||
JSON API does not set a CSP — it returns no HTML, so none is needed.
|
||||
|
||||
@@ -76,20 +81,19 @@ JSON API does not set a CSP — it returns no HTML, so none is needed.
|
||||
- The public read API sends `Access-Control-Allow-Origin: *` (read-only, no
|
||||
credentials) so any front-end may consume it. The admin sends **no** CORS
|
||||
headers and is same-origin only.
|
||||
- `Rack::Protection::HostAuthorization` is disabled (`permitted_hosts: []`)
|
||||
because volumen is intended to run behind nginx, which controls the `Host`
|
||||
header. Do not expose the Puma port directly to the internet.
|
||||
- volumen is intended to run behind nginx, which controls the `Host` header.
|
||||
Do not expose the Uvicorn port directly to the internet.
|
||||
|
||||
## File uploads and path traversal
|
||||
|
||||
- Uploaded media is stored under `content_dir/media` with a **sanitised,
|
||||
randomised** filename (`<hex>-<slug>.<ext>`), so uploads cannot overwrite
|
||||
each other or escape the directory.
|
||||
- `GET /media/*` resolves names with `File.basename` and an explicit
|
||||
- `GET /media/*` resolves names with `Path(...).name` and an explicit
|
||||
containment check, so `../` traversal cannot read files outside the media
|
||||
directory.
|
||||
- Uploads are **rejected (HTTP 413)** when the file exceeds **10 MB**
|
||||
(`MAX_UPLOAD_BYTES`).
|
||||
- Uploads are **rejected (HTTP 413)** when the file exceeds the
|
||||
`[admin].max_upload_bytes` ceiling (default **10 MB**, `10485760` bytes).
|
||||
- Upload MIME type is **whitelisted** to `image/webp` and `image/avif`
|
||||
(`ALLOWED_UPLOAD_TYPES`); anything else returns **HTTP 415** with a
|
||||
conversion hint pointing at `cwebp` / `avifenc`. This matches the
|
||||
@@ -107,21 +111,27 @@ JSON API does not set a CSP — it returns no HTML, so none is needed.
|
||||
|
||||
## Transport
|
||||
|
||||
- Bind to `127.0.0.1` and terminate TLS at nginx. The browser ↔ nginx hop is
|
||||
HTTPS; the nginx ↔ Puma hop is local.
|
||||
- Bind to `::1` (loopback) and terminate TLS at nginx. The browser ↔ nginx hop
|
||||
is HTTPS; the nginx ↔ Uvicorn hop is local.
|
||||
|
||||
## Dependencies
|
||||
|
||||
A small, audited set: `sinatra`, `kramdown` (+ `kramdown-parser-gfm`),
|
||||
`toml-rb`, `puma`, `rackup`. Cryptography uses the Ruby standard library
|
||||
(`openssl`, `securerandom`).
|
||||
A small, audited set declared in `pyproject.toml`:
|
||||
|
||||
- Runtime: `fastapi[standard]` (FastAPI, Uvicorn, itsdangerous, Jinja2 transitively),
|
||||
`python-markdown`, `pymdown-extensions`, `tomli-w`, `python-multipart`.
|
||||
(`itsdangerous`, `Jinja2`, and `python-multipart` are pulled in directly
|
||||
too — see `pyproject.toml`.)
|
||||
- Crypto: Python standard library (`hashlib`, `secrets`, `hmac`).
|
||||
- Dev: `pytest`, `httpx`, `ruff`.
|
||||
|
||||
## Known limitations / non-goals
|
||||
|
||||
- **No rate limiting or lockout** on `/admin/login` at the application layer
|
||||
(the engine does have an in-memory rate limiter, but nginx is the durable
|
||||
line of defence — see `docs/deployment.md`). Put nginx `limit_req` in
|
||||
front of it, or use fail2ban, to slow brute-force attempts.
|
||||
beyond an in-memory per-IP sliding window (`MAX_LOGIN_ATTEMPTS = 10` per 60s
|
||||
in `__init__.py`). nginx is the durable line of defence — see
|
||||
`docs/deployment.md`. Put nginx `limit_req` in front of `/admin/login`, or use
|
||||
fail2ban, to slow brute-force attempts.
|
||||
- **No 2FA** and **no audit log**.
|
||||
- **Raw HTML in posts is trusted** (see the trust model). There is no built-in
|
||||
HTML sanitiser.
|
||||
HTML sanitiser.
|
||||
@@ -1,3 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Volumen — a small, file-based Markdown blog engine."""
|
||||
from volumen.cli import main
|
||||
|
||||
main()
|
||||
|
||||
-178
@@ -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 "$@"
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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", ""))
|
||||
|
||||
@@ -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
@@ -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>"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "")
|
||||
@@ -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 & B <C>" 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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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("")
|
||||
assert "<img" in rendered
|
||||
|
||||
def test_figure_wrapping_still_works(self) -> None:
|
||||
rendered = render('')
|
||||
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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user