36 Commits
Author SHA1 Message Date
petrbalvin 98f7f3c9e3 fix: add missing CSP style-src nonce and include static SVGs in wheel
Test / test (push) Successful in 57s
Release / release (push) Successful in 1m11s
2026-07-27 09:03:44 +02:00
petrbalvin d9b7a53ac2 ci(release): fix tag env, upgrade actions, add release permissions
Test / test (push) Successful in 1m10s
2026-07-26 23:35:30 +02:00
petrbalvin a5c1b519ed fix(ci): use gitea.ref_name and map PYPI_TOKEN env for publish step
Test / test (push) Successful in 1m0s
Release / release (push) Successful in 1m10s
2026-07-26 18:19:11 +02:00
petrbalvin 3b8ed31f67 chore: prepare release v0.4.0
Test / test (push) Successful in 1m7s
Release / release (push) Successful in 1m11s
2026-07-26 18:15:29 +02:00
petrbalvin 19c6161ae0 fix: Markdown import in admin preview route 2026-07-22 21:25:27 +02:00
petrbalvin 3ac1ef78c2 fix: icon path and config.toml structure 2026-07-22 21:22:08 +02:00
petrbalvin 36855afc70 fix: hash-password command import name 2026-07-22 21:17:21 +02:00
petrbalvin 51d744e0ea fix: default bind to :: for IPv6 dual-stack 2026-07-22 21:14:41 +02:00
petrbalvin 44b879371e refactor: rewrite from Ruby/Sinatra to Python/FastAPI 2026-07-22 21:12:28 +02:00
petrbalvin 6f62d3e3f3 chore: prepare release v0.3.0 2026-07-12 14:03:14 +02:00
petrbalvin 1dfbf67921 style(web): polish native date, time and file inputs 2026-07-12 12:55:29 +02:00
petrbalvin 0e8f013bbb feat(web): show version in sidebar footer 2026-07-12 11:25:22 +02:00
petrbalvin 2f90c6da4d feat(admin): add volumen icon and use it in sidebar and login 2026-07-12 11:18:59 +02:00
petrbalvin f40ace64a8 feat(settings): add user profile photo upload and tabbed layout 2026-07-12 11:12:37 +02:00
petrbalvin ff0452bcc3 feat(uploads): restrict to webp/avif with conversion hint 2026-07-12 11:03:49 +02:00
petrbalvin 34dc67ce99 feat(admin): add user fediverse handle setting 2026-07-12 11:00:01 +02:00
petrbalvin e028db6447 feat(users): add display name field for users 2026-07-12 10:57:14 +02:00
petrbalvin cbae03014d fix(form): default to today when date is missing 2026-07-12 10:52:17 +02:00
petrbalvin 10ef258e84 feat(admin): redesign admin UI with modern layout 2026-07-12 10:50:49 +02:00
petrbalvin 5fdf8e5ee8 feat(post): add cover caption metadata 2026-07-12 10:31:21 +02:00
petrbalvin 6ee8e066aa feat(markdown): wrap titled images in figures with caption 2026-07-12 10:24:45 +02:00
petrbalvin 1dbeb66be0 fix(admin): clear posts cache after save/delete 2026-07-12 09:33:30 +02:00
petrbalvin d1295c6633 docs: document all development changes in changelog
Add entries for pagination flags, draft error code, just lint/fmt,
config.toml.example, published_posts memoization, Store mtime
invalidation, Cache-Control, FeedHelpers extraction, CSP header,
upload size limit, and MIME whitelist.
2026-06-26 20:49:48 +02:00
petrbalvin 5cc3a1aabe feat: return distinct 'draft' error for draft post detail requests
Previously the API returned 'not_found' for both missing and draft
posts. Now draft posts return a specific 'draft' error code so API
consumers can distinguish between the two cases and show an
appropriate message.
2026-06-26 20:44:42 +02:00
petrbalvin 781707a3fd chore: bump ClassLength limit to 440
Server sits at 423 lines after extracting FeedHelpers; allow a small
headroom for upcoming changes.
2026-06-26 20:44:03 +02:00
petrbalvin 2da9ec9bba refactor: extract feed and sitemap helpers into FeedHelpers module
Move feed_xml, feed_item, feed_json, json_feed_item, rfc822_date,
iso_date, and sitemap_xml out of Server into a dedicated FeedHelpers
module included via helpers. Reduces Server from ~500 to ~400 lines
and separates feed generation concerns from routing.
2026-06-26 20:43:27 +02:00
petrbalvin 0ba6e379d7 chore: gitignore config.toml, add config.toml.example
The dev config.toml contains a password hash and should not be
committed. Add a config.toml.example template instead and document
the copy step.
2026-06-26 20:41:49 +02:00
petrbalvin db6da86c93 perf: add Cache-Control header to public API responses
Set Cache-Control: public, max-age=60 on all /api/volumen/* endpoints
so browsers and CDNs can cache responses for 60 seconds, reducing
unnecessary re-fetches.
2026-06-26 20:41:30 +02:00
petrbalvin a2f42c6a12 security: add Content-Security-Policy header for admin routes
Set a strict CSP on all /admin/* responses: default-src 'self',
script/style 'unsafe-inline' (needed for the inline editor JS and
layout CSS), img-src 'self' data:, frame-ancestors 'none'. Public
API routes are unaffected. Bump ClassLength limit to 420 to
accommodate the new helper.
2026-06-26 20:41:10 +02:00
petrbalvin 223405dcb9 perf: invalidate Store cache on individual file mtime changes
Previously the store only checked the content directory's own mtime,
which does not change when an existing file is modified in place. Now
track the newest mtime across all .md files as well, so edits picked
up from disk (manual changes, git pull) are detected without a
restart.
2026-06-26 20:40:21 +02:00
petrbalvin 95452477d8 chore: add just lint and just fmt recipes
Add standalone lint and fmt (auto-fix) recipes to the justfile so
developers can check style without running the full build. Also
extract upload validation into a private Server helper to keep
the admin routes method under the complexity threshold.
2026-06-26 20:37:21 +02:00
petrbalvin bc2e056b68 feat: add has_next and has_prev to paginated posts response
Clients no longer need to compute page boundaries themselves; the API
now returns boolean flags indicating whether more pages exist in each
direction. Includes tests for first, middle, and last page.
2026-06-26 20:36:16 +02:00
petrbalvin e11c7d24a4 security: whitelist allowed image MIME types for uploads
Only accept common image formats (JPEG, PNG, GIF, WebP, AVIF, SVG)
on the upload endpoint. Non-image uploads now return HTTP 415 with a
structured JSON error listing the allowed types. Includes a test.
2026-06-26 20:35:46 +02:00
petrbalvin 25ebf691f7 security: reject file uploads exceeding 10 MB
Add a MAX_UPLOAD_BYTES constant (10 MB) to Server and check the
uploaded tempfile size before writing it to disk. Exceeding the limit
returns HTTP 413 with a structured JSON error. Includes a test.
2026-06-26 20:35:23 +02:00
petrbalvin 4abb5585df perf: memoize published_posts within a single request
Store the filtered and sorted list of published posts in an instance
variable so repeated calls during the same request (posts list, tags,
feeds, sitemap) reuse the cached result instead of re-filtering and
re-sorting from scratch each time. Sinatra resets instance variables
between requests, so the cache stays fresh.
2026-06-26 20:34:37 +02:00
petrbalvinandQwen-Coder 5437cef1cd feat: add fediverse_creator handle for Mastodon attribution
Expose a per-post fediverse_creator frontmatter field plus a site-wide
default in config.toml ([site].fediverse_creator). The handle is returned
in the post and site payloads, rendered as <dc:creator> in the RSS feed
and as authors[].name in the JSON feed, so a front-end can drop it into
<meta name="fediverse:creator"> on rendered pages. Per-post value
overrides the site default. Validated against a simple @user@host regex.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-06-26 17:40:48 +02:00
107 changed files with 12879 additions and 4355 deletions
BIN
View File
Binary file not shown.
-43
View File
@@ -1,43 +0,0 @@
# EditorConfig: https://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.{rb,erb,gemspec}]
indent_style = space
indent_size = 2
[{Gemfile,Rakefile}]
indent_style = space
indent_size = 2
[*.{js,html,css,vue}]
indent_style = space
indent_size = 2
[*.{yml,yaml,json,toml}]
indent_style = space
indent_size = 2
[*.{rs,cj}]
indent_style = space
indent_size = 4
[*.go]
indent_style = tab
indent_size = 4
[justfile]
indent_style = tab
[*.md]
indent_style = space
indent_size = 2
trim_trailing_whitespace = false
-129
View File
@@ -1,129 +0,0 @@
name: Release
on:
push:
tags: ["v*"]
jobs:
release:
runs-on: codeberg-small
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- uses: https://github.com/ruby/setup-ruby@v1
with:
ruby-version: "3.4"
bundler-cache: true
- name: Resolve and verify version
id: version
env:
REF: ${{ forgejo.ref }}
run: |
set -euo pipefail
VERSION="${REF##*/}"
if [[ ! "$VERSION" =~ ^v[0-9]+(\.[0-9]+){0,2}([-+].*)?$ ]]; then
echo "ERROR: expected a semver tag like v1.2.3, got: '$VERSION' (ref: '$REF')"
exit 1
fi
VERSION_NO_V="${VERSION#v}"
GEM_VERSION="$(ruby -Ilib -rvolumen/version -e 'print Volumen::VERSION')"
if [ "$VERSION_NO_V" != "$GEM_VERSION" ]; then
echo "ERROR: tag ${VERSION} does not match Volumen::VERSION (${GEM_VERSION})"
exit 1
fi
echo "version_no_v=${VERSION_NO_V}" >> "$FORGEJO_OUTPUT"
- name: Quality gate
run: |
set -euo pipefail
bundle exec rubocop
bundle exec rake test
- name: Build gem
env:
VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
run: |
set -euo pipefail
gem build volumen.gemspec --output "volumen-${VERSION_NO_V}.gem"
ls -lh "volumen-${VERSION_NO_V}.gem"
- name: Extract CHANGELOG section
env:
VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
run: |
set -euo pipefail
sed -n "/^## \[${VERSION_NO_V}\] /,/^## \[/p" CHANGELOG.md \
| sed '$d' \
| tail -n +2 \
> release-body.md
if [ ! -s release-body.md ]; then
echo "ERROR: no CHANGELOG section found for ${VERSION_NO_V}"
echo "Expected a heading like: ## [${VERSION_NO_V}] — YYYY-MM-DD"
exit 1
fi
echo "Release body preview:"
head -n 20 release-body.md
- name: Create release
id: create
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
run: |
set -euo pipefail
BODY="$(ruby -rjson -e 'print JSON.generate($stdin.read)' < release-body.md)"
response="$(curl -sS -w '\n%{http_code}' \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-X POST \
"${FORGEJO_SERVER_URL}/api/v1/repos/${FORGEJO_REPOSITORY}/releases" \
-d "{
\"tag_name\": \"${FORGEJO_REF_NAME}\",
\"name\": \"${FORGEJO_REF_NAME}\",
\"body\": ${BODY},
\"draft\": false,
\"prerelease\": false
}")"
http_code="$(echo "$response" | tail -1)"
payload="$(echo "$response" | sed '$d')"
echo "HTTP ${http_code}"
if [ "$http_code" != "201" ]; then
echo "Failed to create release: ${payload}"
exit 1
fi
RELEASE_ID="$(echo "$payload" | ruby -rjson -e 'print JSON.parse($stdin.read)["id"]')"
echo "Created release ID=${RELEASE_ID}"
echo "release_id=${RELEASE_ID}" >> "$FORGEJO_OUTPUT"
- name: Upload gem asset
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
RELEASE_ID: ${{ steps.create.outputs.release_id }}
run: |
set -euo pipefail
GEM="volumen-${VERSION_NO_V}.gem"
http_code="$(curl -sS -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/octet-stream" \
-X POST \
--data-binary "@${GEM}" \
"${FORGEJO_SERVER_URL}/api/v1/repos/${FORGEJO_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${GEM}")"
echo "HTTP ${http_code}"
if [ "$http_code" != "201" ]; then
echo "Failed to upload ${GEM}"
exit 1
fi
echo "Release ${FORGEJO_REF_NAME} is live with ${GEM}."
-39
View File
@@ -1,39 +0,0 @@
name: Test
on:
push:
branches: [development]
pull_request:
branches: [development]
jobs:
lint:
runs-on: codeberg-small
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- uses: https://github.com/ruby/setup-ruby@v1
with:
ruby-version: "3.4"
bundler-cache: true
- name: RuboCop
run: bundle exec rubocop
test:
runs-on: codeberg-small
needs: lint
strategy:
fail-fast: false
matrix:
ruby-version: ["3.3", "3.4"]
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- uses: https://github.com/ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby-version }}
bundler-cache: true
- name: Tests
run: bundle exec rake test
+145
View File
@@ -0,0 +1,145 @@
# Release — Python package. Runs on version tags (v1.2.3) pushed to main.
# Creates a Gitea release with wheel + sdist and publishes to PyPI.
# Requires a PYPI_TOKEN secret (project-scoped upload token).
name: Release
on:
push:
tags: ["v*"]
jobs:
release:
runs-on: fedora
permissions:
releases: write
steps:
- uses: actions/checkout@v7
- name: Set up Python and uv
uses: astral-sh/setup-uv@v6
with:
python-version: "3.14" # match requires-python in pyproject.toml
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Resolve and verify version
id: version
env:
VERSION: ${{ gitea.ref_name }}
run: |
set -euo pipefail
if ! echo "$VERSION" | grep -qE '^v[0-9]+(\.[0-9]+){0,2}([-+].*)?$'; then
echo "ERROR: expected a semver tag like v1.2.3, got: '$VERSION'"
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 }}
GITEA_REF_NAME: ${{ gitea.ref_name }}
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\":\"${GITEA_REF_NAME}\",\"name\":\"${GITEA_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 }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
RELEASE_ID: ${{ steps.create.outputs.release_id }}
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
- name: Publish to PyPI
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
run: uv publish
+36
View File
@@ -0,0 +1,36 @@
# Test — Python. Runs on push and pull request to development. Never on main.
# The 80 % coverage gate lives in pyproject.toml (addopts --cov-fail-under=80),
# so `uv run pytest` enforces it here exactly as it does locally.
name: Test
on:
push:
branches: [development]
pull_request:
branches: [development]
merge_group:
jobs:
test:
runs-on: fedora
steps:
- uses: actions/checkout@v7
- name: Set up Python and uv
uses: astral-sh/setup-uv@v6
with:
python-version: "3.14" # match requires-python in pyproject.toml
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
+16 -7
View File
@@ -1,18 +1,27 @@
# Bundler
/.bundle/
/vendor/bundle/
# Built gems
/pkg/
*.gem
# Python
__pycache__/
*.pyc
*.pyo
*.egg-info/
.venv/
.pytest_cache/
.ruff_cache/
dist/
# Test / coverage
/coverage/
/tmp/
# mkdocs / Sphinx build output
/site/
/var/
# Local development sandbox (posts, generated config)
/.volumen/
# Local dev config (contains password hash)
/config.toml
# Uploaded media in the dev content directory
/posts/media/
-42
View File
@@ -1,42 +0,0 @@
AllCops:
TargetRubyVersion: 3.3
NewCops: enable
SuggestExtensions: false
Style/StringLiterals:
EnforcedStyle: double_quotes
Style/StringLiteralsInInterpolation:
EnforcedStyle: double_quotes
Style/Documentation:
Exclude:
- "test/**/*"
- "exe/**/*"
Layout/LineLength:
Max: 100
Metrics/AbcSize:
Max: 30
Exclude:
- "lib/volumen/admin_routes.rb"
- "lib/volumen/api_routes.rb"
Metrics/MethodLength:
Max: 25
Exclude:
- "lib/volumen/admin_routes.rb"
- "lib/volumen/api_routes.rb"
Metrics/ClassLength:
Max: 400
Metrics/ModuleLength:
Exclude:
- "lib/volumen/admin_routes.rb"
Metrics/BlockLength:
Exclude:
- "test/**/*"
- "volumen.gemspec"
+245 -1
View File
@@ -5,8 +5,249 @@ 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.1] — 2026-07-27
### Fixed
- **CSP nonce chybí pro `style-src` v admin routách** — přidán `style-src 'self' 'nonce-{nonce}'` do CSP hlavičky pro `/admin` routy. Bez nonce prohlížeč blokoval všechny inline `<style>` tagy v admin šablonách.
- **`web/static/` SVG soubory chybí v distribučním kole** — přidána sekce `[tool.setuptools.package-data]` do `pyproject.toml`, aby `volumen-icon.svg` a `volumen-logo.svg` byly součástí instalovaného wheelu. Admin panel dříve vracel 500 na `/admin/icon.svg` a `/admin/logo.svg`.
## [0.4.0] — 2026-07-26
### Added
- **`volumen init`** — a new `init` subcommand is the *operational*
bootstrap (config, data dirs, admin password, optional systemd unit). Run
as root for a system install (default `--systemd`); pass `--local` for a
per-user install (`~/.config/volumen/`, `~/.local/share/volumen/`). The
command renders the production-hardened config from the same commented
TOML template (`host = "::1"`, `env = "production"`, `trust_proxy = true`,
`cookie_secure = true`), prompts for an admin password (`getpass`), hashes
it via the existing scrypt helper, generates a 64-byte hex session key,
creates the system user (`useradd --system`) and the `/etc/volumen/` /
`/var/lib/volumen/` layout, and (with `--systemd`) installs a hardened
`/etc/systemd/system/volumen.service` and runs
`systemctl daemon-reload && systemctl enable --now`.
- **PyPI publish metadata** — `pyproject.toml` gains `readme`, `keywords`,
a complete `classifiers` array (Development Status, Framework :: FastAPI,
supported Python versions, OS, Topic, Typing), and a `[project.urls]`
block (Homepage, Repository, Issues, Changelog, Documentation). PyPI now
renders the README on the project page and links back to sourcedock.dev.
- **`volumen status`** — read-only inspection of an installation. Prints
human output by default or `--json` for tooling; reports `config_exists`,
`data_dir_exists`, `users_file_exists`, `password_hash_set`,
`session_key_ok`, `systemd_unit_installed`, `service_active`,
`admin_user_present`, and any issues found. Exit code is `1` when any
issue is reported.
- **`volumen check-update`** — compares the installed version (read via
`importlib.metadata`) with the latest release on PyPI. Uses only
`urllib.request` (stdlib, 5 s timeout); exit code is `0` up-to-date,
`1` update available (suggests `uv tool upgrade volumen`), `2` PyPI
unreachable / offline / malformed response. `--json` returns the same
fields plus `checked_at` for cron-friendly alerting.
- **`src/volumen/installer.py`** — a new module exposing `system_install`,
`user_install`, `inspect`, plus the private `_mutate_config_text` helper
used to apply line-anchored substitutions to the rendered TOML template
(preserves comment blocks byte-for-byte).
- **Tests** — `tests/test_installer.py` adds 15 tests covering both install
flows, the config-template mutator, the systemd-unit rendering,
idempotency / backup behaviour, the `inspect` round-trip, and the
`--systemd` + `--local` CLI guard.
### Changed
- **First-run setup is now `volumen init`** — replaces the legacy
`install.sh`. The CLI subcommand renders config, creates data dirs,
prompts for an admin password, generates a session key, and (with
`--systemd`) installs and enables a hardened systemd unit. No bash
required.
- **Publishing now targets PyPI** — `release.yml` runs
`uv publish --token "$PYPI_TOKEN"` after `python -m build` for `v*`
tags. Wheel + sdist continue to be uploaded to sourcedock.dev Releases as a
secondary mirror. End users get `uv tool install volumen` and
`pipx install volumen` as the one-line install path; no checkout
required.
- **Distribution path is `uv tool install`** (or `pipx install`) — the
CLI binary is installed globally; the operational bootstrap is delegated
to `volumen init`. The legacy `uv sync` + project-local venv at
`/opt/volumen/.venv` is gone.
- **`Config.load` round-trips config-file `content_dir` / `users_file`** —
the rendered template places these two keys after the `[server]` header,
so TOML table folding puts them inside `[server]`. `Config.load` now
hoists them to the root so files produced by `volumen init` (and any
hand-edited config) round-trip through the canonical accessors without
relying on CLI overrides.
- **Migrated from Ruby to Python**: the runtime has been rewritten on top of
FastAPI + Uvicorn, replacing Sinatra with FastAPI, kramdown with
python-markdown + pymdown-extensions, Puma with Uvicorn, Bundler with uv,
minitest with pytest, RuboCop with Ruff, and the TOML parser
(`toml-rb`) with the standard library `tomllib` (plus `tomli-w` for
round-tripping edits). The public API (`/api/volumen/*`) and the
server-rendered admin (`/admin/*`) keep the same shape and HTTP semantics.
- **Installer** — `install.sh` now bootstraps `uv` instead of Bundler, runs
`uv sync --frozen` to provision `/opt/volumen/.venv`, and points the
systemd `ExecStart` at the venv-resident `volumen` console script. The
Python runtime is auto-installed on Fedora/openEuler; other distros get a
heredoc with the equivalent `apt` / `pacman` / `zypper` / `apk` lines.
- **CI** — Forgejo Actions moved to `setup-uv` + Ruff + pytest on a single
Python 3.12 job (was: Ruby 3.3/3.4 matrix with Bundler, RuboCop, and
`bundle exec rake test`). The release workflow builds wheel + sdist via
`python -m build` and uploads them with the Forgejo Releases API.
- **Migration guard** — new `.forgejo/workflows/migration-guard.yml` greps
the tracked tree for Ruby-only terms (`bundle exec`, `RuboCop`, `Sinatra`,
`Puma`, `kramdown`, `gem build`, `Volumen::`, `Rack::Session::Cookie`,
`OpenSSL::KDF`, `lib/volumen/`) and fails the build if any of them leak
back into production paths.
- **Configuration** — `config.toml` gains six new keys surfaced in
`config.toml.example`: `[server].env` (`"production"` enables strict
startup), `[server].trust_proxy` (honour `X-Forwarded-Proto` from
nginx), `[admin].min_password_length` (default `10`),
`[admin].max_password_length` (default `1024`, caps scrypt work),
`[admin].max_upload_bytes` (default `10485760`, 10 MB), and
`[admin].cookie_secure` (default `false` in dev, set `true` behind
HTTPS). See `docs/configuration.md` for the full schema.
- **Default bind address** — the installer and `config.toml.example`
default to `host = "::"` (IPv6 with automatic IPv4 fallback) so the
service is reachable on both stacks out of the box. Bind to `"::1"`
when running behind a reverse proxy.
- **Test client now uses `httpx2`** — dev dependencies replace
`httpx>=0.28` with `httpx2>=2.7` (the actively maintained fork by
Pydantic). Starlette 1.3.x prefers `httpx2` in `TestClient` and emits
`StarletteDeprecationWarning` when only legacy `httpx` is installed;
shipping `httpx2` keeps the test suite warning-free. The legacy
`httpx` package is still pulled in via `fastapi[standard]` for the
`fastapi` CLI and remains API-compatible. The unused
`ignore::DeprecationWarning:starlette.testclient` filter is removed
from `[tool.pytest.ini_options]` (it targeted the wrong warning
class and never matched).
### Removed
- `install.sh` — the bash installer is deleted. Use `uv tool install
volumen` (or `pipx install volumen`) followed by `volumen init`.
- `pkg/` build output and `volumen.gem` (Ruby gem packaging is gone; the
project now ships as a wheel + sdist built with `python -m build`).
- `Gemfile`, `volumen.gemspec`, `Rakefile`, `.rubocop.yml` and the
Sinatra-era `lib/` tree.
- `.editorconfig` — Ruff (`pyproject.toml`'s `[tool.ruff]`) is the single
source of truth for Python formatting; modern editors default to UTF-8,
LF, and a trailing newline. Markdown files in this repo use blank lines
between paragraphs (no ` \n` line-break idiom), so trimming trailing
whitespace is safe.
### Security
- **scrypt password hashing** now uses `hashlib.scrypt` and constant-time
verification via `secrets.compare_digest` (was: Ruby's `OpenSSL::KDF`).
- **Session cookies** are signed by Starlette's `SessionMiddleware`
(`itsdangerous`) with `HttpOnly` and `SameSite=Strict`; `Secure` is added
by `SecureCookieMiddleware` when the request was forwarded over HTTPS
(only when `[server].trust_proxy = true`).
- **CSRF tokens** are generated with `secrets.token_hex(32)` and verified
with `secrets.compare_digest` on every state-changing admin POST.
- **Markdown sanitisation policy** — the renderer still passes inline HTML
through (authors are the trusted admin); document the trust model
explicitly in `docs/security.md` so deployments that ingest untrusted
content know to add a sanitiser.
> Note: refine the Security bullets above once the Python agent confirms the
> final cryptographic controls (e.g. whether `image/*` upload signatures get
> re-validated server-side and whether the CSRF token is rotated on login).
## [0.3.0] — 2026-07-12
### Added
**Content & authoring**
- **Fediverse attribution:** a `fediverse_creator` frontmatter field (e.g.
`@user@mastodon.social`) plus a site-wide `[site].fediverse_creator` default
in `config.toml`. The value is exposed as `fediverse_creator` on the site
and post payloads, rendered as `<dc:creator>` in the RSS feed, as
`authors[].name` in the JSON feed, and can be consumed by a front-end to
emit `<meta name="fediverse:creator">` on rendered pages. Per-post values
override the site default.
- **Cover caption:** a `cover_caption` post field rendered next to the cover
image so authors can credit photos or add a short blurb.
- **Titled images as `<figure>`:** Markdown images with a title
(`![alt](url "caption")`) are wrapped in a `<figure>` with a `<figcaption>`
and a small `i` info icon. Images without a title render exactly as before.
**Public API**
- `has_next` and `has_prev` boolean flags in the paginated
`/api/volumen/posts` response so clients can navigate pages without
computing boundaries themselves.
- Distinct `"draft"` error code in `/api/volumen/posts/:slug` when the post
exists but is a draft, replacing the generic `"not_found"`.
**Admin**
- **Modern admin UI:** redesigned layout with a sticky top bar, breadcrumbs
on every page, and a unified design language across login, post list,
post form, and settings.
- **Volumen icon:** a dedicated SVG icon shown in the sidebar and on the
login page (served at `/admin/icon.svg`).
- **Version in sidebar footer** so admins always see which release is
running.
- **Per-user display name** on the user record and admin header.
- **Per-user fediverse handle** editable from the settings page (independent
of the post-level `fediverse_creator`).
- **Profile photo upload:** each admin user can upload a WebP/AVIF avatar
that is stored alongside posts in the content directory and shown in the
settings page.
- **Tabbed settings page** separating account, profile, and admin sections.
- **Restricted uploads:** only WebP (`image/webp`) and AVIF (`image/avif`)
are accepted, with a 415 error message that points users to `cwebp` /
`avifenc` for conversion. Reflects the engine's WebP/AVIF-only posture
end-to-end.
- **Polished native inputs** for `<input type="date|time|file">` so they
match the rest of the admin's design language.
**Tooling**
- `just lint` recipe for standalone RuboCop checks and `just fmt` for
auto-fixing lint issues.
- `config.toml.example` template for local development; `config.toml` is
now gitignored.
### Changed
- Memoize `published_posts` within a single request so repeated calls
(posts list, tags, feeds, sitemap) reuse the cached result.
- `Store#all` cache now invalidates on individual file mtime changes, not
just on the content directory mtime. Manual edits and `git pull` are
detected without a restart.
- `Cache-Control: public, max-age=60` header on all public API responses
for browser and CDN caching.
- Extract feed and sitemap generation into a dedicated `FeedHelpers` module,
reducing `Server` from ~500 to ~420 lines.
### Fixed
- Admin post save/delete now correctly clears the in-memory posts cache, so
the post list reflects edits without a manual reload.
- The post form's date field defaults to today when the underlying post has
no date, preventing the picker from showing an invalid empty value.
### Security
- **Content-Security-Policy** header on all `/admin/*` responses:
`default-src 'self'`, `script-src 'self' 'unsafe-inline'`,
`style-src 'self' 'unsafe-inline'`, `img-src 'self' data:`,
`font-src 'self'`, `connect-src 'self'`, `form-action 'self'`,
`frame-ancestors 'none'`.
- File uploads are rejected (HTTP 413) when exceeding **10 MB**
(`MAX_UPLOAD_BYTES`).
- File uploads are restricted to `image/webp` and `image/avif`
(`ALLOWED_UPLOAD_TYPES`). Anything else is rejected with a 415 and a
conversion hint.
## [0.2.0] — 2026-06-25
### Added
@@ -147,4 +388,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.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
+155 -103
View File
@@ -1,16 +1,20 @@
# Contributing
Thank you for considering contributing to **volumen** — a small, file-based
Markdown blog engine written in CRuby, with a built-in admin and a public JSON
API.
Markdown blog engine written in Python, with a built-in admin and a public
JSON API.
## Development Setup
### Requirements
- **Ruby ≥ 3.3** with a C toolchain (Puma compiles a native extension).
- Fedora: `sudo dnf install ruby ruby-devel @development-tools openssl-devel`
- Debian/Ubuntu: `sudo apt install ruby-full build-essential libssl-dev`
- **Python ≥ 3.14** with the `venv` module (the venv itself is managed by
`uv`).
- Fedora: `sudo dnf install python3.14 python3-virtualenv`
- Debian/Ubuntu: `sudo apt install python3.14 python3-venv python3-pip`
- **[`uv`](https://docs.astral.sh/uv/)** — the package manager. The project
pins every dependency (including transitive ones) in `uv.lock`; install with
`curl -LsSf https://astral.sh/uv/install.sh | sh` (or via your distro).
- [`just`](https://github.com/casey/just) — the `justfile` is the source of
truth for install / run / build / test / uninstall.
- Linux, macOS, or FreeBSD.
@@ -18,11 +22,11 @@ API.
### Quick Start
```bash
git clone https://codeberg.org/petrbalvin/volumen.git
git clone https://sourcedock.dev/petrbalvin/volumen.git
cd volumen
just install # bundle install
just test # bundle exec rake test (minitest)
just build # rubocop (zero warnings) + gem build → pkg/volumen.gem
just install # uv sync
just test # uv run pytest
just build # ruff check + ruff format --check (zero warnings)
just dev # run against ./posts and ./config.toml on :9090
```
@@ -31,7 +35,9 @@ admin password is `admin` (the hash lives in `./config.toml`). Open
<http://localhost:9090/admin/>.
For a fresh `serve` (without `--config`), the first run writes a commented
template to `/etc/volumen/config.toml`; see [`docs/deployment.md`](docs/deployment.md).
template to `/etc/volumen/config.toml`; see [`docs/deployment.md`](docs/deployment.md)
for the full production bootstrap (`uv tool install volumen && sudo volumen
init`).
### Running Tests
@@ -39,49 +45,51 @@ template to `/etc/volumen/config.toml`; see [`docs/deployment.md`](docs/deployme
just test
```
The minitest suite (`test/`) covers:
The pytest suite (`tests/`) covers:
- `frontmatter``+++` TOML block parse/serialise round-trips.
- `post` / `store` — the Post model, reading/writing posts and media.
- `markdown` — GFM rendering (tables, fenced code).
- `markdown` — GFM-ish rendering (tables, fenced code, `<figure>`).
- `config` — defaults, overrides, template generation.
- `password` / `users` — scrypt hashing, roles, last-admin safeguards.
- `server` / `admin` — the JSON API and the admin (auth, CSRF, CRUD, uploads,
roles) via `rack-test`.
- `server` / `admin` — the JSON API and the admin (auth, CSRF, CRUD,
uploads, roles) via `httpx.AsyncClient` against the FastAPI app.
### Linting
RuboCop is the source of truth for style (`.rubocop.yml`). The project enforces
**zero offenses**:
Ruff is the source of truth for style (settings in `pyproject.toml`,
`[tool.ruff]`, `[tool.ruff.lint]`). The project enforces **zero warnings**:
```bash
bundle exec rubocop # check (also run as part of `just build`)
bundle exec rubocop -A # autocorrect
just build # ruff check + ruff format --check
just fmt # ruff format + ruff check --fix (autocorrect)
```
`just build` is what CI runs; both steps must exit 0 with no diff in
`--check` mode.
## Code Style
- Idiomatic Ruby; let RuboCop guide you. CI fails on any offense.
- **Two-space** indentation, **double quotes**, and `# frozen_string_literal: true`
at the top of every Ruby file.
- Document every class/module with a short comment (`Style/Documentation` is on).
- Keep lines ≤ 100 characters (`Layout/LineLength`).
- Prefer guard clauses; return `nil`/objects for expected "not found" cases
rather than raising.
- Keep the dependency set small and deliberate — runtime gems are `sinatra`,
`kramdown` (+ `kramdown-parser-gfm`), `toml-rb`, `puma`, `rackup`. Crypto uses
the standard library (`openssl`, `securerandom`). New runtime dependencies
should be discussed in an issue first.
- Idiomatic Python 3.14+; let Ruff guide you. CI fails on any warning.
- **4-space** indentation, **double quotes**, lines `≤ 100` characters (set
in `[tool.ruff]`).
- Type hints on every public function signature; `from __future__ import
annotations` at the top of every module.
- Prefer the standard library. New runtime dependencies belong in
`pyproject.toml`'s `dependencies` array and should be discussed in an
issue first.
- Prefer `httpx` for HTTP tests; never start a real network server in tests.
- Admin content is authored by trusted users; see [`docs/security.md`](docs/security.md)
for the trust model before touching rendering or uploads.
### Testing
- Put tests in `test/*_test.rb`, each starting with `require "test_helper"`.
- Use `Dir.mktmpdir` for anything touching the filesystem; never assume a clean
working directory.
- Exercise the HTTP layer with `Rack::Test` (`Volumen::Server.configured(...)`).
- Name tests `test_<thing>_<scenario>`; cover both the happy and the error path.
- Put tests in `tests/test_*.py`; use `pytest` fixtures (see
`tests/conftest.py`) for the temp content directory and the FastAPI app.
- Exercise the HTTP layer with `httpx.AsyncClient` driving the FastAPI app
via `app=` in the `AsyncClient` constructor.
- Name tests `test_<thing>_<scenario>`; cover both the happy and the error
path.
## Commit Conventions
@@ -101,11 +109,11 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/).
```
feat: add author role with last-admin safeguards
fix: rename Server.build to .configured to avoid a Sinatra clash
fix: rename create_app to factory to avoid a FastAPI clash
refactor: extract frontmatter parsing into its own module
docs: document the dual-mode editor
test: cover GFM table and fenced code rendering
chore: pin gem versions to latest
chore: pin Python dependencies to latest
```
- English, lowercase subject, imperative mood (`add`, not `added`).
@@ -118,10 +126,11 @@ chore: pin gem versions to latest
2. Make your changes with Conventional-Commits messages.
3. Record your changes under the `## [development]` section at the top of
`CHANGELOG.md`, using the Keep a Changelog categories (`Added`, `Changed`,
`Fixed`, etc.).
4. Ensure `bundle exec rubocop` reports zero offenses and `just test` passes.
`Fixed`, `Removed`, `Security`).
4. Ensure `just build` reports zero warnings and `just test` passes.
5. Add or update tests for your change.
6. Update documentation if the public API, configuration, or behaviour changes:
6. Update documentation if the public API, configuration, or behaviour
changes:
- `README.md` — high-level overview.
- `docs/configuration.md` — config keys.
- `docs/api-reference.md` — JSON API behaviour.
@@ -136,29 +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
│ ├── post.rb # Post model (metadata + body + rendered HTML)
│ ├── store.rb # read/write posts and media on disk
│ ├── config.rb # load/merge config.toml, generate template
│ ├── password.rb # scrypt hashing/verification (OpenSSL::KDF)
│ ├── users.rb # users.toml, admin/author roles
│ ├── server.rb # Sinatra app: public API + admin
│ ├── 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)
├── 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)
├── justfile # install, run, dev, build, test, uninstall
├── volumen.gemspec
├── Gemfile
- `.gitea/workflows/` # test.yml, release.yml, migration-guard.yml (Gitea Actions)
├── justfile # install, run, dev, lint, fmt, build, test, uninstall
├── pyproject.toml # PEP 621 metadata + dependencies + tool config
├── uv.lock # locked dependency graph
├── config.toml.example # bootstrap config template
├── CHANGELOG.md
└── LICENSE
```
@@ -169,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
```
@@ -189,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.
-13
View File
@@ -1,13 +0,0 @@
# frozen_string_literal: true
source "https://rubygems.org"
# Runtime dependencies are declared in the gemspec.
gemspec
group :development, :test do
gem "minitest", "~> 6.0"
gem "rack-test", "~> 2.2"
gem "rake", "~> 13.4"
gem "rubocop", "~> 1.88", require: false
end
-136
View File
@@ -1,136 +0,0 @@
PATH
remote: .
specs:
volumen (0.2.0)
kramdown (~> 2.5)
kramdown-parser-gfm (~> 1.1)
puma (~> 8.0)
rackup (~> 2.3)
sinatra (~> 4.2)
toml-rb (~> 4.2)
GEM
remote: https://rubygems.org/
specs:
ast (2.4.3)
base64 (0.3.0)
citrus (3.0.2)
drb (2.2.3)
json (2.19.9)
kramdown (2.5.2)
rexml (>= 3.4.4)
kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
mustermann (3.1.1)
nio4r (2.7.5)
parallel (2.1.0)
parser (3.3.11.1)
ast (~> 2.4.1)
racc
prism (1.9.0)
puma (8.0.2)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.6)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rainbow (3.1.1)
rake (13.4.2)
regexp_parser (2.12.0)
rexml (3.4.4)
rubocop (1.88.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (>= 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.1)
parser (>= 3.3.7.2)
prism (~> 1.7)
ruby-progressbar (1.13.0)
sinatra (4.2.1)
logger (>= 1.6.0)
mustermann (~> 3.0)
rack (>= 3.0.0, < 4)
rack-protection (= 4.2.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
tilt (2.7.0)
toml-rb (4.2.0)
citrus (~> 3.0, > 3.0)
racc (~> 1.7)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
PLATFORMS
ruby
x86_64-linux
DEPENDENCIES
minitest (~> 6.0)
rack-test (~> 2.2)
rake (~> 13.4)
rubocop (~> 1.88)
volumen!
CHECKSUMS
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
citrus (3.0.2) sha256=4ec2412fc389ad186735f4baee1460f7900a8e130ffe3f216b30d4f9c684f650
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a
kramdown (2.5.2) sha256=1ba542204c66b6f9111ff00dcc26075b95b220b07f2905d8261740c82f7f02fa
kramdown-parser-gfm (1.1.0) sha256=fb39745516427d2988543bf01fc4cf0ab1149476382393e0e9c48592f6581729
language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
mustermann (3.1.1) sha256=4c6170c7234d5499c345562ba7c7dfe73e1754286dcc1abb053064d66a127198
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356
parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2
rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac
rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8
rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
rubocop (1.88.0) sha256=e420ddf1662d0ef34bc8a2910ac4b396a7ddda0b51a708264405241734b08e0b
rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080
tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3
toml-rb (4.2.0) sha256=10a48c91613e20cf63483a7a776767dfb3cd7d70e9327c0237443da601e13776
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
volumen (0.2.0)
BUNDLED WITH
4.0.10
+136 -33
View File
@@ -1,6 +1,6 @@
# volumen — file-based Markdown blog engine
**volumen** is a small, dependency-light blog engine written in **Ruby**. It
**volumen** is a small, dependency-light blog engine written in **Python**. It
serves your Markdown posts as a JSON API and includes a built-in, server-
rendered admin with both a Markdown source editor and a visual editor — so any
front-end (Vue, React, Svelte, plain HTML) can consume your content without
@@ -10,26 +10,33 @@ It is designed to be:
- **File-based** — every post is a plain `.md` file with TOML frontmatter, safe
to commit to Git and edit in your favourite editor.
- **Self-contained** — runs as a single Ruby process; no Node build step is
required to operate the engine or its admin.
- **Self-contained** — runs as a single Python process (Uvicorn); no Node
build step is required to operate the engine or its admin.
- **Multi-user with roles** — username + password login; **admin** (full
access, manages users) and **author** (posts and own account) roles.
- **Multi-site friendly** — one instance per blog, configurable per instance.
- **Markdown-first** — the visual editor round-trips through the same renderer
that powers the public API, so what you store is always Markdown.
- **IPv6-first** — the default bind address is `::` (IPv6 with automatic IPv4
fallback); bind to `::1` when running behind nginx so the admin port is not
exposed to the network.
---
## Stack
| Concern | Choice |
|----------------|------------------------------------------|
| Language | CRuby (≥ 3.2) |
| Web framework | [Sinatra](https://sinatrarb.com) |
| Markdown | [kramdown](https://kramdown.gettalong.org) |
| Frontmatter | TOML via [`toml-rb`](https://github.com/emancu/toml-rb) |
| App server | [Puma](https://puma.io) |
| Packaging | a Ruby gem with an `exe/volumen` CLI |
| Concern | Choice |
|----------------|------------------------------------------------------------------------|
| Language | Python 3.14+ |
| Web framework | [FastAPI](https://fastapi.tiangolo.com/) |
| App server | [Uvicorn](https://www.uvicorn.org/) (via `fastapi[standard]`) |
| Markdown | [python-markdown](https://python-markdown.github.io/) + [pymdown-extensions](https://facelessuser.github.io/pymdown-extensions/) |
| Frontmatter | TOML via stdlib [`tomllib`](https://docs.python.org/3/library/tomllib.html) + [`tomli-w`](https://github.com/camillescott/tomli-w) |
| Templating | [Jinja2](https://jinja.palletsprojects.com/) |
| Sessions | Starlette `SessionMiddleware` (signed cookies via [`itsdangerous`](https://itsdangerous.palletsprojects.com/)) |
| Packaging | Wheel + sdist via [`setuptools`](https://setuptools.pypa.io/); [`uv`](https://docs.astral.sh/uv/) for dependency management |
| Lint / format | [Ruff](https://docs.astral.sh/ruff/) |
| Tests | [pytest](https://docs.pytest.org/) + [httpx](https://www.python-httpx.org/) |
---
@@ -40,19 +47,22 @@ It is designed to be:
- Multi-language posts with a translations map
- Clean JSON API at `/api/volumen/`:
- `GET /site` — site metadata
- `GET /posts` — paginated list with `lang`, `tag`, `page`, `limit` filters
- `GET /posts` — paginated list with `lang`, `tag`, `q`, `page`, `limit`
filters
- `GET /posts/{slug}` — single post (raw Markdown + rendered HTML)
- `GET /tags` — tag cloud with counts
- `GET /feed.xml` — RSS 2.0 feed
- `GET /feed.json` — JSON Feed 1.1
- `GET /sitemap.xml` — sitemap
- Server-rendered admin at `/admin/`:
- Username + password login (scrypt-hashed via Ruby's `OpenSSL::KDF`)
- Username + password login (scrypt-hashed via `hashlib.scrypt`,
verified in constant time with `secrets.compare_digest`)
- Settings page: change password, change username, manage users and roles
(admin only)
- Session cookies (HttpOnly, SameSite=Strict) + CSRF tokens
- List, create, edit, delete posts
- **Markdown source** editor and a **visual** editor with live preview, both
storing Markdown
- **Markdown source** editor and a **visual** editor with live preview,
both storing Markdown
- "Reload from disk" to pick up out-of-band edits
- Permissive CORS for the public API, same-origin only for the admin
@@ -64,16 +74,44 @@ It is designed to be:
## Quick start
### Production / first install
```bash
# 1. Install dependencies (Ruby ≥ 3.2)
# 1. Install the volumen CLI (once, on the machine):
uv tool install volumen # or: pipx install volumen
# 2. Bootstrap (once, typically as root — generates config, prompts for
# an admin password, and installs a hardened systemd unit):
sudo volumen init
sudo systemctl status volumen
# 3. Reverse-proxy with nginx and you're done — see docs/deployment.md.
```
`uv tool install` (or `pipx install`) drops a self-contained `volumen`
executable on `PATH`; `volumen init` is the operational bootstrap that
renders the config, creates the data dirs, generates a session key, and
(with `--systemd`, on by default under root) installs a hardened unit
file. See `volumen init --help` and `docs/deployment.md` for the full
flow.
### Development (this checkout)
```bash
# 1. Install dependencies (Python 3.14+ and uv required)
just install
# 2. Run
just run
```
`just install` runs `uv sync` (creates `.venv/` and installs the locked
dependency set from `uv.lock`). `just run` launches the `volumen` console
script from the venv with the bundled sample posts.
The admin will live at <http://localhost:9090/admin/> and the API at
<http://localhost:9090/api/volumen/posts>.
<http://localhost:9090/api/volumen/posts>. The dev admin password is `admin`
(the hash lives in `./config.toml`).
---
@@ -88,7 +126,8 @@ slug = "my-post" # optional, defaults to the filename
date = 2026-01-15 # first-class TOML date
lang = "en" # language code
author = "Petr Balvín" # optional
tags = ["ruby", "web"] # optional
fediverse_creator = "@petrbalvin@mastodon.social" # optional, for Mastodon attribution
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
@@ -99,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:
@@ -113,6 +152,41 @@ front-end can offer a language switcher.
Set `all_langs = true` on a post to show it in **every** language (useful for
posts that have no per-language translations).
### Cover caption
A `cover_caption` frontmatter field renders a short caption next to the cover
image — useful for photo credits or a one-line blurb:
```toml
cover = "media/cover.webp"
cover_caption = "Photo by Petr Balvín"
```
### Titled images as figures
A Markdown image with a **title** (`![alt](url "caption")`) is rendered as a
`<figure>` with a `<figcaption>` and a small `i` info icon. Images without a
title render exactly as before:
```markdown
![A sunset over the hills](media/sunset.webp "Sunset from the ridge above the village")
```
### Fediverse attribution
Set `fediverse_creator = "@user@instance.tld"` on a post to attach a Mastodon
handle to it. The value is exposed as `fediverse_creator` on the post payload
(`/api/volumen/posts/{slug}` and `/api/volumen/posts`), and as `<dc:creator>` in
the RSS feed and `authors[].name` in the JSON feed. A front-end consuming the
API can drop it straight into `<head>`:
```html
<meta name="fediverse:creator" content="@petrbalvin@mastodon.social">
```
A site-wide default can be set in `config.toml` (`[site].fediverse_creator`); a
per-post value takes precedence.
### Why TOML instead of YAML
- **First-class dates** — `date = 2026-01-15` is a real date, not a guess.
@@ -120,20 +194,28 @@ posts that have no per-language translations).
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"
@@ -143,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`
@@ -161,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 |
@@ -169,6 +261,8 @@ are same-origin only.
"page": 1,
"page_size": 20,
"total": 42,
"has_next": true,
"has_prev": false,
"posts": [
{
"slug": "welcome",
@@ -176,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"
@@ -195,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>…",
@@ -209,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
-12
View File
@@ -1,12 +0,0 @@
# frozen_string_literal: true
require "rake/testtask"
Rake::TestTask.new(:test) do |t|
t.libs << "test"
t.libs << "lib"
t.test_files = FileList["test/**/*_test.rb"]
t.warning = false
end
task default: :test
-23
View File
@@ -1,23 +0,0 @@
# Local development configuration for `just dev`.
# Not shipped with the gem; safe to edit. Keep real admin hashes out of Git.
[server]
host = "127.0.0.1"
port = 9090
content_dir = "./posts"
users_file = "./users.toml"
[site]
title = "volumen (dev)"
description = "Local development instance of volumen."
base_url = "http://localhost:9090"
language = "cs"
author = "Petr Balvín"
[admin]
# Dev credentials: password is "admin". Replace before any real use
# (generate with `volumen hash-password`).
password_hash = "scrypt$16384$8$1$I7XEC8EpyfaptaPxmBQSUg==$gK7TRZAJnYmOSfKJ7xg+cOaYeBV4r5jlqcoL5+jNlSs="
session_key = ""
session_ttl = 86400
+37
View File
@@ -0,0 +1,37 @@
# Local development configuration for `just dev`.
# Copy to config.toml and adjust. Keep real admin hashes out of Git.
[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
content_dir = "./posts"
users_file = "./users.toml"
[site]
title = "volumen (dev)"
description = "Local development instance of volumen."
base_url = "http://localhost:9090"
language = "cs"
author = "Petr Balvín"
[admin]
# 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
+21 -5
View File
@@ -16,10 +16,14 @@ Returns site metadata from the configuration.
"description": "A blog powered by volumen.",
"base_url": "https://example.com",
"language": "en",
"author": "Anonymous"
"author": "Anonymous",
"fediverse_creator": "@petrbalvin@mastodon.social"
}
```
`fediverse_creator` is `null` when the site-wide default is not set; a per-post
value (see below) takes precedence when present.
## `GET /api/volumen/posts`
Paginated list of published posts (drafts are excluded), newest first.
@@ -29,6 +33,7 @@ Posts with `all_langs = true` in their frontmatter match every `lang` filter.
|-------|--------|---------|--------------------|
| lang | string | — | Filter by language |
| tag | string | — | Filter by tag |
| q | string | — | Case-insensitive search across title and body |
| page | int | 1 | Page number |
| limit | int | 20 | Per page (1100) |
@@ -37,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",
@@ -46,6 +53,7 @@ Posts with `all_langs = true` in their frontmatter match every `lang` filter.
"lang": "en",
"tags": ["intro"],
"author": null,
"fediverse_creator": null,
"cover": null,
"reading_time": 1,
"translations": {},
@@ -59,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
{
@@ -70,6 +78,7 @@ no match.
"lang": "en",
"tags": ["intro"],
"author": null,
"fediverse_creator": "@petrbalvin@mastodon.social",
"cover": null,
"reading_time": 1,
"translations": {},
@@ -87,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.
+52 -25
View File
@@ -3,7 +3,7 @@
> Status: the core library, the public JSON API, and the server-rendered admin
> (login, CSRF, post CRUD, Markdown editor + live preview) are implemented.
**volumen** is a small, file-based Markdown blog engine written in CRuby. It
**volumen** is a small, file-based Markdown blog engine written in Python. It
has no database: every post is a `.md` file on disk with a TOML frontmatter
block. The engine reads those files, exposes them as a JSON API, and ships a
server-rendered admin for editing them.
@@ -12,45 +12,72 @@ 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 (planned layout)
## 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
markdown.rb # kramdown wrapper (render + sanitise)
server.rb # Sinatra app: JSON API + admin routes
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/ # admin CSS + the visual-editor JS island
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 (planned)
## Request flow
```mermaid
graph TD
A[HTTP request] --> B{Route}
B -->|/api/volumen/*| C[JSON API]
B -->|/admin/*| D[Admin: session + CSRF]
C --> E[Store]
D --> E
E --> F[(content_dir/*.md)]
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 --> H[FeedHelpers]
C --> G[Markdown renderer]
D --> G
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)
+62 -7
View File
@@ -1,20 +1,28 @@
# Configuration
> Status: initial draft. Field semantics are finalised as the loader lands.
> Status: the loader in `src/volumen/config.py` is the source of truth. This
> document lists every key it understands, with type, default, and meaning.
volumen is configured by a single **TOML** file. By default it lives at
`/etc/volumen/config.toml` and is auto-generated on first start. CLI flags
passed to `volumen serve` override the file.
A template you can copy as a starting point is at
[`config.toml.example`](../config.toml.example) in the repository root. The
template ships with development defaults (`host = "::"`, `env = "development"`,
`cookie_secure = false`) — flip them for production.
## Example `config.toml`
```toml
[server]
host = "0.0.0.0"
host = "::1"
port = 9090
env = "production"
trust_proxy = true
# Directory containing your Markdown posts.
content_dir = "/var/lib/volumen/posts"
users_file = "/var/lib/volumen/users.toml"
[site]
title = "My Blog"
@@ -22,17 +30,60 @@ description = "A blog powered by volumen."
base_url = "https://example.com"
language = "en"
author = "Anonymous"
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
@@ -50,3 +101,7 @@ volumen serve --config /etc/volumen/config.toml \
The same format is used for post frontmatter; see the post format section in
the project [`README.md`](../README.md).
The Python standard library `tomllib` (3.11+) parses the file at load time; no
extra TOML dependency is required at runtime. `tomli-w` is bundled for the
admin to round-trip TOML frontmatter on post edits.
+139 -99
View File
@@ -2,95 +2,113 @@
> Status: production deployment on Linux with systemd + nginx.
volumen runs as a single Ruby process (Puma) bound to localhost, behind nginx
which terminates TLS and reverse-proxies the API and admin. The public website
(e.g. a Vue SPA) is served separately and consumes the JSON API.
volumen runs as a single Python process (Uvicorn via `fastapi[standard]`) bound
to localhost, behind nginx which terminates TLS and reverse-proxies the API and
admin. The public website (e.g. a Vue SPA) is served separately and consumes
the JSON API.
The CLI binary is installed system-wide via `uv tool install` (or `pipx
install`); the *operational* bootstrap — config, data dirs, admin password,
optional systemd unit — is performed by the `volumen init` Python subcommand
that replaces the old `install.sh` bash script.
## Requirements
- **Ruby ≥ 3.3** with a C toolchain (Puma compiles a native extension).
- Fedora: `sudo dnf install ruby ruby-devel @development-tools openssl-devel`
- Debian/Ubuntu: `sudo apt install ruby-full build-essential libssl-dev`
- **nginx** and a TLS certificate (e.g. via certbot).
- A dedicated system user, e.g. `volumen`.
- **Python ≥ 3.14** (the bundled wheel ships its own interpreter when you use
`uv tool install` / `pipx install`; the system Python is only required if
you're building from source).
- Fedora: `sudo dnf install python3.14`
- Debian/Ubuntu: `sudo apt install python3.14 python3-venv python3-pip`
- **[`uv`](https://docs.astral.sh/uv/)** (recommended) — install with
`curl -LsSf https://astral.sh/uv/install.sh | sh`. Alternatively,
[`pipx`](https://pipx.pypa.io/) ships in most distro repos.
- **nginx** and a TLS certificate (e.g. via certbot) for the public site.
- A dedicated system user, `volumen`, created automatically by
`volumen init` when run as root.
## Layout
## Install the CLI
```bash
# Pick one:
uv tool install volumen
# or
pipx install volumen
# Both put a self-contained `volumen` binary on PATH. Verify:
volumen version
```
For platforms where the wheel is not available (or for development), build it
from a source checkout:
```bash
git clone https://sourcedock.dev/petrbalvin/volumen.git
cd volumen
uv tool install .
```
## Bootstrap the deployment
### System install (root, systemd)
```bash
sudo volumen init
sudo systemctl status volumen
```
`volumen init` walks through:
1. Whether `config_path` already exists — if so, leaves it untouched unless
`--force` is passed (and even then it makes a timestamped `.bak.<ts>.toml`
first).
2. Creates `/etc/volumen/`, `/var/lib/volumen/posts` (and a `media/`
subdirectory), `/var/lib/volumen/users.toml`.
3. Creates the system user `volumen` if missing (`useradd --system …`).
4. Prompts for an admin password (`getpass`), hashes it via scrypt, writes the
`password_hash` into the rendered config.
5. Generates a fresh 64-byte session key (`secrets.token_hex(64)`) and writes
it into `[admin].session_key`.
6. Forces production-style settings: `host = "::1"`, `env = "production"`,
`trust_proxy = true`, `cookie_secure = true`.
7. With `--systemd` (default under root), writes
`/etc/systemd/system/volumen.service` and runs
`systemctl daemon-reload && systemctl enable --now volumen`.
Pass `--no-enable` to install the unit without starting it; pass `--force` to
back up and overwrite an existing config. The full flag list lives in
`volumen init --help`.
### Per-user install (no root)
```bash
volumen init --local
```
Per-user paths: `~/.config/volumen/config.toml`,
`~/.local/share/volumen/posts`, `~/.local/share/volumen/users.toml`. No
system user is created and no systemd unit is installed. `--systemd` is
rejected in this mode.
## Configuration locations
| Path | Purpose |
|------|---------|
| `/opt/volumen` | source checkout (or installed gem) |
| `/etc/volumen/config.toml` | configuration (auto-generated on first run) |
| `/etc/volumen/config.toml` | configuration (rendered by `volumen init` on first run) |
| `/var/lib/volumen/posts` | Markdown posts |
| `/var/lib/volumen/users.toml` | admin users (created from Settings) |
| `/var/lib/volumen/posts/media` | uploaded images |
| `/var/lib/volumen/users.toml` | admin users (created from Settings) |
| `/etc/systemd/system/volumen.service` | systemd unit (only when `--systemd`) |
| `~/.config/volumen/config.toml` | per-user config (`--local`) |
| `~/.local/share/volumen/posts` | per-user data dir (`--local`) |
| `~/.local/share/volumen/users.toml` | per-user users file (`--local`) |
```bash
sudo useradd --system --home /var/lib/volumen --shell /usr/sbin/nologin volumen
sudo mkdir -p /etc/volumen /var/lib/volumen/posts
sudo chown -R volumen:volumen /var/lib/volumen
```
## Install
### Quick install (Linux + systemd)
From a cloned repository, run the installer as root. It installs the Ruby
toolchain from the distro repositories on **Fedora** and **openEuler** (prints
guidance on other distros), creates the `volumen` user and directories, runs
`bundle install`, generates `config.toml`, and installs and enables the systemd
unit:
```bash
sudo git clone https://codeberg.org/petrbalvin/volumen.git /opt/volumen
cd /opt/volumen
sudo ./install.sh
```
The service is enabled but not started — finish by setting the admin password
(printed steps), then `sudo systemctl start volumen`. The manual steps below
describe what the script does.
### Manual install
From a source checkout (reproducible, no publishing needed):
```bash
sudo git clone https://codeberg.org/petrbalvin/volumen.git /opt/volumen
cd /opt/volumen
sudo bundle install --deployment
```
Alternatively build and install the gem (`just build` produces `pkg/volumen.gem`,
then `gem install pkg/volumen.gem` puts `volumen` on the PATH).
## First run and the admin password
The first `serve` writes a commented `config.toml` if it is missing:
```bash
sudo -u volumen bundle exec exe/volumen serve \
--config /etc/volumen/config.toml \
--content /var/lib/volumen/posts
# → "volumen: wrote a default config to /etc/volumen/config.toml; review it and restart."
```
Generate a password hash and a stable session secret, then edit the config:
```bash
bundle exec exe/volumen hash-password # → scrypt$...
openssl rand -hex 64 # → session_key
sudo -u volumen $EDITOR /etc/volumen/config.toml
```
Set `host = "127.0.0.1"`, your `site.base_url`, the `admin.password_hash`, and a
`admin.session_key` (so sessions survive restarts). You can now sign in as user
`admin`; from the **Settings** page you can add more users (roles `admin` /
`author`) and change passwords. From then on `users.toml` is the source of
truth.
The config and users files are owned by `volumen:volumen` and `chmod 600` after
a system install — they may carry password hashes, so never make them
world-readable.
## systemd
`/etc/systemd/system/volumen.service`:
`/etc/systemd/system/volumen.service` (rendered by `volumen init --systemd`):
```ini
[Unit]
@@ -98,10 +116,12 @@ Description=volumen blog engine
After=network.target
[Service]
Type=simple
User=volumen
Group=volumen
WorkingDirectory=/opt/volumen
ExecStart=/usr/bin/bundle exec exe/volumen serve --config /etc/volumen/config.toml --content /var/lib/volumen/posts
WorkingDirectory=/etc/volumen
Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/local/bin/volumen serve --config /etc/volumen/config.toml --content /var/lib/volumen/posts
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
@@ -114,26 +134,34 @@ PrivateTmp=true
WantedBy=multi-user.target
```
Hardening notes — `NoNewPrivileges`, `ProtectSystem=strict` (the entire
filesystem is read-only except for `ReadWritePaths`), `ProtectHome`
(`/home`, `/root`, `/run/user` are inaccessible), `PrivateTmp` (private
`/tmp`), and a two-second restart delay on failure.
Inspect health with:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now volumen
sudo systemctl status volumen
journalctl -u volumen -f
volumen status # config / data dir / unit / service state
volumen status --json # machine-readable
```
## FreeBSD (rc.d)
volumen runs on FreeBSD too — it is plain CRuby plus gems; only Puma (and its
`nio4r` dependency) build a native extension, which compiles with the base
clang toolchain.
volumen runs on FreeBSD too — it is plain CPython with no compiled extensions.
```sh
pkg install ruby
# then install volumen as above (git clone + bundle install)
pkg install python3
# install uv (https://docs.astral.sh/uv/getting-started/installation/)
uv tool install volumen
sudo volumen init --local # no systemd on FreeBSD
```
Conventional FreeBSD paths: config in `/usr/local/etc/volumen/`, data in
`/var/db/volumen/`. Service script `/usr/local/etc/rc.d/volumen`:
`/var/db/volumen/`. Drop a tiny `rc.d` script in `/usr/local/etc/rc.d/volumen`
that wraps the `volumen` console script:
```sh
#!/bin/sh
@@ -150,14 +178,13 @@ load_rc_config $name
: ${volumen_enable:="NO"}
: ${volumen_user:="volumen"}
: ${volumen_dir:="/usr/local/volumen"}
: ${volumen_config:="/usr/local/etc/volumen/config.toml"}
: ${volumen_content:="/var/db/volumen/posts"}
pidfile="/var/run/${name}.pid"
command="/usr/sbin/daemon"
command_args="-f -r -P ${pidfile} -u ${volumen_user} \
/bin/sh -c 'cd ${volumen_dir} && exec bundle exec exe/volumen serve --config ${volumen_config} --content ${volumen_content}'"
/bin/sh -c 'exec volumen serve --config ${volumen_config} --content ${volumen_content}'"
run_rc_command "$1"
```
@@ -165,6 +192,7 @@ run_rc_command "$1"
Enable and start:
```sh
chmod +x /usr/local/etc/rc.d/volumen
sysrc volumen_enable=YES
service volumen start
```
@@ -266,22 +294,34 @@ right block. Apply with `sudo nginx -t && sudo systemctl reload nginx`.
## Updating
```bash
cd /opt/volumen
sudo git pull
sudo bundle install --deployment
uv tool upgrade volumen
sudo systemctl restart volumen
volumen status # confirm: config_exists, password_hash_set, session_key_ok, …
```
The CLI binary is replaced in place by `uv tool upgrade`; the systemd unit's
`ExecStart` already points at the on-PATH `volumen` console script, so a
restart is all that's needed. The persistent state (`config.toml`,
`users.toml`, posts) is untouched.
If you used the legacy `install.sh` workflow (no longer documented, the script
was removed), you can migrate forward by deleting `/etc/systemd/system/volumen.service`
+ `/opt/volumen` and then running `uv tool install volumen && sudo volumen init`.
## Security notes
- Bind to `127.0.0.1`; only nginx is public. TLS is terminated by nginx.
- Bind to `::1` (or `127.0.0.1`); only nginx is public. TLS is terminated by
nginx.
- Session cookies are `HttpOnly` and `SameSite=Strict`, and gain the `Secure`
attribute automatically on HTTPS requests (detected via `X-Forwarded-Proto`,
which the nginx snippet above sets) — so the cookie never travels over plain
HTTP in production, while local HTTP testing still works. Combined with
per-form CSRF tokens this protects the admin. Set a stable `admin.session_key`
so sessions survive restarts.
which the nginx snippet above sets, and enabled by
`[server].cookie_secure = true`, set automatically by `volumen init
--systemd`) — so the cookie never travels over plain HTTP in production,
while local HTTP testing still works. Combined with per-form CSRF tokens
this protects the admin. A stable `[admin].session_key` (also written by
`volumen init`) lets sessions survive restarts.
- Keep `config.toml` and `users.toml` readable only by the `volumen` user
(`chmod 600`). Both may contain password hashes.
- `volumen hash-password` reads the password without echo; never pass passwords
on the command line.
(`chmod 600`). Both may contain password hashes. The installer enforces
this automatically.
- Use `volumen hash-password` to rotate; it reads the password without echo.
Never pass passwords on the command line.
+68 -29
View File
@@ -2,7 +2,7 @@
> Status: threat model and security posture for the engine as implemented.
volumen is a small, file-based blog engine: a single Ruby process exposing a
volumen is a small, file-based blog engine: a single Python process exposing a
read-only public JSON API and a session-authenticated admin, behind nginx. This
document describes the trust model, the controls in place, and the known
limitations.
@@ -13,33 +13,37 @@ than opening a public issue.
## Trust model
- **Content authors are trusted.** Posts are written by authenticated admin
users. kramdown renders Markdown and **passes raw HTML through** on purpose,
so an author can embed HTML. The stored/served `html` is therefore only as
safe as the people who can log in. If you need to accept posts from untrusted
authors, add an HTML sanitiser before serving.
users. python-markdown renders Markdown with a few pymdown-extensions
extensions. The engine does **not** strip raw HTML from post bodies by
default (authors are the trusted admin); if you need to accept posts from
untrusted authors, add a sanitiser (e.g. `bleach`) before serving.
- **The public API is read-only.** It exposes published (non-draft) posts, tags,
a feed and a sitemap. No public endpoint mutates state.
## Authentication
- Passwords are hashed with **scrypt** via `OpenSSL::KDF` (`N=16384`, `r=8`,
- Passwords are hashed with **scrypt** via `hashlib.scrypt` (`n=16384`, `r=8`,
`p=1`, 32-byte key, 16-byte random salt). Verification uses
`OpenSSL.fixed_length_secure_compare` (constant-time).
`secrets.compare_digest` (constant-time) on the derived bytes.
- Login takes a **username + password**. The error message is intentionally
generic ("Invalid username or password.") to avoid user enumeration.
- Sessions use signed cookies (`Rack::Session::Cookie`) marked **`HttpOnly`**
and **`SameSite=Strict`**. The **`Secure`** attribute is added automatically on
HTTPS requests (detected via `request.ssl?` / `X-Forwarded-Proto`), so the
cookie never travels over plain HTTP in production. The signing secret comes
from `admin.session_key`; if it is shorter than 64 bytes a random secret is
generated per start (sessions then reset on restart, so set a stable key in
production).
- Sessions use **signed cookies** via Starlette's `SessionMiddleware`
(`itsdangerous` TimestampSigner under the hood), marked **`HttpOnly`** and
**`SameSite=Strict`**. The **`Secure`** attribute is added by
`SecureCookieMiddleware` when `X-Forwarded-Proto: https` is observed (only
honoured when `[server].trust_proxy = true`) so the cookie never travels
over plain HTTP in production while local HTTP testing still works. The
signing secret comes from `admin.session_key`; if it is shorter than 64
bytes a random secret is generated per start (sessions then reset on
restart, so set a stable key in production).
- A minimum password length of `[admin].min_password_length` (default `10`,
must be `>= 8`) is enforced on the Settings page password-change form.
## Authorization (roles)
- Two roles: **admin** (full access, manages users) and **author** (posts and
own account only).
- User management (`add`/`delete`/role change) is gated by `require_admin!`
- User management (`add`/`delete`/role change) is gated by `require_admin`
**on the server**, not merely hidden in the UI.
- Safeguards prevent lockout: the **last admin** cannot be deleted or demoted,
and a user cannot delete their own account or change their own role.
@@ -47,29 +51,56 @@ 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
(via `CSPMiddleware`):
```
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'
```
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.
## CORS and Host handling
- 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.
- Upload content-type is **not** validated (the `accept="image/*"` attribute is
only a UI hint); this is acceptable under the trusted-author model.
- 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
WebP/AVIF-only posture the engine serves content in and rejects binaries
(PHP, executable, …) outright.
- Per-user profile photos go through the same upload validation and storage
pipeline as post media.
## Secrets and files
@@ -80,19 +111,27 @@ than opening a public issue.
## 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`. Put nginx `limit_req` in
front of it, or use fail2ban, to slow brute-force attempts.
- **No rate limiting or lockout** on `/admin/login` at the application layer
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.
+4 -6
View File
@@ -1,7 +1,5 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
#!/usr/bin/env python3
"""Volumen — a small, file-based Markdown blog engine."""
from volumen.cli import main
require "volumen"
require "volumen/cli"
Volumen::CLI.run(ARGV)
main()
-178
View File
@@ -1,178 +0,0 @@
#!/usr/bin/env bash
#
# volumen one-shot installer for Linux + systemd.
#
# Run from a cloned repository as root:
# sudo ./install.sh
#
# The Ruby toolchain is installed automatically from the distro repositories on
# Fedora and openEuler; on other distributions the script prints guidance and
# expects Ruby >= 3.3 with a C toolchain to be present already.
#
# FreeBSD uses rc.d instead of systemd — see docs/deployment.md.
set -euo pipefail
VOLUMEN_USER="volumen"
CONFIG_DIR="/etc/volumen"
CONFIG_FILE="${CONFIG_DIR}/config.toml"
DATA_DIR="/var/lib/volumen"
CONTENT_DIR="${DATA_DIR}/posts"
USERS_FILE="${DATA_DIR}/users.toml"
SERVICE_FILE="/etc/systemd/system/volumen.service"
MIN_RUBY="3.3"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="${SCRIPT_DIR}"
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mWARN:\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
require_root() {
[ "$(id -u)" -eq 0 ] || die "Run as root: sudo ./install.sh"
}
detect_distro() {
[ -r /etc/os-release ] || { printf 'unknown'; return; }
# shellcheck disable=SC1091
. /etc/os-release
printf '%s' "$(printf '%s' "${ID:-unknown}" | tr '[:upper:]' '[:lower:]')"
}
install_toolchain() {
case "$1" in
fedora)
log "Installing Ruby toolchain via dnf (Fedora)…"
dnf install -y ruby ruby-devel gcc gcc-c++ make redhat-rpm-config openssl-devel
;;
openeuler)
log "Installing Ruby toolchain via dnf (openEuler)…"
dnf install -y ruby ruby-devel gcc gcc-c++ make openssl-devel
;;
*)
warn "Automatic toolchain install is wired only for Fedora and openEuler."
cat >&2 <<'EOF'
Install Ruby >= 3.3 and a C toolchain manually, then re-run this script:
Debian/Ubuntu: apt install ruby-full build-essential libssl-dev
Arch: pacman -S ruby base-devel openssl
openSUSE: zypper install ruby ruby-devel gcc make libopenssl-devel
EOF
;;
esac
}
check_ruby() {
command -v ruby >/dev/null 2>&1 || die "Ruby not found; install Ruby >= ${MIN_RUBY} and re-run."
local version
version="$(ruby -e 'print RUBY_VERSION')"
if ! MIN="$MIN_RUBY" ruby -e 'exit(Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(ENV["MIN"]))'; then
die "Ruby ${version} is too old; volumen needs >= ${MIN_RUBY}."
fi
log "Ruby ${version} OK."
}
ensure_bundler() {
if ! command -v bundle >/dev/null 2>&1; then
log "Installing bundler…"
gem install --no-document bundler
fi
}
create_user() {
if id "$VOLUMEN_USER" >/dev/null 2>&1; then
log "User ${VOLUMEN_USER} already exists."
else
log "Creating system user ${VOLUMEN_USER}"
useradd --system --home "$DATA_DIR" --shell /usr/sbin/nologin "$VOLUMEN_USER"
fi
}
create_dirs() {
log "Creating ${CONFIG_DIR} and ${CONTENT_DIR}"
mkdir -p "$CONFIG_DIR" "$CONTENT_DIR"
chown -R "$VOLUMEN_USER:$VOLUMEN_USER" "$DATA_DIR"
}
install_gems() {
log "Installing gem dependencies…"
( cd "$APP_DIR" && bundle install )
}
generate_config() {
if [ -f "$CONFIG_FILE" ]; then
log "Config ${CONFIG_FILE} already exists; leaving it untouched."
return
fi
log "Generating ${CONFIG_FILE}"
( cd "$APP_DIR" && bundle exec ruby -Ilib -rvolumen/config \
-e 'Volumen::Config.generate_default(ARGV[0])' "$CONFIG_FILE" )
sed -i 's/^host = .*/host = "127.0.0.1"/' "$CONFIG_FILE"
sed -i "s#^content_dir = .*#content_dir = \"${CONTENT_DIR}\"#" "$CONFIG_FILE"
sed -i "s#^users_file = .*#users_file = \"${USERS_FILE}\"#" "$CONFIG_FILE"
chown "$VOLUMEN_USER:$VOLUMEN_USER" "$CONFIG_FILE"
chmod 600 "$CONFIG_FILE"
}
install_service() {
log "Installing systemd unit ${SERVICE_FILE}"
cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=volumen blog engine
After=network.target
[Service]
User=${VOLUMEN_USER}
Group=${VOLUMEN_USER}
WorkingDirectory=${APP_DIR}
ExecStart=$(command -v bundle) exec exe/volumen serve --config ${CONFIG_FILE} --content ${CONTENT_DIR}
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=${DATA_DIR}
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable volumen
}
final_notes() {
cat <<EOF
volumen is installed and the service is enabled (not started yet).
Next steps:
1. Create the first admin password (you will log in as user "admin"):
cd ${APP_DIR}
sudo -u ${VOLUMEN_USER} $(command -v bundle) exec exe/volumen hash-password
Paste the printed hash into ${CONFIG_FILE} under [admin] password_hash.
2. (Recommended) set a stable admin.session_key in the config:
openssl rand -hex 64
3. Start the service:
sudo systemctl start volumen
4. Put nginx in front of 127.0.0.1:9090 — see docs/deployment.md.
EOF
}
main() {
require_root
local distro
distro="$(detect_distro)"
log "Detected distribution: ${distro}"
install_toolchain "$distro"
check_ruby
ensure_bundler
create_user
create_dirs
install_gems
generate_config
install_service
final_notes
}
main "$@"
+17 -25
View File
@@ -1,38 +1,30 @@
set dotenv-load := false
set positional-arguments := false
default:
@just --list
@just --list
# Install Ruby dependencies
install:
@echo "→ Installing Ruby dependencies…"
bundle install
uv sync
# Run the development server
run:
@echo "→ Starting volumen…"
bundle exec exe/volumen serve
uv run volumen serve
# Run the development server against ./posts and ./config.toml
dev:
@echo "→ Starting volumen (dev) on http://localhost:9090 …"
bundle exec exe/volumen serve --config ./config.toml --content ./posts
uv run volumen serve --config ./config.toml --content ./posts
# Lint and package the gem (must pass with zero warnings)
build:
@echo "→ Linting…"
bundle exec rubocop
@echo "→ Building gem…"
@mkdir -p pkg
gem build volumen.gemspec --output pkg/volumen.gem
uv sync --frozen
just fmt-check
just test
# Run the test suite
test:
@echo "→ Running tests…"
bundle exec rake test
uv run pytest
fmt:
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/
# Remove build artifacts
uninstall:
@echo "→ Removing build artifacts…"
rm -rf pkg *.gem
rm -rf dist/ __pycache__ .pytest_cache .ruff_cache
-41
View File
@@ -1,41 +0,0 @@
# frozen_string_literal: true
require_relative "volumen/version"
require_relative "volumen/frontmatter"
require_relative "volumen/markdown"
require_relative "volumen/post"
require_relative "volumen/store"
require_relative "volumen/config"
require_relative "volumen/password"
require_relative "volumen/users"
# volumen is a small, file-based Markdown blog engine.
#
# Posts are plain `.md` files with a TOML frontmatter block delimited by
# `+++`. The engine exposes them as a JSON API and ships a server-rendered
# admin with a Markdown source editor and a visual editor.
#
# The Sinatra app (`Volumen::Server`) and the CLI (`Volumen::CLI`) are loaded
# on demand to keep the core library light.
module Volumen
CLEANUP_INTERVAL = 300 # seconds between full sweeps of login_attempts
def self.login_attempts
@login_attempts ||= {}
end
def self.reset_login_attempts!
@login_attempts = {}
@last_cleanup = nil
end
def self.sweep_login_attempts!
now = Time.now.to_f
return if @last_cleanup && (now - @last_cleanup) < CLEANUP_INTERVAL
cutoff = now - Volumen::Server::LOGIN_WINDOW
login_attempts.each_value { |timestamps| timestamps.select! { |t| t > cutoff } }
login_attempts.delete_if { |_ip, timestamps| timestamps.empty? }
@last_cleanup = now
end
end
-144
View File
@@ -1,144 +0,0 @@
# frozen_string_literal: true
module Volumen
# Server-rendered admin routes, registered into Volumen::Server.
module AdminRoutes
def self.registered(app)
app.get "/admin" do
redirect to("/admin/")
end
app.get "/admin/" do
require_login!
@posts = sorted_posts
erb :list
end
app.get "/admin/login" do
redirect to("/admin/") if authenticated?
erb :login
end
app.post "/admin/login" do
check_csrf!
check_login_rate_limit!
user = users.authenticate(params["username"], params["password"])
if user
session[:user] = user.username
redirect to("/admin/")
else
@error = "Invalid username or password."
erb :login
end
end
app.post "/admin/logout" do
check_csrf!
session.clear
redirect to("/admin/login")
end
app.get "/admin/posts/new" do
require_login!
@mode = :new
@post = Post.new(metadata: { "lang" => config.language })
erb :form
end
app.post "/admin/posts" do
require_login!
check_csrf!
create_post
end
app.get "/admin/posts/:slug/edit" do
require_login!
@mode = :edit
@post = store.find(params["slug"])
halt(404, "Not found") if @post.nil?
erb :form
end
app.post "/admin/posts/:slug" do
require_login!
check_csrf!
update_post
end
app.post "/admin/posts/:slug/delete" do
require_login!
check_csrf!
store.delete(params["slug"])
redirect to("/admin/")
end
app.post "/admin/preview" do
require_login!
check_csrf!
content_type :html
Markdown.render(params["body"].to_s)
end
app.post "/admin/uploads" do
require_login!
check_csrf!
content_type :json
upload = params["file"]
halt(400, json("error" => "no_file")) unless upload.is_a?(Hash) && upload[:tempfile]
json("url" => store.store_upload(upload[:filename], upload[:tempfile]))
end
app.get "/media/*" do
path = store.media_file(params["splat"].first)
halt(404) if path.nil?
send_file(path)
end
app.get "/admin/logo.svg" do
send_file(File.expand_path("web/public/volumen-logo.svg", __dir__))
end
app.get "/admin/settings" do
require_login!
render_settings
end
app.post "/admin/settings/password" do
require_login!
check_csrf!
change_password
end
app.post "/admin/settings/username" do
require_login!
check_csrf!
change_username
end
app.post "/admin/settings/users" do
require_login!
require_admin!
check_csrf!
create_user
end
app.post "/admin/settings/users/:username/role" do
require_login!
require_admin!
check_csrf!
change_role(params["username"])
end
app.post "/admin/settings/users/:username/delete" do
require_login!
require_admin!
check_csrf!
remove_user(params["username"])
end
end
end
end
-54
View File
@@ -1,54 +0,0 @@
# frozen_string_literal: true
module Volumen
# Public JSON API routes, registered into Volumen::Server.
module ApiRoutes
def self.registered(app)
app.before "/api/volumen/*" do
headers "Access-Control-Allow-Origin" => "*"
content_type :json
end
app.options "/api/volumen/*" do
headers "Access-Control-Allow-Origin" => "*"
headers "Access-Control-Allow-Methods" => "GET, OPTIONS"
headers "Access-Control-Allow-Headers" => "Content-Type"
content_type :json
""
end
app.get "/api/volumen/site" do
json(site_payload)
end
app.get "/api/volumen/posts" do
json(posts_payload)
end
app.get "/api/volumen/posts/:slug" do
post = store.find(params["slug"], lang: params["lang"])
halt(404, json("error" => "not_found")) if post.nil? || post.draft?
json(post.detail)
end
app.get "/api/volumen/tags" do
json("tags" => tag_counts)
end
app.get "/api/volumen/feed.xml" do
content_type "application/rss+xml"
feed_xml
end
app.get "/api/volumen/feed.json" do
json(feed_json)
end
app.get "/api/volumen/sitemap.xml" do
content_type "application/xml"
sitemap_xml
end
end
end
end
-87
View File
@@ -1,87 +0,0 @@
# frozen_string_literal: true
require "optparse"
require_relative "version"
require_relative "config"
require_relative "store"
require_relative "password"
module Volumen
# Command-line interface: serve, hash-password, version, help.
class CLI
USAGE = <<~TEXT.freeze
volumen #{Volumen::VERSION} — a small, file-based Markdown blog engine.
Usage:
volumen serve [--config PATH] [--content DIR] [--host HOST] [--port PORT]
volumen hash-password
volumen version
volumen help
TEXT
def self.run(argv)
new(argv).run
end
def initialize(argv)
@argv = argv.dup
end
def run
command = @argv.shift
case command
when "version", "--version", "-v" then puts Volumen::VERSION
when "help", "--help", "-h", nil then puts USAGE
when "serve" then serve
when "hash-password" then hash_password
else
warn "volumen: unknown command #{command.inspect}\n\n#{USAGE}"
exit 1
end
end
private
def serve
options = parse_serve_options
prepare_config(options[:config])
config = Config.load(path: options[:config], overrides: options)
require_relative "server"
store = Store.new(config.content_dir, default_lang: config.language)
app = Server.configured(config: config, store: store)
app.run!(bind: config.host, port: config.port)
end
def parse_serve_options
options = { config: Config::DEFAULT_PATH }
OptionParser.new do |parser|
parser.on("--config PATH") { |value| options[:config] = value }
parser.on("--content DIR") { |value| options[:content] = value }
parser.on("--host HOST") { |value| options[:host] = value }
parser.on("--port PORT", Integer) { |value| options[:port] = value }
end.parse!(@argv)
options
end
def prepare_config(path)
if Config.generate_default(path)
warn "volumen: wrote a default config to #{path}; review it and restart."
elsif !File.exist?(path)
warn "volumen: config #{path} not found; using built-in defaults."
end
end
def hash_password
require "io/console"
print "Password: "
password = ($stdin.noecho(&:gets) || "").chomp
puts
if password.empty?
warn "volumen: empty password"
exit 1
end
puts Password.hash(password)
end
end
end
-151
View File
@@ -1,151 +0,0 @@
# frozen_string_literal: true
require "toml-rb"
require "fileutils"
module Volumen
# Loads and merges volumen configuration from a TOML file, then applies
# command-line overrides on top of the file values and built-in defaults.
class Config
DEFAULT_PATH = "/etc/volumen/config.toml"
DEFAULTS = {
"server" => { "host" => "0.0.0.0", "port" => 9090 }.freeze,
"content_dir" => "/var/lib/volumen/posts",
"users_file" => "/var/lib/volumen/users.toml",
"site" => {
"title" => "My Blog",
"description" => "A blog powered by volumen.",
"base_url" => "https://example.com",
"language" => "en",
"author" => "Anonymous"
}.freeze,
"admin" => {
"password_hash" => "",
"session_key" => "",
"session_ttl" => 86_400
}.freeze
}.freeze
TEMPLATE = <<~TOML
# volumen configuration.
[server]
host = "0.0.0.0"
port = 9090
# Directory of your Markdown posts (.md with TOML frontmatter).
content_dir = "/var/lib/volumen/posts"
# File storing admin users (managed from the admin Settings page).
users_file = "/var/lib/volumen/users.toml"
[site]
title = "My Blog"
description = "A blog powered by volumen."
base_url = "https://example.com"
language = "en"
author = "Anonymous"
[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.
password_hash = ""
# Secret signing session cookies (>= 64 bytes). Empty = random per start.
session_key = ""
# Session lifetime in seconds (24h default).
session_ttl = 86400
TOML
def self.load(path: DEFAULT_PATH, overrides: {})
data = File.exist?(path) ? TomlRB.load_file(path) : {}
merged = deep_merge(DEFAULTS, data)
new(apply_overrides(merged, overrides))
end
# Writes the commented template to `path` if it does not exist yet.
# Returns the path on success, or nil if it already existed or could not
# be created.
def self.generate_default(path)
return nil if File.exist?(path)
FileUtils.mkdir_p(File.dirname(path))
File.write(path, TEMPLATE)
path
rescue SystemCallError
nil
end
def self.deep_merge(base, override)
result = {}
base.each do |key, base_val|
result[key] = if override.key?(key)
merge_leaf(base_val, override[key])
elsif base_val.is_a?(Hash)
{}.merge(base_val)
else
base_val
end
end
override.each do |key, val|
result[key] = val unless result.key?(key)
end
result
end
def self.merge_leaf(base_val, override_val)
if base_val.is_a?(Hash) && override_val.is_a?(Hash)
deep_merge(base_val, override_val)
else
override_val
end
end
def self.apply_overrides(data, overrides)
server = data["server"].dup
server["host"] = overrides[:host] if overrides[:host]
server["port"] = overrides[:port] if overrides[:port]
result = data.dup
result["server"] = server
result["content_dir"] = overrides[:content] if overrides[:content]
result
end
private_class_method :deep_merge, :apply_overrides
attr_reader :data
def initialize(data)
@data = data
end
def host
@data["server"]["host"]
end
def port
@data["server"]["port"]
end
def content_dir
@data["content_dir"]
end
def users_file
@data["users_file"]
end
def site
@data["site"]
end
def language
@data["site"]["language"]
end
def admin
@data["admin"]
end
end
end
-68
View File
@@ -1,68 +0,0 @@
# frozen_string_literal: true
require "toml-rb"
module Volumen
# Parses and serialises the TOML frontmatter block of a Markdown file.
#
# A post file starts with a `+++` line, contains a TOML document, is closed
# by another `+++` line, and is followed by the Markdown body:
#
# +++
# title = "Hello"
# +++
#
# Body in **Markdown**.
module Frontmatter
DELIMITER = "+++"
# Returns `[metadata_hash, body_string]`. If the content has no
# frontmatter, the metadata hash is empty and the body is the full input.
def self.parse(content)
text = normalize(content)
lines = text.lines
return [{}, text] unless delimiter?(lines.first)
closing = closing_index(lines)
return [{}, text] if closing.nil?
toml = lines[1...closing].join
body = lines[(closing + 1)..]&.join.to_s
metadata = toml.strip.empty? ? {} : TomlRB.parse(toml)
[metadata, body.sub(/\A\r?\n/, "")]
end
# Serialises metadata + body back into a single post file string.
def self.dump(metadata, body)
toml = serialize(metadata)
buffer = "#{DELIMITER}\n"
buffer << toml
buffer << "\n" unless toml.empty? || toml.end_with?("\n")
buffer << "#{DELIMITER}\n\n"
buffer << body.to_s.sub(/\A\s+/, "")
buffer.end_with?("\n") ? buffer : "#{buffer}\n"
end
def self.normalize(content)
content.to_s.gsub("\r\n", "\n")
end
def self.closing_index(lines)
index = lines[1..].index { |line| delimiter?(line) }
index.nil? ? nil : index + 1
end
def self.delimiter?(line)
line&.chomp&.strip == DELIMITER
end
def self.serialize(metadata)
data = metadata || {}
return "" if data.empty?
TomlRB.dump(data)
end
private_class_method :normalize, :closing_index, :delimiter?, :serialize
end
end
-23
View File
@@ -1,23 +0,0 @@
# frozen_string_literal: true
require "kramdown"
module Volumen
# Renders Markdown to HTML using kramdown's GFM parser (tables, fenced code,
# task lists, strikethrough).
#
# Posts are authored by the single trusted admin, so kramdown's default
# behaviour of passing raw HTML through is intentional.
module Markdown
OPTIONS = {
input: "GFM",
auto_ids: true,
hard_wrap: false,
syntax_highlighter: nil
}.freeze
def self.render(text)
::Kramdown::Document.new(text.to_s, OPTIONS).to_html
end
end
end
-52
View File
@@ -1,52 +0,0 @@
# frozen_string_literal: true
require "openssl"
require "securerandom"
module Volumen
# Hashes and verifies admin passwords with scrypt (via OpenSSL::KDF).
#
# Encoded form: "scrypt$N$r$p$salt_base64$hash_base64".
module Password
COST_N = 16_384
BLOCK_R = 8
PARALLEL_P = 1
KEY_LENGTH = 32
SALT_BYTES = 16
PREFIX = "scrypt"
def self.hash(password)
salt = SecureRandom.bytes(SALT_BYTES)
derived = OpenSSL::KDF.scrypt(
password.to_s, salt: salt, N: COST_N, r: BLOCK_R, p: PARALLEL_P, length: KEY_LENGTH
)
[PREFIX, COST_N, BLOCK_R, PARALLEL_P, encode(salt), encode(derived)].join("$")
end
def self.verify(password, encoded)
parts = encoded.to_s.split("$")
return false unless parts.length == 6 && parts.first == PREFIX
_, cost, block, parallel, salt_b64, hash_b64 = parts
salt = decode(salt_b64)
expected = decode(hash_b64)
derived = OpenSSL::KDF.scrypt(
password.to_s, salt: salt,
N: cost.to_i, r: block.to_i, p: parallel.to_i, length: expected.bytesize
)
OpenSSL.fixed_length_secure_compare(derived, expected)
rescue ArgumentError, OpenSSL::KDF::KDFError
false
end
def self.encode(bytes)
[bytes].pack("m0")
end
def self.decode(string)
string.to_s.unpack1("m0")
end
private_class_method :encode, :decode
end
end
-137
View File
@@ -1,137 +0,0 @@
# frozen_string_literal: true
require "date"
require_relative "frontmatter"
require_relative "markdown"
module Volumen
# A single blog post: TOML frontmatter metadata plus a Markdown body.
class Post
attr_reader :metadata, :body
attr_accessor :path
def initialize(metadata: {}, body: "")
@metadata = (metadata || {}).transform_keys(&:to_s)
@body = body.to_s
end
def self.parse(content)
metadata, body = Frontmatter.parse(content)
new(metadata: metadata, body: body)
end
def slug
metadata["slug"]
end
def title
metadata["title"]
end
def lang
metadata["lang"]
end
def author
metadata["author"]
end
def tags
Array(metadata["tags"]).map(&:to_s)
end
def draft?
metadata["draft"] == true
end
def all_langs?
metadata["all_langs"] == true
end
def translations
metadata["translations"] || {}
end
def cover
metadata["cover"]
end
def publish_at
metadata["publish_at"]
end
def scheduled?
value = publish_at
return false if value.nil?
date = case value
when Date then value
when Time then value.to_date
else Date.iso8601(value.to_s)
end
date > Date.today
rescue ArgumentError
false
end
def reading_time
words = body.split(/\s+/).count { |word| !word.empty? }
[(words / 200.0).ceil, 1].max
end
def date_string
value = metadata["date"]
case value
when nil then nil
when Date, Time then value.strftime("%Y-%m-%d")
else value.to_s
end
end
def excerpt
text = metadata["excerpt"]
return text if text && !text.to_s.empty?
derived_excerpt
end
def html
Markdown.render(body)
end
def to_file
Frontmatter.dump(metadata, body)
end
def summary
{
"slug" => slug,
"title" => title,
"excerpt" => excerpt,
"date" => date_string,
"lang" => lang,
"tags" => tags,
"author" => author,
"cover" => cover,
"reading_time" => reading_time,
"translations" => translations,
"url" => "/api/volumen/posts/#{slug}"
}
end
def detail
summary.merge("body" => body, "html" => html)
end
private
def derived_excerpt(limit = 200)
paragraph = body.split(/\n\s*\n/).map(&:strip)
.find { |para| !para.empty? && !para.start_with?("#") }
return "" if paragraph.nil?
stripped = paragraph.gsub(/<[^>]+>/, "").gsub(/[*_`>#]/, "").strip
stripped.length > limit ? "#{stripped[0, limit].rstrip}" : stripped
end
end
end
-455
View File
@@ -1,455 +0,0 @@
# frozen_string_literal: true
require "sinatra/base"
require "json"
require "date"
require "securerandom"
require_relative "config"
require_relative "store"
require_relative "markdown"
require_relative "password"
require_relative "api_routes"
require_relative "admin_routes"
module Volumen
# Sinatra application: the public JSON API at /api/volumen and the
# server-rendered admin at /admin.
class Server < Sinatra::Base
MAX_LOGIN_ATTEMPTS = 10
LOGIN_WINDOW = 60 # seconds
SLUG_REGEX = /\A[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\z/
register ApiRoutes
register AdminRoutes
configure do
set :show_exceptions, false
set :raise_errors, false
set :host_authorization, { permitted_hosts: [] } # empty = allow all hosts
set :views, File.expand_path("web/views", __dir__)
end
# Returns a configured subclass bound to a specific config + store.
def self.configured(config:, store:)
secret = session_secret(config)
ttl = config.admin["session_ttl"].to_i
ttl = nil if ttl <= 0
Class.new(self) do
set :volumen_config, config
set :volumen_store, store
set :sessions, httponly: true, same_site: :strict, expire_after: ttl
set :session_secret, secret
end
end
def self.session_secret(config)
key = config.admin["session_key"].to_s
return SecureRandom.hex(64) if key.empty?
if key.bytesize < 64
warn "volumen: session_key is shorter than 64 bytes, falling back to random secret"
return SecureRandom.hex(64)
end
key
end
# Mark the session cookie Secure only when the request is HTTPS (directly or
# via a trusted X-Forwarded-Proto from nginx). Over plain HTTP (local dev)
# the cookie stays usable so the admin login round-trips.
before do
options = request.env["rack.session.options"]
options[:secure] = request.ssl? if options
end
private
def config
settings.volumen_config
end
def store
settings.volumen_store
end
# --- API helpers -----------------------------------------------------
def json(data)
JSON.generate(data)
end
def published_posts
store.all.reject { |post| post.draft? || post.scheduled? }
.sort_by { |post| post.date_string || "" }.reverse
end
def site_payload
config.site.slice("title", "description", "base_url", "language", "author")
end
def posts_payload
posts = filtered_posts
page = positive_int(params["page"], 1)
limit = positive_int(params["limit"], 20).clamp(1, 100)
offset = (page - 1) * limit
{
"page" => page,
"page_size" => limit,
"total" => posts.length,
"posts" => posts[offset, limit].to_a.map(&:summary)
}
end
def filtered_posts
posts = published_posts
posts = filter_by_lang(posts)
posts = posts.select { |post| post.tags.include?(params["tag"]) } if params["tag"]
posts = search_posts(posts) if params["q"]
posts
end
def filter_by_lang(posts)
return posts unless params["lang"]
lang = params["lang"]
posts.select { |post| post.lang == lang || post.all_langs? }
end
def search_posts(posts)
query = params["q"].to_s.strip.downcase
return posts if query.empty?
posts.select do |post|
post.title.to_s.downcase.include?(query) ||
post.body.downcase.include?(query)
end
end
def tag_counts
counts = Hash.new(0)
published_posts.each { |post| post.tags.each { |tag| counts[tag] += 1 } }
counts.sort_by { |name, count| [-count, name] }
.map { |name, count| { "name" => name, "count" => count } }
end
def positive_int(value, default)
number = value.to_i
number.positive? ? number : default
end
def base_url
config.site["base_url"].to_s.chomp("/")
end
def feed_xml
site = config.site
items = published_posts.first(20).map { |post| feed_item(post) }.join("\n")
<<~XML
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>#{escape(site["title"])}</title>
<link>#{escape(base_url)}</link>
<description>#{escape(site["description"])}</description>
<language>#{escape(site["language"])}</language>
#{items}
</channel>
</rss>
XML
end
def feed_item(post)
link = "#{base_url}/#{post.slug}"
pub = if post.metadata["date"]
"\n <pubDate>#{escape(rfc822_date(post))}</pubDate>"
else
""
end
<<~XML.chomp
<item>
<title>#{escape(post.title)}</title>
<link>#{escape(link)}</link>
<guid>#{escape(link)}</guid>
<description>#{escape(post.excerpt)}</description>#{pub}
</item>
XML
end
def rfc822_date(post)
value = post.metadata["date"]
case value
when Date then value.to_time.rfc822
when Time then value.rfc822
end
end
def feed_json
site = config.site
feed_url = "#{base_url}/api/volumen/feed.json"
{
"version" => "https://jsonfeed.org/version/1.1",
"title" => site["title"],
"home_page_url" => base_url,
"feed_url" => feed_url,
"description" => site["description"],
"language" => site["language"],
"items" => published_posts.first(20).map { |post| json_feed_item(post) }
}
end
def json_feed_item(post)
link = "#{base_url}/#{post.slug}"
item = {
"id" => link,
"url" => link,
"title" => post.title,
"content_html" => post.html,
"summary" => post.excerpt,
"date_published" => iso_date(post.metadata["date"]),
"tags" => post.tags
}
item.reject { |_k, v| v.nil? || v == "" || v == [] }
end
def iso_date(value)
case value
when Date then value.iso8601
when Time then value.to_date.iso8601
end
end
def sitemap_xml
urls = published_posts.map do |post|
url = " <url><loc>#{escape("#{base_url}/#{post.slug}")}</loc>"
url << "<lastmod>#{escape(post.date_string)}</lastmod>" if post.date_string
url << "</url>"
end.join("\n")
<<~XML
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
#{urls}
</urlset>
XML
end
def escape(text)
Rack::Utils.escape_html(text.to_s)
end
alias h escape
# --- Admin helpers ---------------------------------------------------
def users
Users.new(path: config.users_file, bootstrap_hash: config.admin["password_hash"])
end
def authenticated?
!session[:user].to_s.empty?
end
def current_user
session[:user]
end
def current_user_record
users.find(current_user)
end
def current_role
current_user_record&.role
end
def admin?
current_role == "admin"
end
def require_login!
unless authenticated?
redirect to("/admin/login")
return
end
return unless current_user_record.nil?
session.clear
redirect to("/admin/login")
end
def require_admin!
halt(403, "Forbidden") unless admin?
end
def csrf_token
session[:csrf] ||= SecureRandom.hex(32)
end
def check_csrf!
token = params["_csrf"]
return if token && session[:csrf] && Rack::Utils.secure_compare(session[:csrf], token)
halt(403, "Invalid CSRF token")
end
def check_login_rate_limit!
Volumen.sweep_login_attempts!
ip = request.ip.to_s
now = Time.now.to_f
cutoff = now - LOGIN_WINDOW
attempts = (Volumen.login_attempts[ip] ||= []).select { |t| t > cutoff }
if attempts.length >= MAX_LOGIN_ATTEMPTS
halt(429, "Too many login attempts. Please wait and try again.")
end
attempts << now
Volumen.login_attempts[ip] = attempts
end
def render_settings(error: nil, notice: nil)
@error = error
@notice = notice
@users = users.all
erb :settings
end
def change_password
unless users.authenticate(current_user, params["current_password"])
return render_settings(error: "Current password is incorrect.")
end
fresh = presence(params["new_password"])
return render_settings(error: "New password cannot be empty.") if fresh.nil?
users.update_password(current_user, fresh)
render_settings(notice: "Password updated.")
end
def change_username
new_name = presence(params["username"])
return render_settings(error: "Username cannot be empty.") if new_name.nil?
unless new_name.match?(/\A[a-zA-Z0-9._-]+\z/)
return render_settings(error: "Username may use letters, numbers, dot, dash, underscore.")
end
return render_settings(notice: "Username unchanged.") if new_name == current_user
if users.rename(current_user, new_name)
session[:user] = new_name
render_settings(notice: "Username updated.")
else
render_settings(error: "That username is already taken.")
end
end
def create_user
name = presence(params["username"])
password = presence(params["password"])
if name.nil? || password.nil?
return render_settings(error: "Username and password are required.")
end
unless name.match?(/\A[a-zA-Z0-9._-]+\z/)
return render_settings(error: "Username may use letters, numbers, dot, dash, underscore.")
end
if users.add(name, password, params["role"].to_s)
render_settings(notice: "User added.")
else
render_settings(error: "That username already exists.")
end
end
def remove_user(username)
if username == current_user
return render_settings(error: "You cannot delete your own account.")
end
if users.delete(username)
render_settings(notice: "User removed.")
else
render_settings(error: "Cannot remove the last user or the last admin.")
end
end
def change_role(username)
return render_settings(error: "You cannot change your own role.") if username == current_user
if users.set_role(username, params["role"].to_s)
render_settings(notice: "Role updated.")
else
render_settings(error: "Could not change role (last admin?).")
end
end
def sorted_posts
store.all.sort_by { |post| post.date_string || "" }.reverse
end
def create_post
post = post_from_params(params)
error = creation_error(post)
if error
@mode = :new
@post = post
@error = error
return erb(:form)
end
store.save(post)
redirect to("/admin/")
end
def update_post
existing = store.find(params["slug"])
halt(404, "Not found") if existing.nil?
store.save(post_from_params(params, existing: existing))
redirect to("/admin/")
end
def creation_error(post)
return "Slug is required." if presence(post.slug).nil?
return "Invalid slug." unless post.slug.match?(SLUG_REGEX)
return "A post with that slug already exists." if store.find(post.slug)
nil
end
def post_from_params(http_params, existing: nil)
metadata = {
"title" => presence(http_params["title"]),
"slug" => presence(http_params["slug"]) || existing&.slug,
"lang" => presence(http_params["lang"]),
"author" => presence(http_params["author"]),
"date" => parse_date(http_params["date"]),
"publish_at" => parse_date(http_params["publish_at"]),
"tags" => parse_tags(http_params["tags"]),
"excerpt" => presence(http_params["excerpt"]),
"cover" => presence(http_params["cover"]),
"draft" => http_params["draft"] == "on",
"all_langs" => http_params["all_langs"] == "on"
}
post = Post.new(metadata: clean_metadata(metadata), body: http_params["body"].to_s)
post.path = existing.path if existing
post
end
def clean_metadata(metadata)
metadata.reject { |_key, value| value.nil? || value == "" || value == [] || value == false }
end
def presence(value)
text = value.to_s.strip
text.empty? ? nil : text
end
def parse_date(value)
text = presence(value)
text && Date.iso8601(text)
rescue ArgumentError
nil
end
def parse_tags(value)
presence(value).to_s.split(",").map(&:strip).reject(&:empty?)
end
end
end
-124
View File
@@ -1,124 +0,0 @@
# frozen_string_literal: true
require "fileutils"
require "securerandom"
require_relative "post"
module Volumen
# Reads and writes Markdown posts in a content directory.
#
# Posts live either at `content_dir/<slug>.md` (default language) or at
# `content_dir/<lang>/<slug>.md` (per-language subdirectory). Missing
# `slug`/`lang` frontmatter is derived from the file path.
class Store
attr_reader :content_dir
def initialize(content_dir, default_lang: nil)
@content_dir = File.expand_path(content_dir.to_s)
@default_lang = default_lang
@all = nil
end
def all
mtime = Dir.exist?(@content_dir) ? File.mtime(@content_dir) : nil
@all = nil if mtime != @all_mtime
@all ||= begin
@all_mtime = mtime
markdown_files.filter_map { |path| load_post(path) }
end
end
def find(slug, lang: nil)
all.find do |post|
post.slug == slug && (lang.nil? || post.lang == lang || post.all_langs?)
end
end
def save(post)
target = post.path || default_path_for(post)
FileUtils.mkdir_p(File.dirname(target))
tmp = "#{target}.tmp"
File.write(tmp, post.to_file)
File.rename(tmp, target)
post.path = target
@all = nil
post
end
def delete(slug, lang: nil)
post = find(slug, lang: lang)
if post&.path
File.delete(post.path)
@all = nil
end
post
end
def media_dir
File.join(@content_dir, "media")
end
def store_upload(original_name, source)
FileUtils.mkdir_p(media_dir)
name = safe_media_name(original_name)
File.open(File.join(media_dir, name), "wb") { |file| IO.copy_stream(source, file) }
"/media/#{name}"
end
def media_file(name)
path = File.join(media_dir, File.basename(name.to_s))
return nil unless File.file?(path)
real = File.realpath(path)
within?(media_dir, real) ? path : nil
end
private
def markdown_files
Dir.glob(File.join(@content_dir, "**", "*.md"))
end
def load_post(path)
post = Post.parse(File.read(path))
post.metadata["slug"] ||= File.basename(path, ".md")
post.metadata["lang"] ||= lang_from_path(path) || @default_lang
post.path = path
post
rescue StandardError => e
warn "volumen: skipping #{path}: #{e.message}"
nil
end
def lang_from_path(path)
parent = File.dirname(File.expand_path(path))
return nil if parent == @content_dir
File.basename(parent)
end
def default_path_for(post)
dir = @content_dir
dir = File.join(dir, post.lang) if post.lang && post.lang != @default_lang
target = File.join(dir, "#{post.slug}.md")
unless File.expand_path(target).start_with?(File.expand_path(@content_dir) + File::SEPARATOR)
raise ArgumentError, "slug escapes content directory"
end
target
end
def safe_media_name(original)
base = File.basename(original.to_s)
ext = File.extname(base).gsub(/[^a-zA-Z0-9.]/, "").downcase
stem = File.basename(base, File.extname(base))
.gsub(/[^a-zA-Z0-9_-]+/, "-").gsub(/-+/, "-").downcase
stem = "file" if stem.empty?
"#{SecureRandom.hex(4)}-#{stem}#{ext}"
end
def within?(dir, path)
File.expand_path(path).start_with?(File.expand_path(dir) + File::SEPARATOR)
end
end
end
-139
View File
@@ -1,139 +0,0 @@
# frozen_string_literal: true
require "fileutils"
require "toml-rb"
require_relative "password"
module Volumen
# File-backed set of admin users, stored as a TOML `[[users]]` array.
#
# Each user has a role: "admin" (full access, including user management) or
# "author" (posts and own account only). If the file does not exist yet, a
# single bootstrap "admin" user is synthesised from the legacy
# `admin.password_hash` config value.
class Users
ROLES = %w[admin author].freeze
DEFAULT_ROLE = "author"
User = Struct.new(:username, :password_hash, :role)
def initialize(path:, bootstrap_hash: nil)
@path = path
@bootstrap_hash = bootstrap_hash.to_s
@all = nil
end
def all
mtime = File.exist?(@path) ? File.mtime(@path) : nil
@all = nil if mtime != @all_mtime
@all ||= begin
@all_mtime = mtime
if File.exist?(@path)
read_file
elsif @bootstrap_hash.empty?
[]
else
[User.new("admin", @bootstrap_hash, "admin")]
end
end
end
def any?
all.any?
end
def find(username)
all.find { |user| user.username == username }
end
def authenticate(username, password)
user = find(username)
user if user && Password.verify(password, user.password_hash)
end
def add(username, password, role = DEFAULT_ROLE)
return nil if username.to_s.empty? || find(username)
user = User.new(username, Password.hash(password), normalize_role(role))
persist(all + [user])
user
end
def update_password(username, password)
mutate(username) { |user| user.password_hash = Password.hash(password) }
end
def rename(current_name, new_name)
return nil if new_name.to_s.empty? || find(new_name)
mutate(current_name) { |user| user.username = new_name }
end
def set_role(username, role)
return nil unless ROLES.include?(role)
users = all
user = users.find { |entry| entry.username == username }
return nil if user.nil? || demoting_last_admin?(users, user, role)
user.role = role
persist(users)
user
end
def delete(username)
users = all
target = users.find { |entry| entry.username == username }
return nil if target.nil? || users.length <= 1 || last_admin?(users, target)
persist(users.reject { |entry| entry.username == username })
username
end
private
def mutate(username)
users = all
user = users.find { |entry| entry.username == username }
return nil if user.nil?
yield user
persist(users)
user
end
def normalize_role(role)
ROLES.include?(role) ? role : DEFAULT_ROLE
end
def admins(users)
users.select { |user| user.role == "admin" }
end
def last_admin?(users, user)
user.role == "admin" && admins(users).length <= 1
end
def demoting_last_admin?(users, user, role)
user.role == "admin" && role != "admin" && admins(users).length <= 1
end
def read_file
data = TomlRB.load_file(@path)
Array(data["users"]).map do |entry|
User.new(entry["username"], entry["password_hash"], entry["role"] || "admin")
end
end
def persist(users)
FileUtils.mkdir_p(File.dirname(@path))
tmp = "#{@path}.tmp"
File.write(tmp, TomlRB.dump("users" => users.map { |user| user_hash(user) }))
File.rename(tmp, @path)
end
def user_hash(user)
{ "username" => user.username, "password_hash" => user.password_hash, "role" => user.role }
end
end
end
-5
View File
@@ -1,5 +0,0 @@
# frozen_string_literal: true
module Volumen
VERSION = "0.2.0"
end
-470
View File
@@ -1,470 +0,0 @@
<h1><%= @mode == :new ? "New post" : "Edit post" %></h1>
<% if @error %>
<p class="error"><%= h(@error) %></p>
<% end %>
<form id="post-form" method="post"
action="<%= @mode == :new ? to("/admin/posts") : h(to("/admin/posts/#{@post.slug}")) %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<div class="fields">
<label>Title <input type="text" name="title" value="<%= h(@post.title) %>"></label>
<label>Slug
<input type="text" name="slug" value="<%= h(@post.slug) %>"
<%= @mode == :edit ? "readonly" : "required" %>>
</label>
<label>Language <input type="text" name="lang" value="<%= h(@post.lang) %>"></label>
<label>Author <input type="text" name="author" value="<%= h(@post.author) %>"></label>
<label>Date <input type="date" name="date" value="<%= h(@post.date_string) %>"></label>
<label>Publish at
<input type="date" name="publish_at"
value="<%= @post.publish_at.is_a?(Date) ? @post.publish_at.strftime("%Y-%m-%d") : "" %>"
placeholder="leave empty for immediate">
</label>
<label>Tags
<input type="text" name="tags" value="<%= h(@post.tags.join(", ")) %>"
placeholder="comma, separated">
</label>
<label class="check">
<input type="checkbox" name="draft" <%= @post.draft? ? "checked" : "" %>> Draft
</label>
<label class="check">
<input type="checkbox" name="all_langs" <%= @post.all_langs? ? "checked" : "" %>> All languages
</label>
</div>
<div class="cover-field">
<label for="cover-input">Cover image</label>
<div class="cover-row">
<input type="text" id="cover-input" name="cover" value="<%= h(@post.cover) %>"
placeholder="/media/… or https://…">
<button type="button" id="cover-upload">Upload</button>
<button type="button" id="cover-clear">Clear</button>
</div>
<img id="cover-preview" class="cover-preview" alt="" hidden>
<input type="file" id="cover-file" accept="image/*" hidden>
</div>
<div class="editor-tabs">
<button type="button" data-tab="markdown" class="active">Markdown</button>
<button type="button" data-tab="visual">Visual</button>
</div>
<div id="visual-view" hidden>
<div class="pane">
<div class="ed-toolbar">
<button type="button" data-rich="bold" title="Bold (Ctrl+B)"><b>B</b></button>
<button type="button" data-rich="italic" title="Italic (Ctrl+I)"><i>I</i></button>
<button type="button" data-rich="h2" title="Heading 2">H2</button>
<button type="button" data-rich="h3" title="Heading 3">H3</button>
<button type="button" data-rich="link" title="Link (Ctrl+K)">Link</button>
<button type="button" data-rich="ul" title="Bullet list">&bull; List</button>
<button type="button" data-rich="ol" title="Numbered list">1. List</button>
<button type="button" data-rich="quote" title="Quote">&ldquo;</button>
<button type="button" data-rich="code" title="Inline code">&lt;/&gt;</button>
<button type="button" data-rich="image" title="Image">Img</button>
<button type="button" data-rich="clear" title="Paragraph">P</button>
</div>
<div id="wysiwyg" class="markdown" contenteditable="true"></div>
</div>
</div>
<div id="markdown-view">
<div class="editor">
<div class="pane">
<div class="ed-toolbar">
<button type="button" data-md="bold" title="Bold (Ctrl+B)"><b>B</b></button>
<button type="button" data-md="italic" title="Italic (Ctrl+I)"><i>I</i></button>
<button type="button" data-md="h2" title="Heading">H2</button>
<button type="button" data-md="link" title="Link (Ctrl+K)">Link</button>
<button type="button" data-md="ul" title="List">List</button>
<button type="button" data-md="quote" title="Quote">&ldquo;</button>
<button type="button" data-md="code" title="Code">&lt;/&gt;</button>
<button type="button" data-md="image" title="Image">Img</button>
<button type="button" id="toggle-preview" class="toggle" title="Toggle preview">Preview</button>
</div>
<textarea id="body" name="body" rows="22"><%= h(@post.body) %></textarea>
</div>
<div class="pane preview-pane">
<div class="ed-toolbar"><span>Preview</span></div>
<div id="preview" class="markdown"></div>
</div>
</div>
</div>
<input type="file" id="image-input" accept="image/*" hidden>
<button type="submit" class="primary">Save</button>
</form>
<script>
(function () {
var form = document.getElementById("post-form");
var textarea = document.getElementById("body");
var preview = document.getElementById("preview");
var wysiwyg = document.getElementById("wysiwyg");
var visualView = document.getElementById("visual-view");
var markdownView = document.getElementById("markdown-view");
var imageInput = document.getElementById("image-input");
var coverInput = document.getElementById("cover-input");
var coverFile = document.getElementById("cover-file");
var coverPreview = document.getElementById("cover-preview");
var previewUrl = "<%= to("/admin/preview") %>";
var uploadUrl = "<%= to("/admin/uploads") %>";
var csrf = "<%= csrf_token %>";
var timer = null;
try { document.execCommand("defaultParagraphSeparator", false, "p"); } catch (e) {}
// --- Markdown <-> HTML -------------------------------------------------
function mdToHtml(md) {
var data = new URLSearchParams();
data.set("body", md);
data.set("_csrf", csrf);
return fetch(previewUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: data.toString()
}).then(function (response) { return response.text(); });
}
function inlineToMd(node) {
var out = "";
node.childNodes.forEach(function (child) { out += inlineNode(child); });
return out;
}
function inlineNode(node) {
if (node.nodeType === 3) { return node.nodeValue.replace(/\s+/g, " "); }
if (node.nodeType !== 1) { return ""; }
var tag = node.tagName.toLowerCase();
var inner = inlineToMd(node);
if (tag === "strong" || tag === "b") { return "**" + inner.trim() + "**"; }
if (tag === "em" || tag === "i") { return "*" + inner.trim() + "*"; }
if (tag === "code") { return "`" + node.textContent + "`"; }
if (tag === "a") { return "[" + inner + "](" + (node.getAttribute("href") || "") + ")"; }
if (tag === "img") {
return "![" + (node.getAttribute("alt") || "") + "](" + (node.getAttribute("src") || "") + ")";
}
if (tag === "br") { return "\n"; }
return inner;
}
function listToMd(list, ordered) {
var index = 1;
var items = [];
list.childNodes.forEach(function (li) {
if (li.nodeType === 1 && li.tagName.toLowerCase() === "li") {
var marker = ordered ? (index++ + ". ") : "- ";
items.push(marker + inlineToMd(li).trim());
}
});
return items.join("\n");
}
function htmlToMd(root) {
var blocks = [];
root.childNodes.forEach(function (node) {
if (node.nodeType === 3) {
var text = node.nodeValue.trim();
if (text) { blocks.push(text); }
return;
}
if (node.nodeType !== 1) { return; }
var tag = node.tagName.toLowerCase();
if (/^h[1-6]$/.test(tag)) {
blocks.push("#".repeat(Number(tag.charAt(1))) + " " + inlineToMd(node).trim());
} else if (tag === "ul" || tag === "ol") {
blocks.push(listToMd(node, tag === "ol"));
} else if (tag === "blockquote") {
blocks.push(inlineToMd(node).trim().split("\n").map(function (line) {
return "> " + line;
}).join("\n"));
} else if (tag === "pre") {
blocks.push("```\n" + node.textContent.replace(/\n$/, "") + "\n```");
} else if (tag === "hr") {
blocks.push("---");
} else {
var content = inlineToMd(node).trim();
if (content) { blocks.push(content); }
}
});
return blocks.join("\n\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
}
// --- Live preview (markdown mode) -------------------------------------
function renderPreview() {
mdToHtml(textarea.value).then(function (html) { preview.innerHTML = html; });
}
function schedule() {
clearTimeout(timer);
timer = setTimeout(renderPreview, 250);
}
// --- Image upload ------------------------------------------------------
function uploadImage(file, onDone) {
var data = new FormData();
data.append("file", file);
data.append("_csrf", csrf);
fetch(uploadUrl, { method: "POST", body: data })
.then(function (response) { return response.json(); })
.then(function (json) { if (json.url) { onDone(json.url); } });
}
imageInput.addEventListener("change", function () {
var file = imageInput.files[0];
if (!file) { return; }
uploadImage(file, function (url) {
if (visualView.hidden) {
insertIntoTextarea("![](" + url + ")");
} else {
exec("insertImage", url);
}
});
imageInput.value = "";
});
// --- Cover image -------------------------------------------------------
function refreshCoverPreview() {
if (coverInput.value) {
coverPreview.src = coverInput.value;
coverPreview.hidden = false;
} else {
coverPreview.hidden = true;
coverPreview.removeAttribute("src");
}
}
document.getElementById("cover-upload").addEventListener("click", function () {
coverFile.click();
});
document.getElementById("cover-clear").addEventListener("click", function () {
coverInput.value = "";
refreshCoverPreview();
});
coverFile.addEventListener("change", function () {
var file = coverFile.files[0];
if (!file) { return; }
uploadImage(file, function (url) {
coverInput.value = url;
refreshCoverPreview();
});
coverFile.value = "";
});
coverInput.addEventListener("input", refreshCoverPreview);
refreshCoverPreview();
// --- Markdown toolbar --------------------------------------------------
function insertIntoTextarea(text) {
var start = textarea.selectionStart;
textarea.value = textarea.value.slice(0, start) + text + textarea.value.slice(textarea.selectionEnd);
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + text.length;
schedule();
}
function wrap(before, after) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var selected = textarea.value.slice(start, end);
textarea.value = textarea.value.slice(0, start) + before + selected + after +
textarea.value.slice(end);
textarea.focus();
textarea.selectionStart = start + before.length;
textarea.selectionEnd = end + before.length;
schedule();
}
function linePrefix(prefix) {
var start = textarea.selectionStart;
var lineStart = textarea.value.lastIndexOf("\n", start - 1) + 1;
textarea.value = textarea.value.slice(0, lineStart) + prefix + textarea.value.slice(lineStart);
textarea.focus();
schedule();
}
var mdActions = {
bold: function () { wrap("**", "**"); },
italic: function () { wrap("*", "*"); },
code: function () { wrap("`", "`"); },
link: function () { wrap("[", "](https://)"); },
h2: function () { linePrefix("## "); },
ul: function () { linePrefix("- "); },
quote: function () { linePrefix("> "); },
image: function () { imageInput.click(); }
};
// --- Visual toolbar ----------------------------------------------------
function exec(command, value) {
document.execCommand(command, false, value || null);
wysiwyg.focus();
updateToolbarState();
}
function wrapInline(tagName) {
var selection = window.getSelection();
if (!selection.rangeCount) { return; }
var element = document.createElement(tagName);
try { selection.getRangeAt(0).surroundContents(element); } catch (e) { /* spans blocks */ }
wysiwyg.focus();
updateToolbarState();
}
var richActions = {
bold: function () { exec("bold"); },
italic: function () { exec("italic"); },
h2: function () { exec("formatBlock", "H2"); },
h3: function () { exec("formatBlock", "H3"); },
quote: function () { exec("formatBlock", "BLOCKQUOTE"); },
clear: function () { exec("formatBlock", "P"); },
ul: function () { exec("insertUnorderedList"); },
ol: function () { exec("insertOrderedList"); },
code: function () { wrapInline("code"); },
image: function () { imageInput.click(); },
link: function () { var url = prompt("Link URL", "https://"); if (url) { exec("createLink", url); } }
};
// --- Toolbar active state ----------------------------------------------
function queryState(command) {
try { return document.queryCommandState(command); } catch (e) { return false; }
}
function currentBlock() {
try { return (document.queryCommandValue("formatBlock") || "").toLowerCase().replace(/[<>]/g, ""); }
catch (e) { return ""; }
}
function selectionInEditor() {
var node = window.getSelection().anchorNode;
while (node) { if (node === wysiwyg) { return true; } node = node.parentNode; }
return false;
}
function isInside(tagName) {
var node = window.getSelection().anchorNode;
while (node && node !== wysiwyg) {
if (node.nodeType === 1 && node.tagName.toLowerCase() === tagName) { return true; }
node = node.parentNode;
}
return false;
}
function updateToolbarState() {
if (visualView.hidden) { return; }
var active = {};
if (selectionInEditor()) {
var block = currentBlock();
active = {
bold: queryState("bold"),
italic: queryState("italic"),
ul: queryState("insertUnorderedList"),
ol: queryState("insertOrderedList"),
h2: block === "h2",
h3: block === "h3",
quote: block === "blockquote",
link: isInside("a"),
code: isInside("code")
};
}
document.querySelectorAll("[data-rich]").forEach(function (button) {
button.classList.toggle("active", !!active[button.dataset.rich]);
});
}
// --- Mode switching ----------------------------------------------------
function setActiveTab(name) {
document.querySelectorAll(".editor-tabs button").forEach(function (button) {
button.classList.toggle("active", button.dataset.tab === name);
});
}
function showVisual() {
mdToHtml(textarea.value).then(function (html) { wysiwyg.innerHTML = html; });
visualView.hidden = false;
markdownView.hidden = true;
setActiveTab("visual");
}
function showMarkdown() {
textarea.value = htmlToMd(wysiwyg);
renderPreview();
visualView.hidden = true;
markdownView.hidden = false;
setActiveTab("markdown");
}
// --- Keyboard shortcuts ------------------------------------------------
function shortcut(actions) {
return function (event) {
if (!(event.ctrlKey || event.metaKey)) { return; }
var key = event.key.toLowerCase();
if (key === "b") { event.preventDefault(); actions.bold(); }
else if (key === "i") { event.preventDefault(); actions.italic(); }
else if (key === "k") { event.preventDefault(); actions.link(); }
};
}
// --- Wiring ------------------------------------------------------------
document.querySelectorAll("[data-md]").forEach(function (button) {
button.addEventListener("click", function () { mdActions[button.dataset.md](); });
});
document.querySelectorAll("[data-rich]").forEach(function (button) {
button.addEventListener("click", function () { richActions[button.dataset.rich](); });
});
document.querySelectorAll(".editor-tabs button").forEach(function (button) {
button.addEventListener("click", function () {
if (button.dataset.tab === "visual") { showVisual(); } else { showMarkdown(); }
});
});
wysiwyg.addEventListener("paste", function (event) {
event.preventDefault();
var text = (event.clipboardData || window.clipboardData).getData("text/plain");
document.execCommand("insertText", false, text);
});
wysiwyg.addEventListener("keydown", shortcut(richActions));
wysiwyg.addEventListener("keyup", updateToolbarState);
wysiwyg.addEventListener("mouseup", updateToolbarState);
document.addEventListener("selectionchange", updateToolbarState);
textarea.addEventListener("input", schedule);
textarea.addEventListener("keydown", shortcut(mdActions));
form.addEventListener("submit", function () {
if (!visualView.hidden) { textarea.value = htmlToMd(wysiwyg); }
});
// --- Preview toggle (persisted) ---------------------------------------
var editor = markdownView.querySelector(".editor");
var togglePreviewBtn = document.getElementById("toggle-preview");
function applyPreviewPreference() {
var off = false;
try { off = localStorage.getItem("volumen.preview") === "off"; } catch (e) {}
editor.classList.toggle("no-preview", off);
togglePreviewBtn.classList.toggle("active", !off);
if (!off) { renderPreview(); }
}
togglePreviewBtn.addEventListener("click", function () {
var turningOff = !editor.classList.contains("no-preview");
try { localStorage.setItem("volumen.preview", turningOff ? "off" : "on"); } catch (e) {}
applyPreviewPreference();
});
// Start in Markdown mode (primary).
applyPreviewPreference();
})();
</script>
-254
View File
@@ -1,254 +0,0 @@
<!DOCTYPE html>
<html lang="<%= h(config.language) %>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>volumen admin</title>
<style>
:root {
color-scheme: light dark;
--fg: #1b1b1f; --muted: #6b7280; --bg: #f6f7f9; --card: #ffffff;
--border: #e4e4ec; --accent: #4f46e5; --accent-strong: #4338ca;
--accent-weak: #eef2ff; --on-accent: #ffffff;
--danger: #dc2626; --danger-weak: #fef2f2;
--ok: #16a34a; --ok-weak: #f0fdf4;
--ring: 0 0 0 3px rgba(79, 70, 229, 0.30);
--shadow-sm: 0 1px 2px rgba(17, 17, 26, 0.06);
--shadow: 0 1px 3px rgba(17, 17, 26, 0.06), 0 10px 30px -16px rgba(17, 17, 26, 0.25);
--radius: 12px;
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #e7e7ea; --muted: #9aa0aa; --bg: #121217; --card: #1c1c22;
--border: #2c2c35; --accent: #818cf8; --accent-strong: #a5b4fc;
--accent-weak: rgba(129, 140, 248, 0.16); --on-accent: #14141a;
--danger: #f87171; --danger-weak: rgba(248, 113, 113, 0.14);
--ok: #4ade80; --ok-weak: rgba(74, 222, 128, 0.14);
--ring: 0 0 0 3px rgba(129, 140, 248, 0.40);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 10px 30px -16px rgba(0, 0, 0, 0.7);
}
.brand-logo { background: #f4f4f6; border-radius: 8px; padding: 4px 8px; }
}
* { box-sizing: border-box; }
body { margin: 0; display: flex; min-height: 100vh; background: var(--bg); }
/* Sidebar */
.sidebar {
width: 230px; flex-shrink: 0; position: sticky; top: 0; height: 100vh;
overflow-y: auto; background: var(--card); border-right: 1px solid var(--border);
display: flex; flex-direction: column; padding: 1.25rem 0;
}
.sidebar .brand { display: flex; padding: 0 1.25rem; margin-bottom: 1rem; text-decoration: none; }
.brand-logo { height: 28px; display: block; }
.sidebar .who {
margin: 0 1.25rem; padding: 0.3rem 0.6rem; border: 1px solid var(--border);
border-radius: 999px; font-size: 0.78rem; color: var(--muted); align-self: flex-start;
}
.sidenav { display: flex; flex-direction: column; gap: 1px; padding: 0.75rem 0.75rem; flex: 1; }
.sidenav a, .sidenav button {
display: flex; align-items: center; text-decoration: none; color: var(--fg);
font: inherit; font-size: 0.9rem; font-weight: 500; padding: 0.55rem 0.8rem;
border-radius: 9px; border: none; background: none; cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.sidenav a:hover, .sidenav button:hover { background: var(--accent-weak); color: var(--accent-strong); }
.sidenav a.active { background: var(--accent); color: var(--on-accent); }
.sidebar-footer { padding: 0.75rem 1rem; border-top: 1px solid var(--border); }
/* Main */
main {
flex: 1; overflow: auto; padding: 2rem 2rem 4rem;
color: var(--fg); font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
.main-inner { max-width: 1060px; margin: 0 auto; }
::selection { background: var(--accent-weak); }
a { color: var(--accent); }
h1 { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.01em; margin: 0 0 1.25rem; }
h2 { font-size: 1.15rem; font-weight: 650; margin: 0 0 0.75rem; }
h3 { font-size: 0.95rem; font-weight: 650; }
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; }
.toolbar h1 { margin: 0; }
/* Buttons */
button, .button {
font: inherit; font-weight: 550; cursor: pointer; display: inline-flex;
align-items: center; gap: 0.4rem; border: 1px solid var(--border);
background: var(--card); color: var(--fg); border-radius: 9px;
padding: 0.45rem 0.9rem; text-decoration: none;
transition: background 0.15s, border-color 0.15s, transform 0.05s, box-shadow 0.15s;
}
button:hover, .button:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
button:active, .button:active { transform: translateY(1px); }
button:focus-visible, .button:focus-visible,
input:focus-visible, select:focus-visible, textarea:focus-visible,
[contenteditable]:focus-visible, .sidenav a:focus-visible, .sidenav button:focus-visible {
outline: none; box-shadow: var(--ring);
}
button.primary, .button.primary {
background: var(--accent); border-color: var(--accent); color: var(--on-accent);
box-shadow: var(--shadow-sm);
}
button.primary:hover, .button.primary:hover {
background: var(--accent-strong); border-color: var(--accent-strong);
}
button.danger {
color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, var(--border));
background: transparent;
}
button.danger:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
form.inline { display: inline; margin: 0; }
/* Cards */
.card {
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 1.5rem; margin-bottom: 1.5rem; box-shadow: var(--shadow);
}
.card h2 { margin-top: 0; }
.card h3 { margin: 1.5rem 0 0.6rem; }
.card form { margin-bottom: 1.25rem; }
.card label { max-width: 380px; margin: 0.5rem 0; }
/* Forms */
.fields { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.9rem; margin-bottom: 1.25rem; }
label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.8rem;
font-weight: 550; color: var(--muted); }
label.check { flex-direction: row; align-items: center; gap: 0.5rem; }
.cover-field { margin-bottom: 1rem; }
.cover-field > label { display: block; font-size: 0.8rem; font-weight: 550;
color: var(--muted); margin-bottom: 0.3rem; }
.cover-row { display: flex; gap: 0.5rem; align-items: center; }
.cover-row input { flex: 1; }
.cover-preview { margin-top: 0.6rem; max-height: 120px; border-radius: 8px;
border: 1px solid var(--border); display: block; }
input[type=text], input[type=password], input[type=date], input:not([type]), select, textarea {
font: inherit; color: var(--fg); background: var(--bg);
border: 1px solid var(--border); border-radius: 9px; padding: 0.5rem 0.7rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
/* Tables */
table {
width: 100%; border-collapse: collapse; background: var(--card);
border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
box-shadow: var(--shadow); margin-bottom: 1.5rem;
}
th, td { text-align: left; padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
th { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted);
background: color-mix(in srgb, var(--bg) 60%, var(--card)); }
tbody tr:hover td { background: color-mix(in srgb, var(--accent-weak) 50%, transparent); }
tr:last-child td { border-bottom: none; }
td.actions { display: flex; gap: 0.5rem; align-items: center; }
/* Alerts */
.error, .notice {
border: 1px solid; border-radius: 10px; padding: 0.7rem 0.95rem; margin: 0 0 1.25rem;
font-size: 0.9rem;
}
.error { color: var(--danger); background: var(--danger-weak);
border-color: color-mix(in srgb, var(--danger) 30%, transparent); }
.notice { color: var(--ok); background: var(--ok-weak);
border-color: color-mix(in srgb, var(--ok) 30%, transparent); }
.muted { color: var(--muted); }
/* Login */
.login {
max-width: 380px; margin: 4rem auto; background: var(--card);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 2rem; box-shadow: var(--shadow);
}
.login h1 { text-align: center; }
.login label, .login button { width: 100%; }
.login button { margin-top: 1.25rem; justify-content: center; }
/* Editor */
.editor-tabs {
display: inline-flex; gap: 2px; background: var(--card); padding: 3px;
border: 1px solid var(--border); border-radius: 10px; margin-bottom: 0.9rem;
}
.editor-tabs button { border: none; background: transparent; color: var(--muted);
border-radius: 7px; padding: 0.35rem 0.95rem; font-size: 0.85rem; }
.editor-tabs button.active { background: var(--accent); color: var(--on-accent); }
.editor { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.25rem; }
.editor.no-preview { grid-template-columns: 1fr; }
.editor.no-preview .preview-pane { display: none; }
.pane { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
background: var(--card); box-shadow: var(--shadow-sm); }
.ed-toolbar { display: flex; gap: 0.3rem; padding: 0.45rem; align-items: center;
border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--bg) 50%, var(--card)); }
.ed-toolbar button { padding: 0.28rem 0.55rem; font-size: 0.82rem; border-radius: 7px;
background: transparent; }
.ed-toolbar button:hover { background: var(--accent-weak); }
.ed-toolbar button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.ed-toolbar span { color: var(--muted); font-size: 0.78rem; font-weight: 550; }
.ed-toolbar .toggle { margin-left: auto; }
textarea { width: 100%; border: none; border-radius: 0; padding: 0.85rem 1rem;
font: 13.5px/1.65 ui-monospace, SFMono-Regular, Menlo, monospace; resize: vertical;
background: var(--card); color: var(--fg); }
textarea:focus { box-shadow: none; }
#preview, #wysiwyg { padding: 0.85rem 1.1rem; min-height: 22rem; }
#wysiwyg { outline: none; }
#wysiwyg:focus { box-shadow: inset 0 0 0 2px var(--accent); }
#preview img, #wysiwyg img { max-width: 100%; }
[hidden] { display: none !important; }
/* Rendered Markdown */
.markdown { line-height: 1.7; }
.markdown > :first-child { margin-top: 0; }
.markdown h1, .markdown h2, .markdown h3, .markdown h4 { line-height: 1.25; margin: 1.4em 0 0.5em; }
.markdown h1 { font-size: 1.6rem; }
.markdown h2 { font-size: 1.3rem; }
.markdown h3 { font-size: 1.1rem; }
.markdown p { margin: 0.7em 0; }
.markdown a { color: var(--accent); }
.markdown ul, .markdown ol { padding-left: 1.5rem; margin: 0.7em 0; }
.markdown li { margin: 0.2em 0; }
.markdown blockquote { margin: 0.9em 0; padding: 0.3em 1rem; border-left: 3px solid var(--accent);
color: var(--muted); background: var(--accent-weak); border-radius: 0 8px 8px 0; }
.markdown code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em;
background: var(--bg); border: 1px solid var(--border); border-radius: 5px; padding: 0.1em 0.35em; }
.markdown pre { background: var(--bg); border: 1px solid var(--border); border-radius: 10px;
padding: 0.9rem 1rem; overflow-x: auto; margin: 0.9em 0; }
.markdown pre code { background: none; border: none; padding: 0; font-size: 0.88em; }
.markdown table { border-collapse: collapse; width: 100%; margin: 0.9em 0; font-size: 0.95em;
box-shadow: none; }
.markdown th, .markdown td { border: 1px solid var(--border); padding: 0.45rem 0.7rem; text-align: left; }
.markdown thead th { background: var(--accent-weak); color: var(--fg); }
.markdown tbody tr:hover td { background: transparent; }
.markdown hr { border: none; border-top: 1px solid var(--border); margin: 1.4em 0; }
.markdown img { max-width: 100%; border-radius: 8px; }
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
</style>
</head>
<body>
<aside class="sidebar">
<a class="brand" href="<%= to("/admin/") %>"><img class="brand-logo" src="<%= to("/admin/logo.svg") %>" alt="volumen"></a>
<% if authenticated? %>
<span class="who"><%= h(current_user) %> · <%= h(current_role) %></span>
<% settings_active = request.path_info.start_with?("/admin/settings") %>
<nav class="sidenav">
<a href="<%= to("/admin/") %>" class="<%= settings_active ? "" : "active" %>">Posts</a>
<a href="<%= to("/admin/settings") %>" class="<%= settings_active ? "active" : "" %>">Settings</a>
</nav>
<div class="sidebar-footer">
<form method="post" action="<%= to("/admin/logout") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<button type="submit">Log out</button>
</form>
</div>
<% end %>
</aside>
<main>
<div class="main-inner">
<%= yield %>
</div>
</main>
</body>
</html>
-34
View File
@@ -1,34 +0,0 @@
<div class="toolbar">
<h1>Posts</h1>
<a class="button primary" href="<%= to("/admin/posts/new") %>">New post</a>
</div>
<% if @posts.empty? %>
<p>No posts yet. Create your first one.</p>
<% else %>
<table>
<thead>
<tr><th>Title</th><th>Slug</th><th>Lang</th><th>Date</th><th>Draft</th><th></th></tr>
</thead>
<tbody>
<% @posts.each do |post| %>
<tr>
<td><%= h(post.title) %></td>
<td><%= h(post.slug) %></td>
<td><%= h(post.lang) %></td>
<td><%= h(post.date_string) %></td>
<td><%= post.draft? ? "yes" : "" %></td>
<td class="actions">
<a href="<%= h(to("/admin/posts/#{post.slug}/edit")) %>">Edit</a>
<form class="inline" method="post"
action="<%= h(to("/admin/posts/#{post.slug}/delete")) %>"
onsubmit="return confirm('Delete this post?');">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<button type="submit" class="danger">Delete</button>
</form>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
-23
View File
@@ -1,23 +0,0 @@
<div class="login">
<h1>Sign in</h1>
<% if @error %>
<p class="error"><%= h(@error) %></p>
<% end %>
<% unless users.any? %>
<p class="error">
No users are configured. Set <code>admin.password_hash</code> in your
config (you will sign in as <code>admin</code>) or generate one with
<code>volumen hash-password</code>.
</p>
<% end %>
<form method="post" action="<%= to("/admin/login") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<label>Username
<input type="text" name="username" autofocus autocomplete="username">
</label>
<label>Password
<input type="password" name="password" autocomplete="current-password">
</label>
<button type="submit" class="primary">Sign in</button>
</form>
</div>
-86
View File
@@ -1,86 +0,0 @@
<h1>Settings</h1>
<% if @notice %><p class="notice"><%= h(@notice) %></p><% end %>
<% if @error %><p class="error"><%= h(@error) %></p><% end %>
<section class="card">
<h2>Your account</h2>
<p class="muted">Signed in as <strong><%= h(current_user) %></strong> (<%= h(current_role) %>).</p>
<form method="post" action="<%= to("/admin/settings/password") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3>Change password</h3>
<label>Current password
<input type="password" name="current_password" autocomplete="current-password">
</label>
<label>New password
<input type="password" name="new_password" autocomplete="new-password">
</label>
<button type="submit" class="primary">Update password</button>
</form>
<form method="post" action="<%= to("/admin/settings/username") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3>Change username</h3>
<label>New username <input type="text" name="username" value="<%= h(current_user) %>"></label>
<button type="submit">Update username</button>
</form>
</section>
<% if admin? %>
<section class="card">
<h2>Users</h2>
<table>
<thead><tr><th>Username</th><th>Role</th><th></th></tr></thead>
<tbody>
<% @users.each do |user| %>
<tr>
<td><%= h(user.username) %><%= user.username == current_user ? " (you)" : "" %></td>
<td>
<% if user.username == current_user %>
<%= h(user.role) %>
<% else %>
<form class="inline" method="post"
action="<%= h(to("/admin/settings/users/#{user.username}/role")) %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<select name="role" onchange="this.form.submit()">
<% Volumen::Users::ROLES.each do |role| %>
<option value="<%= role %>" <%= user.role == role ? "selected" : "" %>><%= role %></option>
<% end %>
</select>
</form>
<% end %>
</td>
<td class="actions">
<% unless user.username == current_user %>
<form class="inline" method="post"
action="<%= h(to("/admin/settings/users/#{user.username}/delete")) %>"
onsubmit="return confirm('Remove this user?');">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<button type="submit" class="danger">Remove</button>
</form>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
<form method="post" action="<%= to("/admin/settings/users") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3>Add user</h3>
<div class="fields">
<label>Username <input type="text" name="username"></label>
<label>Password <input type="password" name="password" autocomplete="new-password"></label>
<label>Role
<select name="role">
<% Volumen::Users::ROLES.each do |role| %>
<option value="<%= role %>" <%= role == Volumen::Users::DEFAULT_ROLE ? "selected" : "" %>><%= role %></option>
<% end %>
</select>
</label>
</div>
<button type="submit" class="primary">Add user</button>
</form>
</section>
<% end %>
+96
View File
@@ -0,0 +1,96 @@
[build-system]
requires = ["setuptools>=75", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "volumen"
version = "0.4.1"
description = "A small, file-based Markdown blog engine with a built-in admin."
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 = "opensource@petrbalvin.org"}]
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",
]
[project.scripts]
volumen = "volumen.cli:main"
[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.setuptools.package-data]
volumen = ["web/static/*"]
[tool.ruff]
line-length = 100
indent-width = 4
[tool.ruff.format]
quote-style = "double"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "C4"]
[tool.ruff.lint.per-file-ignores]
"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"
+86
View File
@@ -0,0 +1,86 @@
from __future__ import annotations
import logging
import threading
import time
from collections import OrderedDict
logger = logging.getLogger(__name__)
# ``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
# 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() -> 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)."""
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.
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.keys()):
attempts = [t for t in _login_attempts[ip] if t > cutoff]
if attempts:
_login_attempts[ip] = attempts
else:
_login_attempts.pop(ip, None)
# Bound the dict size with LRU eviction.
while len(_login_attempts) > _MAX_LOGIN_TRACKED_IPS:
_login_attempts.popitem(last=False)
_last_cleanup = now
def record_login_attempt(ip: str, now: float | None = None) -> int:
"""Record a login attempt for *ip* and return the current count in the window.
Uses ``time.monotonic()`` exclusively to avoid skew with wall-clock
changes.
"""
with _login_lock:
sweep_login_attempts()
if now is None:
now = time.monotonic()
cutoff = now - LOGIN_WINDOW
attempts = _login_attempts.get(ip)
if attempts is None:
attempts = []
_login_attempts[ip] = attempts
else:
attempts[:] = [t for t in attempts if t > cutoff]
# Mark as recently used.
_login_attempts.move_to_end(ip)
attempts.append(now)
return len(attempts)
+80
View File
@@ -0,0 +1,80 @@
"""Administrative authentication routes."""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool
from .app import templates
from .auth import admin_context, check_login_rate_limit, validate_csrf_form
from .config import Config
from .dependencies import get_config, get_store, get_users
from .store import Store
from .users import Users
ConfigDep = Annotated[Config, Depends(get_config)]
StoreDep = Annotated[Store, Depends(get_store)]
UsersDep = Annotated[Users, Depends(get_users)]
router = APIRouter(prefix="/admin")
@router.get("", include_in_schema=False)
async def admin_root() -> RedirectResponse:
"""Redirect bare ``/admin`` to ``/admin/``."""
return RedirectResponse(url="/admin/", status_code=303)
@router.get("/login")
async def admin_login_form(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
) -> HTMLResponse:
"""Show the login form, or redirect authenticated users."""
if request.session.get("user", ""):
return RedirectResponse(url="/admin/", status_code=303)
context = admin_context(request, config, store, users_obj)
return templates.TemplateResponse(request, "login.html.jinja", context)
@router.post("/login")
async def admin_login(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
) -> HTMLResponse:
"""Authenticate the user and redirect to the admin dashboard."""
check_login_rate_limit(request)
await validate_csrf_form(request)
form = await request.form()
username = str(form.get("username", ""))
password = str(form.get("password", ""))
authenticated_user = await run_in_threadpool(users_obj.authenticate, username, password)
if authenticated_user:
request.session["user"] = authenticated_user.username
return RedirectResponse(url="/admin/", status_code=303)
context = admin_context(
request,
config,
store,
users_obj,
error="Invalid username or password.",
)
return templates.TemplateResponse(request, "login.html.jinja", context, status_code=401)
@router.post("/logout")
async def admin_logout(request: Request) -> RedirectResponse:
"""Clear the session and redirect to the login page."""
await validate_csrf_form(request)
request.session.clear()
return RedirectResponse(url="/admin/login", status_code=303)
+24
View File
@@ -0,0 +1,24 @@
"""Uploaded media serving routes."""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from starlette.concurrency import run_in_threadpool
from .dependencies import get_store
from .store import Store
router = APIRouter(prefix="/media")
StoreDep = Annotated[Store, Depends(get_store)]
@router.get("/{path:path}")
async def serve_media(path: str, store: StoreDep) -> FileResponse:
"""Serve an uploaded media file through Store media resolution."""
file_path = await run_in_threadpool(store.media_file, path)
if file_path is None:
raise HTTPException(status_code=404)
return FileResponse(file_path)
+238
View File
@@ -0,0 +1,238 @@
"""Administrative post, preview, and upload routes."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool
from .app import templates
from .auth import admin_context, validate_csrf_form
from .config import Config
from .dependencies import get_config, get_store, get_users, require_login
from .payloads import creation_error, post_from_params
from .post import Post
from .store import Store
from .upload_validation import validate_upload
from .users import Users
ConfigDep = Annotated[Config, Depends(get_config)]
StoreDep = Annotated[Store, Depends(get_store)]
UsersDep = Annotated[Users, Depends(get_users)]
LoginDep = Annotated[str, Depends(require_login)]
router = APIRouter(prefix="/admin")
_PKG_DIR = Path(__file__).resolve().parent
_PUBLIC_DIR = _PKG_DIR / "web" / "static"
@router.get("/")
async def admin_index(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
_username: LoginDep,
) -> HTMLResponse:
"""Show the admin post list."""
posts = await run_in_threadpool(_all_posts_sorted, store)
context = admin_context(
request,
config,
store,
users_obj,
posts=posts,
crumbs=[("Posts", None)],
)
return templates.TemplateResponse(request, "list.html.jinja", context)
def _all_posts_sorted(store: Store) -> list[Post]:
return sorted(store.all(), key=lambda post: post.date_string or "", reverse=True)
@router.get("/posts/new")
async def admin_post_new(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
_username: LoginDep,
) -> HTMLResponse:
"""Show the new-post form."""
post = Post(metadata={"lang": config.language})
context = admin_context(
request,
config,
store,
users_obj,
mode="new",
post=post,
crumbs=[("Posts", "/admin/"), ("New post", None)],
)
return templates.TemplateResponse(request, "form.html.jinja", context)
@router.post("/posts")
async def admin_create_post(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
_username: LoginDep,
) -> HTMLResponse:
"""Create a new post from form data."""
await validate_csrf_form(request)
form = await request.form()
form_dict: dict[str, str] = {
key: value for key, value in form.items() if isinstance(value, str)
}
post = post_from_params(form_dict)
error = creation_error(post, store)
if error:
context = admin_context(
request,
config,
store,
users_obj,
mode="new",
post=post,
error=error,
)
return templates.TemplateResponse(request, "form.html.jinja", context, status_code=422)
await run_in_threadpool(store.save, post)
return RedirectResponse(url="/admin/", status_code=303)
@router.get("/posts/{slug}/edit")
async def admin_post_edit(
slug: str,
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
_username: LoginDep,
) -> HTMLResponse:
"""Show the edit-post form for the given slug."""
post = await run_in_threadpool(store.find, slug)
if post is None:
raise HTTPException(status_code=404, detail="Not found")
title = str(post.title or slug)
context = admin_context(
request,
config,
store,
users_obj,
mode="edit",
post=post,
crumbs=[("Posts", "/admin/"), (title, None)],
)
return templates.TemplateResponse(request, "form.html.jinja", context)
@router.post("/posts/{slug}")
async def admin_update_post(
slug: str,
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
_username: LoginDep,
) -> HTMLResponse:
"""Update an existing post."""
await validate_csrf_form(request)
existing = await run_in_threadpool(store.find, slug)
if existing is None:
raise HTTPException(status_code=404, detail="Not found")
form = await request.form()
form_dict: dict[str, str] = {
key: value for key, value in form.items() if isinstance(value, str)
}
post = post_from_params(form_dict, existing=existing)
error = creation_error(post, store, existing=existing)
if error:
context = admin_context(
request,
config,
store,
users_obj,
mode="edit",
post=post,
error=error,
)
return templates.TemplateResponse(request, "form.html.jinja", context, status_code=422)
await run_in_threadpool(store.save, post)
return RedirectResponse(url="/admin/", status_code=303)
@router.post("/posts/{slug}/delete")
async def admin_delete_post(
slug: str,
request: Request,
store: StoreDep,
_username: LoginDep,
) -> RedirectResponse:
"""Delete a post by slug."""
await validate_csrf_form(request)
await run_in_threadpool(store.delete, slug)
return RedirectResponse(url="/admin/", status_code=303)
@router.post("/preview")
async def admin_preview(
request: Request,
_username: LoginDep,
) -> HTMLResponse:
"""Render Markdown body to HTML and return it."""
await validate_csrf_form(request)
from .markdown import render
form = await request.form()
body = str(form.get("body", ""))
html = await run_in_threadpool(render, body)
return HTMLResponse(content=html)
@router.post("/uploads")
async def admin_uploads(
request: Request,
store: StoreDep,
_username: LoginDep,
file: UploadFile | None = None,
) -> JSONResponse:
"""Upload a media file and return its URL as JSON."""
await validate_csrf_form(request)
if file is None or file.filename is None:
raise HTTPException(status_code=400, detail=json.dumps({"error": "no_file"}))
bytes_data, _extension = await validate_upload(file)
url = await run_in_threadpool(store.store_upload, file.filename, file.file, bytes_data)
return JSONResponse(content={"url": url})
@router.get("/logo.svg", include_in_schema=False)
async def admin_logo() -> FileResponse:
"""Serve the volumen logo SVG."""
path = _PUBLIC_DIR / "volumen-logo.svg"
return FileResponse(path)
@router.get("/icon.svg", include_in_schema=False)
async def admin_icon() -> FileResponse:
"""Serve the volumen icon SVG."""
path = _PUBLIC_DIR / "volumen-icon.svg"
return FileResponse(path)
+415
View File
@@ -0,0 +1,415 @@
"""Administrative settings and user-management routes."""
from __future__ import annotations
import json
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse
from starlette.concurrency import run_in_threadpool
from .app import templates
from .auth import admin_context, validate_csrf_form
from .config import Config
from .dependencies import get_config, get_store, get_users, require_admin, require_login
from .store import Store
from .user_settings import (
change_fediverse_creator,
change_name,
change_password,
change_photo,
change_role,
change_username,
create_user,
remove_photo,
remove_user,
)
from .users import Users
ConfigDep = Annotated[Config, Depends(get_config)]
StoreDep = Annotated[Store, Depends(get_store)]
UsersDep = Annotated[Users, Depends(get_users)]
LoginDep = Annotated[str, Depends(require_login)]
AdminDep = Annotated[str, Depends(require_admin)]
router = APIRouter(prefix="/admin")
# ============================================================================
# Auth helpers
# ============================================================================
def _password_policy(config: Config) -> tuple[int, int | None]:
raw_min = config.admin.get("min_password_length", 0)
raw_max = config.admin.get("max_password_length", 0)
min_len = max(1, int(raw_min)) if raw_min else 8
max_len = int(raw_max) if raw_max and int(raw_max) > 0 else None
return min_len, max_len
# ============================================================================
# Settings page
# ============================================================================
@router.get("/settings")
async def admin_settings(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
_username: LoginDep,
) -> HTMLResponse:
"""Show the admin settings page."""
users_list = await run_in_threadpool(lambda: users_obj.all)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_list,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — password
# ============================================================================
@router.post("/settings/password")
async def admin_settings_password(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
current_user: LoginDep,
) -> HTMLResponse:
"""Handle password change."""
await validate_csrf_form(request)
min_len, max_len = _password_policy(config)
form = await request.form()
error, notice = await run_in_threadpool(
change_password,
users_obj,
current_user,
str(form.get("current_password", "")),
str(form.get("new_password", "")),
min_length=min_len,
max_length=max_len,
)
users_list = await run_in_threadpool(lambda: users_obj.all)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_list,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — username
# ============================================================================
@router.post("/settings/username")
async def admin_settings_username(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
current_user: LoginDep,
) -> HTMLResponse:
"""Handle username change."""
await validate_csrf_form(request)
form = await request.form()
error, notice = await run_in_threadpool(
change_username,
users_obj,
current_user,
str(form.get("username", "")),
request.session,
)
users_list = await run_in_threadpool(lambda: users_obj.all)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_list,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — display name
# ============================================================================
@router.post("/settings/name")
async def admin_settings_name(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
current_user: LoginDep,
) -> HTMLResponse:
"""Handle display name change."""
await validate_csrf_form(request)
form = await request.form()
error, notice = await run_in_threadpool(
change_name, users_obj, current_user, str(form.get("name", ""))
)
users_list = await run_in_threadpool(lambda: users_obj.all)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_list,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — fediverse handle
# ============================================================================
@router.post("/settings/fediverse")
async def admin_settings_fediverse(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
current_user: LoginDep,
) -> HTMLResponse:
"""Handle fediverse creator handle change."""
await validate_csrf_form(request)
form = await request.form()
error, notice = await run_in_threadpool(
change_fediverse_creator,
users_obj,
current_user,
str(form.get("fediverse_creator", "")),
)
users_list = await run_in_threadpool(lambda: users_obj.all)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_list,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — profile photo
# ============================================================================
@router.post("/settings/photo")
async def admin_settings_photo(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
current_user: LoginDep,
photo: UploadFile | None = None,
) -> HTMLResponse:
"""Upload a profile photo."""
await validate_csrf_form(request)
error: str | None = None
notice: str | None = None
if photo is None or photo.filename is None:
error = "No file selected."
else:
try:
error, notice = await change_photo(users_obj, store, current_user, photo)
except HTTPException as exc:
# Surface upload validation failures to the settings page.
try:
detail = json.loads(exc.detail) if isinstance(exc.detail, str) else {}
except json.JSONDecodeError, TypeError:
detail = {}
error = detail.get("message", "Upload failed.")
users_list = await run_in_threadpool(lambda: users_obj.all)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_list,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — remove photo
# ============================================================================
@router.post("/settings/photo/remove")
async def admin_settings_photo_remove(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
current_user: LoginDep,
) -> HTMLResponse:
"""Remove the current user's profile photo."""
await validate_csrf_form(request)
error, notice = await remove_photo(users_obj, current_user, store=store)
users_list = await run_in_threadpool(lambda: users_obj.all)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_list,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — create user (admin only)
# ============================================================================
@router.post("/settings/users")
async def admin_settings_users_create(
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
_admin: AdminDep,
) -> HTMLResponse:
"""Create a new user (admin only)."""
await validate_csrf_form(request)
min_len, max_len = _password_policy(config)
form = await request.form()
error, notice = await run_in_threadpool(
create_user,
users_obj,
str(form.get("username", "")),
str(form.get("password", "")),
str(form.get("role", "author")),
min_length=min_len,
max_length=max_len,
)
users_list = await run_in_threadpool(lambda: users_obj.all)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_list,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — change role (admin only)
# ============================================================================
@router.post("/settings/users/{username}/role")
async def admin_settings_users_role(
username: str,
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
current_user: AdminDep,
) -> HTMLResponse:
"""Change a user's role (admin only)."""
await validate_csrf_form(request)
form = await request.form()
error, notice = await run_in_threadpool(
change_role,
users_obj,
current_user,
username,
str(form.get("role", "author")),
)
users_list = await run_in_threadpool(lambda: users_obj.all)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_list,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
# ============================================================================
# Settings — delete user (admin only)
# ============================================================================
@router.post("/settings/users/{username}/delete")
async def admin_settings_users_delete(
username: str,
request: Request,
config: ConfigDep,
store: StoreDep,
users_obj: UsersDep,
current_user: AdminDep,
) -> HTMLResponse:
"""Delete a user (admin only)."""
await validate_csrf_form(request)
error, notice = await remove_user(users_obj, current_user, username, store=store)
users_list = await run_in_threadpool(lambda: users_obj.all)
ctx = admin_context(
request,
config,
store,
users_obj,
users_list=users_list,
error=error,
notice=notice,
crumbs=[("Settings", None)],
)
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
+174
View File
@@ -0,0 +1,174 @@
"""Public JSON API endpoints and feed endpoints."""
from __future__ import annotations
from typing import Any
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 .payloads import posts_payload, published_posts, site_payload, tag_counts
from .store import Store
router = APIRouter(prefix="/api/volumen")
CORS_HEADERS: dict[str, str] = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
}
CACHE_HEADER: dict[str, str] = {"Cache-Control": "public, max-age=60"}
def _cors_response(content: Any, status_code: int = 200) -> JSONResponse:
"""Return a JSON response with CORS and Cache-Control headers."""
return JSONResponse(
content=content,
status_code=status_code,
headers={**CORS_HEADERS, **CACHE_HEADER},
)
def _add_cors_headers(response: Response) -> Response:
"""Attach CORS and Cache-Control headers to an existing response."""
for key, value in {**CORS_HEADERS, **CACHE_HEADER}.items():
response.headers.setdefault(key, value)
return response
# ---------------------------------------------------------------------------
# OPTIONS preflight
# ---------------------------------------------------------------------------
@router.options("/{rest:path}")
async def options_preflight() -> Response:
"""Handle CORS preflight requests for all API endpoints."""
return _add_cors_headers(PlainTextResponse(""))
# ---------------------------------------------------------------------------
# Site metadata
# ---------------------------------------------------------------------------
@router.get("/site")
async def api_site(config: Config = Depends(get_config)) -> JSONResponse:
"""Return site metadata (title, description, base_url, etc.)."""
payload = await run_in_threadpool(site_payload, config)
return _cors_response(payload)
# ---------------------------------------------------------------------------
# Posts list (paginated, filtered)
# ---------------------------------------------------------------------------
@router.get("/posts")
async def api_posts(
store: Store = Depends(get_store),
lang: str = Query(default=""),
tag: str = Query(default=""),
q: 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] = {}
if lang:
params["lang"] = lang
if tag:
params["tag"] = tag
if q:
params["q"] = q
payload = await run_in_threadpool(posts_payload, store, params, page, limit)
return _cors_response(payload)
# ---------------------------------------------------------------------------
# Single post
# ---------------------------------------------------------------------------
@router.get("/posts/{slug}")
async def api_post(
slug: str,
store: Store = Depends(get_store),
lang: str = Query(default=""),
) -> JSONResponse:
"""Return a single published post by slug, optionally filtered by language."""
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"})
payload = await run_in_threadpool(post.detail)
return _cors_response(payload)
# ---------------------------------------------------------------------------
# Tag cloud
# ---------------------------------------------------------------------------
@router.get("/tags")
async def api_tags(store: Store = Depends(get_store)) -> JSONResponse:
"""Return a tag cloud with post counts."""
payload = await run_in_threadpool(tag_counts, store)
return _cors_response({"tags": payload})
# ---------------------------------------------------------------------------
# RSS feed
# ---------------------------------------------------------------------------
@router.get("/feed.xml")
async def api_rss_feed(
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 = await run_in_threadpool(published_posts, store)
base = config.site.get("base_url", "").rstrip("/")
xml = await run_in_threadpool(render_rss_feed, posts, config.site, base)
return _add_cors_headers(Response(content=xml, media_type="application/rss+xml"))
# ---------------------------------------------------------------------------
# JSON Feed
# ---------------------------------------------------------------------------
@router.get("/feed.json")
async def api_json_feed(
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 = await run_in_threadpool(published_posts, store)
base = config.site.get("base_url", "").rstrip("/")
data = await run_in_threadpool(render_json_feed, posts, config.site, base)
return _cors_response(data)
# ---------------------------------------------------------------------------
# Sitemap
# ---------------------------------------------------------------------------
@router.get("/sitemap.xml")
async def api_sitemap(
config: Config = Depends(get_config),
store: Store = Depends(get_store),
) -> Response:
"""Return an XML sitemap for all published posts."""
posts = await run_in_threadpool(published_posts, store)
base = config.site.get("base_url", "").rstrip("/")
xml = await run_in_threadpool(render_sitemap, posts, base)
return _add_cors_headers(Response(content=xml, media_type="application/xml"))
+182
View File
@@ -0,0 +1,182 @@
"""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}'",
f"style-src 'self' 'nonce-{nonce}'",
)
)
response.headers["Content-Security-Policy"] = csp
if self._cookie_secure:
response.headers.setdefault(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains",
)
return response
+159
View File
@@ -0,0 +1,159 @@
"""Authentication, CSRF, rate-limiting, and password-policy helpers."""
from __future__ import annotations
import secrets
import unicodedata
from typing import TYPE_CHECKING, Any
from fastapi import HTTPException, Request
from . import LOGIN_WINDOW, MAX_LOGIN_ATTEMPTS, record_login_attempt
if TYPE_CHECKING:
from .config import Config
from .store import Store
from .users import Users
COMMON_PASSWORDS: frozenset[str] = frozenset(
{
"password",
"password1",
"password123",
"123456",
"12345678",
"123456789",
"qwerty",
"qwerty123",
"letmein",
"iloveyou",
"admin",
"admin123",
"welcome",
"welcome1",
"monkey",
"dragon",
"football",
"baseball",
"sunshine",
"princess",
"abc123",
"111111",
"123123",
"1q2w3e4r",
"passw0rd",
"trustno1",
"changeme",
"secret",
"secret123",
"test",
"test123",
"guest",
"master",
"000000",
"696969",
"qwertyuiop",
"superman",
"batman",
"jordan",
"harley",
"hunter",
"hunter2",
"shadow",
"michael",
"jennifer",
"abcdef",
"abcdefg",
}
)
def csrf_token(request: Request) -> str:
"""Return (and lazily create) the CSRF token stored in the session."""
token = request.session.get("csrf", "")
if not token:
token = secrets.token_hex(32)
request.session["csrf"] = token
return token
async def validate_csrf_form(request: Request) -> None:
"""Perform a constant-time CSRF check against POST form data."""
form = await request.form()
token = form.get("_csrf", "")
session_token = request.session.get("csrf", "")
if not token or not session_token:
raise HTTPException(status_code=403, detail="Invalid CSRF token")
if not secrets.compare_digest(str(session_token), str(token)):
raise HTTPException(status_code=403, detail="Invalid CSRF token")
def check_login_rate_limit(request: Request) -> None:
"""Rate-limit login attempts per IP — raises 429 on excess."""
# Extract real client IP, accounting for reverse proxy.
config = request.app.state.config
ip: str = ""
if config.trust_proxy:
forwarded = request.headers.get("X-Forwarded-For", "")
ip = forwarded.split(",")[0].strip() if forwarded else ""
if not ip and request.client:
ip = request.client.host
if not ip:
ip = "unknown"
count = record_login_attempt(ip)
if count > MAX_LOGIN_ATTEMPTS:
raise HTTPException(
status_code=429,
detail="Too many login attempts. Please wait and try again.",
)
_ = LOGIN_WINDOW
def admin_context(
request: Request,
config: Config,
store: Store,
users_obj: Users,
**extra: Any,
) -> dict[str, Any]:
"""Build the standard Jinja2 template context for admin routes."""
username: str = request.session.get("user", "")
record = users_obj.find(username) if username else None
csrf_val = csrf_token(request)
nonce = getattr(request.state, "csp_nonce", "")
ctx: dict[str, Any] = {
"request": request,
"config": config,
"store": store,
"users": users_obj,
"csrf_token": csrf_val,
"current_user": username or None,
"current_role": record.role if record else None,
"current_user_record": record,
"csp_nonce": nonce,
}
ctx.update(extra)
return ctx
def password_error(
password: str,
*,
min_length: int | None = None,
max_length: int | None = None,
) -> str | None:
"""Validate a newly chosen password; return an error message or ``None``."""
from .config import DEFAULT_MAX_PASSWORD_LENGTH, DEFAULT_MIN_PASSWORD_LENGTH
min_len = min_length if min_length is not None else DEFAULT_MIN_PASSWORD_LENGTH
max_len = max_length if max_length is not None else DEFAULT_MAX_PASSWORD_LENGTH
if not password or not password.strip():
return "New password cannot be empty."
if len(password) < min_len:
return f"Password must be at least {min_len} characters long."
if len(password) > max_len:
return f"Password must be at most {max_len} characters long."
normalized = unicodedata.normalize("NFKC", password)
if normalized.lower() in COMMON_PASSWORDS:
return "This password is too common."
return None
+451
View File
@@ -0,0 +1,451 @@
"""CLI entry point for volumen."""
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(
prog="volumen", description="A small, file-based Markdown blog engine."
)
parser.add_argument("--version", action="version", version=f"volumen {VERSION}")
sub = parser.add_subparsers(dest="command")
serve_parser = sub.add_parser("serve", help="Start the blog server")
serve_parser.add_argument("--config", default="/etc/volumen/config.toml")
serve_parser.add_argument("--host", default=None)
serve_parser.add_argument("--port", type=int, default=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,
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")
args = parser.parse_args()
if args.command == "version":
print(VERSION)
elif args.command == "hash-password":
_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:
parser.print_help()
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 .store import Store
overrides: dict[str, object] = {}
if args.host:
overrides["host"] = args.host
if args.port:
overrides["port"] = args.port
if args.content:
overrides["content"] = args.content
config = Config.load(path=args.config, overrides=overrides)
store = Store(config.content_dir, default_lang=config.language)
app = create_app(config, store)
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
+321
View File
@@ -0,0 +1,321 @@
"""Config layer: loads TOML config, applies overrides, deep-merges defaults."""
from __future__ import annotations
import copy
import logging
import os
import tomllib
from typing import Any
from urllib.parse import urlparse
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": 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.",
"base_url": "https://example.com",
"language": "en",
"author": "Anonymous",
"fediverse_creator": None,
},
"admin": {
"password_hash": "",
"session_key": "",
"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,
},
}
TEMPLATE = """\
# volumen configuration.
[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"
# File storing admin users (managed from the admin Settings page).
users_file = "/var/lib/volumen/users.toml"
[site]
title = "My Blog"
description = "A blog powered by volumen."
base_url = "https://example.com"
language = "en"
author = "Anonymous"
# Default fediverse handle surfaced in <meta name="fediverse:creator">
# by frontends consuming the API. Leave empty to disable.
fediverse_creator = ""
[admin]
# 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
"""
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, base_val in base.items():
if key in override:
result[key] = _merge_leaf(base_val, override[key])
elif isinstance(base_val, dict):
result[key] = dict(base_val)
else:
result[key] = base_val
for key, val in override.items():
if key not in result:
result[key] = val
return result
def _merge_leaf(base_val: Any, override_val: Any) -> Any:
if isinstance(base_val, dict) and isinstance(override_val, dict):
return _deep_merge(base_val, override_val)
return override_val
def _apply_overrides(data: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
server = dict(data["server"])
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 "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."""
def __init__(self, data: dict[str, Any]) -> None:
self._data = data
@classmethod
def load(cls, path: str = DEFAULT_PATH, overrides: dict[str, Any] | None = None) -> Config:
overrides = overrides or {}
if os.path.isfile(path):
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
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"]
@property
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"]
@property
def users_file(self) -> str:
return self._data["users_file"]
@property
def site(self) -> dict[str, Any]:
return self._data["site"]
@property
def language(self) -> str:
return self._data["site"]["language"]
@property
def admin(self) -> dict[str, Any]:
return self._data["admin"]
@property
def min_password_length(self) -> int:
return int(self._data["admin"].get("min_password_length", DEFAULT_MIN_PASSWORD_LENGTH))
@property
def max_password_length(self) -> int:
return int(self._data["admin"].get("max_password_length", DEFAULT_MAX_PASSWORD_LENGTH))
@property
def max_upload_bytes(self) -> int:
return int(self._data["admin"].get("max_upload_bytes", DEFAULT_MAX_UPLOAD_BYTES))
@property
def session_ttl(self) -> int:
return int(self._data["admin"].get("session_ttl", DEFAULT_SESSION_TTL))
@property
def session_key(self) -> str:
return str(self._data["admin"].get("session_key", ""))
+61
View File
@@ -0,0 +1,61 @@
"""FastAPI dependency callables shared by application routers."""
from __future__ import annotations
from typing import TYPE_CHECKING
from fastapi import HTTPException, Request
if TYPE_CHECKING:
from .config import Config
from .store import Store
from .users import Users
def get_config(request: Request) -> Config:
"""Return the :class:`Config` from app state."""
return request.app.state.config
def get_store(request: Request) -> Store:
"""Return the :class:`Store` from app state."""
return request.app.state.store
def get_users(request: Request) -> Users:
"""Return the shared :class:`Users` instance from app state."""
return request.app.state.users
def get_current_user(request: Request) -> str | None:
"""Return the logged-in username from the session or ``None``."""
user = request.session.get("user", "")
return user if user else None
def require_login(request: Request) -> str:
"""Enforce login — redirects to ``/admin/login`` otherwise."""
username = request.session.get("user", "")
if not username:
raise _login_redirect()
users_obj: Users = request.app.state.users
if users_obj.find(username) is None:
request.session.clear()
raise _login_redirect()
return username
def _login_redirect() -> HTTPException:
exc = HTTPException(status_code=303)
exc.headers = {"Location": "/admin/login"} # type: ignore[assignment]
return exc
def require_admin(request: Request) -> str:
"""Enforce admin role — raises 403 otherwise."""
username = require_login(request)
users_obj: Users = request.app.state.users
record = users_obj.find(username)
if record is None or record.role != "admin":
raise HTTPException(status_code=403, detail="Forbidden")
return username
+145
View File
@@ -0,0 +1,145 @@
"""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, 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[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[:FEED_ITEM_LIMIT])
title = html.escape(site.get("title", ""))
link = html.escape(base_url)
description = html.escape(site.get("description", ""))
language = html.escape(site.get("language", "en"))
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/">\n'
" <channel>\n"
f" <title>{title}</title>\n"
f" <link>{link}</link>\n"
f" <description>{description}</description>\n"
f" <language>{language}</language>\n"
f"{items}\n"
" </channel>\n"
"</rss>"
)
def _rss_item(post: Post, base_url: str) -> str:
"""Render a single RSS <item> element."""
permalink = f"{base_url}/{post.slug}"
pub_date = ""
post_date = post.date_string
if post_date:
pub_date = f"\n <pubDate>{html.escape(_rfc822_date(post_date))}</pubDate>"
creator = post.fediverse_creator
creator_tag = ""
if creator:
creator_tag = f"\n <dc:creator>{html.escape(creator)}</dc:creator>"
return (
f" <item>\n"
f" <title>{html.escape(post.title or '')}</title>\n"
f" <link>{html.escape(permalink)}</link>\n"
f" <guid>{html.escape(permalink)}</guid>\n"
f" <description>{html.escape(post.excerpt or '')}</description>"
f"{pub_date}{creator_tag}\n"
f" </item>"
)
def _rfc822_date(value: date | str) -> str:
"""Format a date or date-string as an RFC 822 / RFC 2822 timestamp."""
if isinstance(value, date):
dt = datetime.combine(value, datetime.min.time(), tzinfo=UTC)
return format_datetime(dt, usegmt=True)
try:
dt = datetime.strptime(str(value), "%Y-%m-%d").replace(tzinfo=UTC)
return format_datetime(dt, usegmt=True)
except ValueError:
return str(value)
def _iso_date(value: date | str | None) -> str | None:
"""Format a date or date-string as ISO 8601 (YYYY-MM-DD)."""
if value is None:
return None
if isinstance(value, date):
return value.isoformat()
try:
datetime.strptime(str(value), "%Y-%m-%d")
return str(value)
except ValueError:
return str(value) if value else None
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[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[: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[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[str, Any] = {
"id": permalink,
"url": permalink,
"title": post.title,
"content_html": _sanitize(post.html),
"summary": post.excerpt,
"date_published": _iso_date(post.date_string),
"tags": post.tags,
}
author = post.fediverse_creator or site.get("fediverse_creator")
if author:
item["authors"] = [{"name": author}]
return {k: v for k, v in item.items() if v not in (None, "", [])}
def render_sitemap(posts: list[Post], base_url: str) -> str:
"""Render an XML sitemap for the given posts."""
urls: list[str] = []
for post in posts:
loc = html.escape(f"{base_url}/{post.slug}")
entry = f" <url><loc>{loc}</loc>"
if post.date_string:
entry += f"<lastmod>{html.escape(post.date_string)}</lastmod>"
entry += "</url>"
urls.append(entry)
url_block = "\n".join(urls)
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
f"{url_block}\n"
"</urlset>"
)
+62
View File
@@ -0,0 +1,62 @@
"""TOML frontmatter parser for Markdown post files."""
from __future__ import annotations
import tomllib
import tomli_w
DELIMITER = "+++"
def parse(content: str) -> tuple[dict[str, object], str]:
"""Parse TOML frontmatter and body from a post string.
Returns (metadata_dict, body_string). If there is no frontmatter,
returns an empty dict and the full content as body.
"""
text = content.replace("\r\n", "\n")
lines = text.split("\n")
if not lines or not _is_delimiter(lines[0]):
return {}, text
closing = _closing_index(lines)
if closing is None:
return {}, text
toml_text = "\n".join(lines[1:closing])
body = "\n".join(lines[closing + 1 :])
metadata: dict[str, object] = {}
if toml_text.strip():
metadata = tomllib.loads(toml_text)
if body.startswith("\n"):
body = body[1:]
return metadata, body
def dump(metadata: dict[str, object], body: str) -> str:
"""Serialise metadata + body back into a post file string."""
toml_text = tomli_w.dumps(metadata) if metadata else ""
parts = [DELIMITER, "\n"]
if toml_text:
parts.append(toml_text)
if not toml_text.endswith("\n"):
parts.append("\n")
parts.append(DELIMITER)
parts.append("\n\n")
parts.append(body.lstrip())
if not parts[-1].endswith("\n"):
parts.append("\n")
return "".join(parts)
def _is_delimiter(line: str) -> bool:
return line.strip() == DELIMITER
def _closing_index(lines: list[str]) -> int | None:
for i in range(1, len(lines)):
if _is_delimiter(lines[i]):
return i
return None
+594
View File
@@ -0,0 +1,594 @@
"""First-run bootstrap for volumen (system or per-user installation).
This module replaces the legacy ``install.sh`` script. Instead of a shell
installer, the operational bootstrap is performed by the ``volumen init``
Python subcommand. The bootstrap can be:
* run as root for a system-wide install (config under ``/etc/volumen/``,
data under ``/var/lib/volumen/``, a hardened systemd unit); or
* run as a non-root user for a per-user install (config under
``~/.config/volumen/``, data under ``~/.local/share/volumen/``).
The ``volumen`` CLI binary itself is installed separately via
``uv tool install volumen`` or ``pipx install volumen``; this module only
handles the *operational* bootstrap: config, data directories, admin
password hash, session key, and (optionally) the systemd unit file.
Every public function in this module is safe to import from tests.
Mutating side effects (writing files, calling ``useradd`` / ``systemctl``)
only fire from inside the high-level ``system_install`` / ``user_install``
entry points.
"""
from __future__ import annotations
import logging
import os
try:
import grp
import pwd
except ImportError:
grp = None # type: ignore[assignment]
pwd = None # type: ignore[assignment]
import re
import secrets
import shutil
import subprocess
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from .config import TEMPLATE
logger = logging.getLogger(__name__)
# --- Paths -------------------------------------------------------------------
SYSTEM_CONFIG_DIR = Path("/etc/volumen")
SYSTEM_CONFIG_PATH = SYSTEM_CONFIG_DIR / "config.toml"
SYSTEM_DATA_DIR = Path("/var/lib/volumen")
SYSTEM_CONTENT_DIR = SYSTEM_DATA_DIR / "posts"
SYSTEM_USERS_FILE = SYSTEM_DATA_DIR / "users.toml"
SYSTEMD_UNIT_PATH = Path("/etc/systemd/system/volumen.service")
USER_CONFIG_DIR = Path("~/.config/volumen")
USER_DATA_DIR = Path("~/.local/share/volumen")
USER_CONFIG_PATH = USER_CONFIG_DIR / "config.toml"
USER_CONTENT_DIR = USER_DATA_DIR / "posts"
USER_USERS_FILE = USER_DATA_DIR / "users.toml"
DEFAULT_SERVICE_USER = "volumen"
DEFAULT_SERVICE_HOME = Path("/var/lib/volumen")
# --- systemd unit template ---------------------------------------------------
SYSTEMD_UNIT_TEMPLATE = """\
[Unit]
Description=volumen blog engine
After=network.target
[Service]
Type=simple
User={service_user}
Group={service_user}
WorkingDirectory={working_dir}
Environment=PYTHONUNBUFFERED=1
ExecStart={volumen_bin} serve --config {config_path} --content {content_dir}
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths={content_dir}
PrivateTmp=true
[Install]
WantedBy=multi-user.target
"""
class InstallerError(RuntimeError):
"""Raised when the bootstrap cannot complete (root required, missing binary, etc.)."""
@dataclass
class InstallResult:
"""Structured result of an install bootstrap.
``messages`` carries a human-readable list of actions performed so that
``volumen status`` can echo them.
"""
config_path: Path
content_dir: Path
users_file: Path
service_user: str | None
password_hash: str
session_key: str
systemd_unit_path: Path | None = None
messages: list[str] = field(default_factory=list)
# --- helpers ----------------------------------------------------------------
def _is_root() -> bool:
"""Return True iff the current process holds uid 0 (privileged)."""
try:
return os.geteuid() == 0
except AttributeError: # pragma: no cover — non-POSIX platforms
return False
def _ensure_user(name: str, home: Path | None = None) -> bool:
"""Create system user ``name`` if missing.
Returns ``True`` if the user was created, ``False`` if it already
existed or creation was skipped (because not running as root).
"""
if pwd is not None:
try:
pwd.getpwnam(name)
logger.debug("user %s already exists", name)
return False
except KeyError:
pass
if not _is_root():
logger.debug("not root: skipping useradd %s", name)
return False
if home is None:
home = DEFAULT_SERVICE_HOME
home = Path(home)
home.mkdir(parents=True, exist_ok=True)
subprocess.run(
[
"useradd",
"--system",
"--home",
str(home),
"--shell",
"/usr/sbin/nologin",
name,
],
check=True,
)
return True
def _chown(path: Path, user: str) -> None:
"""chown ``path`` to ``user:user``.
Silently no-ops on non-POSIX platforms or when not running as root so
the function is safe to call from unprivileged test runs.
"""
if not _is_root():
logger.debug("chown %s %s:%s skipped (not root)", path, user, user)
return
if grp is None or pwd is None:
logger.debug("chown %s %s:%s skipped (no grp/pwd modules)", path, user, user)
return
try:
uid = pwd.getpwnam(user).pw_uid
gid = grp.getgrnam(user).gr_gid
except KeyError as exc:
logger.warning("user/group %s missing: %s", user, exc)
return
try:
os.chown(path, uid, gid)
except OSError as exc:
logger.warning("chown %s %s:%s failed: %s", path, user, user, exc)
def _chown_r(path: Path, user: str) -> None:
"""Recursive chown; skips off-tree entries and follows no symlinks."""
if not _is_root():
return
if path.is_dir() and not path.is_symlink():
for child in path.iterdir():
_chown_r(child, user)
_chown(path, user)
def _chmod(path: Path, mode: int) -> None:
"""chmod wrapper that no-ops when not running as root."""
if not _is_root():
return
try:
os.chmod(path, mode)
except OSError as exc:
logger.warning("chmod %s %o failed: %s", path, mode, exc)
def _mutate_config_text(
text: str,
*,
host: str | None = None,
env: str | None = None,
trust_proxy: bool | None = None,
cookie_secure: bool | None = None,
content_dir: str | None = None,
users_file: str | None = None,
password_hash: str | None = None,
session_key: str | None = None,
) -> str:
"""Apply targeted substitutions to a rendered TOML template.
Each parameter, when not ``None``, rewrites the corresponding line in
``text``. The substitution is anchored at the start of the line, so
comment blocks and unrelated lines are left untouched. Comment blocks
above each key are preserved byte-for-byte.
"""
def _sub(key: str, value: str) -> str:
return re.sub(
rf"^{re.escape(key)}\s*=.*$",
f'{key} = "{value}"',
text,
flags=re.MULTILINE,
count=1,
)
def _sub_bool(key: str, value: bool) -> str:
return re.sub(
rf"^{re.escape(key)}\s*=.*$",
f"{key} = {str(value).lower()}",
text,
flags=re.MULTILINE,
count=1,
)
if host is not None:
text = _sub("host", host)
if env is not None:
text = _sub("env", env)
if trust_proxy is not None:
text = _sub_bool("trust_proxy", trust_proxy)
if cookie_secure is not None:
text = _sub_bool("cookie_secure", cookie_secure)
if content_dir is not None:
text = _sub("content_dir", content_dir)
if users_file is not None:
text = _sub("users_file", users_file)
if password_hash is not None:
text = text.replace('password_hash = ""', f'password_hash = "{password_hash}"', 1)
if session_key is not None:
text = text.replace('session_key = ""', f'session_key = "{session_key}"', 1)
return text
def _backup_path(target: Path) -> Path:
ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
return target.with_name(f"{target.name}.bak.{ts}.toml")
def _systemctl_is_active(unit: str) -> str:
"""Best-effort ``systemctl is-active`` query; "unknown" on failure."""
if shutil.which("systemctl") is None:
return "unknown"
try:
proc = subprocess.run(
["systemctl", "is-active", unit],
capture_output=True,
text=True,
check=False,
timeout=2,
)
out = proc.stdout.strip()
return out or "unknown"
except (OSError, subprocess.SubprocessError) as exc:
logger.debug("systemctl is-active failed: %s", exc)
return "unknown"
# --- public API -------------------------------------------------------------
def system_install(
*,
config_path: Path = SYSTEM_CONFIG_PATH,
content_dir: Path = SYSTEM_CONTENT_DIR,
users_file: Path = SYSTEM_USERS_FILE,
service_user: str = DEFAULT_SERVICE_USER,
install_service: bool = False,
enable_service: bool = True,
admin_password: str | None = None,
config_template: str = TEMPLATE,
force: bool = False,
) -> InstallResult:
"""Bootstrap a system-wide install (typically as root).
See the module docstring for the full behavioural contract.
"""
if admin_password is None:
raise InstallerError(
"admin_password must be provided; prompt the user (CLI does this interactively)"
)
config_path = Path(config_path)
content_dir = Path(content_dir)
users_file = Path(users_file)
messages: list[str] = []
if config_path.exists():
if not force:
logger.info("config %s already exists; leaving untouched", config_path)
messages.append("config exists, leaving untouched (use --force to overwrite)")
return InstallResult(
config_path=config_path,
content_dir=content_dir,
users_file=users_file,
service_user=service_user,
password_hash="",
session_key="",
systemd_unit_path=None,
messages=messages,
)
backup = _backup_path(config_path)
backup.write_text(config_path.read_text(encoding="utf-8"), encoding="utf-8")
messages.append(f"backed up existing config to {backup}")
# Directories.
config_path.parent.mkdir(parents=True, exist_ok=True)
users_file.parent.mkdir(parents=True, exist_ok=True)
content_dir.mkdir(parents=True, exist_ok=True)
(content_dir / "media").mkdir(exist_ok=True)
# Service user.
if _ensure_user(service_user):
messages.append(f"created system user {service_user}")
else:
messages.append(f"system user {service_user} already present (or creation skipped)")
# Hashes / secrets.
from .password import hash_password
password_hash = hash_password(admin_password)
session_key = secrets.token_hex(64)
rendered = _mutate_config_text(
config_template,
host="::1",
env="production",
trust_proxy=True,
cookie_secure=True,
content_dir=str(content_dir),
users_file=str(users_file),
password_hash=password_hash,
session_key=session_key,
)
config_path.write_text(rendered, encoding="utf-8")
messages.append(f"wrote config to {config_path}")
# Make sure users_file exists before chown.
users_file.touch(exist_ok=True)
# Permissions.
_chown_r(content_dir, service_user)
_chown(users_file.parent, service_user)
_chown(users_file, service_user)
_chown(config_path, service_user)
_chmod(config_path, 0o600)
# Systemd unit (optional).
systemd_unit_path: Path | None = None
if install_service:
if not _is_root():
raise InstallerError("install_service=True requires running as root")
bin_path = shutil.which("volumen")
if bin_path is None:
raise InstallerError(
"volumen binary not found on PATH; install it first "
"(`uv tool install volumen` or `pipx install volumen`)"
)
systemd_unit_path = SYSTEMD_UNIT_PATH
systemd_unit_path.parent.mkdir(parents=True, exist_ok=True)
unit = SYSTEMD_UNIT_TEMPLATE.format(
service_user=service_user,
working_dir=str(config_path.parent),
volumen_bin=bin_path,
config_path=str(config_path),
content_dir=str(content_dir),
)
systemd_unit_path.write_text(unit, encoding="utf-8")
_chmod(systemd_unit_path, 0o644)
subprocess.run(["systemctl", "daemon-reload"], check=True)
if enable_service:
subprocess.run(["systemctl", "enable", "--now", "volumen"], check=True)
messages.append("systemd unit installed, enabled, and started")
else:
messages.append("systemd unit installed (not enabled)")
return InstallResult(
config_path=config_path,
content_dir=content_dir,
users_file=users_file,
service_user=service_user,
password_hash=password_hash,
session_key=session_key,
systemd_unit_path=systemd_unit_path,
messages=messages,
)
def user_install(
*,
config_path: Path = USER_CONFIG_PATH,
content_dir: Path = USER_CONTENT_DIR,
users_file: Path = USER_USERS_FILE,
admin_password: str | None = None,
config_template: str = TEMPLATE,
force: bool = False,
install_service: bool = False,
) -> InstallResult:
"""Bootstrap a per-user install (never touches system users or systemd)."""
if install_service:
raise InstallerError(
"--systemd is not available for per-user installs; run as root, without --local"
)
if admin_password is None:
raise InstallerError(
"admin_password must be provided; prompt the user (CLI does this interactively)"
)
config_path = Path(config_path).expanduser()
content_dir = Path(content_dir).expanduser()
users_file = Path(users_file).expanduser()
messages: list[str] = []
if config_path.exists():
if not force:
logger.info("config %s already exists; leaving untouched", config_path)
messages.append("config exists, leaving untouched (use --force to overwrite)")
return InstallResult(
config_path=config_path,
content_dir=content_dir,
users_file=users_file,
service_user=None,
password_hash="",
session_key="",
systemd_unit_path=None,
messages=messages,
)
backup = _backup_path(config_path)
backup.write_text(config_path.read_text(encoding="utf-8"), encoding="utf-8")
messages.append(f"backed up existing config to {backup}")
config_path.parent.mkdir(parents=True, exist_ok=True)
users_file.parent.mkdir(parents=True, exist_ok=True)
content_dir.mkdir(parents=True, exist_ok=True)
(content_dir / "media").mkdir(exist_ok=True)
from .password import hash_password
password_hash = hash_password(admin_password)
session_key = secrets.token_hex(64)
rendered = _mutate_config_text(
config_template,
host="::1",
env="development",
trust_proxy=False,
cookie_secure=False,
content_dir=str(content_dir),
users_file=str(users_file),
password_hash=password_hash,
session_key=session_key,
)
config_path.write_text(rendered, encoding="utf-8")
messages.append(f"wrote config to {config_path}")
users_file.touch(exist_ok=True)
messages.append("per-user install complete; no systemd unit installed")
return InstallResult(
config_path=config_path,
content_dir=content_dir,
users_file=users_file,
service_user=None,
password_hash=password_hash,
session_key=session_key,
systemd_unit_path=None,
messages=messages,
)
def inspect(
config_path: Path = SYSTEM_CONFIG_PATH,
content_dir: Path = SYSTEM_CONTENT_DIR,
users_file: Path = SYSTEM_USERS_FILE,
systemd_unit_path: Path = SYSTEMD_UNIT_PATH,
) -> dict[str, object]:
"""Return a JSON-serialisable snapshot of an installation.
The shape matches what ``volumen status`` prints; safe to call without
root and without mutating anything on disk.
"""
config_path = Path(config_path)
content_dir = Path(content_dir)
users_file = Path(users_file)
systemd_unit_path = Path(systemd_unit_path)
issues: list[str] = []
config_exists = config_path.is_file()
data_dir_exists = content_dir.parent.is_dir()
posts_subdir_exists = content_dir.is_dir()
media_subdir_exists = (content_dir / "media").is_dir()
users_file_exists = users_file.is_file()
password_hash_set = False
session_key_ok = False
if config_exists:
try:
import tomllib
with open(config_path, "rb") as fh:
data = tomllib.load(fh)
admin = data.get("admin", {})
pw_hash = str(admin.get("password_hash", ""))
session_key = str(admin.get("session_key", ""))
password_hash_set = bool(pw_hash)
session_key_ok = len(session_key) >= 64
if not password_hash_set:
issues.append("admin password_hash is empty")
if not session_key_ok:
issues.append("admin session_key is missing or shorter than 64 bytes")
except OSError:
issues.append("config not readable")
except Exception as exc: # tomllib.TOMLDecodeError or similar
issues.append(f"config not parseable: {exc}")
else:
issues.append("config not found")
if not data_dir_exists:
issues.append("data dir not found")
if data_dir_exists and not posts_subdir_exists:
issues.append("posts subdir not found")
if data_dir_exists and not media_subdir_exists:
issues.append("media subdir not found")
if not users_file_exists:
issues.append("users file not found")
systemd_unit_installed = systemd_unit_path.is_file()
service_active = _systemctl_is_active("volumen")
admin_user_present = False
if users_file_exists:
try:
import tomllib
with open(users_file, "rb") as fh:
data = tomllib.load(fh)
admin_user_present = any(
entry.get("username") == "admin" for entry in data.get("users", [])
)
if not admin_user_present:
issues.append("admin user not present in users file")
except OSError:
issues.append("users file not readable")
except Exception as exc:
issues.append(f"users file not parseable: {exc}")
if systemd_unit_installed and service_active not in ("active", "unknown"):
issues.append(f"systemd unit installed but service is {service_active}")
return {
"config_path": str(config_path),
"data_dir": str(content_dir.parent),
"users_file": str(users_file),
"config_exists": config_exists,
"data_dir_exists": data_dir_exists,
"posts_subdir_exists": posts_subdir_exists,
"media_subdir_exists": media_subdir_exists,
"users_file_exists": users_file_exists,
"password_hash_set": password_hash_set,
"session_key_ok": session_key_ok,
"systemd_unit_installed": systemd_unit_installed,
"service_active": service_active,
"admin_user_present": admin_user_present,
"issues": issues,
}
+163
View File
@@ -0,0 +1,163 @@
"""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
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, 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>.
Operates on the raw (pre-sanitisation) output so the title attribute
is available before :func:`_sanitize` strips it.
Uses regex matching instead of an XML parser to safely handle
HTML5 void elements (``<br>``, ``<hr>``, etc.) that would cause
``ET.fromstring`` to fail.
"""
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>"
)
return _FIGURE_RE.sub(_replace, html)
+123
View File
@@ -0,0 +1,123 @@
"""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 hmac
import logging
import secrets
logger = logging.getLogger(__name__)
COST_N = 16384
BLOCK_R = 8
PARALLEL_P = 1
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."""
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,
)
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 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:
derived = hashlib.scrypt(
password.encode("utf-8"),
salt=salt,
n=n,
r=r,
p=p,
dklen=len(expected),
)
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 needs_rehash(stored: str) -> bool:
"""Return True if ``stored`` should be re-hashed on next successful login.
Triggers when the hash uses weaker parameters than the current policy
floor or when the hash version prefix is unrecognised.
"""
parsed = _parts(stored)
if parsed is None:
return True
n, r, p, _salt, expected = parsed
return not (n >= MIN_N and r >= MIN_R and p >= MIN_P and len(expected) >= MIN_DKLEN)
+171
View File
@@ -0,0 +1,171 @@
"""Payload construction, filtering, parsing, and post validation helpers."""
from __future__ import annotations
import re
from datetime import date
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .config import Config
from .post import Post
from .store import Store
SLUG_REGEX: re.Pattern[str] = re.compile(r"\A[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\Z")
MAX_SLUG_LENGTH: int = 200
def site_payload(config: Config) -> dict[str, Any]:
"""Build the ``/api/volumen/site`` payload."""
keys = ("title", "description", "base_url", "language", "author", "fediverse_creator")
return {key: config.site.get(key) for key in keys}
def published_posts(store: Store) -> list[Post]:
"""Return all published, non-scheduled posts, newest first."""
all_posts = store.all()
visible = [post for post in all_posts if not post.draft and not post.scheduled]
visible.sort(key=lambda post: post.date_string or "", reverse=True)
return visible
def filtered_posts(store: Store, params: dict[str, str]) -> list[Post]:
"""Filter published posts by ``lang``, ``tag``, and ``q``."""
return _filter_posts(published_posts(store), params)
def _filter_posts(posts: list[Post], params: dict[str, str]) -> list[Post]:
lang = params.get("lang", "")
if lang:
posts = [post for post in posts if post.lang == lang or post.all_langs]
tag = params.get("tag", "")
if tag:
posts = [post for post in posts if tag in post.tags]
query = params.get("q", "").strip().lower()
if query:
posts = [
post
for post in posts
if query in (post.title or "").lower() or query in (post.body or "").lower()
]
return posts
def posts_payload(
store: Store, params: dict[str, str], page: int = 1, limit: int = 20
) -> dict[str, Any]:
"""Build the paginated payload for ``/api/volumen/posts``."""
posts = filtered_posts(store, params)
page = max(page, 1)
limit = min(max(limit, 1), 100)
offset = (page - 1) * limit
total = len(posts)
return {
"page": page,
"page_size": limit,
"total": total,
"has_next": offset + limit < total,
"has_prev": page > 1,
"posts": [post.summary() for post in posts[offset : offset + limit]],
}
def tag_counts(store: Store) -> list[dict[str, Any]]:
"""Build tag-cloud data sorted by frequency and then name."""
counts: dict[str, int] = {}
for post in published_posts(store):
for tag in post.tags:
counts[tag] = counts.get(tag, 0) + 1
sorted_counts = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
return [{"name": name, "count": count} for name, count in sorted_counts]
def presence(value: Any) -> str | None:
"""Return a stripped string or ``None`` if empty."""
text = str(value).strip() if value is not None else ""
return text if text else None
def parse_date(value: Any) -> date | None:
"""Parse an ISO 8601 date string, returning ``None`` on failure."""
text = presence(value)
if text is None:
return None
try:
return date.fromisoformat(text)
except ValueError:
return None
def parse_tags(value: Any) -> list[str]:
"""Parse a comma-separated tag string."""
raw = presence(value)
if raw is None:
return []
return [tag.strip() for tag in raw.split(",") if tag.strip()]
def post_from_params(form_data: dict[str, str], existing: Post | None = None) -> Post:
"""Build a :class:`Post` from HTTP form data, carrying *existing*.path."""
from .post import Post as _Post
metadata = _base_metadata(form_data, existing)
cleaned = _clean_metadata(metadata)
post = _Post(metadata=cleaned, body=form_data.get("body", ""))
if existing is not None:
post.path = existing.path
return post
def _base_metadata(form_data: dict[str, str], existing: Post | None = None) -> dict[str, Any]:
return {
"title": presence(form_data.get("title")),
"slug": presence(form_data.get("slug")) or (existing.slug if existing else None),
"lang": presence(form_data.get("lang")),
"author": presence(form_data.get("author")),
"fediverse_creator": presence(form_data.get("fediverse_creator")),
"date": parse_date(form_data.get("date")),
"publish_at": parse_date(form_data.get("publish_at")),
"tags": parse_tags(form_data.get("tags")),
"excerpt": presence(form_data.get("excerpt")),
"cover": presence(form_data.get("cover")),
"cover_alt": presence(form_data.get("cover_alt")),
"cover_caption": presence(form_data.get("cover_caption")),
"draft": form_data.get("draft") == "on",
"all_langs": form_data.get("all_langs") == "on",
}
def _clean_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
"""Drop nil, empty, and false entries."""
return {
key: value
for key, value in metadata.items()
if value is not None and value != "" and value != [] and value is not False
}
def creation_error(post: Post, store: Store, existing: Post | None = None) -> str | None:
"""Validate a post before saving, returning an error or ``None``."""
if presence(post.slug) is None:
return "Slug is required."
if len(post.slug or "") > MAX_SLUG_LENGTH:
return f"Slug must be at most {MAX_SLUG_LENGTH} characters."
if not SLUG_REGEX.match(post.slug or ""):
return "Invalid slug."
found = store.find(post.slug)
if found is not None and (existing is None or found.path != existing.path):
return "A post with that slug already exists."
return fediverse_creator_error(post)
def fediverse_creator_error(post: Post) -> str | None:
"""Validate the optional fediverse creator field."""
from .post import FEDIVERSE_CREATOR_REGEX
value = post.metadata.get("fediverse_creator") if hasattr(post, "metadata") else None
if value is None:
return None
if re.match(FEDIVERSE_CREATOR_REGEX, str(value)):
return None
return "Fediverse creator must look like @user@host."
+177
View File
@@ -0,0 +1,177 @@
"""A single blog post: TOML frontmatter metadata plus a Markdown body."""
from __future__ import annotations
import re
from datetime import date, datetime
from typing import Any
from .frontmatter import dump as fm_dump
from .frontmatter import parse as fm_parse
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."""
def __init__(
self, metadata: dict[str, Any] | None = None, body: str = "", path: str | None = None
) -> None:
self.metadata: dict[str, Any] = {str(k): v for k, v in (metadata or {}).items()}
self.body = body
self.path: str | None = path
@classmethod
def parse(cls, content: str) -> Post:
metadata, body = fm_parse(content)
return cls(metadata=metadata, body=body)
@property
def slug(self) -> str | None:
return self.metadata.get("slug")
@property
def title(self) -> str | None:
return self.metadata.get("title")
@property
def lang(self) -> str | None:
return self.metadata.get("lang")
@property
def author(self) -> str | None:
return self.metadata.get("author")
@property
def tags(self) -> list[str]:
raw = self.metadata.get("tags", [])
if isinstance(raw, list):
return [str(t) for t in raw]
return []
@property
def draft(self) -> bool:
return self.metadata.get("draft") is True
@property
def all_langs(self) -> bool:
return self.metadata.get("all_langs") is True
@property
def translations(self) -> dict[str, str]:
val = self.metadata.get("translations", {})
if isinstance(val, dict):
return {str(k): str(v) for k, v in val.items()}
return {}
@property
def cover(self) -> str | None:
return self.metadata.get("cover")
@property
def cover_alt(self) -> str | None:
return self.metadata.get("cover_alt")
@property
def cover_caption(self) -> str | None:
return self.metadata.get("cover_caption")
@property
def fediverse_creator(self) -> str | None:
value = self.metadata.get("fediverse_creator")
if value is None:
return None
text = str(value).strip()
return text if text else None
@property
def valid_fediverse_creator(self) -> bool:
fc = self.fediverse_creator
return fc is not None and bool(FEDIVERSE_CREATOR_REGEX.match(fc))
@property
def publish_at(self) -> date | None:
val = self.metadata.get("publish_at")
if val is None:
return None
if isinstance(val, (date, datetime)):
return val if isinstance(val, date) else val.date()
try:
return date.fromisoformat(str(val))
except ValueError, TypeError:
return None
@property
def scheduled(self) -> bool:
pub = self.publish_at
return pub is not None and pub > date.today()
@property
def reading_time(self) -> int:
words = len(self.body.split())
return max(1, (words + 199) // 200)
@property
def date_string(self) -> str | None:
value = self.metadata.get("date")
if value is None:
return None
if isinstance(value, (date, datetime)):
return value.strftime("%Y-%m-%d")
return str(value)
@property
def excerpt(self) -> str:
text = self.metadata.get("excerpt")
if text and str(text).strip():
return str(text)
return self._derived_excerpt()
@property
def html(self) -> str:
return md_render(self.body)
def to_file(self) -> str:
return fm_dump(self.metadata, self.body)
def summary(self) -> dict[str, Any]:
return {
"slug": self.slug,
"title": self.title,
"excerpt": self.excerpt,
"date": self.date_string,
"lang": self.lang,
"tags": self.tags,
"author": self.author,
"fediverse_creator": self.fediverse_creator,
"cover": self.cover,
"cover_alt": self.cover_alt,
"cover_caption": self.cover_caption,
"reading_time": self.reading_time,
"translations": self.translations,
"url": f"/api/volumen/posts/{self.slug}",
}
def detail(self) -> dict[str, Any]:
base = self.summary()
base["body"] = self.body
base["html"] = self.html
return base
def _derived_excerpt(self, limit: int = 200) -> str:
paragraphs = self.body.split("\n\n")
for para in paragraphs:
stripped = para.strip()
if not stripped or stripped.startswith("#"):
continue
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
return ""
+25
View File
@@ -0,0 +1,25 @@
"""Compatibility imports for the former monolithic server module."""
from __future__ import annotations
from typing import Any
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}")
+374
View File
@@ -0,0 +1,374 @@
"""Reads and writes Markdown posts and media in a content directory."""
from __future__ import annotations
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,
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._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]:
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._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)
# 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:
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 _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)
self._write_media_atomic(dest, source, bytes_data)
return f"/media/{name}"
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)
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
if not self._is_within(self.media_dir, path):
return None
return path
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _md_files(self) -> list[str]:
"""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 _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, encoding="utf-8") as fh:
post = Post.parse(fh.read())
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]
if not post.metadata.get("lang"):
post.metadata["lang"] = self._lang_from_path(path) or self._default_lang
post.path = path
return post
def _lang_from_path(self, path: str) -> str | None:
parent = os.path.dirname(os.path.abspath(path))
if parent == self.content_dir:
return None
return os.path.basename(parent)
def _default_path_for(self, post: Post) -> str:
directory = self.content_dir
if post.lang and post.lang != self._default_lang:
directory = os.path.join(directory, post.lang)
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 == content_abs or target_abs.startswith(content_abs + os.sep)):
raise ValueError("slug escapes content directory")
return target
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()
if ext in ALLOWED_IMAGE_EXTENSIONS:
return ext
return ".webp"
def _is_within(self, directory: str, path: str) -> bool:
"""Check that *path* is inside *directory* (canonicalised)."""
try:
dir_resolved = Path(directory).resolve()
path_resolved = Path(path).resolve()
return path_resolved == dir_resolved or path_resolved.is_relative_to(dir_resolved)
except OSError, RuntimeError:
return False
+86
View File
@@ -0,0 +1,86 @@
"""Validation helpers for uploaded media files."""
from __future__ import annotations
import json
from typing import Any
from fastapi import HTTPException, UploadFile
from starlette.concurrency import run_in_threadpool
ALLOWED_UPLOAD_TYPES: tuple[str, ...] = ("image/webp", "image/avif")
ALLOWED_UPLOAD_EXTENSIONS: tuple[str, ...] = (".webp", ".avif")
async def _read_upload_bytes(upload: UploadFile, max_bytes: int) -> bytes:
chunks: list[bytes] = []
total = 0
while True:
chunk = await upload.read(65536)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise HTTPException(
status_code=413,
detail=json.dumps({"message": "Payload too large", "limit": max_bytes}),
)
chunks.append(chunk)
return b"".join(chunks)
async def validate_upload(upload: Any, *, max_bytes: int | None = None) -> tuple[bytes, str]:
"""Validate upload size, MIME type, and signature."""
from .config import DEFAULT_MAX_UPLOAD_BYTES
from .store import detect_image_extension
if max_bytes is None:
max_bytes = DEFAULT_MAX_UPLOAD_BYTES
data = await _read_upload_bytes(upload, max_bytes)
declared = (getattr(upload, "content_type", None) or "").split(";")[0].strip().lower()
if declared not in ALLOWED_UPLOAD_TYPES:
raise HTTPException(
status_code=415,
detail=json.dumps(
{
"error": "unsupported_format",
"message": (
"Only WebP (.webp) and AVIF (.avif) images are supported. "
"Please convert your image before uploading — for example with "
"`cwebp` (JPEG/PNG → WebP) or `avifenc` (PNG/JPEG → AVIF)."
),
"allowed": list(ALLOWED_UPLOAD_TYPES),
}
),
)
extension = await run_in_threadpool(detect_image_extension, data)
if extension is None:
raise HTTPException(
status_code=415,
detail=json.dumps(
{
"error": "invalid_signature",
"message": (
"Uploaded file does not look like a valid WebP or AVIF image. "
"Make sure the bytes match the declared MIME type."
),
}
),
)
expected_mime = {".webp": "image/webp", ".avif": "image/avif"}.get(extension)
if expected_mime is None or expected_mime != declared:
raise HTTPException(
status_code=415,
detail=json.dumps(
{
"error": "mime_mismatch",
"message": (
"Declared MIME does not match the file signature. "
"Only WebP or AVIF images are accepted."
),
}
),
)
return data, extension
+187
View File
@@ -0,0 +1,187 @@
"""User account and profile settings action helpers."""
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any
from starlette.concurrency import run_in_threadpool
from .auth import password_error
from .payloads import presence
from .upload_validation import validate_upload
if TYPE_CHECKING:
from .store import Store
from .users import Users
USERNAME_REGEX: re.Pattern[str] = re.compile(r"\A[a-zA-Z0-9._-]+\Z")
def change_password(
users_obj: Users,
current_username: str,
current_password: str,
new_password: str,
*,
min_length: int | None = None,
max_length: int | None = None,
) -> tuple[str | None, str | None]:
"""Change the user's password."""
if not users_obj.authenticate(current_username, current_password):
return ("Current password is incorrect.", None)
fresh = presence(new_password)
if fresh is None:
return ("New password cannot be empty.", None)
error = password_error(fresh, min_length=min_length, max_length=max_length)
if error is not None:
return (error, None)
users_obj.update_password(current_username, fresh)
return (None, "Password updated.")
def change_username(
users_obj: Users,
current_username: str,
new_username: str,
session: dict[str, Any],
) -> tuple[str | None, str | None]:
"""Rename the current user and update the session."""
name = presence(new_username)
if name is None:
return ("Username cannot be empty.", None)
if not re.match(USERNAME_REGEX, name):
return ("Username may use letters, numbers, dot, dash, underscore.", None)
if name == current_username:
return (None, "Username unchanged.")
if users_obj.rename(current_username, name):
session["user"] = name
return (None, "Username updated.")
return ("That username is already taken.", None)
def change_name(
users_obj: Users,
current_username: str,
display_name: str,
) -> tuple[str | None, str | None]:
"""Update or clear the display name."""
name = display_name.strip()
name = name if name else None
users_obj.update_name(current_username, name)
return (None, "Display name updated." if name else "Display name cleared.")
def change_fediverse_creator(
users_obj: Users,
current_username: str,
value: str,
) -> tuple[str | None, str | None]:
"""Update or clear the fediverse handle."""
from .post import FEDIVERSE_CREATOR_REGEX
text = value.strip()
if not text:
users_obj.update_fediverse_creator(current_username, None)
return (None, "Fediverse handle cleared.")
if not re.match(FEDIVERSE_CREATOR_REGEX, text):
return ("Fediverse handle must look like @user@host.", None)
users_obj.update_fediverse_creator(current_username, text)
return (None, "Fediverse handle updated.")
async def change_photo(
users_obj: Users,
store: Store,
current_username: str,
upload: Any,
) -> tuple[str | None, str | None]:
"""Store a profile photo and remove the previous file if unreferenced."""
bytes_data, _extension = await validate_upload(upload)
url = await run_in_threadpool(store.store_upload, upload.filename, upload.file, bytes_data)
previous = None
user = users_obj.find(current_username)
if user is not None:
previous = user.photo
users_obj.update_photo(current_username, url)
if previous and previous != url:
await run_in_threadpool(_delete_unreferenced_media, users_obj, store, previous)
return (None, "Profile photo updated.")
async def remove_photo(
users_obj: Users,
current_username: str,
store: Store | None = None,
) -> tuple[str | None, str | None]:
"""Remove the profile photo and optionally delete the file."""
user = users_obj.find(current_username)
if user is not None and user.photo and store is not None:
await run_in_threadpool(_delete_unreferenced_media, users_obj, store, user.photo)
users_obj.update_photo(current_username, None)
return (None, "Profile photo removed.")
def create_user(
users_obj: Users,
username: str,
password: str,
role: str,
*,
min_length: int | None = None,
max_length: int | None = None,
) -> tuple[str | None, str | None]:
"""Create a new user."""
name = presence(username)
password_value = presence(password)
if name is None or password_value is None:
return ("Username and password are required.", None)
if not re.match(USERNAME_REGEX, name):
return ("Username may use letters, numbers, dot, dash, underscore.", None)
error = password_error(password_value, min_length=min_length, max_length=max_length)
if error is not None:
return (error, None)
if users_obj.add(name, password_value, role):
return (None, "User added.")
return ("That username already exists.", None)
async def remove_user(
users_obj: Users,
current_username: str,
target_username: str,
store: Store | None = None,
) -> tuple[str | None, str | None]:
"""Delete a user and clean up their profile photo."""
if target_username == current_username:
return ("You cannot delete your own account.", None)
target = users_obj.find(target_username)
if target is not None and target.photo and store is not None:
await run_in_threadpool(_delete_unreferenced_media, users_obj, store, target.photo)
if users_obj.delete(target_username):
return (None, "User removed.")
return ("Cannot remove the last user or the last admin.", None)
def change_role(
users_obj: Users,
current_username: str,
target_username: str,
new_role: str,
) -> tuple[str | None, str | None]:
"""Change a user's role."""
if target_username == current_username:
return ("You cannot change your own role.", None)
if users_obj.set_role(target_username, new_role):
return (None, "Role updated.")
return ("Could not change role (last admin?).", None)
def _delete_unreferenced_media(users_obj: Users, store: Store, url: str) -> None:
"""Delete *url* from disk if no other user record references it."""
if not url or not url.startswith("/media/"):
return
in_use = any(user.photo == url for user in users_obj.all)
if in_use:
return
store.delete_media(url)
+230
View File
@@ -0,0 +1,230 @@
"""File-backed set of admin users, stored as a TOML [[users]] array."""
from __future__ import annotations
import contextlib
import logging
import os
import tempfile
import threading
import tomllib
from dataclasses import dataclass
from typing import Any
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"
@dataclass
class User:
username: str
password_hash: str
role: str = "author"
name: str | None = None
fediverse_creator: str | None = None
photo: str | None = None
class Users:
"""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._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]:
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._snapshot = snapshot
if os.path.isfile(self._path):
self._cached = self._read_file()
elif self._bootstrap_hash:
self._cached = [User("admin", self._bootstrap_hash, "admin")]
else:
self._cached = []
return list(self._cached)
@property
def any(self) -> bool:
return len(self.all) > 0
def find(self, username: str) -> User | None:
for u in self.all:
if u.username == username:
return u
return None
def authenticate(self, username: str, password: str) -> User | None:
user = self.find(username)
if user and _verify_pw(password, user.password_hash):
return user
return None
def add(self, username: str, password: str, role: str = DEFAULT_ROLE) -> User | None:
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))
def update_fediverse_creator(self, username: str, value: str | None) -> User | None:
return self._mutate(
username, lambda u: setattr(u, "fediverse_creator", value if value else None)
)
def update_photo(self, username: str, path: str | None) -> User | None:
return self._mutate(username, lambda u: setattr(u, "photo", path))
def update_password(self, username: str, password: str) -> User | None:
return self._mutate(username, lambda u: setattr(u, "password_hash", _hash_pw(password)))
def rename(self, current_name: str, new_name: str) -> User | None:
if not new_name or self.find(new_name):
return None
return self._mutate(current_name, lambda u: setattr(u, "username", new_name))
def set_role(self, username: str, role: str) -> User | None:
if role not in ROLES:
return None
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:
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:
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]:
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", 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:
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,
"password_hash": u.password_hash,
"role": u.role,
}
if u.name:
entry["name"] = u.name
if u.fediverse_creator:
entry["fediverse_creator"] = u.fediverse_creator
if u.photo:
entry["photo"] = u.photo
user_list.append(entry)
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
+18
View File
@@ -0,0 +1,18 @@
"""Canonical volumen version (read from package metadata)."""
from __future__ import annotations
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()
+159
View File
@@ -0,0 +1,159 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="volumen">
<title>volumen</title>
<defs>
<!-- Background gradient: deep night sky -->
<radialGradient id="bg" cx="50%" cy="35%" r="80%">
<stop offset="0%" stop-color="#4a5bd8"/>
<stop offset="55%" stop-color="#2a2d6e"/>
<stop offset="100%" stop-color="#15163a"/>
</radialGradient>
<!-- Subtle inner ring gradient -->
<linearGradient id="ring" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#8aa0ff" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#3b3f8a" stop-opacity="0.6"/>
</linearGradient>
<!-- Glossy highlight at the top -->
<radialGradient id="gloss" cx="50%" cy="15%" r="55%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.35"/>
<stop offset="60%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
<!-- Wave stroke gradient -->
<linearGradient id="wave" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#7afcff" stop-opacity="0.15"/>
<stop offset="50%" stop-color="#7afcff" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#7afcff" stop-opacity="0.15"/>
</linearGradient>
<linearGradient id="waveDim" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<!-- Central V fill gradient -->
<linearGradient id="vFill" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#fdfdff"/>
<stop offset="100%" stop-color="#b8c4ff"/>
</linearGradient>
<!-- Soft drop shadow -->
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="2" result="off"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.5"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- Outer glow for the disc -->
<filter id="outerGlow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="4"/>
</filter>
<!-- Reusable wave path (sinusoidal, centered around y=128) -->
<path id="wavePath"
d="M 36 128
C 56 96, 76 96, 96 128
S 136 160, 156 128
S 196 96, 216 128"
fill="none"/>
</defs>
<!-- ===== Outer glow halo ===== -->
<circle cx="128" cy="128" r="116" fill="url(#bg)" opacity="0.55" filter="url(#outerGlow)"/>
<!-- ===== Main disc ===== -->
<circle cx="128" cy="128" r="112" fill="url(#bg)"/>
<!-- Inner decorative ring -->
<circle cx="128" cy="128" r="104" fill="none" stroke="url(#ring)" stroke-width="1.2" opacity="0.7"/>
<!-- Dotted ring (tick marks) -->
<g fill="#8aa0ff" opacity="0.55">
<circle cx="128" cy="22" r="1.4"/>
<circle cx="180" cy="32" r="1.2"/>
<circle cx="220" cy="62" r="1.4"/>
<circle cx="234" cy="110" r="1.2"/>
<circle cx="234" cy="146" r="1.4"/>
<circle cx="220" cy="194" r="1.2"/>
<circle cx="180" cy="224" r="1.4"/>
<circle cx="128" cy="234" r="1.2"/>
<circle cx="76" cy="224" r="1.4"/>
<circle cx="36" cy="194" r="1.2"/>
<circle cx="22" cy="146" r="1.4"/>
<circle cx="22" cy="110" r="1.2"/>
<circle cx="36" cy="62" r="1.4"/>
<circle cx="76" cy="32" r="1.2"/>
</g>
<!-- ===== Waves (background layer) ===== -->
<g stroke-linecap="round" fill="none">
<use href="#wavePath" stroke="url(#waveDim)" stroke-width="1.2"
transform="translate(0,-46)" opacity="0.6"/>
<use href="#wavePath" stroke="url(#waveDim)" stroke-width="1.2"
transform="translate(0,46)" opacity="0.6"/>
</g>
<!-- ===== Central stylized "V" ===== -->
<g filter="url(#softShadow)">
<!-- Main V shape, geometric, with a small notch at the bottom for a leaf/quill hint -->
<path d="
M 78 70
L 110 70
L 128 142
L 146 70
L 178 70
L 138 186
L 118 186
Z"
fill="url(#vFill)"/>
<!-- Inner accent line on the V -->
<path d="M 118 186 L 128 142 L 138 186"
fill="none" stroke="#2a2d6e" stroke-width="1.2" stroke-linejoin="round" opacity="0.35"/>
<!-- Subtle highlight stripe on the left arm of V -->
<path d="M 82 74 L 108 74 L 100 100 Z"
fill="#ffffff" opacity="0.25"/>
</g>
<!-- ===== Foreground waves crossing the V ===== -->
<g stroke-linecap="round" fill="none">
<use href="#wavePath" stroke="url(#wave)" stroke-width="3.2" opacity="0.9"/>
<use href="#wavePath" stroke="url(#wave)" stroke-width="1.6"
transform="translate(0,18)" opacity="0.7"/>
<use href="#wavePath" stroke="url(#wave)" stroke-width="1.6"
transform="translate(0,-18)" opacity="0.7"/>
</g>
<!-- ===== Sparkles / dots ===== -->
<g fill="#ffffff">
<circle cx="60" cy="92" r="1.8" opacity="0.95"/>
<circle cx="196" cy="92" r="1.6" opacity="0.85"/>
<circle cx="200" cy="168" r="1.4" opacity="0.8"/>
<circle cx="58" cy="172" r="1.4" opacity="0.8"/>
<circle cx="156" cy="58" r="1.2" opacity="0.7"/>
<circle cx="100" cy="206" r="1.2" opacity="0.7"/>
</g>
<!-- Four-point sparkle (north star) -->
<g transform="translate(70,70)" fill="#ffffff" opacity="0.9">
<path d="M 0 -7 L 1.4 -1.4 L 7 0 L 1.4 1.4 L 0 7 L -1.4 1.4 L -7 0 L -1.4 -1.4 Z"/>
</g>
<g transform="translate(190,190) scale(0.7)" fill="#ffffff" opacity="0.8">
<path d="M 0 -7 L 1.4 -1.4 L 7 0 L 1.4 1.4 L 0 7 L -1.4 1.4 L -7 0 L -1.4 -1.4 Z"/>
</g>
<!-- ===== Glossy top highlight ===== -->
<circle cx="128" cy="128" r="112" fill="url(#gloss)"/>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

+612
View File
@@ -0,0 +1,612 @@
{% extends "layout.html.jinja" %}
{% block content %}
{% if mode == "new" %}
{% set crumbs = [("Posts", "/admin/"), ("New post", None)] %}
{% else %}
{% set crumbs = [("Posts", "/admin/"), (post.title | string | trim if post.title | string | trim | length > 0 else "Untitled", None)] %}
{% endif %}
<div class="page-head">
<div>
<h1>{{ "New post" if mode == "new" else "Edit post" }}</h1>
<p class="lede">{{ "Draft a new article in Markdown." if mode == "new" else "Update and publish this post." }}</p>
</div>
<div style="display: flex; gap: 0.5rem;">
{% if mode == "edit" %}
<a class="btn btn-ghost" href="/api/volumen/posts/{{ post.slug }}" target="_blank">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Preview
</a>
{% endif %}
<a class="btn btn-ghost" href="/admin/">Cancel</a>
<button type="submit" form="post-form" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
Save
</button>
</div>
</div>
{% if error %}
<div class="alert alert-error">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<div>{{ error }}</div>
</div>
{% endif %}
<form id="post-form" method="post"
action="{{ '/admin/posts' if mode == 'new' else '/admin/posts/' + post.slug }}">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div class="form-grid">
<div class="surface full">
<div class="surface-head">
<div>
<h2>Details</h2>
<p class="lede">Title, slug, and publishing options</p>
</div>
</div>
<div class="field full">
<label for="title">Title</label>
<input id="title" class="input" type="text" name="title" value="{{ post.title if post.title else '' }}" placeholder="A clear, descriptive title" required>
</div>
<div class="field-row">
<div class="field">
<label for="slug">Slug</label>
<input id="slug" class="input" type="text" name="slug" value="{{ post.slug if post.slug else '' }}"
{{ 'readonly' if mode == 'edit' else 'required' }} placeholder="my-post-slug">
</div>
<div class="field">
<label for="lang">Language</label>
<input id="lang" class="input" type="text" name="lang" value="{{ post.lang if post.lang else '' }}" placeholder="en">
</div>
</div>
<div class="field-row">
{% set author_value = post.author if post.author and post.author | string | trim | length > 0 else (current_user_record.name if current_user_record else current_user) %}
<div class="field">
<label for="author">Author</label>
<input id="author" class="input" type="text" name="author" value="{{ author_value if author_value else '' }}" placeholder="{{ current_user_record.name if current_user_record else 'Petr Balvín' }}">
</div>
{% set fediverse_default = current_user_record.fediverse_creator if current_user_record and current_user_record.fediverse_creator else config.site.fediverse_creator %}
<div class="field">
<label for="fediverse_creator">Fediverse creator</label>
<input id="fediverse_creator" class="input" type="text" name="fediverse_creator"
value="{{ post.fediverse_creator if post.fediverse_creator and post.fediverse_creator | string | trim | length > 0 else fediverse_default }}" placeholder="@user@instance.tld">
</div>
</div>
<div class="field-row-3">
<div class="field">
<label for="date">Publish date</label>
<input id="date" class="input" type="date" name="date" value="{{ post.date_string if post.date_string else today_date }}">
</div>
<div class="field">
<label for="publish_at">Schedule for</label>
<input id="publish_at" class="input" type="date" name="publish_at"
value="{{ post.publish_at.strftime('%Y-%m-%d') if post.publish_at else '' }}">
<p class="help">Leave empty to publish immediately</p>
</div>
<div class="field">
<label for="tags">Tags</label>
<input id="tags" class="input" type="text" name="tags" value="{{ post.tags | join(', ') }}" placeholder="comma, separated">
</div>
</div>
<div class="field full">
<label for="excerpt">Excerpt</label>
<input id="excerpt" class="input" type="text" name="excerpt" value="{{ post.excerpt if post.excerpt else '' }}" placeholder="Short summary for listings and previews">
<p class="help">Auto-generated from the first paragraph if left empty</p>
</div>
<div style="display: flex; gap: 1.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
<label class="checkbox">
<input type="checkbox" name="draft" {{ 'checked' if post.draft else '' }}> Draft
</label>
<label class="checkbox">
<input type="checkbox" name="all_langs" {{ 'checked' if post.all_langs else '' }}> Available in all languages
</label>
</div>
</div>
<div class="surface full">
<div class="surface-head">
<div>
<h2>Cover image</h2>
<p class="lede">Header image shown on the article page</p>
</div>
</div>
<div class="cover-field">
<div class="cover-input-row">
<input id="cover-input" class="input" type="text" name="cover" value="{{ post.cover if post.cover else '' }}"
placeholder="/media/… or https://…">
<button type="button" id="cover-upload" class="btn">Upload</button>
<button type="button" id="cover-clear" class="btn btn-ghost">Clear</button>
</div>
<input id="cover-alt-input" class="input" type="text" name="cover_alt" value="{{ post.cover_alt if post.cover_alt else '' }}"
placeholder="ALT text (for accessibility)">
<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/webp,image/avif" hidden>
</div>
</div>
<div class="surface full">
<div class="editor-tabs" style="margin-bottom: 0.75rem;">
<button type="button" data-tab="markdown" class="active">Markdown</button>
<button type="button" data-tab="visual">Visual</button>
</div>
<div id="visual-view" hidden>
<div class="editor-grid">
<div class="editor-pane">
<div class="editor-head">
<span class="label">Editor</span>
<span class="spacer"></span>
<button type="button" class="editor-toolbar-btn bold" data-rich="bold" title="Bold (Ctrl+B)">B</button>
<button type="button" class="editor-toolbar-btn italic" data-rich="italic" title="Italic (Ctrl+I)">I</button>
<button type="button" class="editor-toolbar-btn" data-rich="h2" title="Heading 2">H2</button>
<button type="button" class="editor-toolbar-btn" data-rich="h3" title="Heading 3">H3</button>
<button type="button" class="editor-toolbar-btn" data-rich="link" title="Link (Ctrl+K)">⌘</button>
<button type="button" class="editor-toolbar-btn" data-rich="ul" title="Bullet list">•</button>
<button type="button" class="editor-toolbar-btn" data-rich="ol" title="Numbered list">1.</button>
<button type="button" class="editor-toolbar-btn" data-rich="quote" title="Quote">"</button>
<button type="button" class="editor-toolbar-btn" data-rich="code" title="Code">/&gt;</button>
<button type="button" class="editor-toolbar-btn" data-rich="image" title="Image">IMG</button>
</div>
<div id="wysiwyg" class="markdown editor-preview" contenteditable="true"></div>
</div>
</div>
</div>
<div id="markdown-view">
<div class="editor-grid" id="editor-grid">
<div class="editor-pane">
<div class="editor-head">
<span class="label">Markdown</span>
<span class="spacer"></span>
<button type="button" class="editor-toolbar-btn bold" data-md="bold" title="Bold (Ctrl+B)">B</button>
<button type="button" class="editor-toolbar-btn italic" data-md="italic" title="Italic (Ctrl+I)">I</button>
<button type="button" class="editor-toolbar-btn" data-md="h2" title="Heading">H2</button>
<button type="button" class="editor-toolbar-btn" data-md="link" title="Link (Ctrl+K)">↗</button>
<button type="button" class="editor-toolbar-btn" data-md="ul" title="List">•</button>
<button type="button" class="editor-toolbar-btn" data-md="quote" title="Quote">"</button>
<button type="button" class="editor-toolbar-btn" data-md="code" title="Code">&lt;/&gt;</button>
<button type="button" class="editor-toolbar-btn" data-md="image" title="Image">IMG</button>
<button type="button" id="toggle-preview" class="editor-toolbar-btn" title="Toggle preview">PREVIEW</button>
</div>
<textarea id="body" name="body" class="editor-textarea" rows="22" placeholder="Write your post in Markdown…">{{ post.body if post.body else '' }}</textarea>
</div>
<div class="editor-pane" id="preview-pane">
<div class="editor-head">
<span class="label">Preview</span>
</div>
<div id="preview" class="markdown editor-preview"></div>
</div>
</div>
</div>
</div>
</div>
<input type="file" id="image-input" accept="image/webp,image/avif" hidden>
</form>
<script nonce="{{ csp_nonce | default('') }}">
(function () {
var form = document.getElementById("post-form");
var textarea = document.getElementById("body");
var preview = document.getElementById("preview");
var wysiwyg = document.getElementById("wysiwyg");
var visualView = document.getElementById("visual-view");
var markdownView = document.getElementById("markdown-view");
var imageInput = document.getElementById("image-input");
var coverInput = document.getElementById("cover-input");
var coverFile = document.getElementById("cover-file");
var coverPreview = document.getElementById("cover-preview");
var previewUrl = "/admin/preview";
var uploadUrl = "/admin/uploads";
var csrf = "{{ csrf_token }}";
var timer = null;
try { document.execCommand("defaultParagraphSeparator", false, "p"); } catch (e) {}
// --- Markdown <-> HTML -------------------------------------------------
function mdToHtml(md) {
var data = new URLSearchParams();
data.set("body", md);
data.set("_csrf", csrf);
return fetch(previewUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: data.toString()
}).then(function (response) { return response.text(); });
}
function inlineToMd(node) {
var out = "";
node.childNodes.forEach(function (child) { out += inlineNode(child); });
return out;
}
function inlineNode(node) {
if (node.nodeType === 3) { return node.nodeValue.replace(/\s+/g, " "); }
if (node.nodeType !== 1) { return ""; }
var tag = node.tagName.toLowerCase();
var inner = inlineToMd(node);
if (tag === "strong" || tag === "b") { return "**" + inner.trim() + "**"; }
if (tag === "em" || tag === "i") { return "*" + inner.trim() + "*"; }
if (tag === "code") { return "`" + node.textContent + "`"; }
if (tag === "a") { return "[" + inner + "](" + (node.getAttribute("href") || "") + ")"; }
if (tag === "img") {
var title = node.getAttribute("title") || "";
var imgMd = "![" + (node.getAttribute("alt") || "") + "](" + (node.getAttribute("src") || "") + ")";
if (title) { imgMd = imgMd.replace(")", ' "' + title + '")'); }
return imgMd;
}
if (tag === "br") { return "\n"; }
return inner;
}
function listToMd(list, ordered) {
var index = 1;
var items = [];
list.childNodes.forEach(function (li) {
if (li.nodeType === 1 && li.tagName.toLowerCase() === "li") {
var marker = ordered ? (index++ + ". ") : "- ";
items.push(marker + inlineToMd(li).trim());
}
});
return items.join("\n");
}
function htmlToMd(root) {
var blocks = [];
root.childNodes.forEach(function (node) {
if (node.nodeType === 3) {
var text = node.nodeValue.trim();
if (text) { blocks.push(text); }
return;
}
if (node.nodeType !== 1) { return; }
var tag = node.tagName.toLowerCase();
if (/^h[1-6]$/.test(tag)) {
blocks.push("#".repeat(Number(tag.charAt(1))) + " " + inlineToMd(node).trim());
} else if (tag === "ul" || tag === "ol") {
blocks.push(listToMd(node, tag === "ol"));
} else if (tag === "blockquote") {
blocks.push(inlineToMd(node).trim().split("\n").map(function (line) {
return "> " + line;
}).join("\n"));
} else if (tag === "pre") {
blocks.push("```\n" + node.textContent.replace(/\n$/, "") + "\n```");
} else if (tag === "hr") {
blocks.push("---");
} else {
var content = inlineToMd(node).trim();
if (content) { blocks.push(content); }
}
});
return blocks.join("\n\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
}
// --- Live preview (markdown mode) — disabled by default ----------------
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);
timer = setTimeout(renderPreview, 250);
}
// --- Image upload ------------------------------------------------------
function uploadImage(file, onSuccess, onError) {
var data = new FormData();
data.append("file", file);
data.append("_csrf", csrf);
fetch(uploadUrl, { method: "POST", body: data })
.then(function (response) {
return response.json().then(function (json) { return { ok: response.ok, json: json }; });
})
.then(function (result) {
if (result.ok && result.json.url) {
onSuccess(result.json.url);
} else {
onError(result.json.message || ("Upload failed (HTTP " + (result.json.error || "error") + ")"));
}
});
}
function showUploadError(message) {
alert("Upload failed:\n\n" + message);
}
imageInput.addEventListener("change", function () {
var file = imageInput.files[0];
if (!file) { return; }
uploadImage(file, function (url) {
if (visualView.hidden) {
insertIntoTextarea("![](" + url + ")");
} else {
exec("insertImage", url);
}
}, showUploadError);
imageInput.value = "";
});
// --- Cover image -------------------------------------------------------
function refreshCoverPreview() {
if (coverInput.value) {
coverPreview.src = coverInput.value;
coverPreview.hidden = false;
} else {
coverPreview.hidden = true;
coverPreview.removeAttribute("src");
}
}
document.getElementById("cover-upload").addEventListener("click", function () {
coverFile.click();
});
document.getElementById("cover-clear").addEventListener("click", function () {
coverInput.value = "";
refreshCoverPreview();
});
coverFile.addEventListener("change", function () {
var file = coverFile.files[0];
if (!file) { return; }
uploadImage(file, function (url) {
coverInput.value = url;
refreshCoverPreview();
}, showUploadError);
coverFile.value = "";
});
coverInput.addEventListener("input", refreshCoverPreview);
refreshCoverPreview();
// --- Markdown toolbar --------------------------------------------------
function insertIntoTextarea(text) {
var start = textarea.selectionStart;
textarea.value = textarea.value.slice(0, start) + text + textarea.value.slice(textarea.selectionEnd);
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + text.length;
schedule();
}
function wrap(before, after) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var selected = textarea.value.slice(start, end);
textarea.value = textarea.value.slice(0, start) + before + selected + after +
textarea.value.slice(end);
textarea.focus();
textarea.selectionStart = start + before.length;
textarea.selectionEnd = end + before.length;
schedule();
}
function linePrefix(prefix) {
var start = textarea.selectionStart;
var lineStart = textarea.value.lastIndexOf("\n", start - 1) + 1;
textarea.value = textarea.value.slice(0, lineStart) + prefix + textarea.value.slice(lineStart);
textarea.focus();
schedule();
}
var mdActions = {
bold: function () { wrap("**", "**"); },
italic: function () { wrap("*", "*"); },
code: function () { wrap("`", "`"); },
link: function () { wrap("[", "](https://)"); },
h2: function () { linePrefix("## "); },
ul: function () { linePrefix("- "); },
quote: function () { linePrefix("> "); },
image: function () { imageInput.click(); }
};
// --- Visual toolbar ----------------------------------------------------
function exec(command, value) {
document.execCommand(command, false, value || null);
wysiwyg.focus();
updateToolbarState();
}
function wrapInline(tagName) {
var selection = window.getSelection();
if (!selection.rangeCount) { return; }
var element = document.createElement(tagName);
try { selection.getRangeAt(0).surroundContents(element); } catch (e) { /* spans blocks */ }
wysiwyg.focus();
updateToolbarState();
}
var richActions = {
bold: function () { exec("bold"); },
italic: function () { exec("italic"); },
h2: function () { exec("formatBlock", "H2"); },
h3: function () { exec("formatBlock", "H3"); },
quote: function () { exec("formatBlock", "BLOCKQUOTE"); },
clear: function () { exec("formatBlock", "P"); },
ul: function () { exec("insertUnorderedList"); },
ol: function () { exec("insertOrderedList"); },
code: function () { wrapInline("code"); },
image: function () { imageInput.click(); },
link: function () { var url = prompt("Link URL", "https://"); if (url) { exec("createLink", url); } }
};
// --- Toolbar active state ----------------------------------------------
function queryState(command) {
try { return document.queryCommandState(command); } catch (e) { return false; }
}
function currentBlock() {
try { return (document.queryCommandValue("formatBlock") || "").toLowerCase().replace(/[<>]/g, ""); }
catch (e) { return ""; }
}
function selectionInEditor() {
var node = window.getSelection().anchorNode;
while (node) { if (node === wysiwyg) { return true; } node = node.parentNode; }
return false;
}
function isInside(tagName) {
var node = window.getSelection().anchorNode;
while (node && node !== wysiwyg) {
if (node.nodeType === 1 && node.tagName.toLowerCase() === tagName) { return true; }
node = node.parentNode;
}
return false;
}
function updateToolbarState() {
if (visualView.hidden) { return; }
var active = {};
if (selectionInEditor()) {
var block = currentBlock();
active = {
bold: queryState("bold"),
italic: queryState("italic"),
ul: queryState("insertUnorderedList"),
ol: queryState("insertOrderedList"),
h2: block === "h2",
h3: block === "h3",
quote: block === "blockquote",
link: isInside("a"),
code: isInside("code")
};
}
document.querySelectorAll("[data-rich]").forEach(function (button) {
button.classList.toggle("active", !!active[button.dataset.rich]);
});
}
// --- Mode switching ----------------------------------------------------
function setActiveTab(name) {
document.querySelectorAll(".editor-tabs button").forEach(function (button) {
button.classList.toggle("active", button.dataset.tab === name);
});
}
function showVisual() {
mdToHtml(textarea.value).then(function (html) { setSafeHtml(wysiwyg, html); });
visualView.hidden = false;
markdownView.hidden = true;
setActiveTab("visual");
}
function showMarkdown() {
textarea.value = htmlToMd(wysiwyg);
renderPreview();
visualView.hidden = true;
markdownView.hidden = false;
setActiveTab("markdown");
}
// --- Keyboard shortcuts ------------------------------------------------
function shortcut(actions) {
return function (event) {
if (!(event.ctrlKey || event.metaKey)) { return; }
var key = event.key.toLowerCase();
if (key === "b") { event.preventDefault(); actions.bold(); }
else if (key === "i") { event.preventDefault(); actions.italic(); }
else if (key === "k") { event.preventDefault(); actions.link(); }
};
}
// --- Wiring ------------------------------------------------------------
document.querySelectorAll("[data-md]").forEach(function (button) {
button.addEventListener("click", function () { mdActions[button.dataset.md](); });
});
document.querySelectorAll("[data-rich]").forEach(function (button) {
button.addEventListener("click", function () { richActions[button.dataset.rich](); });
});
document.querySelectorAll(".editor-tabs button").forEach(function (button) {
button.addEventListener("click", function () {
if (button.dataset.tab === "visual") { showVisual(); } else { showMarkdown(); }
});
});
wysiwyg.addEventListener("paste", function (event) {
event.preventDefault();
var text = (event.clipboardData || window.clipboardData).getData("text/plain");
document.execCommand("insertText", false, text);
});
wysiwyg.addEventListener("keydown", shortcut(richActions));
wysiwyg.addEventListener("keyup", updateToolbarState);
wysiwyg.addEventListener("mouseup", updateToolbarState);
document.addEventListener("selectionchange", updateToolbarState);
textarea.addEventListener("input", schedule);
textarea.addEventListener("keydown", shortcut(mdActions));
form.addEventListener("submit", function () {
if (!visualView.hidden) { textarea.value = htmlToMd(wysiwyg); }
});
// --- Preview toggle (disabled by default) ------------------------------
var editorGrid = document.getElementById("editor-grid");
var previewPane = document.getElementById("preview-pane");
var togglePreviewBtn = document.getElementById("toggle-preview");
function applyPreviewPreference() {
var on = false;
try { on = localStorage.getItem("volumen.preview") === "on"; } catch (e) {}
if (on) {
editorGrid.classList.add("with-preview");
previewPane.hidden = false;
togglePreviewBtn.classList.add("active");
renderPreview();
} else {
editorGrid.classList.remove("with-preview");
previewPane.hidden = true;
togglePreviewBtn.classList.remove("active");
}
}
togglePreviewBtn.addEventListener("click", function () {
var turningOn = !editorGrid.classList.contains("with-preview");
try { localStorage.setItem("volumen.preview", turningOn ? "on" : "off"); } catch (e) {}
applyPreviewPreference();
});
// Start in Markdown mode (primary), preview OFF by default.
applyPreviewPreference();
})();
</script>
{% endblock %}
File diff suppressed because it is too large Load Diff
+115
View File
@@ -0,0 +1,115 @@
{% extends "layout.html.jinja" %}
{% block content %}
<div class="page-head">
<div>
<h1>Posts</h1>
<p class="lede">{{ posts|length }} post{{ '' if posts|length == 1 else 's' }} in total</p>
</div>
<a class="btn btn-primary" href="/admin/posts/new">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New post
</a>
</div>
<div class="toolbar">
<div class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="post-search" class="input" type="text" placeholder="Search posts...">
</div>
<div class="filters" id="post-filters">
<button type="button" class="filter-chip active" data-filter="all">All</button>
<button type="button" class="filter-chip" data-filter="published">Published</button>
<button type="button" class="filter-chip" data-filter="draft">Drafts</button>
<button type="button" class="filter-chip" data-filter="scheduled">Scheduled</button>
</div>
</div>
{% if not posts %}
<div class="empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<h3>No posts yet</h3>
<p>Create your first post to get started.</p>
<a class="btn btn-primary" href="/admin/posts/new" style="margin-top: 0.75rem;">Create post</a>
</div>
{% else %}
<div class="post-grid" id="post-grid">
{% for post in posts %}
{% if post.draft %}
{% set status = "draft" %}
{% elif post.scheduled %}
{% set status = "scheduled" %}
{% else %}
{% set status = "published" %}
{% endif %}
<a href="/admin/posts/{{ post.slug }}/edit"
class="post-card"
data-title="{{ post.title | default('') | lower }}"
data-slug="{{ post.slug }}"
data-status="{{ status }}">
{% if post.cover %}
<div class="post-cover" style="background-image: url('{{ post.cover }}');"></div>
{% else %}
<div class="post-cover placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
</div>
{% endif %}
<div class="post-body">
<h3 class="post-title">{{ post.title }}</h3>
{% if post.excerpt and post.excerpt | string | trim | length > 0 %}
<p class="post-excerpt">{{ post.excerpt }}</p>
{% endif %}
<div class="post-meta">
<span class="badge badge-lang">{{ post.lang }}</span>
{% if post.draft %}<span class="badge badge-warn">Draft</span>{% endif %}
{% if post.scheduled %}<span class="badge badge-accent">Scheduled</span>{% endif %}
{% if post.date_string %}<span>{{ post.date_string }}</span>{% endif %}
</div>
</div>
<div class="post-actions">
<span class="btn btn-sm btn-ghost" style="cursor: default;">Edit</span>
<span class="spacer" style="flex: 1;"></span>
<form method="post" action="/admin/posts/{{ post.slug }}/delete"
onsubmit="return confirm('Delete this post?');" style="margin: 0;">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-sm btn-ghost" style="color: var(--danger);" onclick="event.preventDefault(); event.stopPropagation(); this.closest('form').submit();">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</form>
</div>
</a>
{% endfor %}
</div>
{% endif %}
<script>
(function () {
var search = document.getElementById("post-search");
var filterButtons = document.querySelectorAll("#post-filters .filter-chip");
var cards = document.querySelectorAll("#post-grid .post-card");
var currentFilter = "all";
function applyFilters() {
var q = (search.value || "").toLowerCase().trim();
cards.forEach(function (card) {
var title = card.dataset.title;
var slug = card.dataset.slug;
var status = card.dataset.status;
var matchesSearch = !q || title.indexOf(q) !== -1 || slug.indexOf(q) !== -1;
var matchesFilter = currentFilter === "all" || status === currentFilter;
card.style.display = matchesSearch && matchesFilter ? "" : "none";
});
}
if (search) search.addEventListener("input", applyFilters);
filterButtons.forEach(function (btn) {
btn.addEventListener("click", function () {
filterButtons.forEach(function (b) { b.classList.remove("active"); });
btn.classList.add("active");
currentFilter = btn.dataset.filter;
applyFilters();
});
});
})();
</script>
{% endblock %}
@@ -0,0 +1,33 @@
{% extends "layout.html.jinja" %}
{% block login_content %}
<h2>Sign in</h2>
<p class="lede">Welcome back. Sign in to manage your posts.</p>
{% if error %}
<div class="alert alert-error">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<div>{{ error }}</div>
</div>
{% endif %}
{% if not users_exist %}
<div class="alert alert-warn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<div>No users configured. Set <code>admin.password_hash</code> in your config or run <code>volumen hash-password</code>.</div>
</div>
{% endif %}
<form method="post" action="/admin/login">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div class="field">
<label for="username">Username</label>
<input id="username" class="input" type="text" name="username" autofocus autocomplete="username" required>
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" class="input" type="password" name="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary">Sign in</button>
</form>
{% endblock %}
@@ -0,0 +1,250 @@
{% extends "layout.html.jinja" %}
{% block content %}
<div class="page-head">
<div>
<h1>Settings</h1>
<p class="lede">Manage your account and users</p>
</div>
</div>
{% if notice %}
<div class="alert alert-success">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<div>{{ notice }}</div>
</div>
{% endif %}
{% if error %}
<div class="alert alert-error">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<div>{{ error }}</div>
</div>
{% endif %}
<div class="settings-layout">
<nav class="settings-nav" aria-label="Settings sections">
<a href="#account" class="active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Account
</a>
{% if current_role == "admin" %}
<a href="#users">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Users
</a>
{% endif %}
</nav>
<div class="settings-panels">
<section id="account" class="surface">
<div class="surface-head">
<div>
<h2>Account</h2>
<p class="lede">Signed in as <strong style="color: var(--fg);">{{ current_user }}</strong> · {{ current_role }}</p>
</div>
</div>
<div class="settings-section">
<div class="settings-section-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/><path d="M12 11v6"/><path d="M9 14h6"/></svg>
</div>
<div class="settings-section-body">
<h3>Profile photo</h3>
<p class="settings-section-desc">WebP or AVIF, max 10 MB. Shown in the sidebar.</p>
<form method="post" action="/admin/settings/photo" enctype="multipart/form-data" class="settings-photo-form">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div class="photo-input-row">
{% if current_user_record and current_user_record.photo %}
<img src="{{ current_user_record.photo }}" alt="" class="photo-preview">
{% else %}
{% set initial_char = (current_user_record.name if current_user_record else current_user)[0] | upper if (current_user_record and current_user_record.name) or current_user else '?' %}
<div class="photo-preview photo-preview-placeholder">{{ initial_char }}</div>
{% endif %}
<div class="photo-input-fields">
<input type="file" name="photo" accept="image/webp,image/avif" required class="file-input">
<div class="photo-buttons">
<button type="submit" class="btn btn-primary">Upload</button>
{% if current_user_record and current_user_record.photo %}
<button type="submit" form="remove-photo-form" class="btn btn-ghost btn-danger">Remove</button>
{% endif %}
</div>
</div>
</div>
</form>
{% if current_user_record and current_user_record.photo %}
<form id="remove-photo-form" method="post" action="/admin/settings/photo/remove" style="display: none;">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
</form>
{% endif %}
</div>
</div>
<div class="settings-section">
<div class="settings-section-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>
</div>
<div class="settings-section-body">
<h3>Identity</h3>
<p class="settings-section-desc">Display name pre-fills the author field. Fediverse handle pre-fills the creator.</p>
<form method="post" action="/admin/settings/name" class="settings-inline-form">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div class="field">
<label for="name">Display name (real name)</label>
<input id="name" class="input" type="text" name="name" value="{{ current_user_record.name if current_user_record else '' }}" placeholder="Your real name">
</div>
<button type="submit" class="btn">Save name</button>
</form>
<form method="post" action="/admin/settings/fediverse" class="settings-inline-form" style="margin-top: 1rem;">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div class="field">
<label for="fediverse_creator">Fediverse handle</label>
<input id="fediverse_creator" class="input" type="text" name="fediverse_creator" value="{{ current_user_record.fediverse_creator if current_user_record else '' }}" placeholder="@user@instance.tld">
</div>
<button type="submit" class="btn">Save handle</button>
</form>
</div>
</div>
<div class="settings-section">
<div class="settings-section-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</div>
<div class="settings-section-body">
<h3>Security</h3>
<p class="settings-section-desc">Change your password and username. You can also lock yourself out.</p>
<form method="post" action="/admin/settings/password" class="settings-inline-form">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div class="field-row">
<div class="field">
<label for="current_password">Current password</label>
<input id="current_password" class="input" type="password" name="current_password" autocomplete="current-password">
</div>
<div class="field">
<label for="new_password">New password</label>
<input id="new_password" class="input" type="password" name="new_password" autocomplete="new-password">
</div>
</div>
<button type="submit" class="btn">Update password</button>
</form>
<form method="post" action="/admin/settings/username" class="settings-inline-form" style="margin-top: 1rem;">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div class="field">
<label for="username">Username</label>
<input id="username" class="input" type="text" name="username" value="{{ current_user }}">
</div>
<button type="submit" class="btn">Update username</button>
</form>
</div>
</div>
</section>
{% if current_role == "admin" %}
<section id="users" class="surface">
<div class="surface-head">
<div>
<h2>Users</h2>
<p class="lede">Add or remove admin and editor accounts</p>
</div>
</div>
<div class="users-list">
{% for user in users_list %}
<div class="user-row">
{% if user.photo %}
<img src="{{ user.photo }}" alt="" class="user-avatar">
{% else %}
{% set user_initial = (user.name or user.username)[0] | upper %}
<div class="user-avatar user-avatar-placeholder">{{ user_initial }}</div>
{% endif %}
<div class="user-info">
<div class="user-name">
{{ user.name or user.username }}
{% if user.username == current_user %}<span class="badge badge-accent" style="margin-left: 0.4rem;">you</span>{% endif %}
</div>
<div class="user-username">@{{ user.username }}</div>
</div>
<div class="user-role">
{% if user.username == current_user %}
<span class="badge badge-neutral">{{ user.role }}</span>
{% else %}
<form method="post" action="/admin/settings/users/{{ user.username }}/role" style="margin: 0;">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<select class="select select-sm" name="role" onchange="this.form.submit()">
{% for role in roles %}
<option value="{{ role }}" {{ 'selected' if user.role == role else '' }}>{{ role }}</option>
{% endfor %}
</select>
</form>
{% endif %}
</div>
{% if user.username != current_user %}
<form method="post" action="/admin/settings/users/{{ user.username }}/delete" onsubmit="return confirm('Remove this user?');" style="margin: 0;">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-sm btn-ghost btn-danger">Remove</button>
</form>
{% endif %}
</div>
{% endfor %}
</div>
<div class="add-user-form">
<h3 style="margin: 0 0 0.75rem;">Add user</h3>
<form method="post" action="/admin/settings/users">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div class="field-row-3">
<div class="field">
<label for="new-username">Username</label>
<input id="new-username" class="input" type="text" name="username" placeholder="jane-doe">
</div>
<div class="field">
<label for="new-password">Password</label>
<input id="new-password" class="input" type="password" name="password" autocomplete="new-password">
</div>
<div class="field">
<label for="new-role">Role</label>
<select id="new-role" class="select" name="role">
{% for role in roles %}
<option value="{{ role }}" {{ 'selected' if role == default_role else '' }}>{{ role }}</option>
{% endfor %}
</select>
</div>
</div>
<button type="submit" class="btn btn-primary">Add user</button>
</form>
</div>
</section>
{% endif %}
</div>
</div>
<script>
(function () {
var sections = document.querySelectorAll(".settings-panels .surface[id]");
var navLinks = document.querySelectorAll(".settings-nav a[href^='#']");
if (!sections.length || !navLinks.length) return;
function showSection(id) {
sections.forEach(function (s) {
s.hidden = s.id !== id;
});
navLinks.forEach(function (a) {
a.classList.toggle("active", a.getAttribute("href") === "#" + id);
});
try { history.replaceState(null, "", "#" + id); } catch (e) {}
window.scrollTo({ top: 0, behavior: "smooth" });
}
navLinks.forEach(function (link) {
link.addEventListener("click", function (event) {
event.preventDefault();
var id = link.getAttribute("href").slice(1);
showSection(id);
});
});
var initial = (window.location.hash || "").slice(1);
var hasInitial = Array.from(sections).some(function (s) { return s.id === initial; });
showSection(hasInitial ? initial : sections[0].id);
})();
</script>
{% endblock %}
-313
View File
@@ -1,313 +0,0 @@
# frozen_string_literal: true
require "test_helper"
require "volumen/server"
require "rack/test"
require "tmpdir"
require "fileutils"
class AdminTest < Minitest::Test
include Rack::Test::Methods
def app
Volumen::Server.configured(config: @config, store: @store)
end
def setup
@dir = Dir.mktmpdir
@posts_dir = File.join(@dir, "posts")
FileUtils.mkdir_p(@posts_dir)
@password = "secret"
@hash = Volumen::Password.hash(@password)
@users_path = File.join(@dir, "users.toml")
config_path = File.join(@dir, "config.toml")
File.write(config_path, <<~TOML)
content_dir = "#{@posts_dir}"
users_file = "#{@users_path}"
[admin]
password_hash = "#{@hash}"
TOML
@config = Volumen::Config.load(path: config_path)
@store = Volumen::Store.new(@posts_dir, default_lang: "en")
Volumen.reset_login_attempts!
end
def teardown
FileUtils.remove_entry(@dir)
end
def test_admin_requires_login
get "/admin/"
assert_equal 302, last_response.status
end
def test_login_page_renders
get "/admin/login"
assert_predicate last_response, :ok?
assert_includes last_response.body, "Sign in"
end
def test_session_cookie_is_secure_over_https
get "https://example.org/admin/login"
post "https://example.org/admin/login",
"username" => "admin", "password" => @password, "_csrf" => csrf(last_response.body)
cookie = last_response["Set-Cookie"].to_s
assert_match(/;\s*secure/i, cookie)
assert_match(/;\s*httponly/i, cookie)
assert_match(/;\s*samesite=strict/i, cookie)
end
def test_session_cookie_plain_http_is_not_secure
login
refute_match(/;\s*secure/i, last_response["Set-Cookie"].to_s)
end
def test_wrong_password_is_rejected
get "/admin/login"
post "/admin/login",
"username" => "admin", "password" => "nope", "_csrf" => csrf(last_response.body)
assert_includes last_response.body, "Invalid username or password"
end
def test_login_then_list
login
assert_equal 302, last_response.status
follow_redirect!
assert_includes last_response.body, "Posts"
end
def test_create_post_writes_file
login
get "/admin/posts/new"
post "/admin/posts",
"_csrf" => csrf(last_response.body), "title" => "Test", "slug" => "test",
"lang" => "en", "tags" => "a, b", "body" => "Hello **world**."
assert_equal 302, last_response.status
path = File.join(@posts_dir, "test.md")
assert File.exist?(path)
saved = Volumen::Post.parse(File.read(path))
assert_equal "Test", saved.title
assert_equal %w[a b], saved.tags
end
def test_create_post_with_all_langs
login
get "/admin/posts/new"
post "/admin/posts",
"_csrf" => csrf(last_response.body), "title" => "Global", "slug" => "global",
"lang" => "en", "all_langs" => "on", "body" => "Hi"
assert_equal 302, last_response.status
saved = Volumen::Post.parse(File.read(File.join(@posts_dir, "global.md")))
assert_predicate saved, :all_langs?
end
def test_create_post_with_cover
login
get "/admin/posts/new"
post "/admin/posts",
"_csrf" => csrf(last_response.body), "title" => "Covered", "slug" => "covered",
"lang" => "en", "cover" => "/media/c.png", "body" => "Hi"
assert_equal 302, last_response.status
saved = Volumen::Post.parse(File.read(File.join(@posts_dir, "covered.md")))
assert_equal "/media/c.png", saved.cover
end
def test_preview_requires_login
post "/admin/preview", "body" => "**hi**"
assert_equal 302, last_response.status
end
def test_csrf_is_enforced
login
post "/admin/posts", "title" => "X", "slug" => "x", "body" => "y"
assert_equal 403, last_response.status
end
def test_image_upload_and_serving
login
get "/admin/posts/new"
token = csrf(last_response.body)
image_path = File.join(@dir, "pic.png")
File.binwrite(image_path, "PNGDATA")
post "/admin/uploads",
"_csrf" => token,
"file" => Rack::Test::UploadedFile.new(image_path, "image/png")
assert_predicate last_response, :ok?
url = JSON.parse(last_response.body)["url"]
assert_match(%r{\A/media/}, url)
get url
assert_predicate last_response, :ok?
assert_equal "PNGDATA", last_response.body
end
def test_settings_requires_login
get "/admin/settings"
assert_equal 302, last_response.status
end
def test_change_password
login
get "/admin/settings"
post "/admin/settings/password",
"_csrf" => csrf(last_response.body),
"current_password" => @password, "new_password" => "brand-new"
assert_includes last_response.body, "Password updated"
fresh = Volumen::Users.new(path: @users_path)
refute fresh.authenticate("admin", @password)
assert fresh.authenticate("admin", "brand-new")
end
def test_add_and_login_as_new_user
login
get "/admin/settings"
post "/admin/settings/users",
"_csrf" => csrf(last_response.body), "username" => "editor", "password" => "pw12345"
assert_includes last_response.body, "User added"
post "/admin/logout", "_csrf" => csrf(last_response.body)
get "/admin/login"
post "/admin/login",
"username" => "editor", "password" => "pw12345", "_csrf" => csrf(last_response.body)
assert_equal 302, last_response.status
end
def test_cannot_delete_self
login
get "/admin/settings"
post "/admin/settings/users/admin/delete", "_csrf" => csrf(last_response.body)
assert_includes last_response.body, "cannot delete your own account"
end
def test_admin_can_assign_role
login
add_user("writer", "pw12345", "author")
assert_includes last_response.body, "User added"
assert_equal "author", Volumen::Users.new(path: @users_path).find("writer").role
end
def test_author_cannot_manage_users
login
add_user("writer", "pw12345", "author")
logout
login_as("writer", "pw12345")
follow_redirect!
post "/admin/settings/users",
"_csrf" => csrf(last_response.body), "username" => "intruder", "password" => "pw12345"
assert_equal 403, last_response.status
end
def test_author_settings_omits_user_management
login
add_user("writer", "pw12345", "author")
logout
login_as("writer", "pw12345")
follow_redirect!
get "/admin/settings"
refute_includes last_response.body, "Add user"
end
def test_author_can_create_post
login
add_user("writer", "pw12345", "author")
logout
login_as("writer", "pw12345")
get "/admin/posts/new"
post "/admin/posts",
"_csrf" => csrf(last_response.body), "title" => "By author", "slug" => "by-author",
"lang" => "en", "body" => "Hi"
assert_equal 302, last_response.status
assert File.exist?(File.join(@posts_dir, "by-author.md"))
end
def test_login_rate_limit_sweep_removes_stale_entries
Volumen.login_attempts["1.2.3.4"] = [Time.now.to_f - 120]
Volumen.instance_variable_set(:@last_cleanup, Time.now.to_f - 600)
Volumen.sweep_login_attempts!
refute Volumen.login_attempts.key?("1.2.3.4"),
"sweep should remove IPs with only stale entries"
end
def test_create_post_rejects_invalid_slug
login
get "/admin/posts/new"
post "/admin/posts",
"_csrf" => csrf(last_response.body), "title" => "X", "slug" => "../../etc/passwd",
"lang" => "en", "body" => "y"
assert_includes last_response.body, "Invalid slug"
end
def test_create_post_rejects_xss_in_slug
login
get "/admin/posts/new"
post "/admin/posts",
"_csrf" => csrf(last_response.body), "title" => "X", "slug" => "foo\" onclick",
"lang" => "en", "body" => "y"
assert_includes last_response.body, "Invalid slug"
end
def test_change_username_rejects_special_chars
login
get "/admin/settings"
post "/admin/settings/username",
"_csrf" => csrf(last_response.body), "username" => "foo\" bar"
assert_includes last_response.body, "Username may use letters"
end
def test_change_username_accepts_same_name
login
get "/admin/settings"
post "/admin/settings/username",
"_csrf" => csrf(last_response.body), "username" => "admin"
assert_includes last_response.body, "Username unchanged"
end
def test_preview_requires_csrf
login
get "/admin/posts/new"
post "/admin/preview", "body" => "**hi**"
assert_equal 403, last_response.status
end
def test_orphaned_session_is_rejected
login
session_for = -> { rack_mock_session.cookie_jar["rack.session"].to_s }
refute_empty session_for.call, "expected a session cookie after login"
# Simulate a deleted user by clearing users.toml while keeping the cookie.
File.write(@users_path, "")
get "/admin/"
assert_equal 302, last_response.status
assert_equal "http://example.org/admin/login", last_response.location
end
private
def csrf(body)
body[/name="_csrf" value="([^"]+)"/, 1]
end
def login
get "/admin/login"
post "/admin/login",
"username" => "admin", "password" => @password, "_csrf" => csrf(last_response.body)
end
def login_as(username, password)
get "/admin/login"
post "/admin/login",
"username" => username, "password" => password, "_csrf" => csrf(last_response.body)
end
def logout
post "/admin/logout", "_csrf" => csrf(last_response.body)
end
def add_user(username, password, role)
get "/admin/settings"
post "/admin/settings/users",
"_csrf" => csrf(last_response.body),
"username" => username, "password" => password, "role" => role
end
end
-63
View File
@@ -1,63 +0,0 @@
# frozen_string_literal: true
require "test_helper"
require "tmpdir"
require "volumen/cli"
class CLITest < Minitest::Test
def test_version
assert_output("#{Volumen::VERSION}\n") do
Volumen::CLI.run(["version"])
end
end
def test_help
output, = capture_io { Volumen::CLI.run(["help"]) }
assert_includes output, "volumen #{Volumen::VERSION}"
assert_includes output, "Usage:"
end
def test_unknown_command
_output, err = capture_io do
assert_raises(SystemExit) { Volumen::CLI.run(["bogus"]) }
end
assert_includes err, "unknown command"
end
def test_parse_serve_defaults
cli = Volumen::CLI.new(["serve"])
options = cli.__send__(:parse_serve_options)
assert_equal Volumen::Config::DEFAULT_PATH, options[:config]
end
def test_parse_serve_overrides
cli = Volumen::CLI.new([
"serve", "--config", "/t.toml", "--host", "127.0.0.1", "--port", "9000"
])
options = cli.__send__(:parse_serve_options)
assert_equal "/t.toml", options[:config]
assert_equal "127.0.0.1", options[:host]
assert_equal 9000, options[:port]
end
def test_prepare_config_writes_template
Dir.mktmpdir do |dir|
path = File.join(dir, "config.toml")
cli = Volumen::CLI.new([])
_output, err = capture_io { cli.__send__(:prepare_config, path) }
assert_includes err, "wrote a default config"
assert_path_exists path
end
end
def test_prepare_config_noops_when_present
Dir.mktmpdir do |dir|
path = File.join(dir, "config.toml")
File.write(path, "[server]\nport = 1\n")
cli = Volumen::CLI.new([])
out, err = capture_io { cli.__send__(:prepare_config, path) }
assert_empty err
assert_empty out
end
end
end
-65
View File
@@ -1,65 +0,0 @@
# frozen_string_literal: true
require "test_helper"
require "tmpdir"
class ConfigTest < Minitest::Test
MISSING = "/nonexistent/volumen.toml"
def test_defaults_when_file_missing
config = Volumen::Config.load(path: MISSING)
assert_equal 9090, config.port
assert_equal "en", config.language
end
def test_overrides_apply
config = Volumen::Config.load(
path: MISSING,
overrides: { host: "127.0.0.1", port: 9000, content: "/srv/posts" }
)
assert_equal "127.0.0.1", config.host
assert_equal 9000, config.port
assert_equal "/srv/posts", config.content_dir
end
def test_does_not_mutate_defaults
Volumen::Config.load(path: MISSING, overrides: { host: "10.0.0.1" })
assert_equal "0.0.0.0", Volumen::Config.load(path: MISSING).host
end
def test_does_not_mutate_default_inner_hashes
config = Volumen::Config.load(path: MISSING)
config.site["title"] = "MUTATED"
fresh = Volumen::Config.load(path: MISSING)
assert_equal "My Blog", fresh.site["title"],
"mutating a loaded config must not poison DEFAULTS for later loads"
end
def test_loads_and_merges_file
Dir.mktmpdir do |dir|
path = File.join(dir, "config.toml")
File.write(path, "[site]\ntitle = \"Custom\"\n")
config = Volumen::Config.load(path: path)
assert_equal "Custom", config.site["title"]
assert_equal 9090, config.port
end
end
def test_generate_default_writes_template
Dir.mktmpdir do |dir|
path = File.join(dir, "sub", "config.toml")
assert_equal path, Volumen::Config.generate_default(path)
assert_path_exists path
assert_equal 9090, Volumen::Config.load(path: path).port
end
end
def test_generate_default_skips_existing
Dir.mktmpdir do |dir|
path = File.join(dir, "config.toml")
File.write(path, "[server]\nport = 9999\n")
assert_nil Volumen::Config.generate_default(path)
assert_equal 9999, Volumen::Config.load(path: path).port
end
end
end
-47
View File
@@ -1,47 +0,0 @@
# frozen_string_literal: true
require "test_helper"
class FrontmatterTest < Minitest::Test
def test_parse_extracts_metadata_and_body
content = <<~POST
+++
title = "Hello"
tags = ["a", "b"]
+++
Body text.
POST
metadata, body = Volumen::Frontmatter.parse(content)
assert_equal "Hello", metadata["title"]
assert_equal %w[a b], metadata["tags"]
assert_equal "Body text.\n", body
end
def test_parse_without_frontmatter
metadata, body = Volumen::Frontmatter.parse("# Just markdown\n")
assert_empty metadata
assert_equal "# Just markdown\n", body
end
def test_parse_without_closing_delimiter
content = "+++\ntitle = \"x\"\n\nbody"
metadata, body = Volumen::Frontmatter.parse(content)
assert_empty metadata
assert_equal content, body
end
def test_round_trip
metadata = { "title" => "Hello", "tags" => ["intro"], "draft" => false }
dumped = Volumen::Frontmatter.dump(metadata, "Hello **world**.")
parsed_meta, parsed_body = Volumen::Frontmatter.parse(dumped)
assert_equal metadata, parsed_meta
assert_equal "Hello **world**.", parsed_body.strip
end
def test_parse_normalises_crlf
metadata, body = Volumen::Frontmatter.parse("+++\r\ntitle = \"x\"\r\n+++\r\n\r\nBody\r\n")
assert_equal "x", metadata["title"]
assert_equal "Body\n", body
end
end
-33
View File
@@ -1,33 +0,0 @@
# frozen_string_literal: true
require "test_helper"
class MarkdownTest < Minitest::Test
def test_renders_bold
assert_includes Volumen::Markdown.render("**bold**"), "<strong>bold</strong>"
end
def test_renders_heading
assert_includes Volumen::Markdown.render("# Title"), "<h1"
end
def test_renders_gfm_table
table = <<~MD
| A | B |
|---|---|
| 1 | 2 |
MD
assert_includes Volumen::Markdown.render(table), "<table"
end
def test_renders_fenced_code_block
code = <<~MD
```ruby
puts 1
```
MD
html = Volumen::Markdown.render(code)
assert_includes html, "<pre"
assert_includes html, "<code"
end
end
-24
View File
@@ -1,24 +0,0 @@
# frozen_string_literal: true
require "test_helper"
class PasswordTest < Minitest::Test
def test_hash_and_verify_round_trip
encoded = Volumen::Password.hash("s3cret")
assert Volumen::Password.verify("s3cret", encoded)
end
def test_verify_rejects_wrong_password
encoded = Volumen::Password.hash("s3cret")
refute Volumen::Password.verify("nope", encoded)
end
def test_verify_rejects_malformed_input
refute Volumen::Password.verify("x", "not-a-hash")
refute Volumen::Password.verify("x", "")
end
def test_encoded_format
assert Volumen::Password.hash("pw").start_with?("scrypt$16384$8$1$")
end
end
-80
View File
@@ -1,80 +0,0 @@
# frozen_string_literal: true
require "test_helper"
class PostTest < Minitest::Test
def test_parse_builds_post
content = <<~POST
+++
title = "Hello"
slug = "hello"
lang = "en"
tags = ["intro", "ruby"]
date = 2026-01-15
draft = true
+++
First paragraph.
POST
post = Volumen::Post.parse(content)
assert_equal "hello", post.slug
assert_equal "Hello", post.title
assert_equal "en", post.lang
assert_equal %w[intro ruby], post.tags
assert_predicate post, :draft?
assert_equal "2026-01-15", post.date_string
end
def test_excerpt_derived_from_body
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "# Heading\n\nThe body paragraph.")
assert_equal "The body paragraph.", post.excerpt
end
def test_derived_excerpt_strips_html_tags
body = "<strong>Bold</strong> and <em>italic</em>."
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: body)
assert_equal "Bold and italic.", post.excerpt
end
def test_summary_shape
post = Volumen::Post.new(metadata: { "slug" => "x", "title" => "X" }, body: "Body")
summary = post.summary
assert_equal "/api/volumen/posts/x", summary["url"]
assert_equal "X", summary["title"]
refute_predicate post, :draft?
end
def test_detail_includes_rendered_html
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "**hi**")
assert_includes post.detail["html"], "<strong>hi</strong>"
end
def test_summary_includes_cover_and_reading_time
post = Volumen::Post.new(
metadata: { "slug" => "x", "cover" => "/media/c.png" },
body: ("word " * 250)
)
assert_equal "/media/c.png", post.summary["cover"]
assert_equal 2, post.summary["reading_time"]
end
def test_reading_time_is_at_least_one
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "short")
assert_equal 1, post.reading_time
end
def test_scheduled_post_is_not_scheduled_when_publish_at_is_past
post = Volumen::Post.new(metadata: { "publish_at" => Date.today - 1 })
refute_predicate post, :scheduled?
end
def test_scheduled_post_is_scheduled_when_publish_at_is_future
post = Volumen::Post.new(metadata: { "publish_at" => Date.today + 7 })
assert_predicate post, :scheduled?
end
def test_post_without_publish_at_is_not_scheduled
post = Volumen::Post.new(metadata: {})
refute_predicate post, :scheduled?
end
end
-181
View File
@@ -1,181 +0,0 @@
# frozen_string_literal: true
require "test_helper"
require "volumen/server"
require "rack/test"
require "tmpdir"
require "fileutils"
require "json"
class ServerTest < Minitest::Test
include Rack::Test::Methods
def app
Volumen::Server.configured(config: @config, store: @store)
end
def setup
@dir = Dir.mktmpdir
File.write(File.join(@dir, "hello.md"), <<~POST)
+++
title = "Hello"
tags = ["intro"]
+++
Hello **world**.
POST
File.write(File.join(@dir, "draft.md"), <<~POST)
+++
title = "Draft"
draft = true
+++
Secret.
POST
@config = Volumen::Config.load(path: "/nonexistent", overrides: { content: @dir })
@store = Volumen::Store.new(@dir, default_lang: "en")
end
def teardown
FileUtils.remove_entry(@dir)
end
def test_site_endpoint
get "/api/volumen/site"
assert_predicate last_response, :ok?
assert_equal "My Blog", JSON.parse(last_response.body)["title"]
end
def test_posts_excludes_drafts
get "/api/volumen/posts"
data = JSON.parse(last_response.body)
assert_equal 1, data["total"]
assert_equal "hello", data["posts"].first["slug"]
end
def test_post_detail_renders_html
get "/api/volumen/posts/hello"
assert_predicate last_response, :ok?
assert_includes JSON.parse(last_response.body)["html"], "<strong>world</strong>"
end
def test_post_not_found
get "/api/volumen/posts/missing"
assert_equal 404, last_response.status
assert_equal "not_found", JSON.parse(last_response.body)["error"]
end
def test_tags_endpoint
get "/api/volumen/tags"
assert_equal [{ "name" => "intro", "count" => 1 }], JSON.parse(last_response.body)["tags"]
end
def test_cors_header_present
get "/api/volumen/site"
header = last_response.headers["Access-Control-Allow-Origin"] ||
last_response.headers["access-control-allow-origin"]
assert_equal "*", header
end
def test_all_langs_post_appears_in_other_languages
File.write(File.join(@dir, "global.md"), <<~POST)
+++
title = "Global"
lang = "en"
all_langs = true
+++
Everywhere.
POST
get "/api/volumen/posts?lang=cs"
slugs = JSON.parse(last_response.body)["posts"].map { |post| post["slug"] }
assert_includes slugs, "global"
refute_includes slugs, "hello"
end
def test_feed_endpoint
get "/api/volumen/feed.xml"
assert_predicate last_response, :ok?
assert_equal "application/rss+xml", last_response["Content-Type"]&.split(";")&.first
body = last_response.body
assert_includes body, "<rss version=\"2.0\">"
assert_includes body, "<title>Hello</title>"
refute_includes body, "Draft"
assert_includes body, "<language>en</language>"
end
def test_draft_post_detail_is_not_found
get "/api/volumen/posts/draft"
assert_equal 404, last_response.status
assert_equal "not_found", JSON.parse(last_response.body)["error"]
end
def test_sitemap_endpoint
get "/api/volumen/sitemap.xml"
assert_predicate last_response, :ok?
assert_equal "application/xml", last_response["Content-Type"]&.split(";")&.first
body = last_response.body
assert_includes body, "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">"
assert_includes body, "<loc>https://example.com/hello</loc>"
refute_includes body, "draft"
end
def test_cors_preflight
options "/api/volumen/site"
assert_predicate last_response, :ok?
assert_equal "*", last_response.headers["Access-Control-Allow-Origin"]
assert_equal "GET, OPTIONS", last_response.headers["Access-Control-Allow-Methods"]
end
def test_scheduled_post_is_hidden
future = (Date.today + 30).strftime("%Y-%m-%d")
File.write(File.join(@dir, "later.md"), <<~POST)
+++
title = "Later"
publish_at = #{future}
+++
Not yet.
POST
get "/api/volumen/posts"
slugs = JSON.parse(last_response.body)["posts"].map { |post| post["slug"] }
refute_includes slugs, "later"
end
def test_json_feed_endpoint
get "/api/volumen/feed.json"
assert_predicate last_response, :ok?
data = JSON.parse(last_response.body)
assert_equal "https://jsonfeed.org/version/1.1", data["version"]
assert_equal "My Blog", data["title"]
assert_equal 1, data["items"].length
assert_equal "Hello", data["items"].first["title"]
assert_includes data["items"].first["content_html"], "<strong>world</strong>"
refute_includes data["items"].map { |i| i["title"] }, "Draft"
end
def test_search_by_title
get "/api/volumen/posts?q=Hello"
data = JSON.parse(last_response.body)
assert_equal 1, data["total"]
assert_equal "hello", data["posts"].first["slug"]
end
def test_search_by_body
get "/api/volumen/posts?q=world"
data = JSON.parse(last_response.body)
assert_equal 1, data["total"]
end
def test_search_no_match
get "/api/volumen/posts?q=nonexistent"
data = JSON.parse(last_response.body)
assert_equal 0, data["total"]
end
def test_search_is_case_insensitive
get "/api/volumen/posts?q=HELLO"
data = JSON.parse(last_response.body)
assert_equal 1, data["total"]
end
end
-73
View File
@@ -1,73 +0,0 @@
# frozen_string_literal: true
require "test_helper"
require "tmpdir"
require "fileutils"
class StoreTest < Minitest::Test
def setup
@dir = Dir.mktmpdir
File.write(File.join(@dir, "hello.md"), "+++\ntitle = \"Hello\"\n+++\n\nBody\n")
FileUtils.mkdir_p(File.join(@dir, "cs"))
File.write(File.join(@dir, "cs", "ahoj.md"), "+++\ntitle = \"Ahoj\"\n+++\n\nTelo\n")
end
def teardown
FileUtils.remove_entry(@dir)
end
def test_all_loads_every_post
assert_equal 2, Volumen::Store.new(@dir).all.length
end
def test_slug_falls_back_to_filename
slugs = Volumen::Store.new(@dir).all.map(&:slug).sort
assert_equal %w[ahoj hello], slugs
end
def test_lang_derived_from_subdirectory
store = Volumen::Store.new(@dir, default_lang: "en")
assert_equal "cs", store.find("ahoj").lang
assert_equal "en", store.find("hello").lang
end
def test_find_returns_nil_when_missing
assert_nil Volumen::Store.new(@dir).find("nope")
end
def test_all_langs_post_is_visible_in_any_language
File.write(File.join(@dir, "global.md"), <<~POST)
+++
title = "Global"
lang = "en"
all_langs = true
+++
Visible everywhere.
POST
store = Volumen::Store.new(@dir, default_lang: "en")
assert store.find("global", lang: "cs")
assert store.find("global", lang: "en")
end
def test_save_rejects_path_traversal_slug
store = Volumen::Store.new(@dir)
post = Volumen::Post.new(metadata: { "slug" => "../../etc/passwd" }, body: "x")
assert_raises(ArgumentError, "slug escapes content directory") { store.save(post) }
end
def test_save_uses_lang_subdirectory_for_non_default_language
store = Volumen::Store.new(@dir, default_lang: "en")
post = Volumen::Post.new(metadata: { "slug" => "bonjour", "lang" => "fr" }, body: "x")
store.save(post)
assert File.exist?(File.join(@dir, "fr", "bonjour.md"))
end
def test_save_uses_root_for_default_language
store = Volumen::Store.new(@dir, default_lang: "en")
post = Volumen::Post.new(metadata: { "slug" => "hello-new", "lang" => "en" }, body: "x")
store.save(post)
assert File.exist?(File.join(@dir, "hello-new.md"))
refute File.exist?(File.join(@dir, "en", "hello-new.md"))
end
end
-4
View File
@@ -1,4 +0,0 @@
# frozen_string_literal: true
require "minitest/autorun"
require "volumen"
-114
View File
@@ -1,114 +0,0 @@
# frozen_string_literal: true
require "test_helper"
require "tmpdir"
require "fileutils"
class UsersTest < Minitest::Test
def setup
@dir = Dir.mktmpdir
@path = File.join(@dir, "users.toml")
end
def teardown
FileUtils.remove_entry(@dir)
end
def test_bootstrap_user_from_hash
hash = Volumen::Password.hash("secret")
users = Volumen::Users.new(path: @path, bootstrap_hash: hash)
assert_equal 1, users.all.length
assert users.authenticate("admin", "secret")
end
def test_no_users_without_file_or_hash
refute_predicate Volumen::Users.new(path: @path), :any?
end
def test_add_and_authenticate
users = Volumen::Users.new(path: @path)
assert users.add("alice", "wonderland")
assert_path_exists @path
assert users.authenticate("alice", "wonderland")
refute users.authenticate("alice", "nope")
end
def test_add_rejects_duplicate
users = Volumen::Users.new(path: @path)
users.add("alice", "x")
assert_nil users.add("alice", "y")
end
def test_update_password
users = Volumen::Users.new(path: @path)
users.add("alice", "old")
users.update_password("alice", "new")
refute users.authenticate("alice", "old")
assert users.authenticate("alice", "new")
end
def test_rename
users = Volumen::Users.new(path: @path)
users.add("alice", "x")
assert users.rename("alice", "alicia")
assert_nil users.find("alice")
assert users.find("alicia")
end
def test_delete_keeps_last_user
users = Volumen::Users.new(path: @path)
users.add("alice", "x")
assert_nil users.delete("alice")
users.add("bob", "y")
assert users.delete("bob")
assert_nil users.find("bob")
end
def test_default_role_is_author
users = Volumen::Users.new(path: @path)
users.add("alice", "x")
assert_equal "author", users.find("alice").role
end
def test_add_with_explicit_role
users = Volumen::Users.new(path: @path)
users.add("boss", "x", "admin")
assert_equal "admin", users.find("boss").role
end
def test_invalid_role_falls_back_to_default
users = Volumen::Users.new(path: @path)
users.add("alice", "x", "wizard")
assert_equal "author", users.find("alice").role
end
def test_bootstrap_user_is_admin
users = Volumen::Users.new(path: @path, bootstrap_hash: Volumen::Password.hash("s"))
assert_equal "admin", users.find("admin").role
end
def test_set_role
users = Volumen::Users.new(path: @path)
users.add("alice", "x", "admin")
users.add("bob", "y", "author")
assert users.set_role("bob", "admin")
assert_equal "admin", users.find("bob").role
end
def test_cannot_demote_last_admin
users = Volumen::Users.new(path: @path)
users.add("alice", "x", "admin")
users.add("bob", "y", "author")
assert_nil users.set_role("alice", "author")
assert_equal "admin", users.find("alice").role
end
def test_cannot_delete_last_admin
users = Volumen::Users.new(path: @path)
users.add("alice", "x", "admin")
users.add("bob", "y", "author")
assert_nil users.delete("alice")
users.add("carol", "z", "admin")
assert users.delete("alice")
end
end
-13
View File
@@ -1,13 +0,0 @@
# frozen_string_literal: true
require "test_helper"
class VolumenTest < Minitest::Test
def test_version_is_defined
refute_nil Volumen::VERSION
end
def test_version_is_semver
assert_match(/\A\d+\.\d+\.\d+\z/, Volumen::VERSION)
end
end
View File
+125
View File
@@ -0,0 +1,125 @@
"""Shared fixtures for the volumen test suite."""
from __future__ import annotations
import tempfile
from collections.abc import Generator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from volumen import reset_login_attempts
from volumen.config import Config
from volumen.password import hash_password as hash_pw
from volumen.store import Store
from volumen.users import Users
@pytest.fixture(autouse=True)
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]:
"""Create a temporary directory that is cleaned up after the test."""
with tempfile.TemporaryDirectory() as d:
yield Path(d)
@pytest.fixture
def tmp_content_dir(tmp_path: Path) -> Path:
"""Temporary content directory with sample posts."""
content = tmp_path / "posts"
content.mkdir()
(content / "hello.md").write_text(
'+++\ntitle = "Hello"\ntags = ["intro"]\n+++\n\nHello **world**.\n'
)
(content / "draft.md").write_text('+++\ntitle = "Draft"\ndraft = true\n+++\n\nSecret.\n')
return content
@pytest.fixture
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), "users_file": users_file},
)
@pytest.fixture
def store(tmp_content_dir: Path) -> Store:
"""Test Store instance backed by a temp content directory."""
return Store(str(tmp_content_dir), default_lang="en")
@pytest.fixture
def users_path(tmp_path: Path) -> str:
"""Path for a temporary users.toml file."""
return str(tmp_path / "users.toml")
@pytest.fixture
def bootstrap_hash() -> str:
"""A pre-hashed password for bootstrap tests."""
return hash_pw("volumen-test-password")
@pytest.fixture
def users(users_path: str, bootstrap_hash: str) -> Users:
"""Test Users instance with bootstrap admin user."""
return Users(path=users_path, bootstrap_hash=bootstrap_hash)
@pytest.fixture
def client(config: Config, store: Store) -> TestClient:
"""FastAPI TestClient for public API tests."""
from volumen.app import create_app
app = create_app(config, store)
return TestClient(app)
@pytest.fixture
def admin_config_dir(tmp_path: Path) -> Path:
"""Create a directory with config and posts for admin tests."""
posts_dir = tmp_path / "posts"
posts_dir.mkdir()
users_path = tmp_path / "users.toml"
config_path = tmp_path / "config.toml"
pw_hash = hash_pw("volumen-test-password")
config_path.write_text(
f'content_dir = "{posts_dir}"\n'
f'users_file = "{users_path}"\n\n'
"[admin]\n"
f'password_hash = "{pw_hash}"\n'
)
return tmp_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.store import Store
config = Config.load(path=str(admin_config_dir / "config.toml"))
store = Store(config.content_dir, default_lang="en")
app = create_app(config, store)
return TestClient(app)
@pytest.fixture
def sample_post_md() -> str:
"""Sample post in Markdown with TOML frontmatter."""
return (
'+++\ntitle = "Test Post"\nslug = "test-post"\nlang = "en"\n'
'tags = ["python", "blog"]\ndate = 2026-01-15\ndraft = false\n+++\n\n'
"This is the **body** of the post.\n\nSecond paragraph."
)
+436
View File
@@ -0,0 +1,436 @@
"""Tests for the admin routes."""
from __future__ import annotations
import re
from pathlib import Path
from fastapi.testclient import TestClient
from volumen.users import Users
def _csrf_from_body(body: str) -> str:
match = re.search(r'name="_csrf" value="([^"]+)"', body)
if match:
return match.group(1)
return ""
class TestAdminLogin:
def test_admin_redirect_to_login(self, admin_client: TestClient) -> None:
response = admin_client.get("/admin/", follow_redirects=False)
assert response.status_code in (302, 307, 301, 303)
def test_login_page_renders(self, admin_client: TestClient) -> None:
response = admin_client.get("/admin/login")
assert response.status_code == 200
assert "Sign in" in response.text
def test_login_with_valid_credentials(self, admin_client: TestClient) -> None:
resp = admin_client.get("/admin/login")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/login",
data={
"username": "admin",
"password": "volumen-test-password",
"_csrf": token,
},
follow_redirects=False,
)
assert resp.status_code in (302, 307, 303)
def test_login_with_invalid_credentials(self, admin_client: TestClient) -> None:
resp = admin_client.get("/admin/login")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/login",
data={"username": "admin", "password": "nope", "_csrf": token},
)
assert "Invalid" in resp.text or "invalid" in resp.text.lower()
def test_wrong_password_is_rejected(self, admin_client: TestClient) -> None:
resp = admin_client.get("/admin/login")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/login",
data={"username": "admin", "password": "nope", "_csrf": token},
)
assert "Invalid username or password" in resp.text
def _login(client: TestClient) -> str:
"""Log in as admin and return the session cookie value."""
resp = client.get("/admin/login")
token = _csrf_from_body(resp.text)
client.post(
"/admin/login",
data={
"username": "admin",
"password": "volumen-test-password",
"_csrf": token,
},
follow_redirects=True,
)
return token
def _login_as(client: TestClient, username: str, password: str) -> str:
resp = client.get("/admin/login")
token = _csrf_from_body(resp.text)
client.post(
"/admin/login",
data={"username": username, "password": password, "_csrf": token},
follow_redirects=True,
)
return token
class TestAdminPostList:
def test_post_list_requires_login(self, admin_client: TestClient) -> None:
resp = admin_client.get("/admin/", follow_redirects=False)
assert resp.status_code in (302, 307, 303)
def test_post_list_after_login(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/")
assert resp.status_code == 200
assert "Posts" in resp.text
class TestCreatePost:
def test_create_post_writes_file(
self, admin_client: TestClient, admin_config_dir: Path
) -> None:
_login(admin_client)
resp = admin_client.get("/admin/posts/new")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/posts",
data={
"_csrf": token,
"title": "Test",
"slug": "test",
"lang": "en",
"tags": "a, b",
"body": "Hello **world**.",
},
follow_redirects=False,
)
assert resp.status_code in (302, 307, 303)
post_path = admin_config_dir / "posts" / "test.md"
assert post_path.exists()
content = post_path.read_text()
assert "Test" in content
assert "a, b" in content or "a" in content
def test_create_post_with_all_langs(
self, admin_client: TestClient, admin_config_dir: Path
) -> None:
_login(admin_client)
resp = admin_client.get("/admin/posts/new")
token = _csrf_from_body(resp.text)
admin_client.post(
"/admin/posts",
data={
"_csrf": token,
"title": "Global",
"slug": "global",
"lang": "en",
"all_langs": "on",
"body": "Hi",
},
follow_redirects=False,
)
post_path = admin_config_dir / "posts" / "global.md"
assert post_path.exists()
content = post_path.read_text()
assert "all_langs" in content
def test_create_post_with_cover(self, admin_client: TestClient, admin_config_dir: Path) -> None:
_login(admin_client)
resp = admin_client.get("/admin/posts/new")
token = _csrf_from_body(resp.text)
admin_client.post(
"/admin/posts",
data={
"_csrf": token,
"title": "Covered",
"slug": "covered",
"lang": "en",
"cover": "/media/c.png",
"body": "Hi",
},
follow_redirects=False,
)
post_path = admin_config_dir / "posts" / "covered.md"
content = post_path.read_text()
assert "/media/c.png" in content
def test_create_post_rejects_invalid_slug(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/posts/new")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/posts",
data={
"_csrf": token,
"title": "X",
"slug": "../../etc/passwd",
"lang": "en",
"body": "y",
},
)
assert "Invalid slug" in resp.text
def test_create_post_rejects_xss_in_slug(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/posts/new")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/posts",
data={
"_csrf": token,
"title": "X",
"slug": 'foo" onclick',
"lang": "en",
"body": "y",
},
)
assert "Invalid slug" in resp.text
def test_create_post_duplicate_slug(
self, admin_client: TestClient, admin_config_dir: Path
) -> None:
(admin_config_dir / "posts" / "dup.md").write_text(
'+++\ntitle = "Existing"\n+++\n\nBody.\n'
)
_login(admin_client)
resp = admin_client.get("/admin/posts/new")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/posts",
data={
"_csrf": token,
"title": "Dup",
"slug": "dup",
"lang": "en",
"body": "x",
},
)
assert "already exists" in resp.text.lower()
class TestCSRF:
def test_csrf_token_required(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.post(
"/admin/posts",
data={"title": "X", "slug": "x", "body": "y"},
)
assert resp.status_code == 403
def test_invalid_csrf_token_rejected(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/posts/new")
resp = admin_client.post(
"/admin/posts",
data={
"_csrf": "invalid-token",
"title": "X",
"slug": "x",
"body": "y",
},
)
assert resp.status_code == 403
class TestLogout:
def test_logout_clears_session(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/")
assert resp.status_code == 200
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
admin_client.post("/admin/logout", data={"_csrf": token})
resp = admin_client.get("/admin/", follow_redirects=False)
assert resp.status_code in (302, 307, 303)
class TestSettings:
def test_settings_requires_login(self, admin_client: TestClient) -> None:
resp = admin_client.get("/admin/settings", follow_redirects=False)
assert resp.status_code in (302, 307, 303)
def test_change_password(self, admin_client: TestClient, admin_config_dir: Path) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/password",
data={
"_csrf": token,
"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", "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)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/password",
data={
"_csrf": token,
"current_password": "wrong",
"new_password": "brand-new",
},
)
assert "incorrect" in resp.text.lower()
def test_change_username(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/username",
data={"_csrf": token, "username": "superadmin"},
)
assert "updated" in resp.text.lower()
def test_change_username_accepts_same_name(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/username",
data={"_csrf": token, "username": "admin"},
)
assert "unchanged" in resp.text.lower()
def test_change_username_rejects_special_chars(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/username",
data={"_csrf": token, "username": 'foo" bar'},
)
assert "letters" in resp.text.lower()
def test_change_display_name(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/name",
data={"_csrf": token, "name": "Admin User"},
)
assert "Display name updated" in resp.text
class TestFediverseSettings:
def test_change_fediverse_creator_valid(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/fediverse",
data={"_csrf": token, "fediverse_creator": "@user@example.com"},
)
assert "updated" in resp.text.lower()
def test_change_fediverse_creator_invalid(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/fediverse",
data={"_csrf": token, "fediverse_creator": "not-a-handle"},
)
assert "handle" in resp.text.lower()
class TestUserManagement:
def test_create_user_admin_only(self, admin_client: TestClient, admin_config_dir: Path) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/users",
data={
"_csrf": token,
"username": "editor",
"password": "editor-test-pw",
"role": "author",
},
)
assert "User added" in resp.text
users_path = str(admin_config_dir / "users.toml")
users = Users(path=users_path)
assert users.find("editor") is not None
def test_create_user_then_login(self, admin_client: TestClient, admin_config_dir: Path) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
admin_client.post(
"/admin/settings/users",
data={"_csrf": token, "username": "editor2", "password": "editor-test-pw"},
)
# Logout
resp = admin_client.get("/admin/login")
token = _csrf_from_body(resp.text)
admin_client.post("/admin/logout", data={"_csrf": token})
# Login as new user
_login_as(admin_client, "editor2", "editor-test-pw")
resp = admin_client.get("/admin/")
assert resp.status_code == 200
def test_cannot_delete_own_account(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/users/admin/delete",
data={"_csrf": token},
)
assert "cannot delete your own" in resp.text.lower()
def test_cannot_create_user_with_empty_fields(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/users",
data={"_csrf": token, "username": "", "password": ""},
)
assert "required" in resp.text.lower() or "empty" in resp.text.lower()
class TestOrphanedSession:
def test_orphaned_session_is_rejected(
self, admin_client: TestClient, admin_config_dir: Path
) -> None:
_login(admin_client)
resp = admin_client.get("/admin/")
assert resp.status_code == 200
# Clear users file to simulate deleted user
users_path = admin_config_dir / "users.toml"
users_path.write_text("")
resp = admin_client.get("/admin/", follow_redirects=False)
assert resp.status_code in (302, 307, 303)
+512
View File
@@ -0,0 +1,512 @@
"""Tests for CLI entry point."""
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
class TestVersionCommand:
def test_version_output(self, capsys: pytest.CaptureFixture[str]) -> None:
with (
mock.patch.object(sys, "argv", ["volumen", "version"]),
contextlib.suppress(SystemExit),
):
main()
captured = capsys.readouterr()
assert VERSION in captured.out
class TestHelpCommand:
def test_help_output(self, capsys: pytest.CaptureFixture[str]) -> None:
with mock.patch.object(sys, "argv", ["volumen", "--help"]), contextlib.suppress(SystemExit):
main()
captured = capsys.readouterr()
assert "volumen" in captured.out or "usage" in captured.out.lower()
class TestUnknownCommand:
def test_unknown_command_exits(self) -> None:
with mock.patch.object(sys, "argv", ["volumen", "bogus"]), pytest.raises(SystemExit):
main()
class TestParseServeOptions:
def test_parse_serve_defaults(self) -> None:
with mock.patch.object(sys, "argv", ["volumen", "serve"]):
import argparse
parser = argparse.ArgumentParser()
sp = parser.add_subparsers(dest="command")
sp_serve = sp.add_parser("serve")
sp_serve.add_argument("--config", default="/etc/volumen/config.toml")
sp_serve.add_argument("--host", default=None)
sp_serve.add_argument("--port", type=int, default=None)
sp_serve.add_argument("--content", default=None)
args = parser.parse_args(["serve"])
assert args.config == "/etc/volumen/config.toml"
def test_parse_serve_overrides(self) -> None:
import argparse
parser = argparse.ArgumentParser()
sp = parser.add_subparsers(dest="command")
sp_serve = sp.add_parser("serve")
sp_serve.add_argument("--config", default="/etc/volumen/config.toml")
sp_serve.add_argument("--host", default=None)
sp_serve.add_argument("--port", type=int, default=None)
sp_serve.add_argument("--content", default=None)
args = parser.parse_args(
["serve", "--config", "/t.toml", "--host", "127.0.0.1", "--port", "9000"]
)
assert args.config == "/t.toml"
assert args.host == "127.0.0.1"
assert args.port == 9000
class TestPrepareConfig:
def test_prepare_config_writes_template(self, tmp_path: Path) -> None:
from volumen.config import Config
path = tmp_path / "config.toml"
result = Config.generate_default(str(path))
assert result == str(path)
assert path.exists()
def test_prepare_config_noops_when_present(self, tmp_path: Path) -> None:
from volumen.config import Config
path = tmp_path / "config.toml"
path.write_text("[server]\nport = 1\n")
result = Config.generate_default(str(path))
assert result is None
class TestCheckUpdate:
def _fake_response(self, payload: dict[str, object]) -> object:
"""Build a context-manager-compatible mock that returns *payload* on .read()."""
response = mock.MagicMock()
response.read.return_value = json.dumps(payload).encode("utf-8")
response.__enter__.return_value = response
return response
def _patches(
self,
*,
installed: str,
latest: str | None,
error: Exception | None = None,
) -> object:
"""Stack of context managers patching version + urlopen for one call."""
from contextlib import ExitStack
stack = ExitStack()
stack.enter_context(
mock.patch.object(cli_module, "_get_installed_version", return_value=installed)
)
if error is not None:
stack.enter_context(
mock.patch.object(cli_module.urllib.request, "urlopen", side_effect=error)
)
else:
stack.enter_context(
mock.patch.object(
cli_module.urllib.request,
"urlopen",
return_value=self._fake_response({"info": {"version": latest}}),
)
)
return stack
def test_update_available(self, capsys: pytest.CaptureFixture[str]) -> None:
args = argparse.Namespace(json=False)
with self._patches(installed="0.4.0", latest="0.5.0"):
rc = cli_module._check_update(args)
captured = capsys.readouterr()
assert rc == 1
assert "0.4.0" in captured.out
assert "0.5.0" in captured.out
assert "uv tool upgrade volumen" in captured.out
def test_up_to_date(self, capsys: pytest.CaptureFixture[str]) -> None:
args = argparse.Namespace(json=False)
with self._patches(installed="0.5.0", latest="0.5.0"):
rc = cli_module._check_update(args)
captured = capsys.readouterr()
assert rc == 0
assert "up to date" in captured.out
def test_local_ahead_of_remote(self, capsys: pytest.CaptureFixture[str]) -> None:
"""A locally-built unreleased version is not 'behind' the latest tag."""
args = argparse.Namespace(json=False)
with self._patches(installed="1.0.0a1", latest="0.9.0"):
rc = cli_module._check_update(args)
captured = capsys.readouterr()
assert rc == 0
assert "up to date" in captured.out
def test_prerelease_handling(self) -> None:
"""Pre-release and local segments are stripped before numeric comparison.
Implementation is intentionally conservative — ``0.5.0a1`` and ``0.5.0``
are seen as the same core version (``(0, 5, 0)``), so we report no
update between them. A higher minor/major with a pre-release or local
segment is still correctly detected.
"""
assert cli_module._compare_versions("0.5.0", "0.5.1a1+build") is True
assert cli_module._compare_versions("0.5.0+a", "0.5.0+a") is False
assert cli_module._compare_versions("0.5.0+a", "0.5.1") is True
def test_offline_returns_exit_code_2(self, capsys: pytest.CaptureFixture[str]) -> None:
args = argparse.Namespace(json=False)
with self._patches(
installed="0.4.0",
latest=None,
error=urllib.error.URLError("no internet"),
):
rc = cli_module._check_update(args)
captured = capsys.readouterr()
assert rc == 2
assert "could not reach" in captured.err
assert "0.4.0" in captured.err
def test_json_output_with_update_available(self, capsys: pytest.CaptureFixture[str]) -> None:
args = argparse.Namespace(json=True)
with self._patches(installed="0.4.0", latest="0.5.0"):
rc = cli_module._check_update(args)
captured = capsys.readouterr()
assert rc == 1
doc = json.loads(captured.out)
assert doc["current_version"] == "0.4.0"
assert doc["latest_version"] == "0.5.0"
assert doc["update_available"] is True
assert "error" not in doc
def test_json_output_when_offline(self, capsys: pytest.CaptureFixture[str]) -> None:
args = argparse.Namespace(json=True)
with self._patches(
installed="0.4.0",
latest=None,
error=urllib.error.URLError("dns failure"),
):
rc = cli_module._check_update(args)
captured = capsys.readouterr()
assert rc == 2
doc = json.loads(captured.out)
assert doc["current_version"] == "0.4.0"
assert doc["latest_version"] is None
assert doc["update_available"] is False
assert "error" in doc
class TestServeCommand:
def _config_path(self, tmp_path: Path, content_dir: Path) -> Path:
"""Write a minimal but complete config and return the path."""
config_path = tmp_path / "config.toml"
users_file = tmp_path / "users.toml"
config_path.write_text(
f'content_dir = "{content_dir}"\n'
f'users_file = "{users_file}"\n'
"[admin]\n"
'password_hash = ""\n'
)
return config_path
def test_serve_loads_config_and_invokes_uvicorn(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
content_dir = tmp_path / "posts"
content_dir.mkdir()
config_path = self._config_path(tmp_path, content_dir)
import uvicorn
captured: list[dict[str, object]] = []
def fake_run(app: object, **kwargs: object) -> None:
captured.append({"app": app, **kwargs})
monkeypatch.setattr(uvicorn, "run", fake_run)
args = argparse.Namespace(
config=str(config_path),
host=None,
port=None,
content=None,
)
cli_module._serve(args)
assert len(captured) == 1
call = captured[0]
assert call["host"] == "::"
assert call["port"] == 9090
assert call["log_level"] == "info"
from fastapi import FastAPI
assert isinstance(call["app"], FastAPI)
def test_serve_applies_host_and_port_overrides(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
content_dir = tmp_path / "posts"
content_dir.mkdir()
config_path = self._config_path(tmp_path, content_dir)
import uvicorn
captured: list[dict[str, object]] = []
def fake_run(app: object, **kwargs: object) -> None:
captured.append(kwargs)
monkeypatch.setattr(uvicorn, "run", fake_run)
args = argparse.Namespace(
config=str(config_path),
host="127.0.0.1",
port=9000,
content=None,
)
cli_module._serve(args)
assert captured[0]["host"] == "127.0.0.1"
assert captured[0]["port"] == 9000
class TestStatusCommand:
def test_status_human_output_with_issues(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
info = {
"config_path": "/etc/volumen/config.toml",
"data_dir": "/var/lib/volumen/posts",
"users_file": "/var/lib/volumen/users.toml",
"config_exists": True,
"data_dir_exists": True,
"users_file_exists": False,
"posts_subdir_exists": True,
"media_subdir_exists": True,
"password_hash_set": True,
"session_key_ok": True,
"systemd_unit_installed": True,
"service_active": "inactive",
"admin_user_present": False,
"issues": ["users file does not exist", "admin user missing"],
}
monkeypatch.setattr(cli_module, "_status", _FakeStatus(info))
with (
mock.patch.object(sys, "argv", ["volumen", "status"]),
pytest.raises(SystemExit) as exc,
):
main()
captured = capsys.readouterr()
assert exc.value.code == 1
assert "==>" in captured.out
assert "volumen status" in captured.out
assert "users file does not exist" in captured.out
assert "admin user missing" in captured.out
assert "Issues:" in captured.out
def test_status_returns_zero_when_no_issues(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
info = {
"config_path": "/etc/volumen/config.toml",
"data_dir": "/var/lib/volumen/posts",
"users_file": "/var/lib/volumen/users.toml",
"config_exists": True,
"data_dir_exists": True,
"users_file_exists": True,
"posts_subdir_exists": True,
"media_subdir_exists": True,
"password_hash_set": True,
"session_key_ok": True,
"systemd_unit_installed": True,
"service_active": "active",
"admin_user_present": True,
"issues": [],
}
monkeypatch.setattr(cli_module, "_status", _FakeStatus(info))
with (
mock.patch.object(sys, "argv", ["volumen", "status"]),
pytest.raises(SystemExit) as exc,
):
main()
captured = capsys.readouterr()
assert exc.value.code == 0
assert "All checks passed." in captured.out
class _FakeStatus:
"""Mock returning a static info dict from cli._status."""
def __init__(self, info: dict[str, object]) -> None:
self._info = info
def __call__(self, args: argparse.Namespace) -> int:
# Mirror the shape of the real _status(). For human mode print plain
# text; for --json print the info as JSON. Exit code 0 if no issues.
import json as _json
if args.json:
print(_json.dumps(self._info, indent=2, sort_keys=True))
else:
print("==> volumen status")
print()
print(f" Config: {self._info['config_path']}")
print(f" Data dir: {self._info['data_dir']}")
print(f" Users file: {self._info['users_file']}")
unit = "installed" if self._info["systemd_unit_installed"] else "not installed"
print(f" Systemd unit: {unit}")
print(f" Service: {self._info['service_active']}")
presence = "present" if self._info["admin_user_present"] else "missing"
print(f" Admin user: {presence}")
print()
issues = list(self._info["issues"])
if not issues:
print("All checks passed.")
else:
print("Issues:")
for issue in issues:
print(f" - {issue}")
return 0 if not self._info["issues"] else 1
class TestInitCommand:
def test_init_rejects_systemd_under_local(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
# Force ``use_user=True`` then ``--systemd`` → exit 2 with explanation.
monkeypatch.setattr(cli_module, "_is_root", lambda: False)
with (
mock.patch.object(
sys,
"argv",
["volumen", "init", "--local", "--systemd"],
),
pytest.raises(SystemExit) as exc,
):
main()
assert exc.value.code == 2
captured = capsys.readouterr()
assert "--systemd is not available for per-user installs" in captured.err
def test_init_propagates_installer_error(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
# ``system_install`` is mocked to raise InstallerError → exit 1 + stderr msg.
monkeypatch.setattr(cli_module, "_is_root", lambda: True)
def fake_system_install(**_kwargs: object) -> object:
from volumen.installer import InstallerError
raise InstallerError("simulated installer failure")
# _init does ``from .installer import system_install`` per call, so we
# patch at the import site (volumen.installer.system_install) rather
# than the local reference inside cli_module.
monkeypatch.setattr("volumen.installer.system_install", fake_system_install)
args = argparse.Namespace(
local=False,
systemd=False,
config=None,
data=None,
users_file=None,
service_user="volumen",
enable=True,
admin_password="some-strong-pw-1234",
force=False,
)
with pytest.raises(SystemExit) as exc:
cli_module._init(args)
captured = capsys.readouterr()
assert exc.value.code == 1
assert "simulated installer failure" in captured.err
def test_init_user_install_branch(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
# ``--local`` → user_install() path. Mock user_install to skip side effects.
monkeypatch.setattr(cli_module, "_is_root", lambda: False)
from volumen.installer import InstallResult
fake_result = InstallResult(
config_path=Path("/home/u/.config/volumen/config.toml"),
content_dir=Path("/home/u/.local/share/volumen/posts"),
users_file=Path("/home/u/.local/share/volumen/users.toml"),
service_user=None,
password_hash="scrypt$...",
session_key="x" * 128,
systemd_unit_path=None,
messages=[],
)
monkeypatch.setattr(
"volumen.installer.user_install",
lambda **_kw: fake_result,
)
args = argparse.Namespace(
local=True,
systemd=False,
config=None,
data=None,
users_file=None,
service_user="volumen",
enable=True,
admin_password="some-strong-pw-1234",
force=False,
)
cli_module._init(args) # Should not raise.
captured = capsys.readouterr()
assert "==> volumen installed." in captured.out
assert "Service: not installed" in captured.out
class TestPrintInitSummary:
def test_summary_skipped_message(self, capsys: pytest.CaptureFixture[str]) -> None:
from volumen.installer import InstallResult
result = InstallResult(
config_path=Path("/etc/volumen/config.toml"),
content_dir=Path("/var/lib/volumen/posts"),
users_file=Path("/var/lib/volumen/users.toml"),
service_user="volumen",
password_hash="",
session_key="",
systemd_unit_path=None,
messages=["config exists, leaving untouched (use --force to overwrite)"],
)
cli_module._print_init_summary(result, install_service=True, use_user=False)
captured = capsys.readouterr()
assert "left untouched" in captured.out
assert "Nothing to do" in captured.out
def test_summary_user_install(self, capsys: pytest.CaptureFixture[str]) -> None:
from volumen.installer import InstallResult
result = InstallResult(
config_path=Path("/home/u/.config/volumen/config.toml"),
content_dir=Path("/home/u/.local/share/volumen/posts"),
users_file=Path("/home/u/.local/share/volumen/users.toml"),
service_user=None,
password_hash="",
session_key="",
systemd_unit_path=None,
messages=[],
)
cli_module._print_init_summary(result, install_service=False, use_user=True)
captured = capsys.readouterr()
assert "Service: not installed" in captured.out
+104
View File
@@ -0,0 +1,104 @@
"""Concurrency tests: store/users racy write scenarios.
These tests hammer the same files from many threads to confirm that
per-target locks, fsync, and unique tempfiles do not corrupt data.
"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from volumen.post import Post
from volumen.store import Store
from volumen.users import Users
class TestStoreRace:
def test_concurrent_saves_keep_all_data(self, tmp_content_dir: Path) -> None:
"""Many threads save distinct posts in parallel."""
store = Store(str(tmp_content_dir), default_lang="en")
def worker(idx: int) -> str:
slug = f"post-{idx}"
post = Post(metadata={"slug": slug, "title": f"Post {idx}"}, body=f"body {idx}")
store.save(post)
return slug
with ThreadPoolExecutor(max_workers=16) as pool:
futures = [pool.submit(worker, i) for i in range(50)]
for f in as_completed(futures):
assert f.result() is not None
posts = store.all()
slugs = {p.slug for p in posts}
for i in range(50):
assert f"post-{i}" in slugs
def test_concurrent_saves_to_same_target(self, tmp_content_dir: Path) -> None:
"""Many threads save to the same target path; no data lost."""
store = Store(str(tmp_content_dir), default_lang="en")
def worker(idx: int) -> None:
post = Post(
metadata={"slug": "shared", "title": f"v{idx}"},
body=f"iteration {idx}",
)
store.save(post)
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(worker, range(20)))
post = store.find("shared")
assert post is not None
# The file content must end with one of our iterations, no torn write.
assert post.body.startswith("iteration ")
class TestUsersRace:
def test_concurrent_adds_keep_all_users(self, users_path: str) -> None:
users = Users(path=users_path)
def worker(idx: int) -> str:
name = f"user-{idx}"
users.add(name, "alice-test-password")
return name
with ThreadPoolExecutor(max_workers=8) as pool:
futures = [pool.submit(worker, i) for i in range(30)]
for f in as_completed(futures):
assert f.result() is not None
assert len(users.all) == 30
class TestSnapshotCache:
def test_snapshot_changes_invalidate_cache(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir), default_lang="en")
store.all() # prime cache
(tmp_content_dir / "extra.md").write_text('+++\ntitle = "Extra"\n+++\n\nNew post.\n')
# Even without touching mtime directly, stat() picks up the new file.
posts = store.all()
slugs = {p.slug for p in posts}
assert "extra" in slugs
def test_size_only_change_invalidates(self, tmp_content_dir: Path) -> None:
"""The cache key includes size; in-place rewrites invalidate."""
import os
store = Store(str(tmp_content_dir), default_lang="en")
# Force initial cache.
store.all()
# Overwrite an existing file with the same mtime but different size.
path = tmp_content_dir / "hello.md"
st = os.stat(path)
os.utime(path, (st.st_atime, st.st_mtime))
path.write_text(
'+++\ntitle = "Hello (resized)"\n+++\n\nNew body that is much longer than before.\n' * 5
)
# Touch back to original mtime so only size differs.
os.utime(path, (st.st_atime, st.st_mtime))
post = store.find("hello")
assert post is not None
assert "resized" in (post.title or "")
+112
View File
@@ -0,0 +1,112 @@
"""Tests for Config — loading, merging, defaults, overrides."""
from __future__ import annotations
from pathlib import Path
from volumen.config import Config
MISSING = "/nonexistent/volumen.toml"
class TestDefaults:
def test_defaults_when_file_missing(self) -> None:
config = Config.load(path=MISSING)
assert config.port == 9090
assert config.language == "en"
assert config.host == "::"
def test_default_content_dir(self) -> None:
config = Config.load(path=MISSING)
assert config.content_dir == "/var/lib/volumen/posts"
def test_default_site_title(self) -> None:
config = Config.load(path=MISSING)
assert config.site["title"] == "My Blog"
class TestOverrides:
def test_overrides_apply(self) -> None:
config = Config.load(
path=MISSING,
overrides={"host": "127.0.0.1", "port": 9000, "content": "/srv/posts"},
)
assert config.host == "127.0.0.1"
assert config.port == 9000
assert config.content_dir == "/srv/posts"
def test_does_not_mutate_defaults(self) -> None:
Config.load(path=MISSING, overrides={"host": "10.0.0.1"})
fresh = Config.load(path=MISSING)
assert fresh.host == "::"
def test_does_not_mutate_default_inner_hashes(self) -> None:
config = Config.load(path=MISSING)
config.site["title"] = "MUTATED"
fresh = Config.load(path=MISSING)
assert fresh.site["title"] == "My Blog"
class TestLoadFromFile:
def test_loads_and_merges_file(self, tmp_path: Path) -> None:
config_path = tmp_path / "config.toml"
config_path.write_text('[site]\ntitle = "Custom"\n')
config = Config.load(path=str(config_path))
assert config.site["title"] == "Custom"
assert config.port == 9090 # default preserved
def test_loads_server_section(self, tmp_path: Path) -> None:
config_path = tmp_path / "config.toml"
config_path.write_text('[server]\nhost = "1.2.3.4"\nport = 8000\n')
config = Config.load(path=str(config_path))
assert config.host == "1.2.3.4"
assert config.port == 8000
class TestGenerateDefault:
def test_generate_default_writes_template(self, tmp_path: Path) -> None:
dest = tmp_path / "sub" / "config.toml"
result = Config.generate_default(str(dest))
assert result == str(dest)
assert dest.exists()
loaded = Config.load(path=str(dest))
assert loaded.port == 9090
def test_generate_default_skips_existing(self, tmp_path: Path) -> None:
config_path = tmp_path / "config.toml"
config_path.write_text("[server]\nport = 9999\n")
result = Config.generate_default(str(config_path))
assert result is None
loaded = Config.load(path=str(config_path))
assert loaded.port == 9999
class TestAccessors:
def test_host(self) -> None:
config = Config.load(path=MISSING, overrides={"host": "127.0.0.1"})
assert config.host == "127.0.0.1"
def test_port(self) -> None:
assert Config.load(path=MISSING).port == 9090
def test_content_dir(self) -> None:
config = Config.load(path=MISSING, overrides={"content": "/tmp/posts"})
assert config.content_dir == "/tmp/posts"
def test_users_file(self) -> None:
config = Config.load(path=MISSING)
assert config.users_file == "/var/lib/volumen/users.toml"
def test_site(self) -> None:
config = Config.load(path=MISSING)
assert isinstance(config.site, dict)
assert "title" in config.site
def test_admin(self) -> None:
config = Config.load(path=MISSING)
assert isinstance(config.admin, dict)
assert "password_hash" in config.admin
def test_language(self) -> None:
config = Config.load(path=MISSING)
assert config.language == "en"
+177
View File
@@ -0,0 +1,177 @@
"""Coverage tests for feed helpers — RSS, JSON Feed, sitemap."""
from __future__ import annotations
from datetime import date
from typing import Any
from volumen.feed_helpers import (
_iso_date,
_rfc822_date,
render_json_feed,
render_rss_feed,
render_sitemap,
)
from volumen.post import Post
SITE: dict[str, Any] = {
"title": "My Blog",
"description": "A blog.",
"language": "en",
}
def _post(
*,
title: str = "Hello",
slug: str = "hello",
post_date: str = "2026-01-15",
tags: list[str] | None = None,
excerpt: str = "",
fediverse_creator: str = "",
body: str = "Body.",
) -> Post:
"""Build a Post with optional fields, simulating parse() without frontmatter I/O."""
metadata: dict[str, Any] = {"title": title, "slug": slug}
if post_date:
metadata["date"] = post_date
if tags is not None:
metadata["tags"] = tags
if excerpt:
metadata["excerpt"] = excerpt
if fediverse_creator:
metadata["fediverse_creator"] = fediverse_creator
return Post(metadata=metadata, body=body)
class TestRenderRSSFeed:
def test_basic_two_posts(self) -> None:
posts = [
_post(slug="a", title="Alpha"),
_post(slug="b", title="Beta"),
]
xml = render_rss_feed(posts, SITE, "https://example.com")
assert '<?xml version="1.0"' in xml
assert "<rss version=" in xml
assert "<title>My Blog</title>" in xml
assert "<link>https://example.com</link>" in xml
assert "<description>A blog.</description>" in xml
assert "<language>en</language>" in xml
assert "<title>Alpha</title>" in xml
assert "<title>Beta</title>" in xml
assert "<link>https://example.com/a</link>" in xml
assert "<link>https://example.com/b</link>" in xml
assert "pubDate" in xml
def test_truncates_to_20_items(self) -> None:
posts = [_post(slug=f"p{i:02d}") for i in range(25)]
xml = render_rss_feed(posts, SITE, "https://x")
assert xml.count("<item>") == 20
def test_escapes_special_characters_in_title(self) -> None:
xml = render_rss_feed(
[_post(title="A & B <C>", slug="x")],
SITE,
"https://x",
)
assert "A &amp; B &lt;C&gt;" in xml
def test_no_pub_date_when_date_missing(self) -> None:
# No `date` in metadata → date_string is None → no <pubDate> in the item.
xml = render_rss_feed([_post(post_date="")], SITE, "https://x")
item_block = xml.split("<item>")[1].split("</item>")[0]
assert "<pubDate>" not in item_block
def test_dc_creator_present_when_fediverse_set(self) -> None:
xml = render_rss_feed(
[_post(fediverse_creator="@alice@example.com")],
SITE,
"https://x",
)
assert "<dc:creator>@alice@example.com</dc:creator>" in xml
def test_no_dc_creator_when_fediverse_empty(self) -> None:
xml = render_rss_feed([_post()], SITE, "https://x")
assert "<dc:creator>" not in xml
class TestRenderJSONFeed:
def test_basic_shape(self) -> None:
feed = render_json_feed([_post(slug="a", tags=["intro"])], SITE, "https://x")
assert feed["version"] == "https://jsonfeed.org/version/1.1"
assert feed["title"] == "My Blog"
assert feed["home_page_url"] == "https://x"
assert feed["feed_url"] == "https://x/api/volumen/feed.json"
assert feed["language"] == "en"
assert feed["items"][0]["id"] == "https://x/a"
assert feed["items"][0]["url"] == "https://x/a"
assert feed["items"][0]["title"] == "Hello"
assert feed["items"][0]["tags"] == ["intro"]
def test_filters_empty_authors_and_tags(self) -> None:
# Default _post has no fediverse handle, no tags. ``summary`` is also
# present (it falls back to the derived excerpt from ``body``).
feed = render_json_feed([_post()], SITE, "https://x")
item = feed["items"][0]
assert "authors" not in item
assert "tags" not in item
def test_author_from_site_wide_fediverse(self) -> None:
site_with_creator = {**SITE, "fediverse_creator": "@site@example.com"}
feed = render_json_feed([_post()], site_with_creator, "https://x")
assert feed["items"][0]["authors"] == [{"name": "@site@example.com"}]
def test_author_from_post_overrides_site(self) -> None:
site_with_creator = {**SITE, "fediverse_creator": "@site@example.com"}
feed = render_json_feed(
[_post(fediverse_creator="@post@example.com")],
site_with_creator,
"https://x",
)
assert feed["items"][0]["authors"] == [{"name": "@post@example.com"}]
class TestRenderSitemap:
def test_basic_urlset(self) -> None:
xml = render_sitemap([_post(slug="x"), _post(slug="y")], "https://x")
assert "<urlset" in xml
assert "<loc>https://x/x</loc>" in xml
assert "<loc>https://x/y</loc>" in xml
assert "</urlset>" in xml
def test_lastmod_when_date_present(self) -> None:
xml = render_sitemap([_post()], "https://x")
assert "<lastmod>2026-01-15</lastmod>" in xml
def test_no_lastmod_when_date_empty(self) -> None:
# When post_date="" is passed, _post() omits "date" → date_string is None
# → no <lastmod> rendered.
xml = render_sitemap([_post(post_date="")], "https://x")
url_block = xml.split("<url>")[1].split("</url>")[0]
assert "<lastmod>" not in url_block
class TestDateHelpers:
def test_rfc822_from_date_object(self) -> None:
out = _rfc822_date(date(2026, 1, 15))
assert "2026" in out and "GMT" in out
def test_rfc822_from_iso_string(self) -> None:
out = _rfc822_date("2026-01-15")
assert "2026" in out
assert "GMT" in out
def test_rfc822_invalid_string_returns_as_is(self) -> None:
assert _rfc822_date("not-a-date") == "not-a-date"
def test_iso_none(self) -> None:
assert _iso_date(None) is None
def test_iso_date_object(self) -> None:
assert _iso_date(date(2026, 1, 15)) == "2026-01-15"
def test_iso_valid_string_passes_through(self) -> None:
assert _iso_date("2026-01-15") == "2026-01-15"
def test_iso_invalid_string_returns_as_is(self) -> None:
assert _iso_date("not-a-date") == "not-a-date"
+78
View File
@@ -0,0 +1,78 @@
"""Tests for Frontmatter parser — TOML frontmatter parsing and serialisation."""
from __future__ import annotations
from volumen.frontmatter import dump, parse
class TestParse:
def test_parse_extracts_metadata_and_body(self) -> None:
content = '+++\ntitle = "Hello"\ntags = ["a", "b"]\n+++\n\nBody text.\n'
metadata, body = parse(content)
assert metadata["title"] == "Hello"
assert metadata["tags"] == ["a", "b"]
assert body == "Body text.\n"
def test_parse_without_frontmatter(self) -> None:
metadata, body = parse("# Just markdown\n")
assert metadata == {}
assert body == "# Just markdown\n"
def test_parse_without_closing_delimiter(self) -> None:
content = '+++\ntitle = "x"\n\nbody'
metadata, body = parse(content)
assert metadata == {}
assert body == content
def test_parse_empty_frontmatter(self) -> None:
content = "+++\n+++\n\nBody here.\n"
metadata, body = parse(content)
assert metadata == {}
assert body == "Body here.\n"
def test_parse_draft_false(self) -> None:
content = '+++\ntitle = "X"\ndraft = false\n+++\n\nBody\n'
metadata, body = parse(content)
assert metadata["draft"] is False
def test_parse_nested_tables(self) -> None:
content = '+++\n[meta]\ntitle = "X"\n+++\n\nBody\n'
metadata, body = parse(content)
assert metadata["meta"]["title"] == "X"
def test_parse_normalises_crlf(self) -> None:
metadata, body = parse('+++\r\ntitle = "x"\r\n+++\r\n\r\nBody\r\n')
assert metadata["title"] == "x"
assert body == "Body\n"
class TestDump:
def test_dump_produces_valid_frontmatter(self) -> None:
result = dump({"title": "Hello", "tags": ["a"]}, "Body text.\n")
assert result.startswith("+++\n")
assert "+++\n\n" in result
assert "Body text." in result
def test_dump_empty_metadata(self) -> None:
result = dump({}, "Just body.\n")
assert "+++" in result
assert "Just body." in result
def test_round_trip(self) -> None:
metadata = {"title": "Hello", "tags": ["intro"], "draft": False}
dumped = dump(metadata, "Hello **world**.")
parsed_meta, parsed_body = parse(dumped)
assert parsed_meta == metadata
assert parsed_body.strip() == "Hello **world**."
def test_round_trip_with_empty_metadata(self) -> None:
dumped = dump({}, "Just body.\n")
parsed_meta, parsed_body = parse(dumped)
assert parsed_meta == {}
assert parsed_body.strip() == "Just body."
def test_round_trip_preserves_dates(self) -> None:
metadata = {"title": "X", "date": "2026-01-15"}
dumped = dump(metadata, "Body\n")
parsed_meta, _ = parse(dumped)
assert parsed_meta["date"] == "2026-01-15"
+461
View File
@@ -0,0 +1,461 @@
"""Tests for the first-run bootstrap installer (system and per-user)."""
from __future__ import annotations
import contextlib
import json
import re
import sys
from pathlib import Path
from unittest import mock
import pytest
from volumen import installer
from volumen.cli import main
from volumen.config import Config
SYSTEM_DIR = Path("/etc/volumen")
SYSTEM_USERS_FILE = Path("/var/lib/volumen/users.toml")
# --- 1. system_install produces the expected config substitutions -----------
class TestSystemInstall:
def test_writes_config_with_expected_substitutions(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "config.toml"
content_dir = tmp_path / "posts"
users_file = tmp_path / "users.toml"
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
result = installer.system_install(
config_path=config_path,
content_dir=content_dir,
users_file=users_file,
service_user="volumen",
admin_password="TopSecret!",
)
assert result.config_path == config_path
text = config_path.read_text()
assert re.search(r'^host = "::1"', text, re.MULTILINE) is not None
assert re.search(r'^env = "production"', text, re.MULTILINE) is not None
assert re.search(r"^trust_proxy = true", text, re.MULTILINE) is not None
assert re.search(r"^cookie_secure = true", text, re.MULTILINE) is not None
assert f'content_dir = "{content_dir}"' in text
assert f'users_file = "{users_file}"' in text
assert content_dir.is_dir()
assert (content_dir / "media").is_dir()
def test_writes_valid_scrypt_password_hash(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "config.toml"
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
result = installer.system_install(
config_path=config_path,
content_dir=tmp_path / "posts",
users_file=tmp_path / "users.toml",
admin_password="TopSecret!",
)
text = config_path.read_text()
m = re.search(r'^password_hash = "(.+)"', text, re.MULTILINE)
assert m is not None
encoded = m.group(1)
assert encoded == result.password_hash
assert encoded.startswith("scrypt$16384$8$1$")
# Verify against password.
from volumen.password import verify_password
assert verify_password("TopSecret!", encoded) is True
def test_writes_long_hex_session_key(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "config.toml"
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
result = installer.system_install(
config_path=config_path,
content_dir=tmp_path / "posts",
users_file=tmp_path / "users.toml",
admin_password="whatever",
)
text = config_path.read_text()
m = re.search(r'^session_key = "([0-9a-f]+)"', text, re.MULTILINE)
assert m is not None
assert len(m.group(1)) >= 128 # 64 bytes hex-encoded
assert m.group(1) == result.session_key
def test_config_round_trips_through_config_load(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "config.toml"
posts = tmp_path / "posts"
users_file = tmp_path / "users.toml"
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
installer.system_install(
config_path=config_path,
content_dir=posts,
users_file=users_file,
admin_password="TopSecret!",
)
loaded = Config.load(path=str(config_path))
assert loaded.content_dir == str(posts)
assert loaded.users_file == str(users_file)
assert loaded.host == "::1"
assert loaded.env == "production"
assert loaded.trust_proxy is True
assert loaded.cookie_secure is True
assert loaded.admin["password_hash"].startswith("scrypt$")
assert len(loaded.admin["session_key"]) >= 128
def test_existing_config_is_not_overwritten_without_force(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "config.toml"
original = 'host = "1.2.3.4"\nport = 1234\n'
config_path.write_text(original)
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
result = installer.system_install(
config_path=config_path,
content_dir=tmp_path / "posts",
users_file=tmp_path / "users.toml",
admin_password="TopSecret!",
)
# File unchanged.
assert config_path.read_text() == original
# Result carries a sentinel message and no fresh password_hash.
assert any("leaving untouched" in m for m in result.messages)
assert result.password_hash == ""
def test_force_makes_backup_with_original_content(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "config.toml"
original = 'host = "1.2.3.4"\nport = 1234\n'
config_path.write_text(original)
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
installer.system_install(
config_path=config_path,
content_dir=tmp_path / "posts",
users_file=tmp_path / "users.toml",
admin_password="TopSecret!",
force=True,
)
backups = list(tmp_path.glob("config.toml.bak.*.toml"))
assert len(backups) == 1
assert backups[0].read_text() == original
# New content must round-trip.
loaded = Config.load(path=str(config_path))
assert loaded.host == "::1"
def test_ensure_user_only_runs_when_user_missing(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
getpwnam_log: list[str] = []
run_log: list[list[str]] = []
def fake_getpwnam(name: str) -> object:
getpwnam_log.append(name)
raise KeyError(name)
def fake_run(cmd: list[str], *args: object, **kwargs: object) -> object:
run_log.append(list(cmd))
return object()
# Redirect the home-dir creation away from /var/lib so the test
# can run unprivileged without hitting real root.
fake_home = tmp_path / "fakehome"
monkeypatch.setattr(installer, "DEFAULT_SERVICE_HOME", fake_home)
with monkeypatch.context() as m:
m.setattr(installer.pwd, "getpwnam", fake_getpwnam)
m.setattr(installer, "_is_root", lambda: True)
m.setattr(installer.subprocess, "run", fake_run)
created = installer._ensure_user("volumen")
assert created is True
assert getpwnam_log == ["volumen"]
# useradd should fire exactly once.
assert any(cmd[:1] == ["useradd"] for cmd in run_log)
# Home dir was created at our redirected location.
assert fake_home.is_dir()
# If the user exists, no useradd is run.
getpwnam_log.clear()
run_log.clear()
class _Pwnam:
pw_uid = 1234
with monkeypatch.context() as m:
m.setattr(installer.pwd, "getpwnam", lambda name: _Pwnam())
m.setattr(installer.subprocess, "run", fake_run)
created = installer._ensure_user("volumen")
assert created is False
assert run_log == []
# If not root, even a missing user doesn't trigger useradd.
getpwnam_log.clear()
run_log.clear()
with monkeypatch.context() as m:
m.setattr(installer.pwd, "getpwnam", fake_getpwnam)
m.setattr(installer, "_is_root", lambda: False)
m.setattr(installer.subprocess, "run", fake_run)
created = installer._ensure_user("volumen")
assert created is False
assert run_log == []
def test_chown_chmod_skipped_silently_when_not_root(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "config.toml"
posts = tmp_path / "posts"
users_file = tmp_path / "users.toml"
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
# Pretend we're not root — the chown/chmod helpers must no-op rather
# than raise, and no subprocess is spawned for useradd/systemctl.
monkeypatch.setattr(installer, "_is_root", lambda: False)
run_log: list[list[str]] = []
monkeypatch.setattr(
installer.subprocess,
"run",
lambda cmd, *a, **kw: run_log.append(list(cmd)) or object(),
)
result = installer.system_install(
config_path=config_path,
content_dir=posts,
users_file=users_file,
admin_password="TopSecret!",
)
assert config_path.is_file()
assert posts.is_dir()
# No subprocess calls touched the system at all.
assert run_log == []
# Chmod was applied as a no-op (mode unchanged).
assert oct(config_path.stat().st_mode & 0o777) != oct(0o600) # default
# Result is a valid InstallResult.
assert result.config_path == config_path
def test_install_service_writes_systemd_unit_and_calls_systemctl(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "config.toml"
content_dir = tmp_path / "posts"
users_file = tmp_path / "users.toml"
systemd_unit = tmp_path / "volumen.service"
cmd_log: list[list[str]] = []
class _Pwnam:
pw_uid = 1234
class _Grnam:
gr_gid = 1234
def fake_getpwnam(name: str) -> object:
return _Pwnam()
def fake_getgrnam(name: str) -> object:
return _Grnam()
def fake_which(name: str) -> str | None:
return "/usr/local/bin/volumen" if name == "volumen" else None
def fake_run(cmd: list[str], *args: object, **kwargs: object) -> object:
cmd_log.append(list(cmd))
return object()
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
monkeypatch.setattr(installer, "_is_root", lambda: True)
monkeypatch.setattr(installer.pwd, "getpwnam", fake_getpwnam)
monkeypatch.setattr(installer.grp, "getgrnam", fake_getgrnam)
monkeypatch.setattr(installer.shutil, "which", fake_which)
monkeypatch.setattr(installer.subprocess, "run", fake_run)
monkeypatch.setattr(installer, "SYSTEMD_UNIT_PATH", systemd_unit)
result = installer.system_install(
config_path=config_path,
content_dir=content_dir,
users_file=users_file,
admin_password="TopSecret!",
install_service=True,
enable_service=True,
)
assert systemd_unit.is_file()
unit_text = systemd_unit.read_text()
assert "/usr/local/bin/volumen" in unit_text
assert "serve --config" in unit_text
assert f"--config {config_path}" in unit_text
assert f"--content {content_dir}" in unit_text
assert "User=volumen" in unit_text
assert "NoNewPrivileges=true" in unit_text
assert "ProtectSystem=strict" in unit_text
assert result.systemd_unit_path == systemd_unit
assert ["systemctl", "daemon-reload"] in cmd_log
assert ["systemctl", "enable", "--now", "volumen"] in cmd_log
# --- 2. user_install ---------------------------------------------------------
class TestUserInstall:
def test_writes_config_under_user_path(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "config.toml"
content_dir = tmp_path / "posts"
users_file = tmp_path / "users.toml"
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
result = installer.user_install(
config_path=config_path,
content_dir=content_dir,
users_file=users_file,
admin_password="TopSecret!",
)
assert result.service_user is None
assert result.systemd_unit_path is None
text = config_path.read_text()
assert re.search(r'^env = "development"', text, re.MULTILINE) is not None
assert re.search(r"^cookie_secure = false", text, re.MULTILINE) is not None
assert config_path.exists()
assert content_dir.is_dir()
def test_rejects_install_service(self, tmp_path: Path) -> None:
with pytest.raises(installer.InstallerError):
installer.user_install(
config_path=tmp_path / "config.toml",
content_dir=tmp_path / "posts",
users_file=tmp_path / "users.toml",
admin_password="x",
install_service=True,
)
# --- 3. _mutate_config_text --------------------------------------------------
class TestMutateConfigText:
def test_substitutes_six_keys_without_disturbing_comments(self) -> None:
text = (
'host = "::"\n'
'env = "development"\n'
"trust_proxy = false\n"
"cookie_secure = false\n"
'content_dir = "/old"\n'
'users_file = "/old/users.toml"\n'
'password_hash = ""\n'
'session_key = ""\n'
)
out = installer._mutate_config_text(
text,
host="::1",
env="production",
trust_proxy=True,
cookie_secure=True,
content_dir="/new",
users_file="/new/users.toml",
)
assert 'host = "::1"' in out
assert 'env = "production"' in out
assert "trust_proxy = true" in out
assert "cookie_secure = true" in out
assert 'content_dir = "/new"' in out
assert 'users_file = "/new/users.toml"' in out
def test_injects_password_hash_and_session_key(self) -> None:
text = 'host = "::"\npassword_hash = ""\nsession_key = ""\n'
out = installer._mutate_config_text(
text,
password_hash="scrypt$abc",
session_key="deadbeef" * 16,
)
assert 'password_hash = "scrypt$abc"' in out
assert 'session_key = "deadbeef"' * 4 not in out # sentinel
assert 'session_key = "' in out
# --- 4. CLI status -----------------------------------------------------------
class TestStatus:
def test_status_json_reports_all_fields(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
from volumen import installer as inst_mod
# Pre-create a config so the inspector finds something.
posts = tmp_path / "posts"
users_file = tmp_path / "users.toml"
posts.mkdir()
users_file.touch()
config_path = tmp_path / "config.toml"
monkeypatch.setattr(inst_mod, "_ensure_user", lambda name: False)
installer.system_install(
config_path=config_path,
content_dir=posts,
users_file=users_file,
admin_password="TopSecret!",
)
monkeypatch.setattr(inst_mod, "SYSTEMD_UNIT_PATH", tmp_path / "volumen.service")
# Force systemctl to a known "unknown" branch.
monkeypatch.setattr(inst_mod.shutil, "which", lambda name: None)
# Inject a users.toml entry for "admin".
users_file.write_text(
'[[users]]\nusername = "admin"\npassword_hash = "scrypt$x"\nrole = "admin"\n'
)
with (
mock.patch.object(
sys,
"argv",
[
"volumen",
"status",
"--config",
str(config_path),
"--data",
str(posts),
"--users-file",
str(users_file),
"--json",
],
),
contextlib.suppress(SystemExit),
):
main()
captured = capsys.readouterr()
assert captured.out.strip().startswith("{")
info = json.loads(captured.out)
for key in (
"config_path",
"data_dir",
"users_file",
"config_exists",
"data_dir_exists",
"users_file_exists",
"password_hash_set",
"session_key_ok",
"systemd_unit_installed",
"service_active",
"admin_user_present",
"issues",
):
assert key in info
assert info["config_exists"] is True
assert info["password_hash_set"] is True
assert info["session_key_ok"] is True
assert info["admin_user_present"] is True
class TestCLISystemdLocalRefuse:
def test_local_with_systemd_exits_nonzero(self, capsys: pytest.CaptureFixture[str]) -> None:
exit_code = 0
with mock.patch.object(
sys,
"argv",
["volumen", "init", "--local", "--systemd", "--admin-password", "x"],
):
try:
main()
except SystemExit as exc:
exit_code = exc.code if isinstance(exc.code, int) else 1
assert exit_code == 2
captured = capsys.readouterr()
assert "--systemd" in captured.err
+80
View File
@@ -0,0 +1,80 @@
"""Tests for Markdown renderer."""
from __future__ import annotations
from volumen.markdown import render
class TestBasicRendering:
def test_renders_bold(self) -> None:
assert "<strong>bold</strong>" in render("**bold**")
def test_renders_italic(self) -> None:
result = render("*italic*")
assert "<em>italic</em>" in result
def test_renders_heading(self) -> None:
assert "<h1" in render("# Title")
def test_renders_paragraph(self) -> None:
result = render("Simple paragraph.")
assert "<p>" in result
def test_renders_link(self) -> None:
result = render("[link](https://example.com)")
assert '<a href="https://example.com"' in result
def test_renders_unordered_list(self) -> None:
result = render("- one\n- two\n")
assert "<ul>" in result
assert "<li>one</li>" in result
class TestGFMFeatures:
def test_renders_gfm_table(self) -> None:
table = "| A | B |\n|---|---|\n| 1 | 2 |\n"
assert "<table" in render(table)
def test_renders_fenced_code_block(self) -> None:
code = "```python\nprint(1)\n```\n"
html = render(code)
assert "<pre" in html or "<code" in html
def test_renders_task_list(self) -> None:
md = "- [x] Done\n- [ ] Todo\n"
result = render(md)
assert "checkbox" in result.lower() or "checked" in result.lower()
def test_renders_strikethrough(self) -> None:
result = render("~~strike~~")
assert "<del>" in result
class TestFigureWrapping:
def test_image_with_title_wrapped_in_figure(self) -> None:
result = render('![alt](url "caption")')
assert "<figure>" in result
def test_image_without_title_no_wrapping(self) -> None:
result = render("![alt](url)")
assert "<figure>" not in result
def test_figure_includes_figcaption(self) -> None:
result = render('![alt](url "caption")')
assert "<figcaption>" in result
assert "caption" in result
class TestEdgeCases:
def test_empty_input(self) -> None:
result = render("")
assert isinstance(result, str)
assert result == ""
def test_plain_text(self) -> None:
result = render("Hello world")
assert "Hello world" in result
def test_html_passthrough(self) -> None:
result = render("<span>inline</span>")
assert "<span>" in result
+23
View File
@@ -0,0 +1,23 @@
"""Import smoke tests for the split application modules."""
from __future__ import annotations
from volumen.admin_auth_routes import router as auth_router
from volumen.admin_media_routes import router as media_router
from volumen.admin_post_routes import router as post_router
from volumen.admin_settings_routes import router as settings_router
from volumen.app import create_app
from volumen.server import create_app as legacy_create_app
def test_split_modules_import_and_construct_app(config, store) -> None:
assert legacy_create_app is create_app
assert auth_router.routes
assert post_router.routes
assert settings_router.routes
assert media_router.routes
app = create_app(config, store)
assert app.state.config is config
assert app.state.store is store
+53
View File
@@ -0,0 +1,53 @@
"""Tests for Password hashing and verification."""
from __future__ import annotations
from volumen.password import hash_password, verify_password
class TestHash:
def test_hash_produces_string(self) -> None:
result = hash_password("secret")
assert isinstance(result, str)
def test_hash_format(self) -> None:
result = hash_password("pw")
assert result.startswith("scrypt$16384$8$1$")
def test_hash_is_deterministic_in_format(self) -> None:
result = hash_password("pw")
parts = result.split("$")
assert len(parts) == 6
assert parts[0] == "scrypt"
assert parts[1] == "16384"
assert parts[2] == "8"
assert parts[3] == "1"
class TestVerify:
def test_verify_correct_password(self) -> None:
encoded = hash_password("s3cret")
assert verify_password("s3cret", encoded) is True
def test_verify_wrong_password(self) -> None:
encoded = hash_password("s3cret")
assert verify_password("nope", encoded) is False
def test_verify_empty_string(self) -> None:
# ``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
assert verify_password("x", "") is False
def test_verify_tampered_hash(self) -> None:
encoded = hash_password("s3cret")
tampered = encoded[:-1] + ("X" if encoded[-1] != "X" else "Y")
assert verify_password("s3cret", tampered) is False
def test_verify_empty_password_against_hash(self) -> None:
encoded = hash_password("secret")
assert verify_password("", encoded) is False
+207
View File
@@ -0,0 +1,207 @@
"""Tests for Post model — parsing, metadata, rendering, serialisation."""
from __future__ import annotations
from datetime import date
import pytest
from volumen.post import Post
class TestPostParsing:
def test_parse_builds_post(self) -> None:
content = (
"+++\n"
'title = "Hello"\n'
'slug = "hello"\n'
'lang = "en"\n'
'tags = ["intro", "ruby"]\n'
"date = 2026-01-15\n"
"draft = true\n"
"+++\n\n"
"First paragraph.\n"
)
post = Post.parse(content)
assert post.slug == "hello"
assert post.title == "Hello"
assert post.lang == "en"
assert post.tags == ["intro", "ruby"]
assert post.draft is True
assert post.date_string == "2026-01-15"
def test_parse_preserves_body_newlines(self) -> None:
content = '+++\ntitle = "X"\nslug = "x"\n+++\n\nLine1\n\nLine2\n'
post = Post.parse(content)
assert "Line1" in post.body
assert "Line2" in post.body
class TestExcerpt:
def test_excerpt_from_metadata(self) -> None:
post = Post(metadata={"slug": "x", "excerpt": "Custom blurb."}, body="Long body text here.")
assert post.excerpt == "Custom blurb."
def test_excerpt_derived_from_body(self) -> None:
post = Post(metadata={"slug": "x"}, body="# Heading\n\nThe body paragraph.")
assert post.excerpt == "The body paragraph."
def test_derived_excerpt_strips_html_tags(self) -> None:
post = Post(metadata={"slug": "x"}, body="<strong>Bold</strong> and <em>italic</em>.")
assert post.excerpt == "Bold and italic."
def test_derived_excerpt_skips_heading(self) -> None:
post = Post(metadata={"slug": "x"}, body="# Heading\n\nText here.")
assert post.excerpt == "Text here."
def test_derived_excerpt_empty_body(self) -> None:
post = Post(metadata={"slug": "x"}, body="")
assert post.excerpt == ""
def test_derived_excerpt_truncates_long(self) -> None:
long_text = "A" * 300
post = Post(metadata={"slug": "x"}, body=long_text)
assert len(post.excerpt) <= 203 # limit + ellipsis
assert post.excerpt.endswith("\u2026")
class TestSummaryDict:
def test_summary_shape(self) -> None:
post = Post(metadata={"slug": "x", "title": "X"}, body="Body")
summary = post.summary()
assert summary["url"] == "/api/volumen/posts/x"
assert summary["title"] == "X"
assert summary["slug"] == "x"
def test_summary_includes_cover_and_reading_time(self) -> None:
post = Post(metadata={"slug": "x", "cover": "/media/c.png"}, body="word " * 250)
assert post.summary()["cover"] == "/media/c.png"
assert post.summary()["reading_time"] == 2
def test_summary_includes_fediverse_creator(self) -> None:
post = Post(
metadata={"slug": "x", "fediverse_creator": "@user@example.social"},
body="body",
)
assert post.summary()["fediverse_creator"] == "@user@example.social"
def test_summary_omits_fediverse_creator_when_absent(self) -> None:
post = Post(metadata={"slug": "x"}, body="body")
assert post.summary()["fediverse_creator"] is None
class TestDetailDict:
def test_detail_includes_rendered_html(self) -> None:
post = Post(metadata={"slug": "x"}, body="**hi**")
assert "<strong>hi</strong>" in post.detail()["html"]
def test_detail_includes_body(self) -> None:
post = Post(metadata={"slug": "x"}, body="raw body")
assert post.detail()["body"] == "raw body"
class TestReadingTime:
def test_reading_time_is_at_least_one(self) -> None:
post = Post(metadata={"slug": "x"}, body="short")
assert post.reading_time == 1
def test_reading_time_200_words(self) -> None:
post = Post(metadata={"slug": "x"}, body="word " * 200)
assert post.reading_time == 1
def test_reading_time_201_words(self) -> None:
post = Post(metadata={"slug": "x"}, body="word " * 201)
assert post.reading_time == 2
class TestScheduled:
def test_scheduled_post_when_publish_at_is_future(self) -> None:
future = date.today().replace(year=date.today().year + 1)
post = Post(metadata={"publish_at": future})
assert post.scheduled is True
def test_scheduled_post_when_publish_at_is_past(self) -> None:
past = date(2000, 1, 1)
post = Post(metadata={"publish_at": past})
assert post.scheduled is False
def test_post_without_publish_at_is_not_scheduled(self) -> None:
post = Post(metadata={})
assert post.scheduled is False
class TestFediverseCreator:
def test_fediverse_creator_returns_metadata_value(self) -> None:
post = Post(metadata={"slug": "x", "fediverse_creator": "@user@example.social"})
assert post.fediverse_creator == "@user@example.social"
def test_fediverse_creator_is_none_when_missing(self) -> None:
post = Post(metadata={"slug": "x"})
assert post.fediverse_creator is None
def test_fediverse_creator_is_none_when_blank(self) -> None:
post = Post(metadata={"slug": "x", "fediverse_creator": " "})
assert post.fediverse_creator is None
@pytest.mark.parametrize(
"handle",
[
"@ada@lovelace.org",
"@user@example.com",
" @user@example.com ",
],
)
def test_valid_fediverse_creator_accepts_well_formed(self, handle: str) -> None:
post = Post(metadata={"slug": "x", "fediverse_creator": handle})
assert post.valid_fediverse_creator is True
@pytest.mark.parametrize(
"handle",
[
"ada@example.com",
"@only-one-at",
"@user@example.com extra",
"@user@",
"@@example.com",
],
)
def test_valid_fediverse_creator_rejects_malformed(self, handle: str) -> None:
post = Post(metadata={"slug": "x", "fediverse_creator": handle})
assert post.valid_fediverse_creator is False
class TestDateString:
def test_date_string_from_string(self) -> None:
post = Post(metadata={"date": "2026-01-15"})
assert post.date_string == "2026-01-15"
def test_date_string_from_date(self) -> None:
post = Post(metadata={"date": date(2026, 1, 15)})
assert post.date_string == "2026-01-15"
def test_date_string_none_when_missing(self) -> None:
post = Post(metadata={})
assert post.date_string is None
class TestTranslations:
def test_translations_from_metadata(self) -> None:
post = Post(metadata={"slug": "x", "translations": {"cs": "ahoj", "de": "hallo"}})
assert post.translations == {"cs": "ahoj", "de": "hallo"}
def test_translations_empty_when_missing(self) -> None:
post = Post(metadata={"slug": "x"})
assert post.translations == {}
class TestFileSerialization:
def test_round_trip(self) -> None:
metadata = {"slug": "hello", "title": "Hello", "tags": ["intro"], "draft": False}
post = Post(metadata=metadata, body="Hello **world**.\n")
serialised = post.to_file()
reloaded = Post.parse(serialised)
assert reloaded.slug == "hello"
assert reloaded.title == "Hello"
assert reloaded.tags == ["intro"]
assert reloaded.draft is False
assert "Hello **world**." in reloaded.body
+150
View File
@@ -0,0 +1,150 @@
"""Coverage tests for ``Post`` properties — non-happy-path branches."""
from __future__ import annotations
from datetime import date, timedelta
from volumen.post import Post
def _post(metadata: dict[str, object] | None = None, body: str = "") -> Post:
return Post(metadata=metadata, body=body)
class TestTagsProperty:
def test_tags_default_is_empty(self) -> None:
assert _post().tags == []
def test_tags_list_of_strings(self) -> None:
assert _post({"tags": ["python", "blog"]}).tags == ["python", "blog"]
def test_tags_non_list_coerces_to_empty(self) -> None:
# When ``tags`` is set to something that isn't a list, the property
# returns an empty list rather than raising.
assert _post({"tags": "python"}).tags == []
assert _post({"tags": 42}).tags == []
assert _post({"tags": None}).tags == []
class TestTranslationsProperty:
def test_default_is_empty(self) -> None:
assert _post().translations == {}
def test_dict_coerces_values_to_strings(self) -> None:
out = _post({"translations": {"cs": "cs-slug", "de": "de-slug"}}).translations
assert out == {"cs": "cs-slug", "de": "de-slug"}
def test_non_dict_returns_empty(self) -> None:
assert _post({"translations": ["cs", "de"]}).translations == {}
assert _post({"translations": "cs"}).translations == {}
class TestFediverseCreator:
def test_none(self) -> None:
assert _post().fediverse_creator is None
def test_empty_string_is_none(self) -> None:
assert _post({"fediverse_creator": ""}).fediverse_creator is None
assert _post({"fediverse_creator": " "}).fediverse_creator is None
def test_valid_fediverse_creator_regex(self) -> None:
p = _post({"fediverse_creator": "@alice@example.com"})
assert p.fediverse_creator == "@alice@example.com"
assert p.valid_fediverse_creator is True
def test_invalid_fediverse_creator(self) -> None:
# No `@host` part → regex doesn't match.
p = _post({"fediverse_creator": "alice"})
assert p.fediverse_creator == "alice"
assert p.valid_fediverse_creator is False
class TestPublishAt:
def test_none(self) -> None:
assert _post().publish_at is None
def test_date_object(self) -> None:
assert _post({"publish_at": date(2026, 6, 1)}).publish_at == date(2026, 6, 1)
def test_iso_string(self) -> None:
assert _post({"publish_at": "2026-06-01"}).publish_at == date(2026, 6, 1)
def test_invalid_string_returns_none(self) -> None:
assert _post({"publish_at": "not-a-date"}).publish_at is None
assert _post({"publish_at": 12345}).publish_at is None
class TestScheduled:
def test_past_date_is_not_scheduled(self) -> None:
past = (date.today() - timedelta(days=1)).isoformat()
assert _post({"publish_at": past}).scheduled is False
def test_future_date_is_scheduled(self) -> None:
future = (date.today() + timedelta(days=1)).isoformat()
assert _post({"publish_at": future}).scheduled is True
def test_no_publish_at(self) -> None:
assert _post().scheduled is False
class TestReadingTime:
def test_empty_body_minimum_one_minute(self) -> None:
assert _post(body="").reading_time == 1
def test_short_body(self) -> None:
# 5 words → ceil(5/200) = 1.
assert _post(body="one two three four five").reading_time == 1
def test_long_body(self) -> None:
body = " ".join(f"word{i}" for i in range(500))
# 500 words → ceil(500/200) = 3.
assert _post(body=body).reading_time == 3
class TestExcerpt:
def test_explicit_excerpt(self) -> None:
assert _post({"excerpt": "Short summary"}).excerpt == "Short summary"
def test_derived_from_body(self) -> None:
# No explicit excerpt → derived from first non-heading paragraph.
p = _post(body="Hello **world**.")
assert "Hello" in p.excerpt
assert "<" not in p.excerpt # HTML stripped
assert "*" not in p.excerpt # Markdown stripped
def test_derived_empty_when_only_headings(self) -> None:
assert _post(body="# Heading\n\n## Subheading").excerpt == ""
class TestSummaryAndDetail:
def test_summary_includes_all_known_fields(self) -> None:
p = _post(
{
"title": "T",
"slug": "s",
"date": "2026-01-15",
"lang": "en",
"tags": ["x"],
"author": "A",
"fediverse_creator": "@a@example.com",
"cover": "media/c.webp",
},
body="Body.",
)
s = p.summary()
assert s["slug"] == "s"
assert s["title"] == "T"
assert s["date"] == "2026-01-15"
assert s["lang"] == "en"
assert s["tags"] == ["x"]
assert s["author"] == "A"
assert s["fediverse_creator"] == "@a@example.com"
assert s["cover"] == "media/c.webp"
assert s["url"] == "/api/volumen/posts/s"
def test_detail_includes_body_and_html(self) -> None:
p = _post({"title": "T", "slug": "s"}, body="Hello.")
d = p.detail()
assert d["body"] == "Hello."
assert "html" in d
assert "<p>" in d["html"]

Some files were not shown because too many files have changed in this diff Show More