59 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
petrbalvin 1440e9a1fd release: bump version to 0.2.0 2026-06-25 22:12:55 +02:00
petrbalvin b24a8cc377 feat: add fulltext search with ?q= parameter
Filter posts by case-insensitive match in title and body. Extracted
filter_by_lang and search_posts helpers to keep complexity in check.
2026-06-25 22:06:49 +02:00
petrbalvin 599c63e5bc feat: add JSON Feed endpoint at /api/volumen/feed.json
Implements jsonfeed.org version 1.1 with title, home_page_url, feed_url,
description, language, and items with content_html and date_published.
2026-06-25 22:06:49 +02:00
petrbalvin 73be893bf8 feat: add scheduled publishing with publish_at frontmatter field
Posts with a future publish_at date are hidden from the public API
and feeds, same as drafts. The admin form exposes a Publish at field.
2026-06-25 22:06:49 +02:00
petrbalvin 52be2bb996 security: CORS preflight, session invalidation, CSRF on preview, symlink containment, and hardening
- Add OPTIONS /api/volumen/* for CORS preflight (S-22).
- Invalidate session in require_login! when user no longer exists (S-8).
- Add check_csrf! to POST /admin/preview (S-1).
- Use File.realpath for symlink-safe containment in media_file (S-2).
- Memoize Users#all with mtime-based invalidation (S-12).
- Document that empty permitted_hosts means allow-all (S-5).
- Warn and fall back to random when session_key is too short (S-20).
2026-06-25 22:06:49 +02:00
petrbalvin 5b8236c0a4 test: add sitemap.xml endpoint test
Verify content type, urlset structure, post URL presence, and draft exclusion.
2026-06-25 22:06:49 +02:00
petrbalvin 51e425e6dd test: add CLI test coverage
Cover version, help, unknown command, parse_serve_options defaults
and overrides, prepare_config template generation and no-op.
2026-06-25 22:06:49 +02:00
petrbalvin ce57410044 fix: Store#save writes non-default language posts into lang subdirectory
default_path_for now routes posts whose lang differs from default_lang
into content_dir/<lang>/<slug>.md, matching the documented multi-language
layout and preventing same-slug posts in different languages from
overwriting each other.
2026-06-25 22:06:49 +02:00
petrbalvin afd8ab4bfb fix: atomic writes for posts and users.toml
Write to a .tmp file and then File.rename into place, so a concurrent
read or a mid-write crash never sees a torn file.
2026-06-25 22:06:49 +02:00
petrbalvin ad00872895 perf: memoize Store#all with mtime-based invalidation
Cache the parsed post list per Store instance, invalidated on save,
delete, or content_dir mtime change. Eliminates re-parsing every .md
file on every API and admin request.
2026-06-25 22:06:49 +02:00
petrbalvin d7f9813484 fix: prevent Config DEFAULTS mutation through deep_merge
Freeze inner hashes in DEFAULTS and rewrite deep_merge to build fresh
hashes with {}.merge for uncopied inner maps. Previously, inner hashes
shared objects with the frozen-looking DEFAULTS, so mutating a loaded
config silently poisoned all later loads.
2026-06-25 22:06:49 +02:00
petrbalvin 263e794669 security: hide draft posts from public detail endpoint
Add post.draft? check to GET /api/volumen/posts/:slug so guessing a
draft slug returns 404 like the list endpoint already does.
2026-06-25 22:06:49 +02:00
petrbalvin 1ca0212197 security: add slug validation, username validation, and template escaping
- Enforce SLUG_REGEX (alphanumeric + dot/dash/underscore only) in creation_error
  to prevent path traversal (C-1) and stored XSS (C-3).
- Add File.expand_path containment check in default_path_for as defense-in-depth.
- Escape slug in form action attributes (list, form views).
- Validate username in change_username using the same regex as create_user (C-4).
- Escape username in settings form action attributes.
- Treat submitting the current username as a no-op success instead of 'taken'.
2026-06-25 22:06:49 +02:00
petrbalvin d26d3b5a5b fix: strip HTML tags from derived excerpt
gsub(/<[^>]+>/, '') before stripping markdown markup, so auto-generated
excerpts don't leak raw HTML from post bodies.
2026-06-25 22:06:49 +02:00
petrbalvin 4597c685da test: add RSS feed endpoint tests
Verify content type, RSS structure, draft exclusion, and language
element.
2026-06-25 21:41:19 +02:00
petrbalvin 2181538691 feat: add language element to RSS feed channel 2026-06-25 21:40:57 +02:00
petrbalvin 9dd55adc30 fix: add periodic cleanup of login_attempts hash
Sweep stale entries every 5 minutes to prevent unbounded memory growth
from IPs that touch the login endpoint once and never return.
2026-06-25 21:40:37 +02:00
petrbalvin 7a31b50ca7 docs: add [development] section to CHANGELOG, document in CONTRIBUTING
Record ongoing changes under [development] in CHANGELOG.md. Update the
release workflow to rename [development] to the versioned section.
2026-06-25 21:37:27 +02:00
petrbalvin b2b8156648 refactor: split Server into api_routes and admin_routes modules
Keep server.rb focused on configuration, session setup, and shared
helpers. Extract route definitions into registered Sinatra extension
modules. Exclude the registered methods from AbcSize/MethodLength
metrics — large registered methods are the idiomatic Sinatra pattern.
2026-06-25 21:35:07 +02:00
petrbalvin 140b16b55a feat: add in-memory rate limiting for login endpoint
Rate-limit POST /admin/login at 10 attempts per 60 seconds per IP.
Stored in Volumen.login_attempts so tests can reset the counter.
2026-06-25 21:33:16 +02:00
petrbalvin 2348352fab feat: add lastmod to sitemap entries 2026-06-25 21:31:57 +02:00
petrbalvin 184aa9b3d8 feat: add pubDate to RSS feed items 2026-06-25 21:31:02 +02:00
petrbalvin 0a70b74b55 fix: wire session_ttl config into Rack session cookie expiration
The admin.session_ttl value was defined in defaults, template, and
documentation but never read — sessions had no expiration. Pass it as
expire_after to Rack::Session::Cookie. A value <= 0 means persistent
(until browser close), matching the documented behaviour.
2026-06-25 21:30:20 +02:00
104 changed files with 12938 additions and 3856 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 # Python
/.bundle/ __pycache__/
/vendor/bundle/ *.pyc
*.pyo
# Built gems *.egg-info/
/pkg/ .venv/
*.gem .pytest_cache/
.ruff_cache/
dist/
# Test / coverage # Test / coverage
/coverage/ /coverage/
/tmp/ /tmp/
# mkdocs / Sphinx build output
/site/
/var/
# Local development sandbox (posts, generated config) # Local development sandbox (posts, generated config)
/.volumen/ /.volumen/
# Local dev config (contains password hash)
/config.toml
# Uploaded media in the dev content directory # Uploaded media in the dev content directory
/posts/media/ /posts/media/
-32
View File
@@ -1,32 +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
Metrics/MethodLength:
Max: 25
Metrics/ClassLength:
Max: 400
Metrics/BlockLength:
Exclude:
- "test/**/*"
- "volumen.gemspec"
+299 -1
View File
@@ -5,6 +5,301 @@ 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/), 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). 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
- `<pubDate>` in RSS feed items, derived from the post date.
- `<lastmod>` in sitemap entries for SEO.
- In-memory rate limiting on the login endpoint: 10 attempts per 60 seconds per IP.
- `<language>` element in the RSS feed channel.
- Periodic cleanup of stale `login_attempts` entries to prevent unbounded memory growth.
- Slug format validation (`[a-z0-9._-]`) preventing path traversal and XSS in templates.
- Username validation in `change_username` matching the create-user regex.
- Escape-on-output for slug and username in admin template attributes.
- Memoization of `Store#all` with mtime-based invalidation, eliminating N×read+parse
per request.
- Atomic file writes (tmp + rename) for posts and `users.toml`.
- Post language directory routing: non-default-language posts now land in
`content_dir/<lang>/` instead of overwriting root-level files.
- CLI test coverage (version, help, unknown command, option parsing, config bootstrap).
- Test coverage for RSS feed and sitemap endpoints.
- CORS preflight (`OPTIONS /api/volumen/*`) so browsers can reach the JSON API.
- `Users#all` memoization with mtime-based invalidation, matching Store#all.
- Session invalidation: deleted users can no longer use existing session cookies.
- CSRF protection on `POST /admin/preview`.
- Symlink-safe containment in `Store#media_file` via `File.realpath`.
### Added
- **Scheduled publishing:** a `publish_at` frontmatter field hides posts from the
public API and feeds until their publication date arrives. The admin form
includes a Publish at date picker.
- **JSON Feed:** `GET /api/volumen/feed.json` serves a jsonfeed.org v1.1 feed
with `content_html` and `date_published` per item.
- **Fulltext search:** `GET /api/volumen/posts?q=…` filters posts by
case-insensitive match in title and body.
### Changed
- Split `lib/volumen/server.rb` into `api_routes.rb` and `admin_routes.rb` modules,
keeping the server class focused on configuration and shared helpers.
### Fixed
- `admin.session_ttl` was defined but never read; it now controls
`Rack::Session::Cookie` expiration via `expire_after`.
- Derived post excerpts now strip HTML tags before stripping markdown markup,
avoiding raw HTML leaks in auto-generated excerpts.
- Draft posts no longer leak through the public detail endpoint (`/api/volumen/posts/:slug`).
- `Config::DEFAULTS` inner hashes are now frozen and `deep_merge` builds fresh
copies, preventing mutation from silently poisoning later `Config.load` calls.
- A warning is emitted when `admin.session_key` is configured but shorter than
64 bytes, instead of silently falling back to a random secret.
## [0.1.0] — 2026-06-20 ## [0.1.0] — 2026-06-20
First public release: a small, file-based Markdown blog engine in CRuby. Posts First public release: a small, file-based Markdown blog engine in CRuby. Posts
@@ -93,4 +388,7 @@ admin — no database, no external services.
- Configurable, stable `session_key` so sessions survive restarts. - Configurable, stable `session_key` so sessions survive restarts.
- Generic login error message to avoid username enumeration. - 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
+160 -104
View File
@@ -1,16 +1,20 @@
# Contributing # Contributing
Thank you for considering contributing to **volumen** — a small, file-based 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 Markdown blog engine written in Python, with a built-in admin and a public
API. JSON API.
## Development Setup ## Development Setup
### Requirements ### Requirements
- **Ruby ≥ 3.3** with a C toolchain (Puma compiles a native extension). - **Python ≥ 3.14** with the `venv` module (the venv itself is managed by
- Fedora: `sudo dnf install ruby ruby-devel @development-tools openssl-devel` `uv`).
- Debian/Ubuntu: `sudo apt install ruby-full build-essential libssl-dev` - 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 - [`just`](https://github.com/casey/just) — the `justfile` is the source of
truth for install / run / build / test / uninstall. truth for install / run / build / test / uninstall.
- Linux, macOS, or FreeBSD. - Linux, macOS, or FreeBSD.
@@ -18,11 +22,11 @@ API.
### Quick Start ### Quick Start
```bash ```bash
git clone https://codeberg.org/petrbalvin/volumen.git git clone https://sourcedock.dev/petrbalvin/volumen.git
cd volumen cd volumen
just install # bundle install just install # uv sync
just test # bundle exec rake test (minitest) just test # uv run pytest
just build # rubocop (zero warnings) + gem build → pkg/volumen.gem just build # ruff check + ruff format --check (zero warnings)
just dev # run against ./posts and ./config.toml on :9090 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/>. <http://localhost:9090/admin/>.
For a fresh `serve` (without `--config`), the first run writes a commented 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 ### Running Tests
@@ -39,49 +45,51 @@ template to `/etc/volumen/config.toml`; see [`docs/deployment.md`](docs/deployme
just test just test
``` ```
The minitest suite (`test/`) covers: The pytest suite (`tests/`) covers:
- `frontmatter``+++` TOML block parse/serialise round-trips. - `frontmatter``+++` TOML block parse/serialise round-trips.
- `post` / `store` — the Post model, reading/writing posts and media. - `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. - `config` — defaults, overrides, template generation.
- `password` / `users` — scrypt hashing, roles, last-admin safeguards. - `password` / `users` — scrypt hashing, roles, last-admin safeguards.
- `server` / `admin` — the JSON API and the admin (auth, CSRF, CRUD, uploads, - `server` / `admin` — the JSON API and the admin (auth, CSRF, CRUD,
roles) via `rack-test`. uploads, roles) via `httpx.AsyncClient` against the FastAPI app.
### Linting ### Linting
RuboCop is the source of truth for style (`.rubocop.yml`). The project enforces Ruff is the source of truth for style (settings in `pyproject.toml`,
**zero offenses**: `[tool.ruff]`, `[tool.ruff.lint]`). The project enforces **zero warnings**:
```bash ```bash
bundle exec rubocop # check (also run as part of `just build`) just build # ruff check + ruff format --check
bundle exec rubocop -A # autocorrect 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 ## Code Style
- Idiomatic Ruby; let RuboCop guide you. CI fails on any offense. - Idiomatic Python 3.14+; let Ruff guide you. CI fails on any warning.
- **Two-space** indentation, **double quotes**, and `# frozen_string_literal: true` - **4-space** indentation, **double quotes**, lines `≤ 100` characters (set
at the top of every Ruby file. in `[tool.ruff]`).
- Document every class/module with a short comment (`Style/Documentation` is on). - Type hints on every public function signature; `from __future__ import
- Keep lines ≤ 100 characters (`Layout/LineLength`). annotations` at the top of every module.
- Prefer guard clauses; return `nil`/objects for expected "not found" cases - Prefer the standard library. New runtime dependencies belong in
rather than raising. `pyproject.toml`'s `dependencies` array and should be discussed in an
- Keep the dependency set small and deliberate — runtime gems are `sinatra`, issue first.
`kramdown` (+ `kramdown-parser-gfm`), `toml-rb`, `puma`, `rackup`. Crypto uses - Prefer `httpx` for HTTP tests; never start a real network server in tests.
the standard library (`openssl`, `securerandom`). New runtime dependencies
should be discussed in an issue first.
- Admin content is authored by trusted users; see [`docs/security.md`](docs/security.md) - Admin content is authored by trusted users; see [`docs/security.md`](docs/security.md)
for the trust model before touching rendering or uploads. for the trust model before touching rendering or uploads.
### Testing ### Testing
- Put tests in `test/*_test.rb`, each starting with `require "test_helper"`. - Put tests in `tests/test_*.py`; use `pytest` fixtures (see
- Use `Dir.mktmpdir` for anything touching the filesystem; never assume a clean `tests/conftest.py`) for the temp content directory and the FastAPI app.
working directory. - Exercise the HTTP layer with `httpx.AsyncClient` driving the FastAPI app
- Exercise the HTTP layer with `Rack::Test` (`Volumen::Server.configured(...)`). via `app=` in the `AsyncClient` constructor.
- Name tests `test_<thing>_<scenario>`; cover both the happy and the error path. - Name tests `test_<thing>_<scenario>`; cover both the happy and the error
path.
## Commit Conventions ## Commit Conventions
@@ -101,11 +109,11 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/).
``` ```
feat: add author role with last-admin safeguards 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 refactor: extract frontmatter parsing into its own module
docs: document the dual-mode editor docs: document the dual-mode editor
test: cover GFM table and fenced code rendering 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`). - English, lowercase subject, imperative mood (`add`, not `added`).
@@ -116,15 +124,19 @@ chore: pin gem versions to latest
1. Create a feature branch from `development`. 1. Create a feature branch from `development`.
2. Make your changes with Conventional-Commits messages. 2. Make your changes with Conventional-Commits messages.
3. Ensure `bundle exec rubocop` reports zero offenses and `just test` passes. 3. Record your changes under the `## [development]` section at the top of
4. Add or update tests for your change. `CHANGELOG.md`, using the Keep a Changelog categories (`Added`, `Changed`,
5. Update documentation if the public API, configuration, or behaviour changes: `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:
- `README.md` — high-level overview. - `README.md` — high-level overview.
- `docs/configuration.md` — config keys. - `docs/configuration.md` — config keys.
- `docs/api-reference.md` — JSON API behaviour. - `docs/api-reference.md` — JSON API behaviour.
- `docs/architecture.md` — structural changes. - `docs/architecture.md` — structural changes.
- `docs/security.md` — anything touching auth, uploads, or rendering. - `docs/security.md` — anything touching auth, uploads, or rendering.
6. Open a pull request against `development`. Releases are cut by merging 7. Open a pull request against `development`. Releases are cut by merging
`development` into `main` and pushing a `vX.Y.Z` tag — see `development` into `main` and pushing a `vX.Y.Z` tag — see
[Cutting a release](#cutting-a-release). [Cutting a release](#cutting-a-release).
@@ -133,29 +145,32 @@ chore: pin gem versions to latest
``` ```
volumen/ volumen/
├── exe/ ├── exe/
│ └── volumen # CLI: serve, hash-password, version, help │ └── volumen # thin shim: from volumen.cli import main
├── lib/ ├── src/
│ ├── volumen.rb # library entry (requires the core modules)
│ └── volumen/ │ └── volumen/
│ ├── version.rb │ ├── __init__.py # VERSION + login-attempt limiter
│ ├── frontmatter.rb # parse/serialise the +++ TOML frontmatter block │ ├── cli.py # argparse entry point: serve, hash-password, version
│ ├── markdown.rb # kramdown (GFM) → HTML │ ├── config.py # tomllib loader + deep-merge defaults
│ ├── post.rb # Post model (metadata + body + rendered HTML) │ ├── frontmatter.py # parse/serialise the +++ TOML frontmatter block
│ ├── store.rb # read/write posts and media on disk │ ├── markdown.py # python-markdown + pymdown-extensions
│ ├── config.rb # load/merge config.toml, generate template │ ├── post.py # Post model (metadata + body + rendered HTML)
│ ├── password.rb # scrypt hashing/verification (OpenSSL::KDF) │ ├── store.py # read/write posts and media on disk (mtime-cached)
│ ├── users.rb # users.toml, admin/author roles │ ├── password.py # scrypt hashing/verification (hashlib + secrets)
│ ├── server.rb # Sinatra app: public API + admin │ ├── users.py # users.toml + admin/author roles
│ ├── cli.rb # command dispatcher │ ├── 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/ │ └── web/
│ ├── views/ # ERB templates (layout, login, list, form, settings) │ ├── templates/ # Jinja2 templates (layout, login, list, form, settings)
│ └── public/ # static assets (volumen-logo.svg) │ └── static/ # static assets (volumen-logo.svg, volumen-icon.svg)
├── test/ # minitest suite ├── tests/ # pytest suite
├── docs/ # architecture, configuration, api-reference, deployment, security ├── docs/ # architecture, configuration, api-reference, deployment, security
├── .forgejo/workflows/ # test.yml, release.yml (Forgejo Actions) - `.gitea/workflows/` # test.yml, release.yml, migration-guard.yml (Gitea Actions)
├── justfile # install, run, dev, build, test, uninstall ├── justfile # install, run, dev, lint, fmt, build, test, uninstall
├── volumen.gemspec ├── pyproject.toml # PEP 621 metadata + dependencies + tool config
├── Gemfile ├── uv.lock # locked dependency graph
├── config.toml.example # bootstrap config template
├── CHANGELOG.md ├── CHANGELOG.md
└── LICENSE └── LICENSE
``` ```
@@ -166,19 +181,24 @@ volumen/
graph TD graph TD
Browser[Browser / Vue SPA] Browser[Browser / Vue SPA]
Nginx[nginx reverse proxy] Nginx[nginx reverse proxy]
Server[Volumen::Server -- Sinatra] App[FastAPI app -- Uvicorn]
Store[Volumen::Store] ApiR[api_routes.py]
Markdown[Volumen::Markdown -- kramdown GFM] AdminR[admin_routes.py + CSRF + CSP]
Users[Volumen::Users] Store[Store]
Markdown[Markdown renderer -- python-markdown]
Users[Users]
Posts[(content_dir/*.md)] Posts[(content_dir/*.md)]
UsersFile[(users.toml)] UsersFile[(users.toml)]
Browser -->|GET /api/volumen/*| Nginx Browser -->|GET /api/volumen/*| Nginx
Browser -->|/admin| Nginx Browser -->|/admin| Nginx
Nginx --> Server Nginx --> App
Server -->|read/write posts| Store App --> ApiR
Server -->|render Markdown| Markdown App --> AdminR
Server -->|auth + roles| Users ApiR --> Store
ApiR --> Markdown
AdminR --> Store
AdminR --> Users
Store --> Posts Store --> Posts
Users --> UsersFile Users --> UsersFile
``` ```
@@ -186,72 +206,108 @@ graph TD
### Key Design Decisions ### Key Design Decisions
- **File-based, no database** — every post is a `.md` file with TOML - **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 frontmatter; users live in a `users.toml`. Everything is plain text,
commit and back up. safe to commit and back up.
- **Headless for the front-end** — volumen serves a read-only JSON API and its - **Headless for the front-end** — volumen serves a read-only JSON API
own server-rendered admin; the public site is a separate consumer. and its own server-rendered admin; the public site is a separate
- **Single universal gem** — volumen is pure Ruby, so `gem build` produces one consumer.
platform-independent gem. Only the `puma` dependency has a native extension, - **Standard library first** — TOML parsing uses `tomllib` (3.11+), scrypt
compiled at install time on the host. uses `hashlib.scrypt`, constant-time compares use `secrets.compare_digest`.
- **Trusted authors** — kramdown passes raw HTML through on purpose; content is No runtime TOML or crypto dependency is required.
only as safe as who can log in (see `docs/security.md`). - **Trusted authors** — python-markdown passes raw HTML through on
- **Roles, server-enforced** — `admin` manages users, `author` manages posts purpose; content is only as safe as who can log in (see
and their own account; `require_admin!` gates user management on the server, `docs/security.md`).
with last-admin safeguards. - **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 ## CI
volumen uses **Forgejo Actions**; both workflows live under `.forgejo/workflows/` volumen uses **Gitea Actions**; the workflows live under
and run on the `codeberg-small` runner. `.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`. Triggered on push and pull requests targeting `development`.
| Job | What it does | | Step | What it does |
|--------|-----------------------------------------------| |------|--------------|
| `lint` | `bundle exec rubocop` (must report zero offenses) | | Set up Python and uv | `astral-sh/setup-uv` with Python 3.14 |
| `test` | `bundle exec rake test` on a Ruby `3.3` / `3.4` matrix | | 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: Run the same locally before opening a PR:
```bash ```bash
bundle exec rubocop just build
just test 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`, Triggered by pushing a `v*` tag. It verifies the tag matches the version
runs the quality gate (rubocop + tests), builds `volumen-X.Y.Z.gem`, extracts in `pyproject.toml`, runs the quality gate, builds the wheel and sdist
the matching `CHANGELOG.md` section, creates a Forgejo release via the REST API, with `python -m build`, extracts the matching `CHANGELOG.md` section,
and attaches the gem. It does **not** publish to RubyGems. 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 ### Cutting a release
1. Bump `VERSION` in `lib/volumen/version.rb`. 1. Bump `version` under `[project]` in `pyproject.toml`.
2. Add a `## [X.Y.Z] — YYYY-MM-DD` section at the top of `CHANGELOG.md`. 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.
3. Ensure CI is green on `development`. 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: 5. Tag and push:
```bash ```bash
git tag -a vX.Y.Z -m "volumen vX.Y.Z" git tag -a vX.Y.Z -m "volumen vX.Y.Z"
git push origin main git push origin main
git push origin vX.Y.Z git push origin vX.Y.Z
``` ```
6. The release workflow builds the gem and publishes the release from the 6. The release workflow builds the wheel + sdist, publishes them to
CHANGELOG section. 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 ## 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. - Operating system and architecture.
- Exact steps (request or admin action) that reproduce it. - 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 For **security issues**, please email **opensource@petrbalvin.org** rather
opening a public issue. 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.1.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.1.0)
BUNDLED WITH
4.0.10
+136 -33
View File
@@ -1,6 +1,6 @@
# volumen — file-based Markdown blog engine # 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- 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 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 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 - **File-based** — every post is a plain `.md` file with TOML frontmatter, safe
to commit to Git and edit in your favourite editor. to commit to Git and edit in your favourite editor.
- **Self-contained** — runs as a single Ruby process; no Node build step is - **Self-contained** — runs as a single Python process (Uvicorn); no Node
required to operate the engine or its admin. build step is required to operate the engine or its admin.
- **Multi-user with roles** — username + password login; **admin** (full - **Multi-user with roles** — username + password login; **admin** (full
access, manages users) and **author** (posts and own account) roles. access, manages users) and **author** (posts and own account) roles.
- **Multi-site friendly** — one instance per blog, configurable per instance. - **Multi-site friendly** — one instance per blog, configurable per instance.
- **Markdown-first** — the visual editor round-trips through the same renderer - **Markdown-first** — the visual editor round-trips through the same renderer
that powers the public API, so what you store is always Markdown. 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 ## Stack
| Concern | Choice | | Concern | Choice |
|----------------|------------------------------------------| |----------------|------------------------------------------------------------------------|
| Language | CRuby (≥ 3.2) | | Language | Python 3.14+ |
| Web framework | [Sinatra](https://sinatrarb.com) | | Web framework | [FastAPI](https://fastapi.tiangolo.com/) |
| Markdown | [kramdown](https://kramdown.gettalong.org) | | App server | [Uvicorn](https://www.uvicorn.org/) (via `fastapi[standard]`) |
| Frontmatter | TOML via [`toml-rb`](https://github.com/emancu/toml-rb) | | Markdown | [python-markdown](https://python-markdown.github.io/) + [pymdown-extensions](https://facelessuser.github.io/pymdown-extensions/) |
| App server | [Puma](https://puma.io) | | Frontmatter | TOML via stdlib [`tomllib`](https://docs.python.org/3/library/tomllib.html) + [`tomli-w`](https://github.com/camillescott/tomli-w) |
| Packaging | a Ruby gem with an `exe/volumen` CLI | | 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 - Multi-language posts with a translations map
- Clean JSON API at `/api/volumen/`: - Clean JSON API at `/api/volumen/`:
- `GET /site` — site metadata - `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 /posts/{slug}` — single post (raw Markdown + rendered HTML)
- `GET /tags` — tag cloud with counts - `GET /tags` — tag cloud with counts
- `GET /feed.xml` — RSS 2.0 feed - `GET /feed.xml` — RSS 2.0 feed
- `GET /feed.json` — JSON Feed 1.1
- `GET /sitemap.xml` — sitemap - `GET /sitemap.xml` — sitemap
- Server-rendered admin at `/admin/`: - 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 - Settings page: change password, change username, manage users and roles
(admin only) (admin only)
- Session cookies (HttpOnly, SameSite=Strict) + CSRF tokens - Session cookies (HttpOnly, SameSite=Strict) + CSRF tokens
- List, create, edit, delete posts - List, create, edit, delete posts
- **Markdown source** editor and a **visual** editor with live preview, both - **Markdown source** editor and a **visual** editor with live preview,
storing Markdown both storing Markdown
- "Reload from disk" to pick up out-of-band edits - "Reload from disk" to pick up out-of-band edits
- Permissive CORS for the public API, same-origin only for the admin - Permissive CORS for the public API, same-origin only for the admin
@@ -64,16 +74,44 @@ It is designed to be:
## Quick start ## Quick start
### Production / first install
```bash ```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 just install
# 2. Run # 2. Run
just 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 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 date = 2026-01-15 # first-class TOML date
lang = "en" # language code lang = "en" # language code
author = "Petr Balvín" # optional 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 draft = false # optional
excerpt = "Short summary" # optional, auto-derived from body if missing excerpt = "Short summary" # optional, auto-derived from body if missing
all_langs = false # optional, show this post in every language all_langs = false # optional, show this post in every language
@@ -99,7 +138,7 @@ cs = "muj-prispevek"
# Heading # Heading
Body in **Markdown**, rendered by kramdown. Body in **Markdown**, rendered by python-markdown.
``` ```
Posts are organised on disk as either: 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 Set `all_langs = true` on a post to show it in **every** language (useful for
posts that have no per-language translations). 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 ### Why TOML instead of YAML
- **First-class dates** — `date = 2026-01-15` is a real date, not a guess. - **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`). becoming `false`).
- **No whitespace pitfalls** — indentation is not significant. - **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 ## Configuration
A single TOML file, auto-generated at `/etc/volumen/config.toml` on first A single TOML file, auto-generated at `/etc/volumen/config.toml` when you
start. See [`docs/configuration.md`](docs/configuration.md) for the full run `volumen init` (or, for local development, by `volumen serve` on first
start). See [`docs/configuration.md`](docs/configuration.md) for the full
schema. schema.
```toml ```toml
[server] [server]
host = "0.0.0.0" host = "::" # IPv6 + IPv4 fallback; use "::1" behind nginx
port = 9090 port = 9090
env = "production"
trust_proxy = true
cookie_secure = true # required in production behind HTTPS
content_dir = "/var/lib/volumen/posts" content_dir = "/var/lib/volumen/posts"
users_file = "/var/lib/volumen/users.toml"
[site] [site]
title = "My Blog" title = "My Blog"
@@ -143,17 +225,26 @@ language = "en"
author = "Anonymous" author = "Anonymous"
[admin] [admin]
password_hash = "" password_hash = "" # populated by `volumen init`
session_key = "" session_key = "" # populated by `volumen init`
session_ttl = 86400 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 ## Public API
The API is at `/api/volumen/`. CORS is open (`*`) for read endpoints; admin routes The API is at `/api/volumen/`. CORS is open (`*`) for read endpoints; admin
are same-origin only. routes are same-origin only.
### `GET /api/volumen/posts` ### `GET /api/volumen/posts`
@@ -161,6 +252,7 @@ are same-origin only.
|-------|--------|---------|----------------------| |-------|--------|---------|----------------------|
| lang | string | — | Filter by language | | lang | string | — | Filter by language |
| tag | string | — | Filter by tag | | tag | string | — | Filter by tag |
| q | string | — | Search in title/body |
| page | int | 1 | Page number | | page | int | 1 | Page number |
| limit | int | 20 | Posts per page | | limit | int | 20 | Posts per page |
@@ -169,6 +261,8 @@ are same-origin only.
"page": 1, "page": 1,
"page_size": 20, "page_size": 20,
"total": 42, "total": 42,
"has_next": true,
"has_prev": false,
"posts": [ "posts": [
{ {
"slug": "welcome", "slug": "welcome",
@@ -176,7 +270,7 @@ are same-origin only.
"excerpt": "A short introduction…", "excerpt": "A short introduction…",
"date": "2026-01-15", "date": "2026-01-15",
"lang": "en", "lang": "en",
"tags": ["intro", "ruby"], "tags": ["intro", "python"],
"author": "Petr Balvín", "author": "Petr Balvín",
"translations": { "cs": "vitame-vas" }, "translations": { "cs": "vitame-vas" },
"url": "/api/volumen/posts/welcome" "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", "title": "Welcome to volumen",
"date": "2026-01-15", "date": "2026-01-15",
"lang": "en", "lang": "en",
"tags": ["intro", "ruby"], "tags": ["intro", "python"],
"excerpt": "…", "excerpt": "…",
"body": "# Welcome to **volumen**\n\n…", "body": "# Welcome to **volumen**\n\n…",
"html": "<h1>Welcome to <strong>volumen</strong></h1>…", "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 ```bash
just # list recipes just # list recipes
just install # bundle install just install # uv sync (creates .venv/ from uv.lock)
just run # run the dev server (exe/volumen serve) just run # run the dev server (volumen serve)
just build # rubocop (zero warnings) + build the gem → pkg/volumen.gem just dev # run against ./posts and ./config.toml on :9090
just test # bundle exec rake test (minitest) just build # ruff check + ruff format --check (zero warnings)
just uninstall # remove build artifacts (pkg/, *.gem) 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 ## 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.", "description": "A blog powered by volumen.",
"base_url": "https://example.com", "base_url": "https://example.com",
"language": "en", "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` ## `GET /api/volumen/posts`
Paginated list of published posts (drafts are excluded), newest first. 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 | | lang | string | — | Filter by language |
| tag | string | — | Filter by tag | | tag | string | — | Filter by tag |
| q | string | — | Case-insensitive search across title and body |
| page | int | 1 | Page number | | page | int | 1 | Page number |
| limit | int | 20 | Per page (1100) | | 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": 1,
"page_size": 20, "page_size": 20,
"total": 1, "total": 1,
"has_next": false,
"has_prev": false,
"posts": [ "posts": [
{ {
"slug": "hello", "slug": "hello",
@@ -46,6 +53,7 @@ Posts with `all_langs = true` in their frontmatter match every `lang` filter.
"lang": "en", "lang": "en",
"tags": ["intro"], "tags": ["intro"],
"author": null, "author": null,
"fediverse_creator": null,
"cover": null, "cover": null,
"reading_time": 1, "reading_time": 1,
"translations": {}, "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 Single post including the raw Markdown body and rendered HTML. Optional
`?lang=` selects a translation. Returns `404` with `{"error":"not_found"}` if `?lang=` selects a translation. Returns `404` with `{"error":"not_found"}` if
no match. no match. Drafts return `404` with `{"error":"draft"}`.
```json ```json
{ {
@@ -70,6 +78,7 @@ no match.
"lang": "en", "lang": "en",
"tags": ["intro"], "tags": ["intro"],
"author": null, "author": null,
"fediverse_creator": "@petrbalvin@mastodon.social",
"cover": null, "cover": null,
"reading_time": 1, "reading_time": 1,
"translations": {}, "translations": {},
@@ -87,7 +96,14 @@ Tag cloud with counts (published posts only), most frequent first.
{ "tags": [{ "name": "intro", "count": 1 }] } { "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 - `feed.xml`RSS 2.0 feed of the 20 most recent posts.
`site.base_url`. - `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 > Status: the core library, the public JSON API, and the server-rendered admin
> (login, CSRF, post CRUD, Markdown editor + live preview) are implemented. > (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 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 block. The engine reads those files, exposes them as a JSON API, and ships a
server-rendered admin for editing them. 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 - **File-based** — posts are plain `.md` files, safe to commit to Git and edit
by hand or through the admin. by hand or through the admin.
- **Single-admin** — one password, one content directory, one user. - **Self-contained** — runs as a single Python process (Uvicorn) under FastAPI;
- **Self-contained** — runs as a single Ruby process; no Node build step is no Node build step is required to operate the engine or its admin.
required to operate the engine or its admin. - **Dependency-light** — a small, well-chosen set of PyPI packages (FastAPI,
- **Dependency-light** — a small, well-chosen set of gems (Sinatra, kramdown, python-markdown, Jinja2, itsdangerous, python-multipart) rather than a
toml-rb, puma) rather than a full framework. full-stack framework. The standard library covers cryptography.
- **Markdown-first** — the visual editor round-trips through the same renderer - **Markdown-first** — the visual editor round-trips through the same renderer
that powers the public API, so stored content is always Markdown. that powers the public API, so stored content is always Markdown.
## Components (planned layout) ## Components
``` ```
lib/volumen/ src/volumen/
version.rb # gem version __init__.py # VERSION + in-memory login-attempt rate limiter
config.rb # loads /etc/volumen/config.toml, applies CLI overrides cli.py # argparse entry point: serve, hash-password, version
frontmatter.rb # parse/serialise the `+++ … +++` TOML block config.py # loads config.toml via stdlib tomllib, deep-merges defaults
post.rb # Post model: metadata + raw body + rendered HTML frontmatter.py # parse/serialise the `+++ … +++` TOML block
store.rb # reads the content directory into Post objects post.py # Post model: metadata + raw body + rendered HTML
markdown.rb # kramdown wrapper (render + sanitise) store.py # reads the content directory into Post objects (mtime-cached)
server.rb # Sinatra app: JSON API + admin routes 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/ web/
views/ # ERB templates for the admin templates/ # Jinja2 templates for the admin
public/ # admin CSS + the visual-editor JS island static/ # static assets (volumen-logo.svg, volumen-icon.svg)
exe/volumen # CLI: serve, hash-password, version, help exe/volumen # thin shim that calls volumen.cli:main
``` ```
## Request flow (planned) ## Request flow
```mermaid ```mermaid
graph TD graph TD
A[HTTP request] --> B{Route} A[HTTP request] --> B{Route}
B -->|/api/volumen/*| C[JSON API] B -->|/api/volumen/*| C[api_routes.py]
B -->|/admin/*| D[Admin: session + CSRF] B -->|/admin/*| D[admin_routes.py + CSRF + CSP]
C --> E[Store] B -->|/media/*| E[admin_routes.py media_router]
D --> E C --> F[Store]
E --> F[(content_dir/*.md)] D --> F
D --> U[Users]
C --> H[FeedHelpers]
C --> G[Markdown renderer] 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 ## Public site integration
The public website (e.g. `petrbalvin.org`, a Vue 3 + Vite + Tailwind SPA) The public website (e.g. `petrbalvin.org`, a Vue 3 + Vite + Tailwind SPA)
+62 -7
View File
@@ -1,20 +1,28 @@
# Configuration # 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 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 `/etc/volumen/config.toml` and is auto-generated on first start. CLI flags
passed to `volumen serve` override the file. 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` ## Example `config.toml`
```toml ```toml
[server] [server]
host = "0.0.0.0" host = "::1"
port = 9090 port = 9090
env = "production"
trust_proxy = true
# Directory containing your Markdown posts.
content_dir = "/var/lib/volumen/posts" content_dir = "/var/lib/volumen/posts"
users_file = "/var/lib/volumen/users.toml"
[site] [site]
title = "My Blog" title = "My Blog"
@@ -22,17 +30,60 @@ description = "A blog powered by volumen."
base_url = "https://example.com" base_url = "https://example.com"
language = "en" language = "en"
author = "Anonymous" author = "Anonymous"
fediverse_creator = "@you@example.social"
[admin] [admin]
# scrypt hash produced by `volumen hash-password`.
password_hash = "" password_hash = ""
# Secret used to sign session cookies. Leave empty to auto-generate per start
# (sessions reset on restart).
session_key = "" session_key = ""
# Session lifetime in seconds (default 24h).
session_ttl = 86400 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 ## CLI overrides
```bash ```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 same format is used for post frontmatter; see the post format section in
the project [`README.md`](../README.md). 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. > Status: production deployment on Linux with systemd + nginx.
volumen runs as a single Ruby process (Puma) bound to localhost, behind nginx volumen runs as a single Python process (Uvicorn via `fastapi[standard]`) bound
which terminates TLS and reverse-proxies the API and admin. The public website to localhost, behind nginx which terminates TLS and reverse-proxies the API and
(e.g. a Vue SPA) is served separately and consumes the JSON API. 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 ## Requirements
- **Ruby ≥ 3.3** with a C toolchain (Puma compiles a native extension). - **Python ≥ 3.14** (the bundled wheel ships its own interpreter when you use
- Fedora: `sudo dnf install ruby ruby-devel @development-tools openssl-devel` `uv tool install` / `pipx install`; the system Python is only required if
- Debian/Ubuntu: `sudo apt install ruby-full build-essential libssl-dev` you're building from source).
- **nginx** and a TLS certificate (e.g. via certbot). - Fedora: `sudo dnf install python3.14`
- A dedicated system user, e.g. `volumen`. - 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 | | Path | Purpose |
|------|---------| |------|---------|
| `/opt/volumen` | source checkout (or installed gem) | | `/etc/volumen/config.toml` | configuration (rendered by `volumen init` on first run) |
| `/etc/volumen/config.toml` | configuration (auto-generated on first run) |
| `/var/lib/volumen/posts` | Markdown posts | | `/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/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 The config and users files are owned by `volumen:volumen` and `chmod 600` after
sudo useradd --system --home /var/lib/volumen --shell /usr/sbin/nologin volumen a system install — they may carry password hashes, so never make them
sudo mkdir -p /etc/volumen /var/lib/volumen/posts world-readable.
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.
## systemd ## systemd
`/etc/systemd/system/volumen.service`: `/etc/systemd/system/volumen.service` (rendered by `volumen init --systemd`):
```ini ```ini
[Unit] [Unit]
@@ -98,10 +116,12 @@ Description=volumen blog engine
After=network.target After=network.target
[Service] [Service]
Type=simple
User=volumen User=volumen
Group=volumen Group=volumen
WorkingDirectory=/opt/volumen WorkingDirectory=/etc/volumen
ExecStart=/usr/bin/bundle exec exe/volumen serve --config /etc/volumen/config.toml --content /var/lib/volumen/posts Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/local/bin/volumen serve --config /etc/volumen/config.toml --content /var/lib/volumen/posts
Restart=on-failure Restart=on-failure
RestartSec=2 RestartSec=2
NoNewPrivileges=true NoNewPrivileges=true
@@ -114,26 +134,34 @@ PrivateTmp=true
WantedBy=multi-user.target 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 ```bash
sudo systemctl daemon-reload
sudo systemctl enable --now volumen
sudo systemctl status volumen sudo systemctl status volumen
journalctl -u volumen -f journalctl -u volumen -f
volumen status # config / data dir / unit / service state
volumen status --json # machine-readable
``` ```
## FreeBSD (rc.d) ## FreeBSD (rc.d)
volumen runs on FreeBSD too — it is plain CRuby plus gems; only Puma (and its volumen runs on FreeBSD too — it is plain CPython with no compiled extensions.
`nio4r` dependency) build a native extension, which compiles with the base
clang toolchain.
```sh ```sh
pkg install ruby pkg install python3
# then install volumen as above (git clone + bundle install) # 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 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 ```sh
#!/bin/sh #!/bin/sh
@@ -150,14 +178,13 @@ load_rc_config $name
: ${volumen_enable:="NO"} : ${volumen_enable:="NO"}
: ${volumen_user:="volumen"} : ${volumen_user:="volumen"}
: ${volumen_dir:="/usr/local/volumen"}
: ${volumen_config:="/usr/local/etc/volumen/config.toml"} : ${volumen_config:="/usr/local/etc/volumen/config.toml"}
: ${volumen_content:="/var/db/volumen/posts"} : ${volumen_content:="/var/db/volumen/posts"}
pidfile="/var/run/${name}.pid" pidfile="/var/run/${name}.pid"
command="/usr/sbin/daemon" command="/usr/sbin/daemon"
command_args="-f -r -P ${pidfile} -u ${volumen_user} \ 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" run_rc_command "$1"
``` ```
@@ -165,6 +192,7 @@ run_rc_command "$1"
Enable and start: Enable and start:
```sh ```sh
chmod +x /usr/local/etc/rc.d/volumen
sysrc volumen_enable=YES sysrc volumen_enable=YES
service volumen start service volumen start
``` ```
@@ -266,22 +294,34 @@ right block. Apply with `sudo nginx -t && sudo systemctl reload nginx`.
## Updating ## Updating
```bash ```bash
cd /opt/volumen uv tool upgrade volumen
sudo git pull
sudo bundle install --deployment
sudo systemctl restart 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 ## 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` - Session cookies are `HttpOnly` and `SameSite=Strict`, and gain the `Secure`
attribute automatically on HTTPS requests (detected via `X-Forwarded-Proto`, attribute automatically on HTTPS requests (detected via `X-Forwarded-Proto`,
which the nginx snippet above sets) — so the cookie never travels over plain which the nginx snippet above sets, and enabled by
HTTP in production, while local HTTP testing still works. Combined with `[server].cookie_secure = true`, set automatically by `volumen init
per-form CSRF tokens this protects the admin. Set a stable `admin.session_key` --systemd`) — so the cookie never travels over plain HTTP in production,
so sessions survive restarts. 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 - Keep `config.toml` and `users.toml` readable only by the `volumen` user
(`chmod 600`). Both may contain password hashes. (`chmod 600`). Both may contain password hashes. The installer enforces
- `volumen hash-password` reads the password without echo; never pass passwords this automatically.
on the command line. - 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. > 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 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 document describes the trust model, the controls in place, and the known
limitations. limitations.
@@ -13,33 +13,37 @@ than opening a public issue.
## Trust model ## Trust model
- **Content authors are trusted.** Posts are written by authenticated admin - **Content authors are trusted.** Posts are written by authenticated admin
users. kramdown renders Markdown and **passes raw HTML through** on purpose, users. python-markdown renders Markdown with a few pymdown-extensions
so an author can embed HTML. The stored/served `html` is therefore only as extensions. The engine does **not** strip raw HTML from post bodies by
safe as the people who can log in. If you need to accept posts from untrusted default (authors are the trusted admin); if you need to accept posts from
authors, add an HTML sanitiser before serving. untrusted authors, add a sanitiser (e.g. `bleach`) before serving.
- **The public API is read-only.** It exposes published (non-draft) posts, tags, - **The public API is read-only.** It exposes published (non-draft) posts, tags,
a feed and a sitemap. No public endpoint mutates state. a feed and a sitemap. No public endpoint mutates state.
## Authentication ## 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 `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 - Login takes a **username + password**. The error message is intentionally
generic ("Invalid username or password.") to avoid user enumeration. generic ("Invalid username or password.") to avoid user enumeration.
- Sessions use signed cookies (`Rack::Session::Cookie`) marked **`HttpOnly`** - Sessions use **signed cookies** via Starlette's `SessionMiddleware`
and **`SameSite=Strict`**. The **`Secure`** attribute is added automatically on (`itsdangerous` TimestampSigner under the hood), marked **`HttpOnly`** and
HTTPS requests (detected via `request.ssl?` / `X-Forwarded-Proto`), so the **`SameSite=Strict`**. The **`Secure`** attribute is added by
cookie never travels over plain HTTP in production. The signing secret comes `SecureCookieMiddleware` when `X-Forwarded-Proto: https` is observed (only
from `admin.session_key`; if it is shorter than 64 bytes a random secret is honoured when `[server].trust_proxy = true`) so the cookie never travels
generated per start (sessions then reset on restart, so set a stable key in over plain HTTP in production while local HTTP testing still works. The
production). 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) ## Authorization (roles)
- Two roles: **admin** (full access, manages users) and **author** (posts and - Two roles: **admin** (full access, manages users) and **author** (posts and
own account only). 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. **on the server**, not merely hidden in the UI.
- Safeguards prevent lockout: the **last admin** cannot be deleted or demoted, - Safeguards prevent lockout: the **last admin** cannot be deleted or demoted,
and a user cannot delete their own account or change their own role. and a user cannot delete their own account or change their own role.
@@ -47,29 +51,56 @@ than opening a public issue.
## CSRF ## CSRF
- Every state-changing form (`POST`) carries a per-session token - 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`. Requests without a valid token get `403`.
- `SameSite=Strict` cookies provide a second, independent layer. - `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 ## CORS and Host handling
- The public read API sends `Access-Control-Allow-Origin: *` (read-only, no - 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 credentials) so any front-end may consume it. The admin sends **no** CORS
headers and is same-origin only. headers and is same-origin only.
- `Rack::Protection::HostAuthorization` is disabled (`permitted_hosts: []`) - volumen is intended to run behind nginx, which controls the `Host` header.
because volumen is intended to run behind nginx, which controls the `Host` Do not expose the Uvicorn port directly to the internet.
header. Do not expose the Puma port directly to the internet.
## File uploads and path traversal ## File uploads and path traversal
- Uploaded media is stored under `content_dir/media` with a **sanitised, - Uploaded media is stored under `content_dir/media` with a **sanitised,
randomised** filename (`<hex>-<slug>.<ext>`), so uploads cannot overwrite randomised** filename (`<hex>-<slug>.<ext>`), so uploads cannot overwrite
each other or escape the directory. 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 containment check, so `../` traversal cannot read files outside the media
directory. directory.
- Upload content-type is **not** validated (the `accept="image/*"` attribute is - Uploads are **rejected (HTTP 413)** when the file exceeds the
only a UI hint); this is acceptable under the trusted-author model. `[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 ## Secrets and files
@@ -80,19 +111,27 @@ than opening a public issue.
## Transport ## Transport
- Bind to `127.0.0.1` and terminate TLS at nginx. The browser ↔ nginx hop is - Bind to `::1` (loopback) and terminate TLS at nginx. The browser ↔ nginx hop
HTTPS; the nginx ↔ Puma hop is local. is HTTPS; the nginx ↔ Uvicorn hop is local.
## Dependencies ## Dependencies
A small, audited set: `sinatra`, `kramdown` (+ `kramdown-parser-gfm`), A small, audited set declared in `pyproject.toml`:
`toml-rb`, `puma`, `rackup`. Cryptography uses the Ruby standard library
(`openssl`, `securerandom`). - 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 ## Known limitations / non-goals
- **No rate limiting or lockout** on `/admin/login`. Put nginx `limit_req` in - **No rate limiting or lockout** on `/admin/login` at the application layer
front of it, or use fail2ban, to slow brute-force attempts. beyond an in-memory per-IP sliding window (`MAX_LOGIN_ATTEMPTS = 10` per 60s
in `__init__.py`). nginx is the durable line of defence — see
`docs/deployment.md`. Put nginx `limit_req` in front of `/admin/login`, or use
fail2ban, to slow brute-force attempts.
- **No 2FA** and **no audit log**. - **No 2FA** and **no audit log**.
- **Raw HTML in posts is trusted** (see the trust model). There is no built-in - **Raw HTML in posts is trusted** (see the trust model). There is no built-in
HTML sanitiser. HTML sanitiser.
+4 -6
View File
@@ -1,7 +1,5 @@
#!/usr/bin/env ruby #!/usr/bin/env python3
# frozen_string_literal: true """Volumen — a small, file-based Markdown blog engine."""
from volumen.cli import main
require "volumen" main()
require "volumen/cli"
Volumen::CLI.run(ARGV)
-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: default:
@just --list @just --list
# Install Ruby dependencies
install: install:
@echo "→ Installing Ruby dependencies…" uv sync
bundle install
# Run the development server
run: run:
@echo "→ Starting volumen…" uv run volumen serve
bundle exec exe/volumen serve
# Run the development server against ./posts and ./config.toml
dev: dev:
@echo "→ Starting volumen (dev) on http://localhost:9090 …" uv run volumen serve --config ./config.toml --content ./posts
bundle exec exe/volumen serve --config ./config.toml --content ./posts
# Lint and package the gem (must pass with zero warnings)
build: build:
@echo "→ Linting…" uv sync --frozen
bundle exec rubocop just fmt-check
@echo "→ Building gem…" just test
@mkdir -p pkg
gem build volumen.gemspec --output pkg/volumen.gem
# Run the test suite
test: test:
@echo "→ Running tests…" uv run pytest
bundle exec rake test
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: uninstall:
@echo "→ Removing build artifacts…" rm -rf dist/ __pycache__ .pytest_cache .ruff_cache
rm -rf pkg *.gem
-21
View File
@@ -1,21 +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
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
-136
View File
@@ -1,136 +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 },
"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"
},
"admin" => {
"password_hash" => "",
"session_key" => "",
"session_ttl" => 86_400
}
}.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)
base.merge(override) do |_key, 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
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
-119
View File
@@ -1,119 +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 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(/[*_`>#]/, "").strip
stripped.length > limit ? "#{stripped[0, limit].rstrip}" : stripped
end
end
end
-513
View File
@@ -1,513 +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"
module Volumen
# Sinatra application: the public JSON API at /api/volumen and the
# server-rendered admin at /admin.
class Server < Sinatra::Base
configure do
set :show_exceptions, false
set :raise_errors, false
set :host_authorization, { permitted_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)
Class.new(self) do
set :volumen_config, config
set :volumen_store, store
set :sessions, httponly: true, same_site: :strict
set :session_secret, secret
end
end
def self.session_secret(config)
key = config.admin["session_key"].to_s
key.bytesize >= 64 ? key : SecureRandom.hex(64)
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
# --- Public JSON API -------------------------------------------------
before "/api/volumen/*" do
headers "Access-Control-Allow-Origin" => "*"
content_type :json
end
get "/api/volumen/site" do
json(site_payload)
end
get "/api/volumen/posts" do
json(posts_payload)
end
get "/api/volumen/posts/:slug" do
post = store.find(params["slug"], lang: params["lang"])
halt(404, json("error" => "not_found")) if post.nil?
json(post.detail)
end
get "/api/volumen/tags" do
json("tags" => tag_counts)
end
get "/api/volumen/feed.xml" do
content_type "application/rss+xml"
feed_xml
end
get "/api/volumen/sitemap.xml" do
content_type "application/xml"
sitemap_xml
end
# --- Admin -----------------------------------------------------------
get "/admin" do
redirect to("/admin/")
end
get "/admin/" do
require_login!
@posts = sorted_posts
erb :list
end
get "/admin/login" do
redirect to("/admin/") if authenticated?
erb :login
end
post "/admin/login" do
check_csrf!
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
post "/admin/logout" do
check_csrf!
session.clear
redirect to("/admin/login")
end
get "/admin/posts/new" do
require_login!
@mode = :new
@post = Post.new(metadata: { "lang" => config.language })
erb :form
end
post "/admin/posts" do
require_login!
check_csrf!
create_post
end
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
post "/admin/posts/:slug" do
require_login!
check_csrf!
update_post
end
post "/admin/posts/:slug/delete" do
require_login!
check_csrf!
store.delete(params["slug"])
redirect to("/admin/")
end
post "/admin/preview" do
require_login!
content_type :html
Markdown.render(params["body"].to_s)
end
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
get "/media/*" do
path = store.media_file(params["splat"].first)
halt(404) if path.nil?
send_file(path)
end
get "/admin/logo.svg" do
send_file(File.expand_path("web/public/volumen-logo.svg", __dir__))
end
get "/admin/settings" do
require_login!
render_settings
end
post "/admin/settings/password" do
require_login!
check_csrf!
change_password
end
post "/admin/settings/username" do
require_login!
check_csrf!
change_username
end
post "/admin/settings/users" do
require_login!
require_admin!
check_csrf!
create_user
end
post "/admin/settings/users/:username/role" do
require_login!
require_admin!
check_csrf!
change_role(params["username"])
end
post "/admin/settings/users/:username/delete" do
require_login!
require_admin!
check_csrf!
remove_user(params["username"])
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(&:draft?).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
if params["lang"]
lang = params["lang"]
posts = posts.select { |post| post.lang == lang || post.all_langs? }
end
posts = posts.select { |post| post.tags.include?(params["tag"]) } if params["tag"]
posts
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>
#{items}
</channel>
</rss>
XML
end
def feed_item(post)
link = "#{base_url}/#{post.slug}"
<<~XML.chomp
<item>
<title>#{escape(post.title)}</title>
<link>#{escape(link)}</link>
<guid>#{escape(link)}</guid>
<description>#{escape(post.excerpt)}</description>
</item>
XML
end
def sitemap_xml
urls = published_posts.map do |post|
" <url><loc>#{escape("#{base_url}/#{post.slug}")}</loc></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!
redirect to("/admin/login") unless authenticated?
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 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?
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 "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"]),
"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
-104
View File
@@ -1,104 +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
end
def all
markdown_files.filter_map { |path| load_post(path) }
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))
File.write(target, post.to_file)
post.path = target
post
end
def delete(slug, lang: nil)
post = find(slug, lang: lang)
File.delete(post.path) if post&.path
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) && within?(media_dir, path)
path
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)
File.join(@content_dir, "#{post.slug}.md")
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
-128
View File
@@ -1,128 +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
end
def all
return read_file if File.exist?(@path)
return [] if @bootstrap_hash.empty?
[User.new("admin", @bootstrap_hash, "admin")]
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))
File.write(@path, TomlRB.dump("users" => users.map { |user| user_hash(user) }))
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.1.0"
end
-465
View File
@@ -1,465 +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") : 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>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="<%= to("/admin/posts/#{post.slug}/edit") %>">Edit</a>
<form class="inline" method="post"
action="<%= 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="<%= 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="<%= 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 %}
-251
View File
@@ -1,251 +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")
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
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
-57
View File
@@ -1,57 +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_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
-59
View File
@@ -1,59 +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_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
end
-95
View File
@@ -1,95 +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
end
-52
View File
@@ -1,52 +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
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"]
+422
View File
@@ -0,0 +1,422 @@
"""Security-focused tests: signature validation, sanitisation, cookies, headers."""
from __future__ import annotations
import io
from fastapi.testclient import TestClient
from volumen.markdown import render
from volumen.password import (
BLOCK_R,
COST_N,
KEY_LENGTH,
PARALLEL_P,
hash_password,
needs_rehash,
verify_password,
)
from volumen.store import (
ALLOWED_IMAGE_EXTENSIONS,
detect_image_extension,
validate_image_signature,
)
# ---------------------------------------------------------------------------
# Image signature validation
# ---------------------------------------------------------------------------
class TestImageSignature:
def test_webp_magic_accepted(self) -> None:
# Minimal RIFF/WEBP header (12 bytes).
good = b"RIFF" + b"\x10\x00\x00\x00" + b"WEBP" + b"VP8 "
assert validate_image_signature(good, ".webp") is True
def test_webp_wrong_magic_rejected(self) -> None:
# RIFF but not WEBP at offset 8.
bad = b"RIFF" + b"\x10\x00\x00\x00" + b"WAVE" + b"VP8 "
assert validate_image_signature(bad, ".webp") is False
def test_webp_too_short_rejected(self) -> None:
assert validate_image_signature(b"RIFF", ".webp") is False
def test_avif_magic_accepted(self) -> None:
# ftyp box with size=0x18, brand=avif.
avif = b"\x00\x00\x00\x18ftyp" + b"avif" + b"\x00\x00\x00\x00" + b"avif" + b"mif1"
assert validate_image_signature(avif, ".avif") is True
def test_avif_alt_brand_accepted(self) -> None:
avif = b"\x00\x00\x00\x18ftyp" + b"avis" + b"\x00\x00\x00\x00" + b"avis" + b"mif1"
assert validate_image_signature(avif, ".avif") is True
def test_avif_wrong_brand_rejected(self) -> None:
avif = b"\x00\x00\x00\x18ftyp" + b"mp42" + b"\x00\x00\x00\x00" + b"mp42" + b"isom"
assert validate_image_signature(avif, ".avif") is False
def test_avif_too_short_rejected(self) -> None:
assert validate_image_signature(b"\x00\x00\x00\x18ftyp", ".avif") is False
def test_unknown_extension_rejected(self) -> None:
assert validate_image_signature(b"anything", ".png") is False
def test_detect_extension_webp(self) -> None:
data = b"RIFF" + b"\x00\x00\x00\x00" + b"WEBP"
assert detect_image_extension(data) == ".webp"
def test_detect_extension_avif(self) -> None:
data = b"\x00\x00\x00\x18ftypavif" + b"\x00" * 8
assert detect_image_extension(data) == ".avif"
def test_detect_extension_unknown(self) -> None:
assert detect_image_extension(b"random") is None
def test_allowed_extensions_match_policy(self) -> None:
assert ALLOWED_IMAGE_EXTENSIONS == (".webp", ".avif")
# ---------------------------------------------------------------------------
# Markdown XSS sanitisation
# ---------------------------------------------------------------------------
class TestMarkdownSanitisation:
def test_script_tag_stripped(self) -> None:
rendered = render("Hello <script>alert(1)</script>world")
assert "<script" not in rendered.lower()
assert "alert(1)" not in rendered
def test_inline_event_handler_stripped(self) -> None:
rendered = render('<img src="x" onerror="alert(1)">')
assert "onerror" not in rendered.lower()
assert "alert" not in rendered.lower()
def test_javascript_url_sanitised(self) -> None:
rendered = render("[click me](javascript:alert(1))")
assert "javascript:" not in rendered.lower()
def test_data_image_url_kept(self) -> None:
rendered = render("![alt](data:image/webp;base64,UklGRg==)")
assert "<img" in rendered
def test_figure_wrapping_still_works(self) -> None:
rendered = render('![alt](url "caption")')
assert "<figure>" in rendered
assert "caption" in rendered
# ---------------------------------------------------------------------------
# Password hashing policy
# ---------------------------------------------------------------------------
class TestPasswordPolicy:
def test_strong_hash_passes(self) -> None:
encoded = hash_password("a-strong-password")
assert verify_password("a-strong-password", encoded) is True
def test_weak_params_rejected(self) -> None:
encoded = f"scrypt$8$1$1${'A' * 24}${'B' * 32}"
assert verify_password("anything", encoded) is False
def test_needs_rehash_for_strong_hash(self) -> None:
encoded = hash_password("a-strong-password")
assert needs_rehash(encoded) is False
def test_needs_rehash_for_weak_hash(self) -> None:
encoded = f"scrypt$8$1$1${'A' * 24}${'B' * 32}"
assert needs_rehash(encoded) is True
def test_needs_rehash_for_malformed(self) -> None:
assert needs_rehash("not-a-hash") is True
def test_module_constants_match_policy(self) -> None:
# Policy floor equals current implementation.
assert COST_N >= 16384
assert BLOCK_R >= 8
assert PARALLEL_P >= 1
assert KEY_LENGTH >= 32
# ---------------------------------------------------------------------------
# HTTP security headers and CSP
# ---------------------------------------------------------------------------
class TestSecurityHeaders:
def test_global_security_headers_present(self, client: TestClient) -> None:
response = client.get("/api/volumen/site")
assert response.headers.get("x-content-type-options") == "nosniff"
assert response.headers.get("x-frame-options") == "DENY"
assert response.headers.get("referrer-policy") == "strict-origin-when-cross-origin"
assert "permissions-policy" in response.headers
def test_csp_on_public_routes(self, client: TestClient) -> None:
response = client.get("/api/volumen/site")
csp = response.headers.get("content-security-policy", "")
assert "default-src 'self'" in csp
# Public routes have script-src but without nonce (nonce is admin-only)
assert "script-src 'self'" in csp
assert "nonce-" not in csp
def test_admin_route_has_csp_with_nonce(self, admin_client: TestClient) -> None:
response = admin_client.get("/admin/login")
csp = response.headers.get("content-security-policy", "")
assert "default-src 'self'" in csp
assert "'unsafe-inline'" not in csp
assert "nonce-" in csp
def test_https_only_cookie_unset_when_untrusted(self, client: TestClient) -> None:
# Default trust_proxy=false — X-Forwarded-Proto is ignored.
response = client.get("/admin/login")
# No cookie issued, but the middleware must mark session_secure=False.
assert response.status_code == 200
def test_https_only_cookie_with_trusted_proxy(self, admin_config_dir) -> None:
# Build a fresh app with trust_proxy=True and confirm it boots.
from volumen.app import create_app
from volumen.config import Config
from volumen.store import Store
config = Config.load(path=str(admin_config_dir / "config.toml"))
config._data["server"]["trust_proxy"] = True # type: ignore[index]
store = Store(config.content_dir)
app = create_app(config, store)
client = TestClient(app)
resp = client.get("/admin/login")
assert resp.status_code == 200
def test_cookie_secure_false_without_proxy_or_flag(self, admin_client: TestClient) -> None:
# When trust_proxy=false and cookie_secure=false, the SessionMiddleware
# is configured with https_only=False.
resp = admin_client.get("/admin/login")
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Config validation
# ---------------------------------------------------------------------------
class TestConfigValidation:
def test_default_config_is_valid(self, config) -> None:
# Should not raise.
config.validate()
def test_invalid_port_rejected(self, tmp_path) -> None:
from volumen.config import Config
(tmp_path / "posts").mkdir()
cfg = Config.load(
path="/nonexistent.toml",
overrides={
"users_file": str(tmp_path / "users.toml"),
"content": str(tmp_path / "posts"),
},
)
cfg._data["server"]["port"] = 999999 # type: ignore[index]
try:
cfg.validate()
except Exception as exc:
assert "port" in str(exc).lower()
else:
raise AssertionError("expected ConfigError")
def test_invalid_env_rejected(self, tmp_path) -> None:
from volumen.config import Config, ConfigError
cfg = Config.load(
path="/nonexistent.toml",
overrides={"users_file": str(tmp_path / "users.toml")},
)
cfg._data["server"]["env"] = "staging" # type: ignore[index]
try:
cfg.validate()
except ConfigError as exc:
assert "env" in str(exc).lower()
else:
raise AssertionError("expected ConfigError")
def test_invalid_base_url_rejected(self, tmp_path) -> None:
from volumen.config import Config, ConfigError
cfg = Config.load(
path="/nonexistent.toml",
overrides={"users_file": str(tmp_path / "users.toml")},
)
cfg._data["site"]["base_url"] = "not-a-url" # type: ignore[index]
try:
cfg.validate()
except ConfigError as exc:
assert "base_url" in str(exc).lower()
else:
raise AssertionError("expected ConfigError")
def test_production_requires_session_key(self, tmp_path) -> None:
from volumen.config import Config
(tmp_path / "posts").mkdir()
cfg = Config.load(
path="/nonexistent.toml",
overrides={
"users_file": str(tmp_path / "users.toml"),
"content": str(tmp_path / "posts"),
},
)
cfg._data["server"]["env"] = "production" # type: ignore[index]
cfg._data["admin"]["session_key"] = "" # type: ignore[index]
# Validation succeeds (file is allowed) but create_app will refuse.
cfg.validate()
# ---------------------------------------------------------------------------
# Session secret validation
# ---------------------------------------------------------------------------
class TestSessionSecret:
def test_production_without_session_key_fails(self, config) -> None:
from volumen.app import create_app
from volumen.store import Store
config._data["server"]["env"] = "production" # type: ignore[index]
config._data["admin"]["session_key"] = "" # type: ignore[index]
store = Store(config.content_dir)
try:
create_app(config, store)
except RuntimeError as exc:
assert "session_key" in str(exc)
else:
raise AssertionError("expected RuntimeError")
def test_production_with_short_key_fails(self, config) -> None:
from volumen.app import create_app
from volumen.store import Store
config._data["server"]["env"] = "production" # type: ignore[index]
config._data["admin"]["session_key"] = "tooshort" # type: ignore[index]
store = Store(config.content_dir)
try:
create_app(config, store)
except RuntimeError as exc:
assert "64 bytes" in str(exc)
else:
raise AssertionError("expected RuntimeError")
# ---------------------------------------------------------------------------
# Password policy helpers
# ---------------------------------------------------------------------------
class TestPasswordPolicyHelpers:
def test_password_error_min_length(self) -> None:
from volumen.auth import password_error
assert password_error("short", min_length=10) is not None
def test_password_error_max_length(self) -> None:
from volumen.auth import password_error
assert password_error("x" * 2000, min_length=10, max_length=1024) is not None
def test_password_error_empty(self) -> None:
from volumen.auth import password_error
assert password_error("", min_length=10) is not None
def test_password_error_common_rejected(self) -> None:
from volumen.auth import password_error
assert password_error("password", min_length=10) is not None
def test_password_error_accepts_strong(self) -> None:
from volumen.auth import password_error
assert password_error("volumen-strong-pw", min_length=10) is None
# ---------------------------------------------------------------------------
# Store hardening
# ---------------------------------------------------------------------------
class _Upload:
"""Fake UploadFile for testing validate_upload."""
content_type: str
filename: str
file: io.BytesIO
def __init__(
self, data: bytes, content_type: str = "image/webp", filename: str = "x.webp"
) -> None:
self._data = data
self.content_type = content_type
self.filename = filename
self.file = io.BytesIO(data)
self._pos = 0
async def read(self, size: int = -1) -> bytes:
if size == -1:
result = self._data[self._pos :]
self._pos = len(self._data)
return result
result = self._data[self._pos : self._pos + size]
self._pos += size
return result
class TestStoreHardening:
def test_validate_upload_accepts_webp(self, tmp_content_dir) -> None:
# Build a fake UploadFile-shaped object.
good_webp = b"RIFF" + b"\x10\x00\x00\x00" + b"WEBP" + b"\x00" * 100
import asyncio
from volumen.upload_validation import validate_upload
async def go() -> tuple[bytes, str]:
return await validate_upload(_Upload(good_webp), max_bytes=10_000)
data, ext = asyncio.run(go())
assert ext == ".webp"
assert data == good_webp
def test_validate_upload_rejects_mime_mismatch(self, tmp_content_dir) -> None:
import asyncio
from volumen.upload_validation import validate_upload
async def go() -> None:
try:
await validate_upload(
_Upload(b"nope", content_type="image/png", filename="x.png"),
max_bytes=10_000,
)
except Exception as exc:
# The detail is JSON-encoded text.
msg = str(exc)
assert "unsupported_format" in msg or "signature" in msg.lower()
else:
raise AssertionError("expected HTTPException")
asyncio.run(go())
def test_store_upload_uses_signature_extension(self, tmp_content_dir) -> None:
from volumen.store import Store
store = Store(str(tmp_content_dir))
avif_bytes = b"\x00\x00\x00\x18ftyp" + b"avif" + b"\x00" * 100
url = store.store_upload("evil.exe", None, bytes_data=avif_bytes)
assert url.endswith(".avif")
assert "evil" not in url
def test_store_upload_resolves_whitelisted_extension(self, tmp_content_dir) -> None:
from volumen.store import Store
store = Store(str(tmp_content_dir))
webp_bytes = b"RIFF" + b"\x10\x00\x00\x00" + b"WEBP" + b"\x00" * 100
# File name has an unrelated extension; signature wins.
url = store.store_upload("cover.png", None, bytes_data=webp_bytes)
assert url.endswith(".webp")
+267
View File
@@ -0,0 +1,267 @@
"""Tests for the public API endpoints."""
from __future__ import annotations
from datetime import date
from pathlib import Path
from fastapi.testclient import TestClient
class TestSiteEndpoint:
def test_site_endpoint(self, client: TestClient) -> None:
response = client.get("/api/volumen/site")
assert response.status_code == 200
data = response.json()
assert data["title"] == "My Blog"
def test_site_endpoint_exposes_fediverse_creator_default(self, client: TestClient) -> None:
response = client.get("/api/volumen/site")
assert response.status_code == 200
assert "fediverse_creator" in response.json()
class TestPostsEndpoint:
def test_posts_excludes_drafts(self, client: TestClient) -> None:
response = client.get("/api/volumen/posts")
data = response.json()
assert data["total"] == 1
assert data["posts"][0]["slug"] == "hello"
def test_post_detail_renders_html(self, client: TestClient) -> None:
response = client.get("/api/volumen/posts/hello")
assert response.status_code == 200
assert "<strong>world</strong>" in response.json()["html"]
def test_post_not_found(self, client: TestClient) -> None:
response = client.get("/api/volumen/posts/missing")
assert response.status_code == 404
assert response.json()["detail"]["error"] == "not_found"
def test_draft_post_detail_returns_error(self, client: TestClient) -> None:
response = client.get("/api/volumen/posts/draft")
assert response.status_code == 404
assert response.json()["detail"]["error"] == "draft"
class TestTagsEndpoint:
def test_tags_endpoint(self, client: TestClient) -> None:
response = client.get("/api/volumen/tags")
data = response.json()
assert data["tags"] == [{"name": "intro", "count": 1}]
class TestCORS:
def test_cors_header_present(self, client: TestClient) -> None:
response = client.get("/api/volumen/site")
assert response.headers.get("access-control-allow-origin") == "*"
def test_cors_preflight(self, client: TestClient) -> None:
response = client.options("/api/volumen/site")
assert response.status_code == 200
assert response.headers["access-control-allow-origin"] == "*"
assert "GET" in response.headers["access-control-allow-methods"]
class TestLanguageFilter:
def test_all_langs_post_appears_in_other_languages(
self, client: TestClient, tmp_content_dir: Path
) -> None:
(tmp_content_dir / "global.md").write_text(
'+++\ntitle = "Global"\nlang = "en"\nall_langs = true\n+++\n\nEverywhere.\n'
)
response = client.get("/api/volumen/posts?lang=cs")
slugs = [p["slug"] for p in response.json()["posts"]]
assert "global" in slugs
assert "hello" not in slugs
def test_lang_filter_default_shows_default_lang_posts(self, client: TestClient) -> None:
response = client.get("/api/volumen/posts")
slugs = [p["slug"] for p in response.json()["posts"]]
assert "hello" in slugs
class TestScheduledPosts:
def test_scheduled_post_is_hidden(self, client: TestClient, tmp_content_dir: Path) -> None:
future = date.today().replace(year=date.today().year + 1).isoformat()
(tmp_content_dir / "later.md").write_text(
f'+++\ntitle = "Later"\npublish_at = {future}\n+++\n\nNot yet.\n'
)
response = client.get("/api/volumen/posts")
slugs = [p["slug"] for p in response.json()["posts"]]
assert "later" not in slugs
class TestSearch:
def test_search_by_title(self, client: TestClient) -> None:
response = client.get("/api/volumen/posts?q=Hello")
data = response.json()
assert data["total"] == 1
assert data["posts"][0]["slug"] == "hello"
def test_search_by_body(self, client: TestClient) -> None:
response = client.get("/api/volumen/posts?q=world")
data = response.json()
assert data["total"] == 1
def test_search_no_match(self, client: TestClient) -> None:
response = client.get("/api/volumen/posts?q=nonexistent")
assert response.json()["total"] == 0
def test_search_is_case_insensitive(self, client: TestClient) -> None:
response = client.get("/api/volumen/posts?q=HELLO")
assert response.json()["total"] == 1
class TestPagination:
def test_pagination_has_next_and_prev(self, client: TestClient, tmp_content_dir: Path) -> None:
for i in range(5):
(tmp_content_dir / f"post-{i}.md").write_text(
f'+++\ntitle = "Post {i}"\n+++\n\nBody {i}.\n'
)
resp = client.get("/api/volumen/posts?page=1&limit=2")
data = resp.json()
assert data["has_next"] is True
assert data["has_prev"] is False
resp = client.get("/api/volumen/posts?page=2&limit=2")
data = resp.json()
assert data["has_next"] is True
assert data["has_prev"] is True
resp = client.get("/api/volumen/posts?page=3&limit=2")
data = resp.json()
assert data["has_next"] is False
assert data["has_prev"] is True
class TestRSSFeed:
def test_feed_endpoint(self, client: TestClient) -> None:
response = client.get("/api/volumen/feed.xml")
assert response.status_code == 200
ct = response.headers.get("content-type", "")
assert "application/rss+xml" in ct or "application/xml" in ct
body = response.text
assert '<rss version="2.0"' in body
assert "<title>Hello</title>" in body
assert "Draft" not in body
assert "<language>en</language>" in body
def test_rss_feed_includes_dc_creator_when_set(
self, client: TestClient, tmp_content_dir: Path
) -> None:
(tmp_content_dir / "rss.md").write_text(
'+++\ntitle = "Rss"\nslug = "rss"\n'
'fediverse_creator = "@rss@example.com"\n+++\n\nBody.\n'
)
response = client.get("/api/volumen/feed.xml")
assert "<dc:creator>@rss@example.com</dc:creator>" in response.text
assert "xmlns:dc" in response.text
def test_rss_feed_omits_dc_creator_when_not_set(self, client: TestClient) -> None:
response = client.get("/api/volumen/feed.xml")
assert "<dc:creator>" not in response.text
class TestJSONFeed:
def test_json_feed_endpoint(self, client: TestClient) -> None:
response = client.get("/api/volumen/feed.json")
assert response.status_code == 200
data = response.json()
assert data["version"] == "https://jsonfeed.org/version/1.1"
assert data["title"] == "My Blog"
assert len(data["items"]) == 1
assert data["items"][0]["title"] == "Hello"
assert "<strong>world</strong>" in data["items"][0]["content_html"]
titles = [i["title"] for i in data["items"]]
assert "Draft" not in titles
def test_json_feed_includes_authors_from_post(
self, client: TestClient, tmp_content_dir: Path
) -> None:
(tmp_content_dir / "jsonfed.md").write_text(
'+++\ntitle = "Jsonfed"\nslug = "jsonfed"\n'
'fediverse_creator = "@jf@example.io"\n+++\n\nBody.\n'
)
response = client.get("/api/volumen/feed.json")
items = response.json()["items"]
item = next(i for i in items if i.get("id", "").endswith("jsonfed"))
assert item["authors"] == [{"name": "@jf@example.io"}]
def test_json_feed_falls_back_to_site_default_fediverse_creator(
self, tmp_path: Path, tmp_content_dir: Path
) -> None:
"""When a post has no fediverse_creator, fall back to site.fediverse_creator."""
from fastapi.testclient import TestClient
from volumen.app import create_app
from volumen.config import Config
from volumen.store import Store
config_path = tmp_path / "custom.toml"
users_path = tmp_path / "users.toml"
users_path.write_text("")
config_path.write_text(
f'content_dir = "{tmp_content_dir}"\n'
f'users_file = "{users_path}"\n'
"\n[site]\n"
'fediverse_creator = "@siteowner@example.social"\n'
)
custom_config = Config.load(path=str(config_path))
custom_store = Store(str(tmp_content_dir), default_lang="en")
custom_app = create_app(custom_config, custom_store)
custom_client = TestClient(custom_app)
response = custom_client.get("/api/volumen/feed.json")
assert response.status_code == 200
item = response.json()["items"][0]
assert "authors" in item
assert item["authors"] == [{"name": "@siteowner@example.social"}]
class TestSitemap:
def test_sitemap_endpoint(self, client: TestClient) -> None:
response = client.get("/api/volumen/sitemap.xml")
assert response.status_code == 200
ct = response.headers.get("content-type", "")
assert "application/xml" in ct or "text/xml" in ct
body = response.text
assert "urlset" in body
assert "<loc>https://example.com/hello</loc>" in body
assert "draft" not in body
class TestFediverseCreator:
def test_post_summary_exposes_fediverse_creator(
self, client: TestClient, tmp_content_dir: Path
) -> None:
(tmp_content_dir / "attributed.md").write_text(
'+++\ntitle = "Attributed"\nslug = "attributed"\n'
'fediverse_creator = "@ada@lovelace.org"\n+++\n\nBody.\n'
)
response = client.get("/api/volumen/posts/attributed")
assert response.status_code == 200
assert response.json()["fediverse_creator"] == "@ada@lovelace.org"
def test_post_summary_fediverse_creator_is_null_when_not_set(self, client: TestClient) -> None:
response = client.get("/api/volumen/posts/hello")
assert response.status_code == 200
assert response.json()["fediverse_creator"] is None
def test_posts_list_summary_exposes_fediverse_creator(
self, client: TestClient, tmp_content_dir: Path
) -> None:
(tmp_content_dir / "fed.md").write_text(
'+++\ntitle = "Fed"\nslug = "fed"\n'
'fediverse_creator = "@user@example.social"\n+++\n\nBody.\n'
)
response = client.get("/api/volumen/posts")
fed = next(p for p in response.json()["posts"] if p["slug"] == "fed")
assert fed["fediverse_creator"] == "@user@example.social"
class TestCacheHeaders:
def test_cache_control_on_api_responses(self, client: TestClient) -> None:
response = client.get("/api/volumen/site")
assert "cache-control" in response.headers
+130
View File
@@ -0,0 +1,130 @@
"""Tests for Store — post loading, saving, deleting, media handling."""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from volumen.post import Post
from volumen.store import Store
class TestLoading:
def test_all_loads_every_post(self, store: Store) -> None:
posts = store.all()
assert len(posts) == 2 # hello.md + draft.md
def test_slug_falls_back_to_filename(self, store: Store) -> None:
slugs = sorted(p.slug for p in store.all())
assert slugs == ["draft", "hello"]
def test_lang_derived_from_subdirectory(self, tmp_path: Path) -> None:
content = tmp_path / "posts"
content.mkdir()
(content / "hello.md").write_text('+++\ntitle = "Hello"\n+++\n\nBody\n')
cs_dir = content / "cs"
cs_dir.mkdir()
(cs_dir / "ahoj.md").write_text('+++\ntitle = "Ahoj"\n+++\n\nTelo\n')
st = Store(str(content), default_lang="en")
assert st.find("ahoj").lang == "cs"
assert st.find("hello").lang == "en"
def test_find_returns_none_when_missing(self, store: Store) -> None:
assert store.find("nope") is None
def test_all_langs_post_visible_in_any_language(self, tmp_content_dir: Path) -> None:
(tmp_content_dir / "global.md").write_text(
'+++\ntitle = "Global"\nlang = "en"\nall_langs = true\n+++\n\nVisible everywhere.\n'
)
store = Store(str(tmp_content_dir), default_lang="en")
assert store.find("global", lang="cs") is not None
assert store.find("global", lang="en") is not None
def test_draft_post_is_loaded(self, store: Store) -> None:
draft = store.find("draft")
assert draft is not None
assert draft.draft is True
def test_post_with_missing_slug_derived_from_path(self, tmp_content_dir: Path) -> None:
(tmp_content_dir / "noslug.md").write_text('+++\ntitle = "No Slug"\n+++\n\nBody\n')
store = Store(str(tmp_content_dir), default_lang="en")
post = store.find("noslug")
assert post is not None
assert post.slug == "noslug"
assert post.lang == "en"
class TestSave:
def test_save_new_post(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir), default_lang="en")
post = Post(metadata={"slug": "newpost", "title": "New"}, body="Content")
store.save(post)
assert os.path.isfile(os.path.join(str(tmp_content_dir), "newpost.md"))
def test_save_updates_existing_post(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir), default_lang="en")
post = store.find("hello")
assert post is not None
post.body = "Updated content."
store.save(post)
reloaded = store.find("hello")
assert reloaded is not None
assert "Updated content." in reloaded.body
def test_save_rejects_path_traversal_slug(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir))
post = Post(metadata={"slug": "../../etc/passwd"}, body="x")
with pytest.raises(ValueError, match="slug escapes"):
store.save(post)
def test_save_uses_lang_subdirectory(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir), default_lang="en")
post = Post(metadata={"slug": "bonjour", "lang": "fr"}, body="x")
store.save(post)
assert os.path.isfile(os.path.join(str(tmp_content_dir), "fr", "bonjour.md"))
def test_save_uses_root_for_default_language(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir), default_lang="en")
post = Post(metadata={"slug": "hello-new", "lang": "en"}, body="x")
store.save(post)
assert os.path.isfile(os.path.join(str(tmp_content_dir), "hello-new.md"))
assert not os.path.isfile(os.path.join(str(tmp_content_dir), "en", "hello-new.md"))
class TestDelete:
def test_delete_post(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir), default_lang="en")
path = os.path.join(str(tmp_content_dir), "hello.md")
assert os.path.isfile(path)
store.delete("hello")
assert not os.path.isfile(path)
class TestMedia:
def test_media_file_path_traversal_prevention(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir))
bad = store.media_file("../etc/passwd")
assert bad is None
def test_media_file_nonexistent(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir))
assert store.media_file("nonexistent.webp") is None
class TestCacheInvalidation:
def test_mtime_cache_invalidation(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir), default_lang="en")
initial = len(store.all())
# Add a new file
(tmp_content_dir / "new.md").write_text('+++\ntitle = "New"\n+++\n\nBody\n')
assert len(store.all()) == initial + 1
def test_cache_invalidation_on_save(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir), default_lang="en")
initial = len(store.all())
post = Post(metadata={"slug": "extra"}, body="x")
store.save(post)
assert len(store.all()) == initial + 1

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