Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a66a36abbc | ||
|
|
38544b84cf | ||
|
|
98f7f3c9e3 | ||
|
|
d9b7a53ac2 | ||
|
|
a5c1b519ed | ||
|
|
3b8ed31f67 | ||
|
|
19c6161ae0 | ||
|
|
3ac1ef78c2 | ||
|
|
36855afc70 | ||
|
|
51d744e0ea | ||
|
|
44b879371e | ||
|
|
6f62d3e3f3 | ||
|
|
1dfbf67921 | ||
|
|
0e8f013bbb | ||
|
|
2f90c6da4d | ||
|
|
f40ace64a8 | ||
|
|
ff0452bcc3 | ||
|
|
34dc67ce99 | ||
|
|
e028db6447 | ||
|
|
cbae03014d | ||
|
|
10ef258e84 | ||
|
|
5fdf8e5ee8 | ||
|
|
6ee8e066aa | ||
|
|
1dbeb66be0 | ||
|
|
d1295c6633 | ||
|
|
5cc3a1aabe | ||
|
|
781707a3fd | ||
|
|
2da9ec9bba | ||
|
|
0ba6e379d7 | ||
|
|
db6da86c93 | ||
|
|
a2f42c6a12 | ||
|
|
223405dcb9 | ||
|
|
95452477d8 | ||
|
|
bc2e056b68 | ||
|
|
e11c7d24a4 | ||
|
|
25ebf691f7 | ||
|
|
4abb5585df | ||
|
|
5437cef1cd |
@@ -1,43 +0,0 @@
|
|||||||
# EditorConfig: https://editorconfig.org
|
|
||||||
|
|
||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
charset = utf-8
|
|
||||||
end_of_line = lf
|
|
||||||
insert_final_newline = true
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[*.{rb,erb,gemspec}]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[{Gemfile,Rakefile}]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[*.{js,html,css,vue}]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[*.{yml,yaml,json,toml}]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[*.{rs,cj}]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 4
|
|
||||||
|
|
||||||
[*.go]
|
|
||||||
indent_style = tab
|
|
||||||
indent_size = 4
|
|
||||||
|
|
||||||
[justfile]
|
|
||||||
indent_style = tab
|
|
||||||
|
|
||||||
[*.md]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ["v*"]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: codeberg-small
|
|
||||||
steps:
|
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: https://github.com/ruby/setup-ruby@v1
|
|
||||||
with:
|
|
||||||
ruby-version: "3.4"
|
|
||||||
bundler-cache: true
|
|
||||||
|
|
||||||
- name: Resolve and verify version
|
|
||||||
id: version
|
|
||||||
env:
|
|
||||||
REF: ${{ forgejo.ref }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
VERSION="${REF##*/}"
|
|
||||||
if [[ ! "$VERSION" =~ ^v[0-9]+(\.[0-9]+){0,2}([-+].*)?$ ]]; then
|
|
||||||
echo "ERROR: expected a semver tag like v1.2.3, got: '$VERSION' (ref: '$REF')"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
VERSION_NO_V="${VERSION#v}"
|
|
||||||
GEM_VERSION="$(ruby -Ilib -rvolumen/version -e 'print Volumen::VERSION')"
|
|
||||||
if [ "$VERSION_NO_V" != "$GEM_VERSION" ]; then
|
|
||||||
echo "ERROR: tag ${VERSION} does not match Volumen::VERSION (${GEM_VERSION})"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "version_no_v=${VERSION_NO_V}" >> "$FORGEJO_OUTPUT"
|
|
||||||
|
|
||||||
- name: Quality gate
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
bundle exec rubocop
|
|
||||||
bundle exec rake test
|
|
||||||
|
|
||||||
- name: Build gem
|
|
||||||
env:
|
|
||||||
VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
gem build volumen.gemspec --output "volumen-${VERSION_NO_V}.gem"
|
|
||||||
ls -lh "volumen-${VERSION_NO_V}.gem"
|
|
||||||
|
|
||||||
- name: Extract CHANGELOG section
|
|
||||||
env:
|
|
||||||
VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
sed -n "/^## \[${VERSION_NO_V}\] /,/^## \[/p" CHANGELOG.md \
|
|
||||||
| sed '$d' \
|
|
||||||
| tail -n +2 \
|
|
||||||
> release-body.md
|
|
||||||
|
|
||||||
if [ ! -s release-body.md ]; then
|
|
||||||
echo "ERROR: no CHANGELOG section found for ${VERSION_NO_V}"
|
|
||||||
echo "Expected a heading like: ## [${VERSION_NO_V}] — YYYY-MM-DD"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Release body preview:"
|
|
||||||
head -n 20 release-body.md
|
|
||||||
|
|
||||||
- name: Create release
|
|
||||||
id: create
|
|
||||||
env:
|
|
||||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
BODY="$(ruby -rjson -e 'print JSON.generate($stdin.read)' < release-body.md)"
|
|
||||||
|
|
||||||
response="$(curl -sS -w '\n%{http_code}' \
|
|
||||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-X POST \
|
|
||||||
"${FORGEJO_SERVER_URL}/api/v1/repos/${FORGEJO_REPOSITORY}/releases" \
|
|
||||||
-d "{
|
|
||||||
\"tag_name\": \"${FORGEJO_REF_NAME}\",
|
|
||||||
\"name\": \"${FORGEJO_REF_NAME}\",
|
|
||||||
\"body\": ${BODY},
|
|
||||||
\"draft\": false,
|
|
||||||
\"prerelease\": false
|
|
||||||
}")"
|
|
||||||
|
|
||||||
http_code="$(echo "$response" | tail -1)"
|
|
||||||
payload="$(echo "$response" | sed '$d')"
|
|
||||||
echo "HTTP ${http_code}"
|
|
||||||
|
|
||||||
if [ "$http_code" != "201" ]; then
|
|
||||||
echo "Failed to create release: ${payload}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
RELEASE_ID="$(echo "$payload" | ruby -rjson -e 'print JSON.parse($stdin.read)["id"]')"
|
|
||||||
echo "Created release ID=${RELEASE_ID}"
|
|
||||||
echo "release_id=${RELEASE_ID}" >> "$FORGEJO_OUTPUT"
|
|
||||||
|
|
||||||
- name: Upload gem asset
|
|
||||||
env:
|
|
||||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
|
||||||
VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
|
|
||||||
RELEASE_ID: ${{ steps.create.outputs.release_id }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
GEM="volumen-${VERSION_NO_V}.gem"
|
|
||||||
|
|
||||||
http_code="$(curl -sS -o /dev/null -w '%{http_code}' \
|
|
||||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
|
||||||
-H "Content-Type: application/octet-stream" \
|
|
||||||
-X POST \
|
|
||||||
--data-binary "@${GEM}" \
|
|
||||||
"${FORGEJO_SERVER_URL}/api/v1/repos/${FORGEJO_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${GEM}")"
|
|
||||||
|
|
||||||
echo "HTTP ${http_code}"
|
|
||||||
if [ "$http_code" != "201" ]; then
|
|
||||||
echo "Failed to upload ${GEM}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Release ${FORGEJO_REF_NAME} is live with ${GEM}."
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
name: Test
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [development]
|
|
||||||
pull_request:
|
|
||||||
branches: [development]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: codeberg-small
|
|
||||||
steps:
|
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: https://github.com/ruby/setup-ruby@v1
|
|
||||||
with:
|
|
||||||
ruby-version: "3.4"
|
|
||||||
bundler-cache: true
|
|
||||||
|
|
||||||
- name: RuboCop
|
|
||||||
run: bundle exec rubocop
|
|
||||||
|
|
||||||
test:
|
|
||||||
runs-on: codeberg-small
|
|
||||||
needs: lint
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
ruby-version: ["3.3", "3.4"]
|
|
||||||
steps:
|
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: https://github.com/ruby/setup-ruby@v1
|
|
||||||
with:
|
|
||||||
ruby-version: ${{ matrix.ruby-version }}
|
|
||||||
bundler-cache: true
|
|
||||||
|
|
||||||
- name: Tests
|
|
||||||
run: bundle exec rake test
|
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -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
|
||||||
+24
-6
@@ -1,18 +1,36 @@
|
|||||||
# Bundler
|
# Python
|
||||||
/.bundle/
|
__pycache__/
|
||||||
/vendor/bundle/
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
dist/
|
||||||
|
|
||||||
# Built gems
|
# setuptools / uv build output
|
||||||
/pkg/
|
/build/
|
||||||
*.gem
|
/src/*.egg-info/
|
||||||
|
|
||||||
# Test / coverage
|
# Test / coverage
|
||||||
/coverage/
|
/coverage/
|
||||||
/tmp/
|
/tmp/
|
||||||
|
/.coverage
|
||||||
|
/.coverage.*
|
||||||
|
|
||||||
|
# Transient log files (pytest, dev servers, scratch)
|
||||||
|
/*.log
|
||||||
|
|
||||||
|
# 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/
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
AllCops:
|
|
||||||
TargetRubyVersion: 3.3
|
|
||||||
NewCops: enable
|
|
||||||
SuggestExtensions: false
|
|
||||||
|
|
||||||
Style/StringLiterals:
|
|
||||||
EnforcedStyle: double_quotes
|
|
||||||
|
|
||||||
Style/StringLiteralsInInterpolation:
|
|
||||||
EnforcedStyle: double_quotes
|
|
||||||
|
|
||||||
Style/Documentation:
|
|
||||||
Exclude:
|
|
||||||
- "test/**/*"
|
|
||||||
- "exe/**/*"
|
|
||||||
|
|
||||||
Layout/LineLength:
|
|
||||||
Max: 100
|
|
||||||
|
|
||||||
Metrics/AbcSize:
|
|
||||||
Max: 30
|
|
||||||
Exclude:
|
|
||||||
- "lib/volumen/admin_routes.rb"
|
|
||||||
- "lib/volumen/api_routes.rb"
|
|
||||||
|
|
||||||
Metrics/MethodLength:
|
|
||||||
Max: 25
|
|
||||||
Exclude:
|
|
||||||
- "lib/volumen/admin_routes.rb"
|
|
||||||
- "lib/volumen/api_routes.rb"
|
|
||||||
|
|
||||||
Metrics/ClassLength:
|
|
||||||
Max: 400
|
|
||||||
|
|
||||||
Metrics/ModuleLength:
|
|
||||||
Exclude:
|
|
||||||
- "lib/volumen/admin_routes.rb"
|
|
||||||
|
|
||||||
Metrics/BlockLength:
|
|
||||||
Exclude:
|
|
||||||
- "test/**/*"
|
|
||||||
- "volumen.gemspec"
|
|
||||||
+255
-2
@@ -5,8 +5,263 @@ 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]
|
## [development]
|
||||||
|
|
||||||
|
## [0.4.2] — 2026-07-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`web/templates/*.jinja` missing from distribution wheel** — added `web/templates/*.jinja` to `[tool.setuptools.package-data]` in `pyproject.toml`. After a fresh install from PyPI the admin panel returned 500 because `Jinja2Templates` could not find any templates.
|
||||||
|
- **Duplicate `script-src` / `style-src` in admin CSP** — the nonce-augmented directives used to be *appended* after the base ones, so the browser (which honours the first occurrence) ignored the nonce and blocked every inline JS/CSS. They are now *replaced*: `script-src` and `style-src` are filtered out of the base CSP and the nonce versions are appended as the only occurrence.
|
||||||
|
- **CSP nonce generated after template rendering** — `request.state.csp_nonce` is now set in `GlobalSecurityHeadersMiddleware.dispatch()` *before* `call_next(request)` so templates see it during rendering. Previously it was generated afterwards, so `csp_nonce` was an empty string in the template context and every inline script was blocked.
|
||||||
|
- **Missing `nonce` attribute on `<script>` in `list.html.jinja` and `settings.html.jinja`** — inline scripts in these templates now carry `nonce="{{ csp_nonce | default('') }}"` so they match the nonce in the CSP header.
|
||||||
|
- **Bootstrap `admin.password_hash` ignored when `users.toml` is empty** — `Users.all` now falls back to the bootstrap admin even when `users.toml` exists but is empty (e.g. right after `volumen init`, which creates the file via `Path.touch()`). Previously the first login after a clean install failed.
|
||||||
|
- **`admin_context` did not pass `users_exist`** — the login page showed the “No users configured” warning even when authentication worked. Added `"users_exist": users_obj.any` to the admin template context.
|
||||||
|
- **Inline `style="..."` blocked by strict admin CSP** — the admin templates used inline `style="..."` attributes (and one `card.style.display = ...` JS assignment) which the new strict `style-src 'self' 'nonce-…'` directive blocks. Replaced every offending attribute with a utility class in the `<style>` block (`page-head-actions`, `form-options`, `form-inline`, `form-hidden`, `form-spaced`, `flex-spacer`, `btn-static`, `btn-danger-text`, `text-fg-strong`, `badge-inline`, `h3-section`, `mt-3`, `is-hidden`); the dynamic `background-image: url(...)` on `.post-cover` now flows through a `--cover-url` custom property (`style="--cover-url: …"`) which CSP3 does not treat as an inline style application.
|
||||||
|
- **`/favicon.ico` returned 404** — browsers auto-request `/favicon.ico` even when the page declares an explicit `<link rel="icon">`. Added a tiny route in `create_app()` that serves the packaged `web/static/volumen-icon.svg` with `Content-Type: image/svg+xml` and a 1-day `Cache-Control`; added the matching `<link rel="icon" type="image/svg+xml" href="/favicon.ico">` to the admin layout so the tab gets the volumen icon instead of a generic placeholder.
|
||||||
|
- **Sidebar version label rendered as bare `v`** — `admin_context` did not include the package `VERSION`, so the footer `<div class="sidebar-version">v{{ version }}</div>` rendered as just `v` with no number behind it. Added `"version": VERSION` to the context.
|
||||||
|
|
||||||
|
## [0.4.1] — 2026-07-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Missing CSP nonce for `style-src` on admin routes** — added `style-src 'self' 'nonce-{nonce}'` to the CSP header on `/admin` routes. Without a nonce the browser blocked every inline `<style>` tag in the admin templates.
|
||||||
|
- **`web/static/` SVG assets missing from distribution wheel** — added a `[tool.setuptools.package-data]` section to `pyproject.toml` so `volumen-icon.svg` and `volumen-logo.svg` ship inside the installed wheel. The admin panel previously returned 500 on `/admin/icon.svg` and `/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
|
||||||
|
(``) 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
|
## [0.2.0] — 2026-06-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -146,5 +401,3 @@ admin — no database, no external services.
|
|||||||
production.
|
production.
|
||||||
- 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
|
|
||||||
|
|||||||
+155
-103
@@ -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`).
|
||||||
@@ -118,10 +126,11 @@ chore: pin gem versions to latest
|
|||||||
2. Make your changes with Conventional-Commits messages.
|
2. Make your changes with Conventional-Commits messages.
|
||||||
3. Record your changes under the `## [development]` section at the top of
|
3. Record your changes under the `## [development]` section at the top of
|
||||||
`CHANGELOG.md`, using the Keep a Changelog categories (`Added`, `Changed`,
|
`CHANGELOG.md`, using the Keep a Changelog categories (`Added`, `Changed`,
|
||||||
`Fixed`, etc.).
|
`Fixed`, `Removed`, `Security`).
|
||||||
4. Ensure `bundle exec rubocop` reports zero offenses and `just test` passes.
|
4. Ensure `just build` reports zero warnings and `just test` passes.
|
||||||
5. Add or update tests for your change.
|
5. Add or update tests for your change.
|
||||||
6. Update documentation if the public API, configuration, or behaviour changes:
|
6. Update documentation if the public API, configuration, or behaviour
|
||||||
|
changes:
|
||||||
- `README.md` — high-level overview.
|
- `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.
|
||||||
@@ -136,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
|
||||||
```
|
```
|
||||||
@@ -169,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
|
||||||
```
|
```
|
||||||
@@ -189,73 +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. Rename `## [development]` to `## [X.Y.Z] — YYYY-MM-DD` at the top of
|
2. Rename `## [development]` to `## [X.Y.Z] — YYYY-MM-DD` at the top of
|
||||||
`CHANGELOG.md`, and add a fresh empty `## [development]` section above it.
|
`CHANGELOG.md`, and add a fresh empty `## [development]` section
|
||||||
|
above it.
|
||||||
3. Ensure CI is green on `development`.
|
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.
|
||||||
@@ -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
@@ -1,136 +0,0 @@
|
|||||||
PATH
|
|
||||||
remote: .
|
|
||||||
specs:
|
|
||||||
volumen (0.2.0)
|
|
||||||
kramdown (~> 2.5)
|
|
||||||
kramdown-parser-gfm (~> 1.1)
|
|
||||||
puma (~> 8.0)
|
|
||||||
rackup (~> 2.3)
|
|
||||||
sinatra (~> 4.2)
|
|
||||||
toml-rb (~> 4.2)
|
|
||||||
|
|
||||||
GEM
|
|
||||||
remote: https://rubygems.org/
|
|
||||||
specs:
|
|
||||||
ast (2.4.3)
|
|
||||||
base64 (0.3.0)
|
|
||||||
citrus (3.0.2)
|
|
||||||
drb (2.2.3)
|
|
||||||
json (2.19.9)
|
|
||||||
kramdown (2.5.2)
|
|
||||||
rexml (>= 3.4.4)
|
|
||||||
kramdown-parser-gfm (1.1.0)
|
|
||||||
kramdown (~> 2.0)
|
|
||||||
language_server-protocol (3.17.0.5)
|
|
||||||
lint_roller (1.1.0)
|
|
||||||
logger (1.7.0)
|
|
||||||
minitest (6.0.6)
|
|
||||||
drb (~> 2.0)
|
|
||||||
prism (~> 1.5)
|
|
||||||
mustermann (3.1.1)
|
|
||||||
nio4r (2.7.5)
|
|
||||||
parallel (2.1.0)
|
|
||||||
parser (3.3.11.1)
|
|
||||||
ast (~> 2.4.1)
|
|
||||||
racc
|
|
||||||
prism (1.9.0)
|
|
||||||
puma (8.0.2)
|
|
||||||
nio4r (~> 2.0)
|
|
||||||
racc (1.8.1)
|
|
||||||
rack (3.2.6)
|
|
||||||
rack-protection (4.2.1)
|
|
||||||
base64 (>= 0.1.0)
|
|
||||||
logger (>= 1.6.0)
|
|
||||||
rack (>= 3.0.0, < 4)
|
|
||||||
rack-session (2.1.2)
|
|
||||||
base64 (>= 0.1.0)
|
|
||||||
rack (>= 3.0.0)
|
|
||||||
rack-test (2.2.0)
|
|
||||||
rack (>= 1.3)
|
|
||||||
rackup (2.3.1)
|
|
||||||
rack (>= 3)
|
|
||||||
rainbow (3.1.1)
|
|
||||||
rake (13.4.2)
|
|
||||||
regexp_parser (2.12.0)
|
|
||||||
rexml (3.4.4)
|
|
||||||
rubocop (1.88.0)
|
|
||||||
json (~> 2.3)
|
|
||||||
language_server-protocol (~> 3.17.0.2)
|
|
||||||
lint_roller (~> 1.1.0)
|
|
||||||
parallel (>= 1.10)
|
|
||||||
parser (>= 3.3.0.2)
|
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
|
||||||
regexp_parser (>= 2.9.3, < 3.0)
|
|
||||||
rubocop-ast (>= 1.49.0, < 2.0)
|
|
||||||
ruby-progressbar (~> 1.7)
|
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
|
||||||
rubocop-ast (1.49.1)
|
|
||||||
parser (>= 3.3.7.2)
|
|
||||||
prism (~> 1.7)
|
|
||||||
ruby-progressbar (1.13.0)
|
|
||||||
sinatra (4.2.1)
|
|
||||||
logger (>= 1.6.0)
|
|
||||||
mustermann (~> 3.0)
|
|
||||||
rack (>= 3.0.0, < 4)
|
|
||||||
rack-protection (= 4.2.1)
|
|
||||||
rack-session (>= 2.0.0, < 3)
|
|
||||||
tilt (~> 2.0)
|
|
||||||
tilt (2.7.0)
|
|
||||||
toml-rb (4.2.0)
|
|
||||||
citrus (~> 3.0, > 3.0)
|
|
||||||
racc (~> 1.7)
|
|
||||||
unicode-display_width (3.2.0)
|
|
||||||
unicode-emoji (~> 4.1)
|
|
||||||
unicode-emoji (4.2.0)
|
|
||||||
|
|
||||||
PLATFORMS
|
|
||||||
ruby
|
|
||||||
x86_64-linux
|
|
||||||
|
|
||||||
DEPENDENCIES
|
|
||||||
minitest (~> 6.0)
|
|
||||||
rack-test (~> 2.2)
|
|
||||||
rake (~> 13.4)
|
|
||||||
rubocop (~> 1.88)
|
|
||||||
volumen!
|
|
||||||
|
|
||||||
CHECKSUMS
|
|
||||||
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
|
|
||||||
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
|
||||||
citrus (3.0.2) sha256=4ec2412fc389ad186735f4baee1460f7900a8e130ffe3f216b30d4f9c684f650
|
|
||||||
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
|
|
||||||
json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a
|
|
||||||
kramdown (2.5.2) sha256=1ba542204c66b6f9111ff00dcc26075b95b220b07f2905d8261740c82f7f02fa
|
|
||||||
kramdown-parser-gfm (1.1.0) sha256=fb39745516427d2988543bf01fc4cf0ab1149476382393e0e9c48592f6581729
|
|
||||||
language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
|
|
||||||
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
|
|
||||||
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
|
||||||
minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
|
|
||||||
mustermann (3.1.1) sha256=4c6170c7234d5499c345562ba7c7dfe73e1754286dcc1abb053064d66a127198
|
|
||||||
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
|
|
||||||
parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356
|
|
||||||
parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54
|
|
||||||
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
|
||||||
puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb
|
|
||||||
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
|
|
||||||
rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2
|
|
||||||
rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac
|
|
||||||
rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8
|
|
||||||
rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
|
|
||||||
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
|
|
||||||
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
|
|
||||||
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
|
|
||||||
regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
|
|
||||||
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
|
||||||
rubocop (1.88.0) sha256=e420ddf1662d0ef34bc8a2910ac4b396a7ddda0b51a708264405241734b08e0b
|
|
||||||
rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035
|
|
||||||
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
|
|
||||||
sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080
|
|
||||||
tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3
|
|
||||||
toml-rb (4.2.0) sha256=10a48c91613e20cf63483a7a776767dfb3cd7d70e9327c0237443da601e13776
|
|
||||||
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
|
|
||||||
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
|
|
||||||
volumen (0.2.0)
|
|
||||||
|
|
||||||
BUNDLED WITH
|
|
||||||
4.0.10
|
|
||||||
@@ -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** (``) is rendered as a
|
||||||
|
`<figure>` with a `<figcaption>` and a small `i` info icon. Images without a
|
||||||
|
title render exactly as before:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
@@ -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
@@ -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 (1–100) |
|
| limit | int | 20 | Per page (1–100) |
|
||||||
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 "$@"
|
|
||||||
@@ -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
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require_relative "volumen/version"
|
|
||||||
require_relative "volumen/frontmatter"
|
|
||||||
require_relative "volumen/markdown"
|
|
||||||
require_relative "volumen/post"
|
|
||||||
require_relative "volumen/store"
|
|
||||||
require_relative "volumen/config"
|
|
||||||
require_relative "volumen/password"
|
|
||||||
require_relative "volumen/users"
|
|
||||||
|
|
||||||
# volumen is a small, file-based Markdown blog engine.
|
|
||||||
#
|
|
||||||
# Posts are plain `.md` files with a TOML frontmatter block delimited by
|
|
||||||
# `+++`. The engine exposes them as a JSON API and ships a server-rendered
|
|
||||||
# admin with a Markdown source editor and a visual editor.
|
|
||||||
#
|
|
||||||
# The Sinatra app (`Volumen::Server`) and the CLI (`Volumen::CLI`) are loaded
|
|
||||||
# on demand to keep the core library light.
|
|
||||||
module Volumen
|
|
||||||
CLEANUP_INTERVAL = 300 # seconds between full sweeps of login_attempts
|
|
||||||
|
|
||||||
def self.login_attempts
|
|
||||||
@login_attempts ||= {}
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.reset_login_attempts!
|
|
||||||
@login_attempts = {}
|
|
||||||
@last_cleanup = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.sweep_login_attempts!
|
|
||||||
now = Time.now.to_f
|
|
||||||
return if @last_cleanup && (now - @last_cleanup) < CLEANUP_INTERVAL
|
|
||||||
|
|
||||||
cutoff = now - Volumen::Server::LOGIN_WINDOW
|
|
||||||
login_attempts.each_value { |timestamps| timestamps.select! { |t| t > cutoff } }
|
|
||||||
login_attempts.delete_if { |_ip, timestamps| timestamps.empty? }
|
|
||||||
@last_cleanup = now
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module Volumen
|
|
||||||
# Server-rendered admin routes, registered into Volumen::Server.
|
|
||||||
module AdminRoutes
|
|
||||||
def self.registered(app)
|
|
||||||
app.get "/admin" do
|
|
||||||
redirect to("/admin/")
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/admin/" do
|
|
||||||
require_login!
|
|
||||||
@posts = sorted_posts
|
|
||||||
erb :list
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/admin/login" do
|
|
||||||
redirect to("/admin/") if authenticated?
|
|
||||||
|
|
||||||
erb :login
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/login" do
|
|
||||||
check_csrf!
|
|
||||||
check_login_rate_limit!
|
|
||||||
user = users.authenticate(params["username"], params["password"])
|
|
||||||
if user
|
|
||||||
session[:user] = user.username
|
|
||||||
redirect to("/admin/")
|
|
||||||
else
|
|
||||||
@error = "Invalid username or password."
|
|
||||||
erb :login
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/logout" do
|
|
||||||
check_csrf!
|
|
||||||
session.clear
|
|
||||||
redirect to("/admin/login")
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/admin/posts/new" do
|
|
||||||
require_login!
|
|
||||||
@mode = :new
|
|
||||||
@post = Post.new(metadata: { "lang" => config.language })
|
|
||||||
erb :form
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/posts" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
create_post
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/admin/posts/:slug/edit" do
|
|
||||||
require_login!
|
|
||||||
@mode = :edit
|
|
||||||
@post = store.find(params["slug"])
|
|
||||||
halt(404, "Not found") if @post.nil?
|
|
||||||
|
|
||||||
erb :form
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/posts/:slug" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
update_post
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/posts/:slug/delete" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
store.delete(params["slug"])
|
|
||||||
redirect to("/admin/")
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/preview" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
content_type :html
|
|
||||||
Markdown.render(params["body"].to_s)
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/uploads" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
content_type :json
|
|
||||||
upload = params["file"]
|
|
||||||
halt(400, json("error" => "no_file")) unless upload.is_a?(Hash) && upload[:tempfile]
|
|
||||||
|
|
||||||
json("url" => store.store_upload(upload[:filename], upload[:tempfile]))
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/media/*" do
|
|
||||||
path = store.media_file(params["splat"].first)
|
|
||||||
halt(404) if path.nil?
|
|
||||||
|
|
||||||
send_file(path)
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/admin/logo.svg" do
|
|
||||||
send_file(File.expand_path("web/public/volumen-logo.svg", __dir__))
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/admin/settings" do
|
|
||||||
require_login!
|
|
||||||
render_settings
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/settings/password" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
change_password
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/settings/username" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
change_username
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/settings/users" do
|
|
||||||
require_login!
|
|
||||||
require_admin!
|
|
||||||
check_csrf!
|
|
||||||
create_user
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/settings/users/:username/role" do
|
|
||||||
require_login!
|
|
||||||
require_admin!
|
|
||||||
check_csrf!
|
|
||||||
change_role(params["username"])
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/settings/users/:username/delete" do
|
|
||||||
require_login!
|
|
||||||
require_admin!
|
|
||||||
check_csrf!
|
|
||||||
remove_user(params["username"])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module Volumen
|
|
||||||
# Public JSON API routes, registered into Volumen::Server.
|
|
||||||
module ApiRoutes
|
|
||||||
def self.registered(app)
|
|
||||||
app.before "/api/volumen/*" do
|
|
||||||
headers "Access-Control-Allow-Origin" => "*"
|
|
||||||
content_type :json
|
|
||||||
end
|
|
||||||
|
|
||||||
app.options "/api/volumen/*" do
|
|
||||||
headers "Access-Control-Allow-Origin" => "*"
|
|
||||||
headers "Access-Control-Allow-Methods" => "GET, OPTIONS"
|
|
||||||
headers "Access-Control-Allow-Headers" => "Content-Type"
|
|
||||||
content_type :json
|
|
||||||
""
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/api/volumen/site" do
|
|
||||||
json(site_payload)
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/api/volumen/posts" do
|
|
||||||
json(posts_payload)
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/api/volumen/posts/:slug" do
|
|
||||||
post = store.find(params["slug"], lang: params["lang"])
|
|
||||||
halt(404, json("error" => "not_found")) if post.nil? || post.draft?
|
|
||||||
|
|
||||||
json(post.detail)
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/api/volumen/tags" do
|
|
||||||
json("tags" => tag_counts)
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/api/volumen/feed.xml" do
|
|
||||||
content_type "application/rss+xml"
|
|
||||||
feed_xml
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/api/volumen/feed.json" do
|
|
||||||
json(feed_json)
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/api/volumen/sitemap.xml" do
|
|
||||||
content_type "application/xml"
|
|
||||||
sitemap_xml
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -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
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "toml-rb"
|
|
||||||
require "fileutils"
|
|
||||||
|
|
||||||
module Volumen
|
|
||||||
# Loads and merges volumen configuration from a TOML file, then applies
|
|
||||||
# command-line overrides on top of the file values and built-in defaults.
|
|
||||||
class Config
|
|
||||||
DEFAULT_PATH = "/etc/volumen/config.toml"
|
|
||||||
|
|
||||||
DEFAULTS = {
|
|
||||||
"server" => { "host" => "0.0.0.0", "port" => 9090 }.freeze,
|
|
||||||
"content_dir" => "/var/lib/volumen/posts",
|
|
||||||
"users_file" => "/var/lib/volumen/users.toml",
|
|
||||||
"site" => {
|
|
||||||
"title" => "My Blog",
|
|
||||||
"description" => "A blog powered by volumen.",
|
|
||||||
"base_url" => "https://example.com",
|
|
||||||
"language" => "en",
|
|
||||||
"author" => "Anonymous"
|
|
||||||
}.freeze,
|
|
||||||
"admin" => {
|
|
||||||
"password_hash" => "",
|
|
||||||
"session_key" => "",
|
|
||||||
"session_ttl" => 86_400
|
|
||||||
}.freeze
|
|
||||||
}.freeze
|
|
||||||
|
|
||||||
TEMPLATE = <<~TOML
|
|
||||||
# volumen configuration.
|
|
||||||
|
|
||||||
[server]
|
|
||||||
host = "0.0.0.0"
|
|
||||||
port = 9090
|
|
||||||
|
|
||||||
# Directory of your Markdown posts (.md with TOML frontmatter).
|
|
||||||
content_dir = "/var/lib/volumen/posts"
|
|
||||||
|
|
||||||
# File storing admin users (managed from the admin Settings page).
|
|
||||||
users_file = "/var/lib/volumen/users.toml"
|
|
||||||
|
|
||||||
[site]
|
|
||||||
title = "My Blog"
|
|
||||||
description = "A blog powered by volumen."
|
|
||||||
base_url = "https://example.com"
|
|
||||||
language = "en"
|
|
||||||
author = "Anonymous"
|
|
||||||
|
|
||||||
[admin]
|
|
||||||
# Bootstrap admin login: paste a hash from `volumen hash-password` to sign
|
|
||||||
# in as user "admin". Once you manage users in Settings, users_file wins.
|
|
||||||
password_hash = ""
|
|
||||||
# Secret signing session cookies (>= 64 bytes). Empty = random per start.
|
|
||||||
session_key = ""
|
|
||||||
# Session lifetime in seconds (24h default).
|
|
||||||
session_ttl = 86400
|
|
||||||
TOML
|
|
||||||
|
|
||||||
def self.load(path: DEFAULT_PATH, overrides: {})
|
|
||||||
data = File.exist?(path) ? TomlRB.load_file(path) : {}
|
|
||||||
merged = deep_merge(DEFAULTS, data)
|
|
||||||
new(apply_overrides(merged, overrides))
|
|
||||||
end
|
|
||||||
|
|
||||||
# Writes the commented template to `path` if it does not exist yet.
|
|
||||||
# Returns the path on success, or nil if it already existed or could not
|
|
||||||
# be created.
|
|
||||||
def self.generate_default(path)
|
|
||||||
return nil if File.exist?(path)
|
|
||||||
|
|
||||||
FileUtils.mkdir_p(File.dirname(path))
|
|
||||||
File.write(path, TEMPLATE)
|
|
||||||
path
|
|
||||||
rescue SystemCallError
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.deep_merge(base, override)
|
|
||||||
result = {}
|
|
||||||
base.each do |key, base_val|
|
|
||||||
result[key] = if override.key?(key)
|
|
||||||
merge_leaf(base_val, override[key])
|
|
||||||
elsif base_val.is_a?(Hash)
|
|
||||||
{}.merge(base_val)
|
|
||||||
else
|
|
||||||
base_val
|
|
||||||
end
|
|
||||||
end
|
|
||||||
override.each do |key, val|
|
|
||||||
result[key] = val unless result.key?(key)
|
|
||||||
end
|
|
||||||
result
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.merge_leaf(base_val, override_val)
|
|
||||||
if base_val.is_a?(Hash) && override_val.is_a?(Hash)
|
|
||||||
deep_merge(base_val, override_val)
|
|
||||||
else
|
|
||||||
override_val
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.apply_overrides(data, overrides)
|
|
||||||
server = data["server"].dup
|
|
||||||
server["host"] = overrides[:host] if overrides[:host]
|
|
||||||
server["port"] = overrides[:port] if overrides[:port]
|
|
||||||
|
|
||||||
result = data.dup
|
|
||||||
result["server"] = server
|
|
||||||
result["content_dir"] = overrides[:content] if overrides[:content]
|
|
||||||
result
|
|
||||||
end
|
|
||||||
|
|
||||||
private_class_method :deep_merge, :apply_overrides
|
|
||||||
|
|
||||||
attr_reader :data
|
|
||||||
|
|
||||||
def initialize(data)
|
|
||||||
@data = data
|
|
||||||
end
|
|
||||||
|
|
||||||
def host
|
|
||||||
@data["server"]["host"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def port
|
|
||||||
@data["server"]["port"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def content_dir
|
|
||||||
@data["content_dir"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def users_file
|
|
||||||
@data["users_file"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def site
|
|
||||||
@data["site"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def language
|
|
||||||
@data["site"]["language"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def admin
|
|
||||||
@data["admin"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "date"
|
|
||||||
require_relative "frontmatter"
|
|
||||||
require_relative "markdown"
|
|
||||||
|
|
||||||
module Volumen
|
|
||||||
# A single blog post: TOML frontmatter metadata plus a Markdown body.
|
|
||||||
class Post
|
|
||||||
attr_reader :metadata, :body
|
|
||||||
attr_accessor :path
|
|
||||||
|
|
||||||
def initialize(metadata: {}, body: "")
|
|
||||||
@metadata = (metadata || {}).transform_keys(&:to_s)
|
|
||||||
@body = body.to_s
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.parse(content)
|
|
||||||
metadata, body = Frontmatter.parse(content)
|
|
||||||
new(metadata: metadata, body: body)
|
|
||||||
end
|
|
||||||
|
|
||||||
def slug
|
|
||||||
metadata["slug"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def title
|
|
||||||
metadata["title"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def lang
|
|
||||||
metadata["lang"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def author
|
|
||||||
metadata["author"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def tags
|
|
||||||
Array(metadata["tags"]).map(&:to_s)
|
|
||||||
end
|
|
||||||
|
|
||||||
def draft?
|
|
||||||
metadata["draft"] == true
|
|
||||||
end
|
|
||||||
|
|
||||||
def all_langs?
|
|
||||||
metadata["all_langs"] == true
|
|
||||||
end
|
|
||||||
|
|
||||||
def translations
|
|
||||||
metadata["translations"] || {}
|
|
||||||
end
|
|
||||||
|
|
||||||
def cover
|
|
||||||
metadata["cover"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def publish_at
|
|
||||||
metadata["publish_at"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def scheduled?
|
|
||||||
value = publish_at
|
|
||||||
return false if value.nil?
|
|
||||||
|
|
||||||
date = case value
|
|
||||||
when Date then value
|
|
||||||
when Time then value.to_date
|
|
||||||
else Date.iso8601(value.to_s)
|
|
||||||
end
|
|
||||||
date > Date.today
|
|
||||||
rescue ArgumentError
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
def reading_time
|
|
||||||
words = body.split(/\s+/).count { |word| !word.empty? }
|
|
||||||
[(words / 200.0).ceil, 1].max
|
|
||||||
end
|
|
||||||
|
|
||||||
def date_string
|
|
||||||
value = metadata["date"]
|
|
||||||
case value
|
|
||||||
when nil then nil
|
|
||||||
when Date, Time then value.strftime("%Y-%m-%d")
|
|
||||||
else value.to_s
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def excerpt
|
|
||||||
text = metadata["excerpt"]
|
|
||||||
return text if text && !text.to_s.empty?
|
|
||||||
|
|
||||||
derived_excerpt
|
|
||||||
end
|
|
||||||
|
|
||||||
def html
|
|
||||||
Markdown.render(body)
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_file
|
|
||||||
Frontmatter.dump(metadata, body)
|
|
||||||
end
|
|
||||||
|
|
||||||
def summary
|
|
||||||
{
|
|
||||||
"slug" => slug,
|
|
||||||
"title" => title,
|
|
||||||
"excerpt" => excerpt,
|
|
||||||
"date" => date_string,
|
|
||||||
"lang" => lang,
|
|
||||||
"tags" => tags,
|
|
||||||
"author" => author,
|
|
||||||
"cover" => cover,
|
|
||||||
"reading_time" => reading_time,
|
|
||||||
"translations" => translations,
|
|
||||||
"url" => "/api/volumen/posts/#{slug}"
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def detail
|
|
||||||
summary.merge("body" => body, "html" => html)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def derived_excerpt(limit = 200)
|
|
||||||
paragraph = body.split(/\n\s*\n/).map(&:strip)
|
|
||||||
.find { |para| !para.empty? && !para.start_with?("#") }
|
|
||||||
return "" if paragraph.nil?
|
|
||||||
|
|
||||||
stripped = paragraph.gsub(/<[^>]+>/, "").gsub(/[*_`>#]/, "").strip
|
|
||||||
stripped.length > limit ? "#{stripped[0, limit].rstrip}…" : stripped
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,455 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "sinatra/base"
|
|
||||||
require "json"
|
|
||||||
require "date"
|
|
||||||
require "securerandom"
|
|
||||||
require_relative "config"
|
|
||||||
require_relative "store"
|
|
||||||
require_relative "markdown"
|
|
||||||
require_relative "password"
|
|
||||||
require_relative "api_routes"
|
|
||||||
require_relative "admin_routes"
|
|
||||||
|
|
||||||
module Volumen
|
|
||||||
# Sinatra application: the public JSON API at /api/volumen and the
|
|
||||||
# server-rendered admin at /admin.
|
|
||||||
class Server < Sinatra::Base
|
|
||||||
MAX_LOGIN_ATTEMPTS = 10
|
|
||||||
LOGIN_WINDOW = 60 # seconds
|
|
||||||
SLUG_REGEX = /\A[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\z/
|
|
||||||
|
|
||||||
register ApiRoutes
|
|
||||||
register AdminRoutes
|
|
||||||
|
|
||||||
configure do
|
|
||||||
set :show_exceptions, false
|
|
||||||
set :raise_errors, false
|
|
||||||
set :host_authorization, { permitted_hosts: [] } # empty = allow all hosts
|
|
||||||
set :views, File.expand_path("web/views", __dir__)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Returns a configured subclass bound to a specific config + store.
|
|
||||||
def self.configured(config:, store:)
|
|
||||||
secret = session_secret(config)
|
|
||||||
ttl = config.admin["session_ttl"].to_i
|
|
||||||
ttl = nil if ttl <= 0
|
|
||||||
Class.new(self) do
|
|
||||||
set :volumen_config, config
|
|
||||||
set :volumen_store, store
|
|
||||||
set :sessions, httponly: true, same_site: :strict, expire_after: ttl
|
|
||||||
set :session_secret, secret
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.session_secret(config)
|
|
||||||
key = config.admin["session_key"].to_s
|
|
||||||
return SecureRandom.hex(64) if key.empty?
|
|
||||||
|
|
||||||
if key.bytesize < 64
|
|
||||||
warn "volumen: session_key is shorter than 64 bytes, falling back to random secret"
|
|
||||||
return SecureRandom.hex(64)
|
|
||||||
end
|
|
||||||
|
|
||||||
key
|
|
||||||
end
|
|
||||||
|
|
||||||
# Mark the session cookie Secure only when the request is HTTPS (directly or
|
|
||||||
# via a trusted X-Forwarded-Proto from nginx). Over plain HTTP (local dev)
|
|
||||||
# the cookie stays usable so the admin login round-trips.
|
|
||||||
before do
|
|
||||||
options = request.env["rack.session.options"]
|
|
||||||
options[:secure] = request.ssl? if options
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def config
|
|
||||||
settings.volumen_config
|
|
||||||
end
|
|
||||||
|
|
||||||
def store
|
|
||||||
settings.volumen_store
|
|
||||||
end
|
|
||||||
|
|
||||||
# --- API helpers -----------------------------------------------------
|
|
||||||
|
|
||||||
def json(data)
|
|
||||||
JSON.generate(data)
|
|
||||||
end
|
|
||||||
|
|
||||||
def published_posts
|
|
||||||
store.all.reject { |post| post.draft? || post.scheduled? }
|
|
||||||
.sort_by { |post| post.date_string || "" }.reverse
|
|
||||||
end
|
|
||||||
|
|
||||||
def site_payload
|
|
||||||
config.site.slice("title", "description", "base_url", "language", "author")
|
|
||||||
end
|
|
||||||
|
|
||||||
def posts_payload
|
|
||||||
posts = filtered_posts
|
|
||||||
page = positive_int(params["page"], 1)
|
|
||||||
limit = positive_int(params["limit"], 20).clamp(1, 100)
|
|
||||||
offset = (page - 1) * limit
|
|
||||||
{
|
|
||||||
"page" => page,
|
|
||||||
"page_size" => limit,
|
|
||||||
"total" => posts.length,
|
|
||||||
"posts" => posts[offset, limit].to_a.map(&:summary)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def filtered_posts
|
|
||||||
posts = published_posts
|
|
||||||
posts = filter_by_lang(posts)
|
|
||||||
posts = posts.select { |post| post.tags.include?(params["tag"]) } if params["tag"]
|
|
||||||
posts = search_posts(posts) if params["q"]
|
|
||||||
posts
|
|
||||||
end
|
|
||||||
|
|
||||||
def filter_by_lang(posts)
|
|
||||||
return posts unless params["lang"]
|
|
||||||
|
|
||||||
lang = params["lang"]
|
|
||||||
posts.select { |post| post.lang == lang || post.all_langs? }
|
|
||||||
end
|
|
||||||
|
|
||||||
def search_posts(posts)
|
|
||||||
query = params["q"].to_s.strip.downcase
|
|
||||||
return posts if query.empty?
|
|
||||||
|
|
||||||
posts.select do |post|
|
|
||||||
post.title.to_s.downcase.include?(query) ||
|
|
||||||
post.body.downcase.include?(query)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def tag_counts
|
|
||||||
counts = Hash.new(0)
|
|
||||||
published_posts.each { |post| post.tags.each { |tag| counts[tag] += 1 } }
|
|
||||||
counts.sort_by { |name, count| [-count, name] }
|
|
||||||
.map { |name, count| { "name" => name, "count" => count } }
|
|
||||||
end
|
|
||||||
|
|
||||||
def positive_int(value, default)
|
|
||||||
number = value.to_i
|
|
||||||
number.positive? ? number : default
|
|
||||||
end
|
|
||||||
|
|
||||||
def base_url
|
|
||||||
config.site["base_url"].to_s.chomp("/")
|
|
||||||
end
|
|
||||||
|
|
||||||
def feed_xml
|
|
||||||
site = config.site
|
|
||||||
items = published_posts.first(20).map { |post| feed_item(post) }.join("\n")
|
|
||||||
<<~XML
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<rss version="2.0">
|
|
||||||
<channel>
|
|
||||||
<title>#{escape(site["title"])}</title>
|
|
||||||
<link>#{escape(base_url)}</link>
|
|
||||||
<description>#{escape(site["description"])}</description>
|
|
||||||
<language>#{escape(site["language"])}</language>
|
|
||||||
#{items}
|
|
||||||
</channel>
|
|
||||||
</rss>
|
|
||||||
XML
|
|
||||||
end
|
|
||||||
|
|
||||||
def feed_item(post)
|
|
||||||
link = "#{base_url}/#{post.slug}"
|
|
||||||
pub = if post.metadata["date"]
|
|
||||||
"\n <pubDate>#{escape(rfc822_date(post))}</pubDate>"
|
|
||||||
else
|
|
||||||
""
|
|
||||||
end
|
|
||||||
<<~XML.chomp
|
|
||||||
<item>
|
|
||||||
<title>#{escape(post.title)}</title>
|
|
||||||
<link>#{escape(link)}</link>
|
|
||||||
<guid>#{escape(link)}</guid>
|
|
||||||
<description>#{escape(post.excerpt)}</description>#{pub}
|
|
||||||
</item>
|
|
||||||
XML
|
|
||||||
end
|
|
||||||
|
|
||||||
def rfc822_date(post)
|
|
||||||
value = post.metadata["date"]
|
|
||||||
case value
|
|
||||||
when Date then value.to_time.rfc822
|
|
||||||
when Time then value.rfc822
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def feed_json
|
|
||||||
site = config.site
|
|
||||||
feed_url = "#{base_url}/api/volumen/feed.json"
|
|
||||||
{
|
|
||||||
"version" => "https://jsonfeed.org/version/1.1",
|
|
||||||
"title" => site["title"],
|
|
||||||
"home_page_url" => base_url,
|
|
||||||
"feed_url" => feed_url,
|
|
||||||
"description" => site["description"],
|
|
||||||
"language" => site["language"],
|
|
||||||
"items" => published_posts.first(20).map { |post| json_feed_item(post) }
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def json_feed_item(post)
|
|
||||||
link = "#{base_url}/#{post.slug}"
|
|
||||||
item = {
|
|
||||||
"id" => link,
|
|
||||||
"url" => link,
|
|
||||||
"title" => post.title,
|
|
||||||
"content_html" => post.html,
|
|
||||||
"summary" => post.excerpt,
|
|
||||||
"date_published" => iso_date(post.metadata["date"]),
|
|
||||||
"tags" => post.tags
|
|
||||||
}
|
|
||||||
item.reject { |_k, v| v.nil? || v == "" || v == [] }
|
|
||||||
end
|
|
||||||
|
|
||||||
def iso_date(value)
|
|
||||||
case value
|
|
||||||
when Date then value.iso8601
|
|
||||||
when Time then value.to_date.iso8601
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def sitemap_xml
|
|
||||||
urls = published_posts.map do |post|
|
|
||||||
url = " <url><loc>#{escape("#{base_url}/#{post.slug}")}</loc>"
|
|
||||||
url << "<lastmod>#{escape(post.date_string)}</lastmod>" if post.date_string
|
|
||||||
url << "</url>"
|
|
||||||
end.join("\n")
|
|
||||||
<<~XML
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
||||||
#{urls}
|
|
||||||
</urlset>
|
|
||||||
XML
|
|
||||||
end
|
|
||||||
|
|
||||||
def escape(text)
|
|
||||||
Rack::Utils.escape_html(text.to_s)
|
|
||||||
end
|
|
||||||
|
|
||||||
alias h escape
|
|
||||||
|
|
||||||
# --- Admin helpers ---------------------------------------------------
|
|
||||||
|
|
||||||
def users
|
|
||||||
Users.new(path: config.users_file, bootstrap_hash: config.admin["password_hash"])
|
|
||||||
end
|
|
||||||
|
|
||||||
def authenticated?
|
|
||||||
!session[:user].to_s.empty?
|
|
||||||
end
|
|
||||||
|
|
||||||
def current_user
|
|
||||||
session[:user]
|
|
||||||
end
|
|
||||||
|
|
||||||
def current_user_record
|
|
||||||
users.find(current_user)
|
|
||||||
end
|
|
||||||
|
|
||||||
def current_role
|
|
||||||
current_user_record&.role
|
|
||||||
end
|
|
||||||
|
|
||||||
def admin?
|
|
||||||
current_role == "admin"
|
|
||||||
end
|
|
||||||
|
|
||||||
def require_login!
|
|
||||||
unless authenticated?
|
|
||||||
redirect to("/admin/login")
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
return unless current_user_record.nil?
|
|
||||||
|
|
||||||
session.clear
|
|
||||||
redirect to("/admin/login")
|
|
||||||
end
|
|
||||||
|
|
||||||
def require_admin!
|
|
||||||
halt(403, "Forbidden") unless admin?
|
|
||||||
end
|
|
||||||
|
|
||||||
def csrf_token
|
|
||||||
session[:csrf] ||= SecureRandom.hex(32)
|
|
||||||
end
|
|
||||||
|
|
||||||
def check_csrf!
|
|
||||||
token = params["_csrf"]
|
|
||||||
return if token && session[:csrf] && Rack::Utils.secure_compare(session[:csrf], token)
|
|
||||||
|
|
||||||
halt(403, "Invalid CSRF token")
|
|
||||||
end
|
|
||||||
|
|
||||||
def check_login_rate_limit!
|
|
||||||
Volumen.sweep_login_attempts!
|
|
||||||
ip = request.ip.to_s
|
|
||||||
now = Time.now.to_f
|
|
||||||
cutoff = now - LOGIN_WINDOW
|
|
||||||
attempts = (Volumen.login_attempts[ip] ||= []).select { |t| t > cutoff }
|
|
||||||
if attempts.length >= MAX_LOGIN_ATTEMPTS
|
|
||||||
halt(429, "Too many login attempts. Please wait and try again.")
|
|
||||||
end
|
|
||||||
|
|
||||||
attempts << now
|
|
||||||
Volumen.login_attempts[ip] = attempts
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_settings(error: nil, notice: nil)
|
|
||||||
@error = error
|
|
||||||
@notice = notice
|
|
||||||
@users = users.all
|
|
||||||
erb :settings
|
|
||||||
end
|
|
||||||
|
|
||||||
def change_password
|
|
||||||
unless users.authenticate(current_user, params["current_password"])
|
|
||||||
return render_settings(error: "Current password is incorrect.")
|
|
||||||
end
|
|
||||||
|
|
||||||
fresh = presence(params["new_password"])
|
|
||||||
return render_settings(error: "New password cannot be empty.") if fresh.nil?
|
|
||||||
|
|
||||||
users.update_password(current_user, fresh)
|
|
||||||
render_settings(notice: "Password updated.")
|
|
||||||
end
|
|
||||||
|
|
||||||
def change_username
|
|
||||||
new_name = presence(params["username"])
|
|
||||||
return render_settings(error: "Username cannot be empty.") if new_name.nil?
|
|
||||||
unless new_name.match?(/\A[a-zA-Z0-9._-]+\z/)
|
|
||||||
return render_settings(error: "Username may use letters, numbers, dot, dash, underscore.")
|
|
||||||
end
|
|
||||||
|
|
||||||
return render_settings(notice: "Username unchanged.") if new_name == current_user
|
|
||||||
|
|
||||||
if users.rename(current_user, new_name)
|
|
||||||
session[:user] = new_name
|
|
||||||
render_settings(notice: "Username updated.")
|
|
||||||
else
|
|
||||||
render_settings(error: "That username is already taken.")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_user
|
|
||||||
name = presence(params["username"])
|
|
||||||
password = presence(params["password"])
|
|
||||||
if name.nil? || password.nil?
|
|
||||||
return render_settings(error: "Username and password are required.")
|
|
||||||
end
|
|
||||||
unless name.match?(/\A[a-zA-Z0-9._-]+\z/)
|
|
||||||
return render_settings(error: "Username may use letters, numbers, dot, dash, underscore.")
|
|
||||||
end
|
|
||||||
|
|
||||||
if users.add(name, password, params["role"].to_s)
|
|
||||||
render_settings(notice: "User added.")
|
|
||||||
else
|
|
||||||
render_settings(error: "That username already exists.")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def remove_user(username)
|
|
||||||
if username == current_user
|
|
||||||
return render_settings(error: "You cannot delete your own account.")
|
|
||||||
end
|
|
||||||
|
|
||||||
if users.delete(username)
|
|
||||||
render_settings(notice: "User removed.")
|
|
||||||
else
|
|
||||||
render_settings(error: "Cannot remove the last user or the last admin.")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def change_role(username)
|
|
||||||
return render_settings(error: "You cannot change your own role.") if username == current_user
|
|
||||||
|
|
||||||
if users.set_role(username, params["role"].to_s)
|
|
||||||
render_settings(notice: "Role updated.")
|
|
||||||
else
|
|
||||||
render_settings(error: "Could not change role (last admin?).")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def sorted_posts
|
|
||||||
store.all.sort_by { |post| post.date_string || "" }.reverse
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_post
|
|
||||||
post = post_from_params(params)
|
|
||||||
error = creation_error(post)
|
|
||||||
if error
|
|
||||||
@mode = :new
|
|
||||||
@post = post
|
|
||||||
@error = error
|
|
||||||
return erb(:form)
|
|
||||||
end
|
|
||||||
store.save(post)
|
|
||||||
redirect to("/admin/")
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_post
|
|
||||||
existing = store.find(params["slug"])
|
|
||||||
halt(404, "Not found") if existing.nil?
|
|
||||||
|
|
||||||
store.save(post_from_params(params, existing: existing))
|
|
||||||
redirect to("/admin/")
|
|
||||||
end
|
|
||||||
|
|
||||||
def creation_error(post)
|
|
||||||
return "Slug is required." if presence(post.slug).nil?
|
|
||||||
return "Invalid slug." unless post.slug.match?(SLUG_REGEX)
|
|
||||||
return "A post with that slug already exists." if store.find(post.slug)
|
|
||||||
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def post_from_params(http_params, existing: nil)
|
|
||||||
metadata = {
|
|
||||||
"title" => presence(http_params["title"]),
|
|
||||||
"slug" => presence(http_params["slug"]) || existing&.slug,
|
|
||||||
"lang" => presence(http_params["lang"]),
|
|
||||||
"author" => presence(http_params["author"]),
|
|
||||||
"date" => parse_date(http_params["date"]),
|
|
||||||
"publish_at" => parse_date(http_params["publish_at"]),
|
|
||||||
"tags" => parse_tags(http_params["tags"]),
|
|
||||||
"excerpt" => presence(http_params["excerpt"]),
|
|
||||||
"cover" => presence(http_params["cover"]),
|
|
||||||
"draft" => http_params["draft"] == "on",
|
|
||||||
"all_langs" => http_params["all_langs"] == "on"
|
|
||||||
}
|
|
||||||
post = Post.new(metadata: clean_metadata(metadata), body: http_params["body"].to_s)
|
|
||||||
post.path = existing.path if existing
|
|
||||||
post
|
|
||||||
end
|
|
||||||
|
|
||||||
def clean_metadata(metadata)
|
|
||||||
metadata.reject { |_key, value| value.nil? || value == "" || value == [] || value == false }
|
|
||||||
end
|
|
||||||
|
|
||||||
def presence(value)
|
|
||||||
text = value.to_s.strip
|
|
||||||
text.empty? ? nil : text
|
|
||||||
end
|
|
||||||
|
|
||||||
def parse_date(value)
|
|
||||||
text = presence(value)
|
|
||||||
text && Date.iso8601(text)
|
|
||||||
rescue ArgumentError
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def parse_tags(value)
|
|
||||||
presence(value).to_s.split(",").map(&:strip).reject(&:empty?)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "fileutils"
|
|
||||||
require "securerandom"
|
|
||||||
require_relative "post"
|
|
||||||
|
|
||||||
module Volumen
|
|
||||||
# Reads and writes Markdown posts in a content directory.
|
|
||||||
#
|
|
||||||
# Posts live either at `content_dir/<slug>.md` (default language) or at
|
|
||||||
# `content_dir/<lang>/<slug>.md` (per-language subdirectory). Missing
|
|
||||||
# `slug`/`lang` frontmatter is derived from the file path.
|
|
||||||
class Store
|
|
||||||
attr_reader :content_dir
|
|
||||||
|
|
||||||
def initialize(content_dir, default_lang: nil)
|
|
||||||
@content_dir = File.expand_path(content_dir.to_s)
|
|
||||||
@default_lang = default_lang
|
|
||||||
@all = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def all
|
|
||||||
mtime = Dir.exist?(@content_dir) ? File.mtime(@content_dir) : nil
|
|
||||||
@all = nil if mtime != @all_mtime
|
|
||||||
@all ||= begin
|
|
||||||
@all_mtime = mtime
|
|
||||||
markdown_files.filter_map { |path| load_post(path) }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def find(slug, lang: nil)
|
|
||||||
all.find do |post|
|
|
||||||
post.slug == slug && (lang.nil? || post.lang == lang || post.all_langs?)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def save(post)
|
|
||||||
target = post.path || default_path_for(post)
|
|
||||||
FileUtils.mkdir_p(File.dirname(target))
|
|
||||||
tmp = "#{target}.tmp"
|
|
||||||
File.write(tmp, post.to_file)
|
|
||||||
File.rename(tmp, target)
|
|
||||||
post.path = target
|
|
||||||
@all = nil
|
|
||||||
post
|
|
||||||
end
|
|
||||||
|
|
||||||
def delete(slug, lang: nil)
|
|
||||||
post = find(slug, lang: lang)
|
|
||||||
if post&.path
|
|
||||||
File.delete(post.path)
|
|
||||||
@all = nil
|
|
||||||
end
|
|
||||||
post
|
|
||||||
end
|
|
||||||
|
|
||||||
def media_dir
|
|
||||||
File.join(@content_dir, "media")
|
|
||||||
end
|
|
||||||
|
|
||||||
def store_upload(original_name, source)
|
|
||||||
FileUtils.mkdir_p(media_dir)
|
|
||||||
name = safe_media_name(original_name)
|
|
||||||
File.open(File.join(media_dir, name), "wb") { |file| IO.copy_stream(source, file) }
|
|
||||||
"/media/#{name}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def media_file(name)
|
|
||||||
path = File.join(media_dir, File.basename(name.to_s))
|
|
||||||
return nil unless File.file?(path)
|
|
||||||
|
|
||||||
real = File.realpath(path)
|
|
||||||
within?(media_dir, real) ? path : nil
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def markdown_files
|
|
||||||
Dir.glob(File.join(@content_dir, "**", "*.md"))
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_post(path)
|
|
||||||
post = Post.parse(File.read(path))
|
|
||||||
post.metadata["slug"] ||= File.basename(path, ".md")
|
|
||||||
post.metadata["lang"] ||= lang_from_path(path) || @default_lang
|
|
||||||
post.path = path
|
|
||||||
post
|
|
||||||
rescue StandardError => e
|
|
||||||
warn "volumen: skipping #{path}: #{e.message}"
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def lang_from_path(path)
|
|
||||||
parent = File.dirname(File.expand_path(path))
|
|
||||||
return nil if parent == @content_dir
|
|
||||||
|
|
||||||
File.basename(parent)
|
|
||||||
end
|
|
||||||
|
|
||||||
def default_path_for(post)
|
|
||||||
dir = @content_dir
|
|
||||||
dir = File.join(dir, post.lang) if post.lang && post.lang != @default_lang
|
|
||||||
target = File.join(dir, "#{post.slug}.md")
|
|
||||||
unless File.expand_path(target).start_with?(File.expand_path(@content_dir) + File::SEPARATOR)
|
|
||||||
raise ArgumentError, "slug escapes content directory"
|
|
||||||
end
|
|
||||||
|
|
||||||
target
|
|
||||||
end
|
|
||||||
|
|
||||||
def safe_media_name(original)
|
|
||||||
base = File.basename(original.to_s)
|
|
||||||
ext = File.extname(base).gsub(/[^a-zA-Z0-9.]/, "").downcase
|
|
||||||
stem = File.basename(base, File.extname(base))
|
|
||||||
.gsub(/[^a-zA-Z0-9_-]+/, "-").gsub(/-+/, "-").downcase
|
|
||||||
stem = "file" if stem.empty?
|
|
||||||
"#{SecureRandom.hex(4)}-#{stem}#{ext}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def within?(dir, path)
|
|
||||||
File.expand_path(path).start_with?(File.expand_path(dir) + File::SEPARATOR)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "fileutils"
|
|
||||||
require "toml-rb"
|
|
||||||
require_relative "password"
|
|
||||||
|
|
||||||
module Volumen
|
|
||||||
# File-backed set of admin users, stored as a TOML `[[users]]` array.
|
|
||||||
#
|
|
||||||
# Each user has a role: "admin" (full access, including user management) or
|
|
||||||
# "author" (posts and own account only). If the file does not exist yet, a
|
|
||||||
# single bootstrap "admin" user is synthesised from the legacy
|
|
||||||
# `admin.password_hash` config value.
|
|
||||||
class Users
|
|
||||||
ROLES = %w[admin author].freeze
|
|
||||||
DEFAULT_ROLE = "author"
|
|
||||||
|
|
||||||
User = Struct.new(:username, :password_hash, :role)
|
|
||||||
|
|
||||||
def initialize(path:, bootstrap_hash: nil)
|
|
||||||
@path = path
|
|
||||||
@bootstrap_hash = bootstrap_hash.to_s
|
|
||||||
@all = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def all
|
|
||||||
mtime = File.exist?(@path) ? File.mtime(@path) : nil
|
|
||||||
@all = nil if mtime != @all_mtime
|
|
||||||
@all ||= begin
|
|
||||||
@all_mtime = mtime
|
|
||||||
if File.exist?(@path)
|
|
||||||
read_file
|
|
||||||
elsif @bootstrap_hash.empty?
|
|
||||||
[]
|
|
||||||
else
|
|
||||||
[User.new("admin", @bootstrap_hash, "admin")]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def any?
|
|
||||||
all.any?
|
|
||||||
end
|
|
||||||
|
|
||||||
def find(username)
|
|
||||||
all.find { |user| user.username == username }
|
|
||||||
end
|
|
||||||
|
|
||||||
def authenticate(username, password)
|
|
||||||
user = find(username)
|
|
||||||
user if user && Password.verify(password, user.password_hash)
|
|
||||||
end
|
|
||||||
|
|
||||||
def add(username, password, role = DEFAULT_ROLE)
|
|
||||||
return nil if username.to_s.empty? || find(username)
|
|
||||||
|
|
||||||
user = User.new(username, Password.hash(password), normalize_role(role))
|
|
||||||
persist(all + [user])
|
|
||||||
user
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_password(username, password)
|
|
||||||
mutate(username) { |user| user.password_hash = Password.hash(password) }
|
|
||||||
end
|
|
||||||
|
|
||||||
def rename(current_name, new_name)
|
|
||||||
return nil if new_name.to_s.empty? || find(new_name)
|
|
||||||
|
|
||||||
mutate(current_name) { |user| user.username = new_name }
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_role(username, role)
|
|
||||||
return nil unless ROLES.include?(role)
|
|
||||||
|
|
||||||
users = all
|
|
||||||
user = users.find { |entry| entry.username == username }
|
|
||||||
return nil if user.nil? || demoting_last_admin?(users, user, role)
|
|
||||||
|
|
||||||
user.role = role
|
|
||||||
persist(users)
|
|
||||||
user
|
|
||||||
end
|
|
||||||
|
|
||||||
def delete(username)
|
|
||||||
users = all
|
|
||||||
target = users.find { |entry| entry.username == username }
|
|
||||||
return nil if target.nil? || users.length <= 1 || last_admin?(users, target)
|
|
||||||
|
|
||||||
persist(users.reject { |entry| entry.username == username })
|
|
||||||
username
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def mutate(username)
|
|
||||||
users = all
|
|
||||||
user = users.find { |entry| entry.username == username }
|
|
||||||
return nil if user.nil?
|
|
||||||
|
|
||||||
yield user
|
|
||||||
persist(users)
|
|
||||||
user
|
|
||||||
end
|
|
||||||
|
|
||||||
def normalize_role(role)
|
|
||||||
ROLES.include?(role) ? role : DEFAULT_ROLE
|
|
||||||
end
|
|
||||||
|
|
||||||
def admins(users)
|
|
||||||
users.select { |user| user.role == "admin" }
|
|
||||||
end
|
|
||||||
|
|
||||||
def last_admin?(users, user)
|
|
||||||
user.role == "admin" && admins(users).length <= 1
|
|
||||||
end
|
|
||||||
|
|
||||||
def demoting_last_admin?(users, user, role)
|
|
||||||
user.role == "admin" && role != "admin" && admins(users).length <= 1
|
|
||||||
end
|
|
||||||
|
|
||||||
def read_file
|
|
||||||
data = TomlRB.load_file(@path)
|
|
||||||
Array(data["users"]).map do |entry|
|
|
||||||
User.new(entry["username"], entry["password_hash"], entry["role"] || "admin")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def persist(users)
|
|
||||||
FileUtils.mkdir_p(File.dirname(@path))
|
|
||||||
tmp = "#{@path}.tmp"
|
|
||||||
File.write(tmp, TomlRB.dump("users" => users.map { |user| user_hash(user) }))
|
|
||||||
File.rename(tmp, @path)
|
|
||||||
end
|
|
||||||
|
|
||||||
def user_hash(user)
|
|
||||||
{ "username" => user.username, "password_hash" => user.password_hash, "role" => user.role }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module Volumen
|
|
||||||
VERSION = "0.2.0"
|
|
||||||
end
|
|
||||||
@@ -1,470 +0,0 @@
|
|||||||
<h1><%= @mode == :new ? "New post" : "Edit post" %></h1>
|
|
||||||
|
|
||||||
<% if @error %>
|
|
||||||
<p class="error"><%= h(@error) %></p>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<form id="post-form" method="post"
|
|
||||||
action="<%= @mode == :new ? to("/admin/posts") : h(to("/admin/posts/#{@post.slug}")) %>">
|
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
|
||||||
|
|
||||||
<div class="fields">
|
|
||||||
<label>Title <input type="text" name="title" value="<%= h(@post.title) %>"></label>
|
|
||||||
<label>Slug
|
|
||||||
<input type="text" name="slug" value="<%= h(@post.slug) %>"
|
|
||||||
<%= @mode == :edit ? "readonly" : "required" %>>
|
|
||||||
</label>
|
|
||||||
<label>Language <input type="text" name="lang" value="<%= h(@post.lang) %>"></label>
|
|
||||||
<label>Author <input type="text" name="author" value="<%= h(@post.author) %>"></label>
|
|
||||||
<label>Date <input type="date" name="date" value="<%= h(@post.date_string) %>"></label>
|
|
||||||
<label>Publish at
|
|
||||||
<input type="date" name="publish_at"
|
|
||||||
value="<%= @post.publish_at.is_a?(Date) ? @post.publish_at.strftime("%Y-%m-%d") : "" %>"
|
|
||||||
placeholder="leave empty for immediate">
|
|
||||||
</label>
|
|
||||||
<label>Tags
|
|
||||||
<input type="text" name="tags" value="<%= h(@post.tags.join(", ")) %>"
|
|
||||||
placeholder="comma, separated">
|
|
||||||
</label>
|
|
||||||
<label class="check">
|
|
||||||
<input type="checkbox" name="draft" <%= @post.draft? ? "checked" : "" %>> Draft
|
|
||||||
</label>
|
|
||||||
<label class="check">
|
|
||||||
<input type="checkbox" name="all_langs" <%= @post.all_langs? ? "checked" : "" %>> All languages
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cover-field">
|
|
||||||
<label for="cover-input">Cover image</label>
|
|
||||||
<div class="cover-row">
|
|
||||||
<input type="text" id="cover-input" name="cover" value="<%= h(@post.cover) %>"
|
|
||||||
placeholder="/media/… or https://…">
|
|
||||||
<button type="button" id="cover-upload">Upload</button>
|
|
||||||
<button type="button" id="cover-clear">Clear</button>
|
|
||||||
</div>
|
|
||||||
<img id="cover-preview" class="cover-preview" alt="" hidden>
|
|
||||||
<input type="file" id="cover-file" accept="image/*" hidden>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="editor-tabs">
|
|
||||||
<button type="button" data-tab="markdown" class="active">Markdown</button>
|
|
||||||
<button type="button" data-tab="visual">Visual</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="visual-view" hidden>
|
|
||||||
<div class="pane">
|
|
||||||
<div class="ed-toolbar">
|
|
||||||
<button type="button" data-rich="bold" title="Bold (Ctrl+B)"><b>B</b></button>
|
|
||||||
<button type="button" data-rich="italic" title="Italic (Ctrl+I)"><i>I</i></button>
|
|
||||||
<button type="button" data-rich="h2" title="Heading 2">H2</button>
|
|
||||||
<button type="button" data-rich="h3" title="Heading 3">H3</button>
|
|
||||||
<button type="button" data-rich="link" title="Link (Ctrl+K)">Link</button>
|
|
||||||
<button type="button" data-rich="ul" title="Bullet list">• List</button>
|
|
||||||
<button type="button" data-rich="ol" title="Numbered list">1. List</button>
|
|
||||||
<button type="button" data-rich="quote" title="Quote">“</button>
|
|
||||||
<button type="button" data-rich="code" title="Inline code"></></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">“</button>
|
|
||||||
<button type="button" data-md="code" title="Code"></></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 " || "") + ")";
|
|
||||||
}
|
|
||||||
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("");
|
|
||||||
} 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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<div class="toolbar">
|
|
||||||
<h1>Posts</h1>
|
|
||||||
<a class="button primary" href="<%= to("/admin/posts/new") %>">New post</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% if @posts.empty? %>
|
|
||||||
<p>No posts yet. Create your first one.</p>
|
|
||||||
<% else %>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Title</th><th>Slug</th><th>Lang</th><th>Date</th><th>Draft</th><th></th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<% @posts.each do |post| %>
|
|
||||||
<tr>
|
|
||||||
<td><%= h(post.title) %></td>
|
|
||||||
<td><%= h(post.slug) %></td>
|
|
||||||
<td><%= h(post.lang) %></td>
|
|
||||||
<td><%= h(post.date_string) %></td>
|
|
||||||
<td><%= post.draft? ? "yes" : "" %></td>
|
|
||||||
<td class="actions">
|
|
||||||
<a href="<%= h(to("/admin/posts/#{post.slug}/edit")) %>">Edit</a>
|
|
||||||
<form class="inline" method="post"
|
|
||||||
action="<%= h(to("/admin/posts/#{post.slug}/delete")) %>"
|
|
||||||
onsubmit="return confirm('Delete this post?');">
|
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
|
||||||
<button type="submit" class="danger">Delete</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<% end %>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<% end %>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
<h1>Settings</h1>
|
|
||||||
|
|
||||||
<% if @notice %><p class="notice"><%= h(@notice) %></p><% end %>
|
|
||||||
<% if @error %><p class="error"><%= h(@error) %></p><% end %>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<h2>Your account</h2>
|
|
||||||
<p class="muted">Signed in as <strong><%= h(current_user) %></strong> (<%= h(current_role) %>).</p>
|
|
||||||
|
|
||||||
<form method="post" action="<%= to("/admin/settings/password") %>">
|
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
|
||||||
<h3>Change password</h3>
|
|
||||||
<label>Current password
|
|
||||||
<input type="password" name="current_password" autocomplete="current-password">
|
|
||||||
</label>
|
|
||||||
<label>New password
|
|
||||||
<input type="password" name="new_password" autocomplete="new-password">
|
|
||||||
</label>
|
|
||||||
<button type="submit" class="primary">Update password</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form method="post" action="<%= to("/admin/settings/username") %>">
|
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
|
||||||
<h3>Change username</h3>
|
|
||||||
<label>New username <input type="text" name="username" value="<%= h(current_user) %>"></label>
|
|
||||||
<button type="submit">Update username</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<% if admin? %>
|
|
||||||
<section class="card">
|
|
||||||
<h2>Users</h2>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Username</th><th>Role</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<% @users.each do |user| %>
|
|
||||||
<tr>
|
|
||||||
<td><%= h(user.username) %><%= user.username == current_user ? " (you)" : "" %></td>
|
|
||||||
<td>
|
|
||||||
<% if user.username == current_user %>
|
|
||||||
<%= h(user.role) %>
|
|
||||||
<% else %>
|
|
||||||
<form class="inline" method="post"
|
|
||||||
action="<%= h(to("/admin/settings/users/#{user.username}/role")) %>">
|
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
|
||||||
<select name="role" onchange="this.form.submit()">
|
|
||||||
<% Volumen::Users::ROLES.each do |role| %>
|
|
||||||
<option value="<%= role %>" <%= user.role == role ? "selected" : "" %>><%= role %></option>
|
|
||||||
<% end %>
|
|
||||||
</select>
|
|
||||||
</form>
|
|
||||||
<% end %>
|
|
||||||
</td>
|
|
||||||
<td class="actions">
|
|
||||||
<% unless user.username == current_user %>
|
|
||||||
<form class="inline" method="post"
|
|
||||||
action="<%= h(to("/admin/settings/users/#{user.username}/delete")) %>"
|
|
||||||
onsubmit="return confirm('Remove this user?');">
|
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
|
||||||
<button type="submit" class="danger">Remove</button>
|
|
||||||
</form>
|
|
||||||
<% end %>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<% end %>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<form method="post" action="<%= to("/admin/settings/users") %>">
|
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
|
||||||
<h3>Add user</h3>
|
|
||||||
<div class="fields">
|
|
||||||
<label>Username <input type="text" name="username"></label>
|
|
||||||
<label>Password <input type="password" name="password" autocomplete="new-password"></label>
|
|
||||||
<label>Role
|
|
||||||
<select name="role">
|
|
||||||
<% Volumen::Users::ROLES.each do |role| %>
|
|
||||||
<option value="<%= role %>" <%= role == Volumen::Users::DEFAULT_ROLE ? "selected" : "" %>><%= role %></option>
|
|
||||||
<% end %>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="primary">Add user</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
<% end %>
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=75", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "volumen"
|
||||||
|
version = "0.4.2"
|
||||||
|
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/templates/*.jinja", "web/static/*.svg"]
|
||||||
|
|
||||||
|
|
||||||
|
[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"
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Administrative authentication routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
from .app import templates
|
||||||
|
from .auth import admin_context, check_login_rate_limit, validate_csrf_form
|
||||||
|
from .config import Config
|
||||||
|
from .dependencies import get_config, get_store, get_users
|
||||||
|
from .store import Store
|
||||||
|
from .users import Users
|
||||||
|
|
||||||
|
ConfigDep = Annotated[Config, Depends(get_config)]
|
||||||
|
StoreDep = Annotated[Store, Depends(get_store)]
|
||||||
|
UsersDep = Annotated[Users, Depends(get_users)]
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", include_in_schema=False)
|
||||||
|
async def admin_root() -> RedirectResponse:
|
||||||
|
"""Redirect bare ``/admin`` to ``/admin/``."""
|
||||||
|
return RedirectResponse(url="/admin/", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login")
|
||||||
|
async def admin_login_form(
|
||||||
|
request: Request,
|
||||||
|
config: ConfigDep,
|
||||||
|
store: StoreDep,
|
||||||
|
users_obj: UsersDep,
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Show the login form, or redirect authenticated users."""
|
||||||
|
if request.session.get("user", ""):
|
||||||
|
return RedirectResponse(url="/admin/", status_code=303)
|
||||||
|
context = admin_context(request, config, store, users_obj)
|
||||||
|
return templates.TemplateResponse(request, "login.html.jinja", context)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def admin_login(
|
||||||
|
request: Request,
|
||||||
|
config: ConfigDep,
|
||||||
|
store: StoreDep,
|
||||||
|
users_obj: UsersDep,
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Authenticate the user and redirect to the admin dashboard."""
|
||||||
|
check_login_rate_limit(request)
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
username = str(form.get("username", ""))
|
||||||
|
password = str(form.get("password", ""))
|
||||||
|
|
||||||
|
authenticated_user = await run_in_threadpool(users_obj.authenticate, username, password)
|
||||||
|
if authenticated_user:
|
||||||
|
request.session["user"] = authenticated_user.username
|
||||||
|
return RedirectResponse(url="/admin/", status_code=303)
|
||||||
|
|
||||||
|
context = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
error="Invalid username or password.",
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "login.html.jinja", context, status_code=401)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def admin_logout(request: Request) -> RedirectResponse:
|
||||||
|
"""Clear the session and redirect to the login page."""
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
request.session.clear()
|
||||||
|
return RedirectResponse(url="/admin/login", status_code=303)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Uploaded media serving routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
from .dependencies import get_store
|
||||||
|
from .store import Store
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/media")
|
||||||
|
StoreDep = Annotated[Store, Depends(get_store)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{path:path}")
|
||||||
|
async def serve_media(path: str, store: StoreDep) -> FileResponse:
|
||||||
|
"""Serve an uploaded media file through Store media resolution."""
|
||||||
|
file_path = await run_in_threadpool(store.media_file, path)
|
||||||
|
if file_path is None:
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
return FileResponse(file_path)
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"""Administrative post, preview, and upload routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
|
||||||
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
from .app import templates
|
||||||
|
from .auth import admin_context, validate_csrf_form
|
||||||
|
from .config import Config
|
||||||
|
from .dependencies import get_config, get_store, get_users, require_login
|
||||||
|
from .payloads import creation_error, post_from_params
|
||||||
|
from .post import Post
|
||||||
|
from .store import Store
|
||||||
|
from .upload_validation import validate_upload
|
||||||
|
from .users import Users
|
||||||
|
|
||||||
|
ConfigDep = Annotated[Config, Depends(get_config)]
|
||||||
|
StoreDep = Annotated[Store, Depends(get_store)]
|
||||||
|
UsersDep = Annotated[Users, Depends(get_users)]
|
||||||
|
LoginDep = Annotated[str, Depends(require_login)]
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin")
|
||||||
|
|
||||||
|
_PKG_DIR = Path(__file__).resolve().parent
|
||||||
|
_PUBLIC_DIR = _PKG_DIR / "web" / "static"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def admin_index(
|
||||||
|
request: Request,
|
||||||
|
config: ConfigDep,
|
||||||
|
store: StoreDep,
|
||||||
|
users_obj: UsersDep,
|
||||||
|
_username: LoginDep,
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Show the admin post list."""
|
||||||
|
posts = await run_in_threadpool(_all_posts_sorted, store)
|
||||||
|
context = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
posts=posts,
|
||||||
|
crumbs=[("Posts", None)],
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "list.html.jinja", context)
|
||||||
|
|
||||||
|
|
||||||
|
def _all_posts_sorted(store: Store) -> list[Post]:
|
||||||
|
return sorted(store.all(), key=lambda post: post.date_string or "", reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/posts/new")
|
||||||
|
async def admin_post_new(
|
||||||
|
request: Request,
|
||||||
|
config: ConfigDep,
|
||||||
|
store: StoreDep,
|
||||||
|
users_obj: UsersDep,
|
||||||
|
_username: LoginDep,
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Show the new-post form."""
|
||||||
|
post = Post(metadata={"lang": config.language})
|
||||||
|
context = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
mode="new",
|
||||||
|
post=post,
|
||||||
|
crumbs=[("Posts", "/admin/"), ("New post", None)],
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "form.html.jinja", context)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/posts")
|
||||||
|
async def admin_create_post(
|
||||||
|
request: Request,
|
||||||
|
config: ConfigDep,
|
||||||
|
store: StoreDep,
|
||||||
|
users_obj: UsersDep,
|
||||||
|
_username: LoginDep,
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Create a new post from form data."""
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
form_dict: dict[str, str] = {
|
||||||
|
key: value for key, value in form.items() if isinstance(value, str)
|
||||||
|
}
|
||||||
|
post = post_from_params(form_dict)
|
||||||
|
|
||||||
|
error = creation_error(post, store)
|
||||||
|
if error:
|
||||||
|
context = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
mode="new",
|
||||||
|
post=post,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "form.html.jinja", context, status_code=422)
|
||||||
|
|
||||||
|
await run_in_threadpool(store.save, post)
|
||||||
|
return RedirectResponse(url="/admin/", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/posts/{slug}/edit")
|
||||||
|
async def admin_post_edit(
|
||||||
|
slug: str,
|
||||||
|
request: Request,
|
||||||
|
config: ConfigDep,
|
||||||
|
store: StoreDep,
|
||||||
|
users_obj: UsersDep,
|
||||||
|
_username: LoginDep,
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Show the edit-post form for the given slug."""
|
||||||
|
post = await run_in_threadpool(store.find, slug)
|
||||||
|
if post is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
title = str(post.title or slug)
|
||||||
|
context = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
mode="edit",
|
||||||
|
post=post,
|
||||||
|
crumbs=[("Posts", "/admin/"), (title, None)],
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "form.html.jinja", context)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/posts/{slug}")
|
||||||
|
async def admin_update_post(
|
||||||
|
slug: str,
|
||||||
|
request: Request,
|
||||||
|
config: ConfigDep,
|
||||||
|
store: StoreDep,
|
||||||
|
users_obj: UsersDep,
|
||||||
|
_username: LoginDep,
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Update an existing post."""
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
|
||||||
|
existing = await run_in_threadpool(store.find, slug)
|
||||||
|
if existing is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
form_dict: dict[str, str] = {
|
||||||
|
key: value for key, value in form.items() if isinstance(value, str)
|
||||||
|
}
|
||||||
|
post = post_from_params(form_dict, existing=existing)
|
||||||
|
|
||||||
|
error = creation_error(post, store, existing=existing)
|
||||||
|
if error:
|
||||||
|
context = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
mode="edit",
|
||||||
|
post=post,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "form.html.jinja", context, status_code=422)
|
||||||
|
|
||||||
|
await run_in_threadpool(store.save, post)
|
||||||
|
return RedirectResponse(url="/admin/", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/posts/{slug}/delete")
|
||||||
|
async def admin_delete_post(
|
||||||
|
slug: str,
|
||||||
|
request: Request,
|
||||||
|
store: StoreDep,
|
||||||
|
_username: LoginDep,
|
||||||
|
) -> RedirectResponse:
|
||||||
|
"""Delete a post by slug."""
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
await run_in_threadpool(store.delete, slug)
|
||||||
|
return RedirectResponse(url="/admin/", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preview")
|
||||||
|
async def admin_preview(
|
||||||
|
request: Request,
|
||||||
|
_username: LoginDep,
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Render Markdown body to HTML and return it."""
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
|
||||||
|
from .markdown import render
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
body = str(form.get("body", ""))
|
||||||
|
html = await run_in_threadpool(render, body)
|
||||||
|
return HTMLResponse(content=html)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/uploads")
|
||||||
|
async def admin_uploads(
|
||||||
|
request: Request,
|
||||||
|
store: StoreDep,
|
||||||
|
_username: LoginDep,
|
||||||
|
file: UploadFile | None = None,
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Upload a media file and return its URL as JSON."""
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
|
||||||
|
if file is None or file.filename is None:
|
||||||
|
raise HTTPException(status_code=400, detail=json.dumps({"error": "no_file"}))
|
||||||
|
|
||||||
|
bytes_data, _extension = await validate_upload(file)
|
||||||
|
url = await run_in_threadpool(store.store_upload, file.filename, file.file, bytes_data)
|
||||||
|
return JSONResponse(content={"url": url})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logo.svg", include_in_schema=False)
|
||||||
|
async def admin_logo() -> FileResponse:
|
||||||
|
"""Serve the volumen logo SVG."""
|
||||||
|
path = _PUBLIC_DIR / "volumen-logo.svg"
|
||||||
|
return FileResponse(path)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/icon.svg", include_in_schema=False)
|
||||||
|
async def admin_icon() -> FileResponse:
|
||||||
|
"""Serve the volumen icon SVG."""
|
||||||
|
path = _PUBLIC_DIR / "volumen-icon.svg"
|
||||||
|
return FileResponse(path)
|
||||||
@@ -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)
|
||||||
@@ -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"))
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""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.responses import FileResponse
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Browsers auto-request `/favicon.ico` even when the page declares an
|
||||||
|
# explicit icon link, so serve the admin icon from the same source as
|
||||||
|
# `/admin/icon.svg` (the packaged SVG in web/static/). Modern browsers
|
||||||
|
# accept SVG content here as long as the Content-Type is set correctly.
|
||||||
|
@app.get("/favicon.ico", include_in_schema=False)
|
||||||
|
async def favicon() -> FileResponse:
|
||||||
|
icon = _STATIC_DIR / "volumen-icon.svg"
|
||||||
|
return FileResponse(
|
||||||
|
icon,
|
||||||
|
media_type="image/svg+xml",
|
||||||
|
headers={"Cache-Control": "public, max-age=86400"},
|
||||||
|
)
|
||||||
|
|
||||||
|
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]
|
||||||
|
# Generate nonce BEFORE call_next so templates can access
|
||||||
|
# request.state.csp_nonce during rendering.
|
||||||
|
nonce: str | None = None
|
||||||
|
if request.url.path.startswith("/admin"):
|
||||||
|
nonce = secrets.token_urlsafe(18)
|
||||||
|
request.state.csp_nonce = nonce
|
||||||
|
|
||||||
|
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, replace base script-src / style-src with
|
||||||
|
# nonce-based versions (do NOT append — browsers use the first
|
||||||
|
# occurrence when a directive is duplicated).
|
||||||
|
if nonce is not None:
|
||||||
|
csp = "; ".join(
|
||||||
|
tuple(
|
||||||
|
d
|
||||||
|
for d in _BASE_CSP_DIRECTIVES
|
||||||
|
if not d.startswith("script-src") and not d.startswith("style-src")
|
||||||
|
)
|
||||||
|
+ (
|
||||||
|
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
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""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."""
|
||||||
|
from .version import VERSION
|
||||||
|
|
||||||
|
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,
|
||||||
|
"users_exist": users_obj.any,
|
||||||
|
"csrf_token": csrf_val,
|
||||||
|
"current_user": username or None,
|
||||||
|
"current_role": record.role if record else None,
|
||||||
|
"current_user_record": record,
|
||||||
|
"csp_nonce": nonce,
|
||||||
|
"version": VERSION,
|
||||||
|
}
|
||||||
|
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
|
||||||
@@ -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
|
||||||
@@ -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", ""))
|
||||||
@@ -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
|
||||||
@@ -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>"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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."
|
||||||
@@ -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 ""
|
||||||
@@ -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}")
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Validation helpers for uploaded media files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, UploadFile
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
ALLOWED_UPLOAD_TYPES: tuple[str, ...] = ("image/webp", "image/avif")
|
||||||
|
ALLOWED_UPLOAD_EXTENSIONS: tuple[str, ...] = (".webp", ".avif")
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_upload_bytes(upload: UploadFile, max_bytes: int) -> bytes:
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
total = 0
|
||||||
|
while True:
|
||||||
|
chunk = await upload.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
total += len(chunk)
|
||||||
|
if total > max_bytes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=json.dumps({"message": "Payload too large", "limit": max_bytes}),
|
||||||
|
)
|
||||||
|
chunks.append(chunk)
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_upload(upload: Any, *, max_bytes: int | None = None) -> tuple[bytes, str]:
|
||||||
|
"""Validate upload size, MIME type, and signature."""
|
||||||
|
from .config import DEFAULT_MAX_UPLOAD_BYTES
|
||||||
|
from .store import detect_image_extension
|
||||||
|
|
||||||
|
if max_bytes is None:
|
||||||
|
max_bytes = DEFAULT_MAX_UPLOAD_BYTES
|
||||||
|
|
||||||
|
data = await _read_upload_bytes(upload, max_bytes)
|
||||||
|
declared = (getattr(upload, "content_type", None) or "").split(";")[0].strip().lower()
|
||||||
|
if declared not in ALLOWED_UPLOAD_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=415,
|
||||||
|
detail=json.dumps(
|
||||||
|
{
|
||||||
|
"error": "unsupported_format",
|
||||||
|
"message": (
|
||||||
|
"Only WebP (.webp) and AVIF (.avif) images are supported. "
|
||||||
|
"Please convert your image before uploading — for example with "
|
||||||
|
"`cwebp` (JPEG/PNG → WebP) or `avifenc` (PNG/JPEG → AVIF)."
|
||||||
|
),
|
||||||
|
"allowed": list(ALLOWED_UPLOAD_TYPES),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
extension = await run_in_threadpool(detect_image_extension, data)
|
||||||
|
if extension is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=415,
|
||||||
|
detail=json.dumps(
|
||||||
|
{
|
||||||
|
"error": "invalid_signature",
|
||||||
|
"message": (
|
||||||
|
"Uploaded file does not look like a valid WebP or AVIF image. "
|
||||||
|
"Make sure the bytes match the declared MIME type."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expected_mime = {".webp": "image/webp", ".avif": "image/avif"}.get(extension)
|
||||||
|
if expected_mime is None or expected_mime != declared:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=415,
|
||||||
|
detail=json.dumps(
|
||||||
|
{
|
||||||
|
"error": "mime_mismatch",
|
||||||
|
"message": (
|
||||||
|
"Declared MIME does not match the file signature. "
|
||||||
|
"Only WebP or AVIF images are accepted."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return data, extension
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""User account and profile settings action helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
from .auth import password_error
|
||||||
|
from .payloads import presence
|
||||||
|
from .upload_validation import validate_upload
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .store import Store
|
||||||
|
from .users import Users
|
||||||
|
|
||||||
|
USERNAME_REGEX: re.Pattern[str] = re.compile(r"\A[a-zA-Z0-9._-]+\Z")
|
||||||
|
|
||||||
|
|
||||||
|
def change_password(
|
||||||
|
users_obj: Users,
|
||||||
|
current_username: str,
|
||||||
|
current_password: str,
|
||||||
|
new_password: str,
|
||||||
|
*,
|
||||||
|
min_length: int | None = None,
|
||||||
|
max_length: int | None = None,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Change the user's password."""
|
||||||
|
if not users_obj.authenticate(current_username, current_password):
|
||||||
|
return ("Current password is incorrect.", None)
|
||||||
|
fresh = presence(new_password)
|
||||||
|
if fresh is None:
|
||||||
|
return ("New password cannot be empty.", None)
|
||||||
|
error = password_error(fresh, min_length=min_length, max_length=max_length)
|
||||||
|
if error is not None:
|
||||||
|
return (error, None)
|
||||||
|
users_obj.update_password(current_username, fresh)
|
||||||
|
return (None, "Password updated.")
|
||||||
|
|
||||||
|
|
||||||
|
def change_username(
|
||||||
|
users_obj: Users,
|
||||||
|
current_username: str,
|
||||||
|
new_username: str,
|
||||||
|
session: dict[str, Any],
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Rename the current user and update the session."""
|
||||||
|
name = presence(new_username)
|
||||||
|
if name is None:
|
||||||
|
return ("Username cannot be empty.", None)
|
||||||
|
if not re.match(USERNAME_REGEX, name):
|
||||||
|
return ("Username may use letters, numbers, dot, dash, underscore.", None)
|
||||||
|
if name == current_username:
|
||||||
|
return (None, "Username unchanged.")
|
||||||
|
if users_obj.rename(current_username, name):
|
||||||
|
session["user"] = name
|
||||||
|
return (None, "Username updated.")
|
||||||
|
return ("That username is already taken.", None)
|
||||||
|
|
||||||
|
|
||||||
|
def change_name(
|
||||||
|
users_obj: Users,
|
||||||
|
current_username: str,
|
||||||
|
display_name: str,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Update or clear the display name."""
|
||||||
|
name = display_name.strip()
|
||||||
|
name = name if name else None
|
||||||
|
users_obj.update_name(current_username, name)
|
||||||
|
return (None, "Display name updated." if name else "Display name cleared.")
|
||||||
|
|
||||||
|
|
||||||
|
def change_fediverse_creator(
|
||||||
|
users_obj: Users,
|
||||||
|
current_username: str,
|
||||||
|
value: str,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Update or clear the fediverse handle."""
|
||||||
|
from .post import FEDIVERSE_CREATOR_REGEX
|
||||||
|
|
||||||
|
text = value.strip()
|
||||||
|
if not text:
|
||||||
|
users_obj.update_fediverse_creator(current_username, None)
|
||||||
|
return (None, "Fediverse handle cleared.")
|
||||||
|
if not re.match(FEDIVERSE_CREATOR_REGEX, text):
|
||||||
|
return ("Fediverse handle must look like @user@host.", None)
|
||||||
|
users_obj.update_fediverse_creator(current_username, text)
|
||||||
|
return (None, "Fediverse handle updated.")
|
||||||
|
|
||||||
|
|
||||||
|
async def change_photo(
|
||||||
|
users_obj: Users,
|
||||||
|
store: Store,
|
||||||
|
current_username: str,
|
||||||
|
upload: Any,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Store a profile photo and remove the previous file if unreferenced."""
|
||||||
|
bytes_data, _extension = await validate_upload(upload)
|
||||||
|
url = await run_in_threadpool(store.store_upload, upload.filename, upload.file, bytes_data)
|
||||||
|
previous = None
|
||||||
|
user = users_obj.find(current_username)
|
||||||
|
if user is not None:
|
||||||
|
previous = user.photo
|
||||||
|
users_obj.update_photo(current_username, url)
|
||||||
|
if previous and previous != url:
|
||||||
|
await run_in_threadpool(_delete_unreferenced_media, users_obj, store, previous)
|
||||||
|
return (None, "Profile photo updated.")
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_photo(
|
||||||
|
users_obj: Users,
|
||||||
|
current_username: str,
|
||||||
|
store: Store | None = None,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Remove the profile photo and optionally delete the file."""
|
||||||
|
user = users_obj.find(current_username)
|
||||||
|
if user is not None and user.photo and store is not None:
|
||||||
|
await run_in_threadpool(_delete_unreferenced_media, users_obj, store, user.photo)
|
||||||
|
users_obj.update_photo(current_username, None)
|
||||||
|
return (None, "Profile photo removed.")
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(
|
||||||
|
users_obj: Users,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
role: str,
|
||||||
|
*,
|
||||||
|
min_length: int | None = None,
|
||||||
|
max_length: int | None = None,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Create a new user."""
|
||||||
|
name = presence(username)
|
||||||
|
password_value = presence(password)
|
||||||
|
if name is None or password_value is None:
|
||||||
|
return ("Username and password are required.", None)
|
||||||
|
if not re.match(USERNAME_REGEX, name):
|
||||||
|
return ("Username may use letters, numbers, dot, dash, underscore.", None)
|
||||||
|
error = password_error(password_value, min_length=min_length, max_length=max_length)
|
||||||
|
if error is not None:
|
||||||
|
return (error, None)
|
||||||
|
if users_obj.add(name, password_value, role):
|
||||||
|
return (None, "User added.")
|
||||||
|
return ("That username already exists.", None)
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_user(
|
||||||
|
users_obj: Users,
|
||||||
|
current_username: str,
|
||||||
|
target_username: str,
|
||||||
|
store: Store | None = None,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Delete a user and clean up their profile photo."""
|
||||||
|
if target_username == current_username:
|
||||||
|
return ("You cannot delete your own account.", None)
|
||||||
|
target = users_obj.find(target_username)
|
||||||
|
if target is not None and target.photo and store is not None:
|
||||||
|
await run_in_threadpool(_delete_unreferenced_media, users_obj, store, target.photo)
|
||||||
|
if users_obj.delete(target_username):
|
||||||
|
return (None, "User removed.")
|
||||||
|
return ("Cannot remove the last user or the last admin.", None)
|
||||||
|
|
||||||
|
|
||||||
|
def change_role(
|
||||||
|
users_obj: Users,
|
||||||
|
current_username: str,
|
||||||
|
target_username: str,
|
||||||
|
new_role: str,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Change a user's role."""
|
||||||
|
if target_username == current_username:
|
||||||
|
return ("You cannot change your own role.", None)
|
||||||
|
if users_obj.set_role(target_username, new_role):
|
||||||
|
return (None, "Role updated.")
|
||||||
|
return ("Could not change role (last admin?).", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_unreferenced_media(users_obj: Users, store: Store, url: str) -> None:
|
||||||
|
"""Delete *url* from disk if no other user record references it."""
|
||||||
|
if not url or not url.startswith("/media/"):
|
||||||
|
return
|
||||||
|
in_use = any(user.photo == url for user in users_obj.all)
|
||||||
|
if in_use:
|
||||||
|
return
|
||||||
|
store.delete_media(url)
|
||||||
@@ -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()
|
||||||
|
else:
|
||||||
|
self._cached = []
|
||||||
|
if not self._cached and self._bootstrap_hash:
|
||||||
|
self._cached = [User("admin", self._bootstrap_hash, "admin")]
|
||||||
|
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
|
||||||
@@ -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()
|
||||||
@@ -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 |
@@ -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 class="page-head-actions">
|
||||||
|
{% 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"><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"><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 class="form-options">
|
||||||
|
<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">
|
||||||
|
<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">/></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"></></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 = " || "") + ")";
|
||||||
|
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("");
|
||||||
|
} 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
@@ -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 mt-3" href="/admin/posts/new">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="--cover-url: 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 btn-static">Edit</span>
|
||||||
|
<span class="flex-spacer"></span>
|
||||||
|
<form method="post" action="/admin/posts/{{ post.slug }}/delete"
|
||||||
|
onsubmit="return confirm('Delete this post?');" class="form-inline">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-ghost btn-danger-text" 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 nonce="{{ csp_nonce | default('') }}">
|
||||||
|
(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.classList.toggle("is-hidden", !(matchesSearch && matchesFilter));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 class="text-fg-strong">{{ 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" class="form-hidden">
|
||||||
|
<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 form-spaced">
|
||||||
|
<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 form-spaced">
|
||||||
|
<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 badge-inline">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" class="form-inline">
|
||||||
|
<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?');" class="form-inline">
|
||||||
|
<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 class="h3-section">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 nonce="{{ csp_nonce | default('') }}">
|
||||||
|
(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 %}
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "test_helper"
|
|
||||||
require "volumen/server"
|
|
||||||
require "rack/test"
|
|
||||||
require "tmpdir"
|
|
||||||
require "fileutils"
|
|
||||||
|
|
||||||
class AdminTest < Minitest::Test
|
|
||||||
include Rack::Test::Methods
|
|
||||||
|
|
||||||
def app
|
|
||||||
Volumen::Server.configured(config: @config, store: @store)
|
|
||||||
end
|
|
||||||
|
|
||||||
def setup
|
|
||||||
@dir = Dir.mktmpdir
|
|
||||||
@posts_dir = File.join(@dir, "posts")
|
|
||||||
FileUtils.mkdir_p(@posts_dir)
|
|
||||||
@password = "secret"
|
|
||||||
@hash = Volumen::Password.hash(@password)
|
|
||||||
@users_path = File.join(@dir, "users.toml")
|
|
||||||
config_path = File.join(@dir, "config.toml")
|
|
||||||
File.write(config_path, <<~TOML)
|
|
||||||
content_dir = "#{@posts_dir}"
|
|
||||||
users_file = "#{@users_path}"
|
|
||||||
|
|
||||||
[admin]
|
|
||||||
password_hash = "#{@hash}"
|
|
||||||
TOML
|
|
||||||
@config = Volumen::Config.load(path: config_path)
|
|
||||||
@store = Volumen::Store.new(@posts_dir, default_lang: "en")
|
|
||||||
Volumen.reset_login_attempts!
|
|
||||||
end
|
|
||||||
|
|
||||||
def teardown
|
|
||||||
FileUtils.remove_entry(@dir)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_admin_requires_login
|
|
||||||
get "/admin/"
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_login_page_renders
|
|
||||||
get "/admin/login"
|
|
||||||
assert_predicate last_response, :ok?
|
|
||||||
assert_includes last_response.body, "Sign in"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_session_cookie_is_secure_over_https
|
|
||||||
get "https://example.org/admin/login"
|
|
||||||
post "https://example.org/admin/login",
|
|
||||||
"username" => "admin", "password" => @password, "_csrf" => csrf(last_response.body)
|
|
||||||
cookie = last_response["Set-Cookie"].to_s
|
|
||||||
assert_match(/;\s*secure/i, cookie)
|
|
||||||
assert_match(/;\s*httponly/i, cookie)
|
|
||||||
assert_match(/;\s*samesite=strict/i, cookie)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_session_cookie_plain_http_is_not_secure
|
|
||||||
login
|
|
||||||
refute_match(/;\s*secure/i, last_response["Set-Cookie"].to_s)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_wrong_password_is_rejected
|
|
||||||
get "/admin/login"
|
|
||||||
post "/admin/login",
|
|
||||||
"username" => "admin", "password" => "nope", "_csrf" => csrf(last_response.body)
|
|
||||||
assert_includes last_response.body, "Invalid username or password"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_login_then_list
|
|
||||||
login
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
follow_redirect!
|
|
||||||
assert_includes last_response.body, "Posts"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_create_post_writes_file
|
|
||||||
login
|
|
||||||
get "/admin/posts/new"
|
|
||||||
post "/admin/posts",
|
|
||||||
"_csrf" => csrf(last_response.body), "title" => "Test", "slug" => "test",
|
|
||||||
"lang" => "en", "tags" => "a, b", "body" => "Hello **world**."
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
path = File.join(@posts_dir, "test.md")
|
|
||||||
assert File.exist?(path)
|
|
||||||
saved = Volumen::Post.parse(File.read(path))
|
|
||||||
assert_equal "Test", saved.title
|
|
||||||
assert_equal %w[a b], saved.tags
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_create_post_with_all_langs
|
|
||||||
login
|
|
||||||
get "/admin/posts/new"
|
|
||||||
post "/admin/posts",
|
|
||||||
"_csrf" => csrf(last_response.body), "title" => "Global", "slug" => "global",
|
|
||||||
"lang" => "en", "all_langs" => "on", "body" => "Hi"
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
saved = Volumen::Post.parse(File.read(File.join(@posts_dir, "global.md")))
|
|
||||||
assert_predicate saved, :all_langs?
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_create_post_with_cover
|
|
||||||
login
|
|
||||||
get "/admin/posts/new"
|
|
||||||
post "/admin/posts",
|
|
||||||
"_csrf" => csrf(last_response.body), "title" => "Covered", "slug" => "covered",
|
|
||||||
"lang" => "en", "cover" => "/media/c.png", "body" => "Hi"
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
saved = Volumen::Post.parse(File.read(File.join(@posts_dir, "covered.md")))
|
|
||||||
assert_equal "/media/c.png", saved.cover
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_preview_requires_login
|
|
||||||
post "/admin/preview", "body" => "**hi**"
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_csrf_is_enforced
|
|
||||||
login
|
|
||||||
post "/admin/posts", "title" => "X", "slug" => "x", "body" => "y"
|
|
||||||
assert_equal 403, last_response.status
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_image_upload_and_serving
|
|
||||||
login
|
|
||||||
get "/admin/posts/new"
|
|
||||||
token = csrf(last_response.body)
|
|
||||||
image_path = File.join(@dir, "pic.png")
|
|
||||||
File.binwrite(image_path, "PNGDATA")
|
|
||||||
post "/admin/uploads",
|
|
||||||
"_csrf" => token,
|
|
||||||
"file" => Rack::Test::UploadedFile.new(image_path, "image/png")
|
|
||||||
assert_predicate last_response, :ok?
|
|
||||||
url = JSON.parse(last_response.body)["url"]
|
|
||||||
assert_match(%r{\A/media/}, url)
|
|
||||||
|
|
||||||
get url
|
|
||||||
assert_predicate last_response, :ok?
|
|
||||||
assert_equal "PNGDATA", last_response.body
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_settings_requires_login
|
|
||||||
get "/admin/settings"
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_change_password
|
|
||||||
login
|
|
||||||
get "/admin/settings"
|
|
||||||
post "/admin/settings/password",
|
|
||||||
"_csrf" => csrf(last_response.body),
|
|
||||||
"current_password" => @password, "new_password" => "brand-new"
|
|
||||||
assert_includes last_response.body, "Password updated"
|
|
||||||
fresh = Volumen::Users.new(path: @users_path)
|
|
||||||
refute fresh.authenticate("admin", @password)
|
|
||||||
assert fresh.authenticate("admin", "brand-new")
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_add_and_login_as_new_user
|
|
||||||
login
|
|
||||||
get "/admin/settings"
|
|
||||||
post "/admin/settings/users",
|
|
||||||
"_csrf" => csrf(last_response.body), "username" => "editor", "password" => "pw12345"
|
|
||||||
assert_includes last_response.body, "User added"
|
|
||||||
|
|
||||||
post "/admin/logout", "_csrf" => csrf(last_response.body)
|
|
||||||
get "/admin/login"
|
|
||||||
post "/admin/login",
|
|
||||||
"username" => "editor", "password" => "pw12345", "_csrf" => csrf(last_response.body)
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_cannot_delete_self
|
|
||||||
login
|
|
||||||
get "/admin/settings"
|
|
||||||
post "/admin/settings/users/admin/delete", "_csrf" => csrf(last_response.body)
|
|
||||||
assert_includes last_response.body, "cannot delete your own account"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_admin_can_assign_role
|
|
||||||
login
|
|
||||||
add_user("writer", "pw12345", "author")
|
|
||||||
assert_includes last_response.body, "User added"
|
|
||||||
assert_equal "author", Volumen::Users.new(path: @users_path).find("writer").role
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_author_cannot_manage_users
|
|
||||||
login
|
|
||||||
add_user("writer", "pw12345", "author")
|
|
||||||
logout
|
|
||||||
login_as("writer", "pw12345")
|
|
||||||
follow_redirect!
|
|
||||||
post "/admin/settings/users",
|
|
||||||
"_csrf" => csrf(last_response.body), "username" => "intruder", "password" => "pw12345"
|
|
||||||
assert_equal 403, last_response.status
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_author_settings_omits_user_management
|
|
||||||
login
|
|
||||||
add_user("writer", "pw12345", "author")
|
|
||||||
logout
|
|
||||||
login_as("writer", "pw12345")
|
|
||||||
follow_redirect!
|
|
||||||
get "/admin/settings"
|
|
||||||
refute_includes last_response.body, "Add user"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_author_can_create_post
|
|
||||||
login
|
|
||||||
add_user("writer", "pw12345", "author")
|
|
||||||
logout
|
|
||||||
login_as("writer", "pw12345")
|
|
||||||
get "/admin/posts/new"
|
|
||||||
post "/admin/posts",
|
|
||||||
"_csrf" => csrf(last_response.body), "title" => "By author", "slug" => "by-author",
|
|
||||||
"lang" => "en", "body" => "Hi"
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
assert File.exist?(File.join(@posts_dir, "by-author.md"))
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_login_rate_limit_sweep_removes_stale_entries
|
|
||||||
Volumen.login_attempts["1.2.3.4"] = [Time.now.to_f - 120]
|
|
||||||
Volumen.instance_variable_set(:@last_cleanup, Time.now.to_f - 600)
|
|
||||||
Volumen.sweep_login_attempts!
|
|
||||||
refute Volumen.login_attempts.key?("1.2.3.4"),
|
|
||||||
"sweep should remove IPs with only stale entries"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_create_post_rejects_invalid_slug
|
|
||||||
login
|
|
||||||
get "/admin/posts/new"
|
|
||||||
post "/admin/posts",
|
|
||||||
"_csrf" => csrf(last_response.body), "title" => "X", "slug" => "../../etc/passwd",
|
|
||||||
"lang" => "en", "body" => "y"
|
|
||||||
assert_includes last_response.body, "Invalid slug"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_create_post_rejects_xss_in_slug
|
|
||||||
login
|
|
||||||
get "/admin/posts/new"
|
|
||||||
post "/admin/posts",
|
|
||||||
"_csrf" => csrf(last_response.body), "title" => "X", "slug" => "foo\" onclick",
|
|
||||||
"lang" => "en", "body" => "y"
|
|
||||||
assert_includes last_response.body, "Invalid slug"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_change_username_rejects_special_chars
|
|
||||||
login
|
|
||||||
get "/admin/settings"
|
|
||||||
post "/admin/settings/username",
|
|
||||||
"_csrf" => csrf(last_response.body), "username" => "foo\" bar"
|
|
||||||
assert_includes last_response.body, "Username may use letters"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_change_username_accepts_same_name
|
|
||||||
login
|
|
||||||
get "/admin/settings"
|
|
||||||
post "/admin/settings/username",
|
|
||||||
"_csrf" => csrf(last_response.body), "username" => "admin"
|
|
||||||
assert_includes last_response.body, "Username unchanged"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_preview_requires_csrf
|
|
||||||
login
|
|
||||||
get "/admin/posts/new"
|
|
||||||
post "/admin/preview", "body" => "**hi**"
|
|
||||||
assert_equal 403, last_response.status
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_orphaned_session_is_rejected
|
|
||||||
login
|
|
||||||
session_for = -> { rack_mock_session.cookie_jar["rack.session"].to_s }
|
|
||||||
refute_empty session_for.call, "expected a session cookie after login"
|
|
||||||
|
|
||||||
# Simulate a deleted user by clearing users.toml while keeping the cookie.
|
|
||||||
File.write(@users_path, "")
|
|
||||||
get "/admin/"
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
assert_equal "http://example.org/admin/login", last_response.location
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def csrf(body)
|
|
||||||
body[/name="_csrf" value="([^"]+)"/, 1]
|
|
||||||
end
|
|
||||||
|
|
||||||
def login
|
|
||||||
get "/admin/login"
|
|
||||||
post "/admin/login",
|
|
||||||
"username" => "admin", "password" => @password, "_csrf" => csrf(last_response.body)
|
|
||||||
end
|
|
||||||
|
|
||||||
def login_as(username, password)
|
|
||||||
get "/admin/login"
|
|
||||||
post "/admin/login",
|
|
||||||
"username" => username, "password" => password, "_csrf" => csrf(last_response.body)
|
|
||||||
end
|
|
||||||
|
|
||||||
def logout
|
|
||||||
post "/admin/logout", "_csrf" => csrf(last_response.body)
|
|
||||||
end
|
|
||||||
|
|
||||||
def add_user(username, password, role)
|
|
||||||
get "/admin/settings"
|
|
||||||
post "/admin/settings/users",
|
|
||||||
"_csrf" => csrf(last_response.body),
|
|
||||||
"username" => username, "password" => password, "role" => role
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "test_helper"
|
|
||||||
require "tmpdir"
|
|
||||||
require "volumen/cli"
|
|
||||||
|
|
||||||
class CLITest < Minitest::Test
|
|
||||||
def test_version
|
|
||||||
assert_output("#{Volumen::VERSION}\n") do
|
|
||||||
Volumen::CLI.run(["version"])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_help
|
|
||||||
output, = capture_io { Volumen::CLI.run(["help"]) }
|
|
||||||
assert_includes output, "volumen #{Volumen::VERSION}"
|
|
||||||
assert_includes output, "Usage:"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_unknown_command
|
|
||||||
_output, err = capture_io do
|
|
||||||
assert_raises(SystemExit) { Volumen::CLI.run(["bogus"]) }
|
|
||||||
end
|
|
||||||
assert_includes err, "unknown command"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_parse_serve_defaults
|
|
||||||
cli = Volumen::CLI.new(["serve"])
|
|
||||||
options = cli.__send__(:parse_serve_options)
|
|
||||||
assert_equal Volumen::Config::DEFAULT_PATH, options[:config]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_parse_serve_overrides
|
|
||||||
cli = Volumen::CLI.new([
|
|
||||||
"serve", "--config", "/t.toml", "--host", "127.0.0.1", "--port", "9000"
|
|
||||||
])
|
|
||||||
options = cli.__send__(:parse_serve_options)
|
|
||||||
assert_equal "/t.toml", options[:config]
|
|
||||||
assert_equal "127.0.0.1", options[:host]
|
|
||||||
assert_equal 9000, options[:port]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_prepare_config_writes_template
|
|
||||||
Dir.mktmpdir do |dir|
|
|
||||||
path = File.join(dir, "config.toml")
|
|
||||||
cli = Volumen::CLI.new([])
|
|
||||||
_output, err = capture_io { cli.__send__(:prepare_config, path) }
|
|
||||||
assert_includes err, "wrote a default config"
|
|
||||||
assert_path_exists path
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_prepare_config_noops_when_present
|
|
||||||
Dir.mktmpdir do |dir|
|
|
||||||
path = File.join(dir, "config.toml")
|
|
||||||
File.write(path, "[server]\nport = 1\n")
|
|
||||||
cli = Volumen::CLI.new([])
|
|
||||||
out, err = capture_io { cli.__send__(:prepare_config, path) }
|
|
||||||
assert_empty err
|
|
||||||
assert_empty out
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "test_helper"
|
|
||||||
require "tmpdir"
|
|
||||||
|
|
||||||
class ConfigTest < Minitest::Test
|
|
||||||
MISSING = "/nonexistent/volumen.toml"
|
|
||||||
|
|
||||||
def test_defaults_when_file_missing
|
|
||||||
config = Volumen::Config.load(path: MISSING)
|
|
||||||
assert_equal 9090, config.port
|
|
||||||
assert_equal "en", config.language
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_overrides_apply
|
|
||||||
config = Volumen::Config.load(
|
|
||||||
path: MISSING,
|
|
||||||
overrides: { host: "127.0.0.1", port: 9000, content: "/srv/posts" }
|
|
||||||
)
|
|
||||||
assert_equal "127.0.0.1", config.host
|
|
||||||
assert_equal 9000, config.port
|
|
||||||
assert_equal "/srv/posts", config.content_dir
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_does_not_mutate_defaults
|
|
||||||
Volumen::Config.load(path: MISSING, overrides: { host: "10.0.0.1" })
|
|
||||||
assert_equal "0.0.0.0", Volumen::Config.load(path: MISSING).host
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_does_not_mutate_default_inner_hashes
|
|
||||||
config = Volumen::Config.load(path: MISSING)
|
|
||||||
config.site["title"] = "MUTATED"
|
|
||||||
fresh = Volumen::Config.load(path: MISSING)
|
|
||||||
assert_equal "My Blog", fresh.site["title"],
|
|
||||||
"mutating a loaded config must not poison DEFAULTS for later loads"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_loads_and_merges_file
|
|
||||||
Dir.mktmpdir do |dir|
|
|
||||||
path = File.join(dir, "config.toml")
|
|
||||||
File.write(path, "[site]\ntitle = \"Custom\"\n")
|
|
||||||
config = Volumen::Config.load(path: path)
|
|
||||||
assert_equal "Custom", config.site["title"]
|
|
||||||
assert_equal 9090, config.port
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_generate_default_writes_template
|
|
||||||
Dir.mktmpdir do |dir|
|
|
||||||
path = File.join(dir, "sub", "config.toml")
|
|
||||||
assert_equal path, Volumen::Config.generate_default(path)
|
|
||||||
assert_path_exists path
|
|
||||||
assert_equal 9090, Volumen::Config.load(path: path).port
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_generate_default_skips_existing
|
|
||||||
Dir.mktmpdir do |dir|
|
|
||||||
path = File.join(dir, "config.toml")
|
|
||||||
File.write(path, "[server]\nport = 9999\n")
|
|
||||||
assert_nil Volumen::Config.generate_default(path)
|
|
||||||
assert_equal 9999, Volumen::Config.load(path: path).port
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "test_helper"
|
|
||||||
|
|
||||||
class PostTest < Minitest::Test
|
|
||||||
def test_parse_builds_post
|
|
||||||
content = <<~POST
|
|
||||||
+++
|
|
||||||
title = "Hello"
|
|
||||||
slug = "hello"
|
|
||||||
lang = "en"
|
|
||||||
tags = ["intro", "ruby"]
|
|
||||||
date = 2026-01-15
|
|
||||||
draft = true
|
|
||||||
+++
|
|
||||||
|
|
||||||
First paragraph.
|
|
||||||
POST
|
|
||||||
post = Volumen::Post.parse(content)
|
|
||||||
assert_equal "hello", post.slug
|
|
||||||
assert_equal "Hello", post.title
|
|
||||||
assert_equal "en", post.lang
|
|
||||||
assert_equal %w[intro ruby], post.tags
|
|
||||||
assert_predicate post, :draft?
|
|
||||||
assert_equal "2026-01-15", post.date_string
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_excerpt_derived_from_body
|
|
||||||
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "# Heading\n\nThe body paragraph.")
|
|
||||||
assert_equal "The body paragraph.", post.excerpt
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_derived_excerpt_strips_html_tags
|
|
||||||
body = "<strong>Bold</strong> and <em>italic</em>."
|
|
||||||
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: body)
|
|
||||||
assert_equal "Bold and italic.", post.excerpt
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_summary_shape
|
|
||||||
post = Volumen::Post.new(metadata: { "slug" => "x", "title" => "X" }, body: "Body")
|
|
||||||
summary = post.summary
|
|
||||||
assert_equal "/api/volumen/posts/x", summary["url"]
|
|
||||||
assert_equal "X", summary["title"]
|
|
||||||
refute_predicate post, :draft?
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_detail_includes_rendered_html
|
|
||||||
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "**hi**")
|
|
||||||
assert_includes post.detail["html"], "<strong>hi</strong>"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_summary_includes_cover_and_reading_time
|
|
||||||
post = Volumen::Post.new(
|
|
||||||
metadata: { "slug" => "x", "cover" => "/media/c.png" },
|
|
||||||
body: ("word " * 250)
|
|
||||||
)
|
|
||||||
assert_equal "/media/c.png", post.summary["cover"]
|
|
||||||
assert_equal 2, post.summary["reading_time"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_reading_time_is_at_least_one
|
|
||||||
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "short")
|
|
||||||
assert_equal 1, post.reading_time
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_scheduled_post_is_not_scheduled_when_publish_at_is_past
|
|
||||||
post = Volumen::Post.new(metadata: { "publish_at" => Date.today - 1 })
|
|
||||||
refute_predicate post, :scheduled?
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_scheduled_post_is_scheduled_when_publish_at_is_future
|
|
||||||
post = Volumen::Post.new(metadata: { "publish_at" => Date.today + 7 })
|
|
||||||
assert_predicate post, :scheduled?
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_post_without_publish_at_is_not_scheduled
|
|
||||||
post = Volumen::Post.new(metadata: {})
|
|
||||||
refute_predicate post, :scheduled?
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "test_helper"
|
|
||||||
require "volumen/server"
|
|
||||||
require "rack/test"
|
|
||||||
require "tmpdir"
|
|
||||||
require "fileutils"
|
|
||||||
require "json"
|
|
||||||
|
|
||||||
class ServerTest < Minitest::Test
|
|
||||||
include Rack::Test::Methods
|
|
||||||
|
|
||||||
def app
|
|
||||||
Volumen::Server.configured(config: @config, store: @store)
|
|
||||||
end
|
|
||||||
|
|
||||||
def setup
|
|
||||||
@dir = Dir.mktmpdir
|
|
||||||
File.write(File.join(@dir, "hello.md"), <<~POST)
|
|
||||||
+++
|
|
||||||
title = "Hello"
|
|
||||||
tags = ["intro"]
|
|
||||||
+++
|
|
||||||
|
|
||||||
Hello **world**.
|
|
||||||
POST
|
|
||||||
File.write(File.join(@dir, "draft.md"), <<~POST)
|
|
||||||
+++
|
|
||||||
title = "Draft"
|
|
||||||
draft = true
|
|
||||||
+++
|
|
||||||
|
|
||||||
Secret.
|
|
||||||
POST
|
|
||||||
@config = Volumen::Config.load(path: "/nonexistent", overrides: { content: @dir })
|
|
||||||
@store = Volumen::Store.new(@dir, default_lang: "en")
|
|
||||||
end
|
|
||||||
|
|
||||||
def teardown
|
|
||||||
FileUtils.remove_entry(@dir)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_site_endpoint
|
|
||||||
get "/api/volumen/site"
|
|
||||||
assert_predicate last_response, :ok?
|
|
||||||
assert_equal "My Blog", JSON.parse(last_response.body)["title"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_posts_excludes_drafts
|
|
||||||
get "/api/volumen/posts"
|
|
||||||
data = JSON.parse(last_response.body)
|
|
||||||
assert_equal 1, data["total"]
|
|
||||||
assert_equal "hello", data["posts"].first["slug"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_post_detail_renders_html
|
|
||||||
get "/api/volumen/posts/hello"
|
|
||||||
assert_predicate last_response, :ok?
|
|
||||||
assert_includes JSON.parse(last_response.body)["html"], "<strong>world</strong>"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_post_not_found
|
|
||||||
get "/api/volumen/posts/missing"
|
|
||||||
assert_equal 404, last_response.status
|
|
||||||
assert_equal "not_found", JSON.parse(last_response.body)["error"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_tags_endpoint
|
|
||||||
get "/api/volumen/tags"
|
|
||||||
assert_equal [{ "name" => "intro", "count" => 1 }], JSON.parse(last_response.body)["tags"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_cors_header_present
|
|
||||||
get "/api/volumen/site"
|
|
||||||
header = last_response.headers["Access-Control-Allow-Origin"] ||
|
|
||||||
last_response.headers["access-control-allow-origin"]
|
|
||||||
assert_equal "*", header
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_all_langs_post_appears_in_other_languages
|
|
||||||
File.write(File.join(@dir, "global.md"), <<~POST)
|
|
||||||
+++
|
|
||||||
title = "Global"
|
|
||||||
lang = "en"
|
|
||||||
all_langs = true
|
|
||||||
+++
|
|
||||||
|
|
||||||
Everywhere.
|
|
||||||
POST
|
|
||||||
get "/api/volumen/posts?lang=cs"
|
|
||||||
slugs = JSON.parse(last_response.body)["posts"].map { |post| post["slug"] }
|
|
||||||
assert_includes slugs, "global"
|
|
||||||
refute_includes slugs, "hello"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_feed_endpoint
|
|
||||||
get "/api/volumen/feed.xml"
|
|
||||||
assert_predicate last_response, :ok?
|
|
||||||
assert_equal "application/rss+xml", last_response["Content-Type"]&.split(";")&.first
|
|
||||||
body = last_response.body
|
|
||||||
assert_includes body, "<rss version=\"2.0\">"
|
|
||||||
assert_includes body, "<title>Hello</title>"
|
|
||||||
refute_includes body, "Draft"
|
|
||||||
assert_includes body, "<language>en</language>"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_draft_post_detail_is_not_found
|
|
||||||
get "/api/volumen/posts/draft"
|
|
||||||
assert_equal 404, last_response.status
|
|
||||||
assert_equal "not_found", JSON.parse(last_response.body)["error"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_sitemap_endpoint
|
|
||||||
get "/api/volumen/sitemap.xml"
|
|
||||||
assert_predicate last_response, :ok?
|
|
||||||
assert_equal "application/xml", last_response["Content-Type"]&.split(";")&.first
|
|
||||||
body = last_response.body
|
|
||||||
assert_includes body, "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">"
|
|
||||||
assert_includes body, "<loc>https://example.com/hello</loc>"
|
|
||||||
refute_includes body, "draft"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_cors_preflight
|
|
||||||
options "/api/volumen/site"
|
|
||||||
assert_predicate last_response, :ok?
|
|
||||||
assert_equal "*", last_response.headers["Access-Control-Allow-Origin"]
|
|
||||||
assert_equal "GET, OPTIONS", last_response.headers["Access-Control-Allow-Methods"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_scheduled_post_is_hidden
|
|
||||||
future = (Date.today + 30).strftime("%Y-%m-%d")
|
|
||||||
File.write(File.join(@dir, "later.md"), <<~POST)
|
|
||||||
+++
|
|
||||||
title = "Later"
|
|
||||||
publish_at = #{future}
|
|
||||||
+++
|
|
||||||
|
|
||||||
Not yet.
|
|
||||||
POST
|
|
||||||
get "/api/volumen/posts"
|
|
||||||
slugs = JSON.parse(last_response.body)["posts"].map { |post| post["slug"] }
|
|
||||||
refute_includes slugs, "later"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_json_feed_endpoint
|
|
||||||
get "/api/volumen/feed.json"
|
|
||||||
assert_predicate last_response, :ok?
|
|
||||||
data = JSON.parse(last_response.body)
|
|
||||||
assert_equal "https://jsonfeed.org/version/1.1", data["version"]
|
|
||||||
assert_equal "My Blog", data["title"]
|
|
||||||
assert_equal 1, data["items"].length
|
|
||||||
assert_equal "Hello", data["items"].first["title"]
|
|
||||||
assert_includes data["items"].first["content_html"], "<strong>world</strong>"
|
|
||||||
refute_includes data["items"].map { |i| i["title"] }, "Draft"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_search_by_title
|
|
||||||
get "/api/volumen/posts?q=Hello"
|
|
||||||
data = JSON.parse(last_response.body)
|
|
||||||
assert_equal 1, data["total"]
|
|
||||||
assert_equal "hello", data["posts"].first["slug"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_search_by_body
|
|
||||||
get "/api/volumen/posts?q=world"
|
|
||||||
data = JSON.parse(last_response.body)
|
|
||||||
assert_equal 1, data["total"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_search_no_match
|
|
||||||
get "/api/volumen/posts?q=nonexistent"
|
|
||||||
data = JSON.parse(last_response.body)
|
|
||||||
assert_equal 0, data["total"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_search_is_case_insensitive
|
|
||||||
get "/api/volumen/posts?q=HELLO"
|
|
||||||
data = JSON.parse(last_response.body)
|
|
||||||
assert_equal 1, data["total"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "test_helper"
|
|
||||||
require "tmpdir"
|
|
||||||
require "fileutils"
|
|
||||||
|
|
||||||
class StoreTest < Minitest::Test
|
|
||||||
def setup
|
|
||||||
@dir = Dir.mktmpdir
|
|
||||||
File.write(File.join(@dir, "hello.md"), "+++\ntitle = \"Hello\"\n+++\n\nBody\n")
|
|
||||||
FileUtils.mkdir_p(File.join(@dir, "cs"))
|
|
||||||
File.write(File.join(@dir, "cs", "ahoj.md"), "+++\ntitle = \"Ahoj\"\n+++\n\nTelo\n")
|
|
||||||
end
|
|
||||||
|
|
||||||
def teardown
|
|
||||||
FileUtils.remove_entry(@dir)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_all_loads_every_post
|
|
||||||
assert_equal 2, Volumen::Store.new(@dir).all.length
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_slug_falls_back_to_filename
|
|
||||||
slugs = Volumen::Store.new(@dir).all.map(&:slug).sort
|
|
||||||
assert_equal %w[ahoj hello], slugs
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_lang_derived_from_subdirectory
|
|
||||||
store = Volumen::Store.new(@dir, default_lang: "en")
|
|
||||||
assert_equal "cs", store.find("ahoj").lang
|
|
||||||
assert_equal "en", store.find("hello").lang
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_find_returns_nil_when_missing
|
|
||||||
assert_nil Volumen::Store.new(@dir).find("nope")
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_all_langs_post_is_visible_in_any_language
|
|
||||||
File.write(File.join(@dir, "global.md"), <<~POST)
|
|
||||||
+++
|
|
||||||
title = "Global"
|
|
||||||
lang = "en"
|
|
||||||
all_langs = true
|
|
||||||
+++
|
|
||||||
|
|
||||||
Visible everywhere.
|
|
||||||
POST
|
|
||||||
store = Volumen::Store.new(@dir, default_lang: "en")
|
|
||||||
assert store.find("global", lang: "cs")
|
|
||||||
assert store.find("global", lang: "en")
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_save_rejects_path_traversal_slug
|
|
||||||
store = Volumen::Store.new(@dir)
|
|
||||||
post = Volumen::Post.new(metadata: { "slug" => "../../etc/passwd" }, body: "x")
|
|
||||||
assert_raises(ArgumentError, "slug escapes content directory") { store.save(post) }
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_save_uses_lang_subdirectory_for_non_default_language
|
|
||||||
store = Volumen::Store.new(@dir, default_lang: "en")
|
|
||||||
post = Volumen::Post.new(metadata: { "slug" => "bonjour", "lang" => "fr" }, body: "x")
|
|
||||||
store.save(post)
|
|
||||||
assert File.exist?(File.join(@dir, "fr", "bonjour.md"))
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_save_uses_root_for_default_language
|
|
||||||
store = Volumen::Store.new(@dir, default_lang: "en")
|
|
||||||
post = Volumen::Post.new(metadata: { "slug" => "hello-new", "lang" => "en" }, body: "x")
|
|
||||||
store.save(post)
|
|
||||||
assert File.exist?(File.join(@dir, "hello-new.md"))
|
|
||||||
refute File.exist?(File.join(@dir, "en", "hello-new.md"))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "minitest/autorun"
|
|
||||||
require "volumen"
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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."
|
||||||
|
)
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
"""Tests for the admin routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
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_page_omits_warning_when_users_exist(self, admin_client: TestClient) -> None:
|
||||||
|
# Regression: `users_exist` was missing from the admin template
|
||||||
|
# context, so the "No users configured" warning was shown on every
|
||||||
|
# login page render regardless of whether authentication worked.
|
||||||
|
response = admin_client.get("/admin/login")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "No users configured" not in response.text
|
||||||
|
|
||||||
|
def test_login_page_shows_warning_when_no_users(self, config, store) -> None:
|
||||||
|
# No bootstrap_hash, no users.toml → `users_exist` must be False
|
||||||
|
# so the warning block is rendered.
|
||||||
|
from volumen.app import create_app
|
||||||
|
|
||||||
|
users_path = config.users_file
|
||||||
|
# Make sure no stale users file exists.
|
||||||
|
if os.path.exists(users_path):
|
||||||
|
os.remove(users_path)
|
||||||
|
app = create_app(config, store)
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/admin/login")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "No users configured" 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:
|
||||||
|
import tomli_w
|
||||||
|
|
||||||
|
from volumen.password import hash_password as hash_pw
|
||||||
|
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Replace users file with a different admin so the logged-in
|
||||||
|
# 'admin' is truly orphaned. An empty file would resurrect the
|
||||||
|
# bootstrap admin via the password_hash fallback, so we must
|
||||||
|
# write a real users list that does not include 'admin'.
|
||||||
|
users_path = admin_config_dir / "users.toml"
|
||||||
|
payload = tomli_w.dumps(
|
||||||
|
{
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"username": "other",
|
||||||
|
"password_hash": hash_pw("other-password"),
|
||||||
|
"role": "admin",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
).encode("utf-8")
|
||||||
|
users_path.write_bytes(payload)
|
||||||
|
|
||||||
|
resp = admin_client.get("/admin/", follow_redirects=False)
|
||||||
|
assert resp.status_code in (302, 307, 303)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Concurrency tests: store/users racy write scenarios.
|
||||||
|
|
||||||
|
These tests hammer the same files from many threads to confirm that
|
||||||
|
per-target locks, fsync, and unique tempfiles do not corrupt data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from volumen.post import Post
|
||||||
|
from volumen.store import Store
|
||||||
|
from volumen.users import Users
|
||||||
|
|
||||||
|
|
||||||
|
class TestStoreRace:
|
||||||
|
def test_concurrent_saves_keep_all_data(self, tmp_content_dir: Path) -> None:
|
||||||
|
"""Many threads save distinct posts in parallel."""
|
||||||
|
store = Store(str(tmp_content_dir), default_lang="en")
|
||||||
|
|
||||||
|
def worker(idx: int) -> str:
|
||||||
|
slug = f"post-{idx}"
|
||||||
|
post = Post(metadata={"slug": slug, "title": f"Post {idx}"}, body=f"body {idx}")
|
||||||
|
store.save(post)
|
||||||
|
return slug
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=16) as pool:
|
||||||
|
futures = [pool.submit(worker, i) for i in range(50)]
|
||||||
|
for f in as_completed(futures):
|
||||||
|
assert f.result() is not None
|
||||||
|
|
||||||
|
posts = store.all()
|
||||||
|
slugs = {p.slug for p in posts}
|
||||||
|
for i in range(50):
|
||||||
|
assert f"post-{i}" in slugs
|
||||||
|
|
||||||
|
def test_concurrent_saves_to_same_target(self, tmp_content_dir: Path) -> None:
|
||||||
|
"""Many threads save to the same target path; no data lost."""
|
||||||
|
store = Store(str(tmp_content_dir), default_lang="en")
|
||||||
|
|
||||||
|
def worker(idx: int) -> None:
|
||||||
|
post = Post(
|
||||||
|
metadata={"slug": "shared", "title": f"v{idx}"},
|
||||||
|
body=f"iteration {idx}",
|
||||||
|
)
|
||||||
|
store.save(post)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||||
|
list(pool.map(worker, range(20)))
|
||||||
|
|
||||||
|
post = store.find("shared")
|
||||||
|
assert post is not None
|
||||||
|
# The file content must end with one of our iterations, no torn write.
|
||||||
|
assert post.body.startswith("iteration ")
|
||||||
|
|
||||||
|
|
||||||
|
class TestUsersRace:
|
||||||
|
def test_concurrent_adds_keep_all_users(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
|
||||||
|
def worker(idx: int) -> str:
|
||||||
|
name = f"user-{idx}"
|
||||||
|
users.add(name, "alice-test-password")
|
||||||
|
return name
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||||
|
futures = [pool.submit(worker, i) for i in range(30)]
|
||||||
|
for f in as_completed(futures):
|
||||||
|
assert f.result() is not None
|
||||||
|
|
||||||
|
assert len(users.all) == 30
|
||||||
|
|
||||||
|
|
||||||
|
class TestSnapshotCache:
|
||||||
|
def test_snapshot_changes_invalidate_cache(self, tmp_content_dir: Path) -> None:
|
||||||
|
store = Store(str(tmp_content_dir), default_lang="en")
|
||||||
|
store.all() # prime cache
|
||||||
|
|
||||||
|
(tmp_content_dir / "extra.md").write_text('+++\ntitle = "Extra"\n+++\n\nNew post.\n')
|
||||||
|
# Even without touching mtime directly, stat() picks up the new file.
|
||||||
|
posts = store.all()
|
||||||
|
slugs = {p.slug for p in posts}
|
||||||
|
assert "extra" in slugs
|
||||||
|
|
||||||
|
def test_size_only_change_invalidates(self, tmp_content_dir: Path) -> None:
|
||||||
|
"""The cache key includes size; in-place rewrites invalidate."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
store = Store(str(tmp_content_dir), default_lang="en")
|
||||||
|
# Force initial cache.
|
||||||
|
store.all()
|
||||||
|
# Overwrite an existing file with the same mtime but different size.
|
||||||
|
path = tmp_content_dir / "hello.md"
|
||||||
|
st = os.stat(path)
|
||||||
|
os.utime(path, (st.st_atime, st.st_mtime))
|
||||||
|
path.write_text(
|
||||||
|
'+++\ntitle = "Hello (resized)"\n+++\n\nNew body that is much longer than before.\n' * 5
|
||||||
|
)
|
||||||
|
# Touch back to original mtime so only size differs.
|
||||||
|
os.utime(path, (st.st_atime, st.st_mtime))
|
||||||
|
post = store.find("hello")
|
||||||
|
assert post is not None
|
||||||
|
assert "resized" in (post.title or "")
|
||||||
@@ -0,0 +1,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"
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Coverage tests for feed helpers — RSS, JSON Feed, sitemap."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from volumen.feed_helpers import (
|
||||||
|
_iso_date,
|
||||||
|
_rfc822_date,
|
||||||
|
render_json_feed,
|
||||||
|
render_rss_feed,
|
||||||
|
render_sitemap,
|
||||||
|
)
|
||||||
|
from volumen.post import Post
|
||||||
|
|
||||||
|
SITE: dict[str, Any] = {
|
||||||
|
"title": "My Blog",
|
||||||
|
"description": "A blog.",
|
||||||
|
"language": "en",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _post(
|
||||||
|
*,
|
||||||
|
title: str = "Hello",
|
||||||
|
slug: str = "hello",
|
||||||
|
post_date: str = "2026-01-15",
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
excerpt: str = "",
|
||||||
|
fediverse_creator: str = "",
|
||||||
|
body: str = "Body.",
|
||||||
|
) -> Post:
|
||||||
|
"""Build a Post with optional fields, simulating parse() without frontmatter I/O."""
|
||||||
|
metadata: dict[str, Any] = {"title": title, "slug": slug}
|
||||||
|
if post_date:
|
||||||
|
metadata["date"] = post_date
|
||||||
|
if tags is not None:
|
||||||
|
metadata["tags"] = tags
|
||||||
|
if excerpt:
|
||||||
|
metadata["excerpt"] = excerpt
|
||||||
|
if fediverse_creator:
|
||||||
|
metadata["fediverse_creator"] = fediverse_creator
|
||||||
|
return Post(metadata=metadata, body=body)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderRSSFeed:
|
||||||
|
def test_basic_two_posts(self) -> None:
|
||||||
|
posts = [
|
||||||
|
_post(slug="a", title="Alpha"),
|
||||||
|
_post(slug="b", title="Beta"),
|
||||||
|
]
|
||||||
|
xml = render_rss_feed(posts, SITE, "https://example.com")
|
||||||
|
assert '<?xml version="1.0"' in xml
|
||||||
|
assert "<rss version=" in xml
|
||||||
|
assert "<title>My Blog</title>" in xml
|
||||||
|
assert "<link>https://example.com</link>" in xml
|
||||||
|
assert "<description>A blog.</description>" in xml
|
||||||
|
assert "<language>en</language>" in xml
|
||||||
|
assert "<title>Alpha</title>" in xml
|
||||||
|
assert "<title>Beta</title>" in xml
|
||||||
|
assert "<link>https://example.com/a</link>" in xml
|
||||||
|
assert "<link>https://example.com/b</link>" in xml
|
||||||
|
assert "pubDate" in xml
|
||||||
|
|
||||||
|
def test_truncates_to_20_items(self) -> None:
|
||||||
|
posts = [_post(slug=f"p{i:02d}") for i in range(25)]
|
||||||
|
xml = render_rss_feed(posts, SITE, "https://x")
|
||||||
|
assert xml.count("<item>") == 20
|
||||||
|
|
||||||
|
def test_escapes_special_characters_in_title(self) -> None:
|
||||||
|
xml = render_rss_feed(
|
||||||
|
[_post(title="A & B <C>", slug="x")],
|
||||||
|
SITE,
|
||||||
|
"https://x",
|
||||||
|
)
|
||||||
|
assert "A & B <C>" in xml
|
||||||
|
|
||||||
|
def test_no_pub_date_when_date_missing(self) -> None:
|
||||||
|
# No `date` in metadata → date_string is None → no <pubDate> in the item.
|
||||||
|
xml = render_rss_feed([_post(post_date="")], SITE, "https://x")
|
||||||
|
item_block = xml.split("<item>")[1].split("</item>")[0]
|
||||||
|
assert "<pubDate>" not in item_block
|
||||||
|
|
||||||
|
def test_dc_creator_present_when_fediverse_set(self) -> None:
|
||||||
|
xml = render_rss_feed(
|
||||||
|
[_post(fediverse_creator="@alice@example.com")],
|
||||||
|
SITE,
|
||||||
|
"https://x",
|
||||||
|
)
|
||||||
|
assert "<dc:creator>@alice@example.com</dc:creator>" in xml
|
||||||
|
|
||||||
|
def test_no_dc_creator_when_fediverse_empty(self) -> None:
|
||||||
|
xml = render_rss_feed([_post()], SITE, "https://x")
|
||||||
|
assert "<dc:creator>" not in xml
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderJSONFeed:
|
||||||
|
def test_basic_shape(self) -> None:
|
||||||
|
feed = render_json_feed([_post(slug="a", tags=["intro"])], SITE, "https://x")
|
||||||
|
assert feed["version"] == "https://jsonfeed.org/version/1.1"
|
||||||
|
assert feed["title"] == "My Blog"
|
||||||
|
assert feed["home_page_url"] == "https://x"
|
||||||
|
assert feed["feed_url"] == "https://x/api/volumen/feed.json"
|
||||||
|
assert feed["language"] == "en"
|
||||||
|
assert feed["items"][0]["id"] == "https://x/a"
|
||||||
|
assert feed["items"][0]["url"] == "https://x/a"
|
||||||
|
assert feed["items"][0]["title"] == "Hello"
|
||||||
|
assert feed["items"][0]["tags"] == ["intro"]
|
||||||
|
|
||||||
|
def test_filters_empty_authors_and_tags(self) -> None:
|
||||||
|
# Default _post has no fediverse handle, no tags. ``summary`` is also
|
||||||
|
# present (it falls back to the derived excerpt from ``body``).
|
||||||
|
feed = render_json_feed([_post()], SITE, "https://x")
|
||||||
|
item = feed["items"][0]
|
||||||
|
assert "authors" not in item
|
||||||
|
assert "tags" not in item
|
||||||
|
|
||||||
|
def test_author_from_site_wide_fediverse(self) -> None:
|
||||||
|
site_with_creator = {**SITE, "fediverse_creator": "@site@example.com"}
|
||||||
|
feed = render_json_feed([_post()], site_with_creator, "https://x")
|
||||||
|
assert feed["items"][0]["authors"] == [{"name": "@site@example.com"}]
|
||||||
|
|
||||||
|
def test_author_from_post_overrides_site(self) -> None:
|
||||||
|
site_with_creator = {**SITE, "fediverse_creator": "@site@example.com"}
|
||||||
|
feed = render_json_feed(
|
||||||
|
[_post(fediverse_creator="@post@example.com")],
|
||||||
|
site_with_creator,
|
||||||
|
"https://x",
|
||||||
|
)
|
||||||
|
assert feed["items"][0]["authors"] == [{"name": "@post@example.com"}]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderSitemap:
|
||||||
|
def test_basic_urlset(self) -> None:
|
||||||
|
xml = render_sitemap([_post(slug="x"), _post(slug="y")], "https://x")
|
||||||
|
assert "<urlset" in xml
|
||||||
|
assert "<loc>https://x/x</loc>" in xml
|
||||||
|
assert "<loc>https://x/y</loc>" in xml
|
||||||
|
assert "</urlset>" in xml
|
||||||
|
|
||||||
|
def test_lastmod_when_date_present(self) -> None:
|
||||||
|
xml = render_sitemap([_post()], "https://x")
|
||||||
|
assert "<lastmod>2026-01-15</lastmod>" in xml
|
||||||
|
|
||||||
|
def test_no_lastmod_when_date_empty(self) -> None:
|
||||||
|
# When post_date="" is passed, _post() omits "date" → date_string is None
|
||||||
|
# → no <lastmod> rendered.
|
||||||
|
xml = render_sitemap([_post(post_date="")], "https://x")
|
||||||
|
url_block = xml.split("<url>")[1].split("</url>")[0]
|
||||||
|
assert "<lastmod>" not in url_block
|
||||||
|
|
||||||
|
|
||||||
|
class TestDateHelpers:
|
||||||
|
def test_rfc822_from_date_object(self) -> None:
|
||||||
|
out = _rfc822_date(date(2026, 1, 15))
|
||||||
|
assert "2026" in out and "GMT" in out
|
||||||
|
|
||||||
|
def test_rfc822_from_iso_string(self) -> None:
|
||||||
|
out = _rfc822_date("2026-01-15")
|
||||||
|
assert "2026" in out
|
||||||
|
assert "GMT" in out
|
||||||
|
|
||||||
|
def test_rfc822_invalid_string_returns_as_is(self) -> None:
|
||||||
|
assert _rfc822_date("not-a-date") == "not-a-date"
|
||||||
|
|
||||||
|
def test_iso_none(self) -> None:
|
||||||
|
assert _iso_date(None) is None
|
||||||
|
|
||||||
|
def test_iso_date_object(self) -> None:
|
||||||
|
assert _iso_date(date(2026, 1, 15)) == "2026-01-15"
|
||||||
|
|
||||||
|
def test_iso_valid_string_passes_through(self) -> None:
|
||||||
|
assert _iso_date("2026-01-15") == "2026-01-15"
|
||||||
|
|
||||||
|
def test_iso_invalid_string_returns_as_is(self) -> None:
|
||||||
|
assert _iso_date("not-a-date") == "not-a-date"
|
||||||
@@ -0,0 +1,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"
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
"""Tests for the first-run bootstrap installer (system and per-user)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from volumen import installer
|
||||||
|
from volumen.cli import main
|
||||||
|
from volumen.config import Config
|
||||||
|
|
||||||
|
SYSTEM_DIR = Path("/etc/volumen")
|
||||||
|
SYSTEM_USERS_FILE = Path("/var/lib/volumen/users.toml")
|
||||||
|
|
||||||
|
|
||||||
|
# --- 1. system_install produces the expected config substitutions -----------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSystemInstall:
|
||||||
|
def test_writes_config_with_expected_substitutions(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
content_dir = tmp_path / "posts"
|
||||||
|
users_file = tmp_path / "users.toml"
|
||||||
|
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
|
||||||
|
result = installer.system_install(
|
||||||
|
config_path=config_path,
|
||||||
|
content_dir=content_dir,
|
||||||
|
users_file=users_file,
|
||||||
|
service_user="volumen",
|
||||||
|
admin_password="TopSecret!",
|
||||||
|
)
|
||||||
|
assert result.config_path == config_path
|
||||||
|
text = config_path.read_text()
|
||||||
|
assert re.search(r'^host = "::1"', text, re.MULTILINE) is not None
|
||||||
|
assert re.search(r'^env = "production"', text, re.MULTILINE) is not None
|
||||||
|
assert re.search(r"^trust_proxy = true", text, re.MULTILINE) is not None
|
||||||
|
assert re.search(r"^cookie_secure = true", text, re.MULTILINE) is not None
|
||||||
|
assert f'content_dir = "{content_dir}"' in text
|
||||||
|
assert f'users_file = "{users_file}"' in text
|
||||||
|
assert content_dir.is_dir()
|
||||||
|
assert (content_dir / "media").is_dir()
|
||||||
|
|
||||||
|
def test_writes_valid_scrypt_password_hash(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
|
||||||
|
result = installer.system_install(
|
||||||
|
config_path=config_path,
|
||||||
|
content_dir=tmp_path / "posts",
|
||||||
|
users_file=tmp_path / "users.toml",
|
||||||
|
admin_password="TopSecret!",
|
||||||
|
)
|
||||||
|
text = config_path.read_text()
|
||||||
|
m = re.search(r'^password_hash = "(.+)"', text, re.MULTILINE)
|
||||||
|
assert m is not None
|
||||||
|
encoded = m.group(1)
|
||||||
|
assert encoded == result.password_hash
|
||||||
|
assert encoded.startswith("scrypt$16384$8$1$")
|
||||||
|
# Verify against password.
|
||||||
|
from volumen.password import verify_password
|
||||||
|
|
||||||
|
assert verify_password("TopSecret!", encoded) is True
|
||||||
|
|
||||||
|
def test_writes_long_hex_session_key(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
|
||||||
|
result = installer.system_install(
|
||||||
|
config_path=config_path,
|
||||||
|
content_dir=tmp_path / "posts",
|
||||||
|
users_file=tmp_path / "users.toml",
|
||||||
|
admin_password="whatever",
|
||||||
|
)
|
||||||
|
text = config_path.read_text()
|
||||||
|
m = re.search(r'^session_key = "([0-9a-f]+)"', text, re.MULTILINE)
|
||||||
|
assert m is not None
|
||||||
|
assert len(m.group(1)) >= 128 # 64 bytes hex-encoded
|
||||||
|
assert m.group(1) == result.session_key
|
||||||
|
|
||||||
|
def test_config_round_trips_through_config_load(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
posts = tmp_path / "posts"
|
||||||
|
users_file = tmp_path / "users.toml"
|
||||||
|
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
|
||||||
|
installer.system_install(
|
||||||
|
config_path=config_path,
|
||||||
|
content_dir=posts,
|
||||||
|
users_file=users_file,
|
||||||
|
admin_password="TopSecret!",
|
||||||
|
)
|
||||||
|
loaded = Config.load(path=str(config_path))
|
||||||
|
assert loaded.content_dir == str(posts)
|
||||||
|
assert loaded.users_file == str(users_file)
|
||||||
|
assert loaded.host == "::1"
|
||||||
|
assert loaded.env == "production"
|
||||||
|
assert loaded.trust_proxy is True
|
||||||
|
assert loaded.cookie_secure is True
|
||||||
|
assert loaded.admin["password_hash"].startswith("scrypt$")
|
||||||
|
assert len(loaded.admin["session_key"]) >= 128
|
||||||
|
|
||||||
|
def test_existing_config_is_not_overwritten_without_force(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
original = 'host = "1.2.3.4"\nport = 1234\n'
|
||||||
|
config_path.write_text(original)
|
||||||
|
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
|
||||||
|
result = installer.system_install(
|
||||||
|
config_path=config_path,
|
||||||
|
content_dir=tmp_path / "posts",
|
||||||
|
users_file=tmp_path / "users.toml",
|
||||||
|
admin_password="TopSecret!",
|
||||||
|
)
|
||||||
|
# File unchanged.
|
||||||
|
assert config_path.read_text() == original
|
||||||
|
# Result carries a sentinel message and no fresh password_hash.
|
||||||
|
assert any("leaving untouched" in m for m in result.messages)
|
||||||
|
assert result.password_hash == ""
|
||||||
|
|
||||||
|
def test_force_makes_backup_with_original_content(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
original = 'host = "1.2.3.4"\nport = 1234\n'
|
||||||
|
config_path.write_text(original)
|
||||||
|
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
|
||||||
|
installer.system_install(
|
||||||
|
config_path=config_path,
|
||||||
|
content_dir=tmp_path / "posts",
|
||||||
|
users_file=tmp_path / "users.toml",
|
||||||
|
admin_password="TopSecret!",
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
backups = list(tmp_path.glob("config.toml.bak.*.toml"))
|
||||||
|
assert len(backups) == 1
|
||||||
|
assert backups[0].read_text() == original
|
||||||
|
# New content must round-trip.
|
||||||
|
loaded = Config.load(path=str(config_path))
|
||||||
|
assert loaded.host == "::1"
|
||||||
|
|
||||||
|
def test_ensure_user_only_runs_when_user_missing(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
getpwnam_log: list[str] = []
|
||||||
|
run_log: list[list[str]] = []
|
||||||
|
|
||||||
|
def fake_getpwnam(name: str) -> object:
|
||||||
|
getpwnam_log.append(name)
|
||||||
|
raise KeyError(name)
|
||||||
|
|
||||||
|
def fake_run(cmd: list[str], *args: object, **kwargs: object) -> object:
|
||||||
|
run_log.append(list(cmd))
|
||||||
|
return object()
|
||||||
|
|
||||||
|
# Redirect the home-dir creation away from /var/lib so the test
|
||||||
|
# can run unprivileged without hitting real root.
|
||||||
|
fake_home = tmp_path / "fakehome"
|
||||||
|
monkeypatch.setattr(installer, "DEFAULT_SERVICE_HOME", fake_home)
|
||||||
|
with monkeypatch.context() as m:
|
||||||
|
m.setattr(installer.pwd, "getpwnam", fake_getpwnam)
|
||||||
|
m.setattr(installer, "_is_root", lambda: True)
|
||||||
|
m.setattr(installer.subprocess, "run", fake_run)
|
||||||
|
created = installer._ensure_user("volumen")
|
||||||
|
assert created is True
|
||||||
|
assert getpwnam_log == ["volumen"]
|
||||||
|
# useradd should fire exactly once.
|
||||||
|
assert any(cmd[:1] == ["useradd"] for cmd in run_log)
|
||||||
|
# Home dir was created at our redirected location.
|
||||||
|
assert fake_home.is_dir()
|
||||||
|
|
||||||
|
# If the user exists, no useradd is run.
|
||||||
|
getpwnam_log.clear()
|
||||||
|
run_log.clear()
|
||||||
|
|
||||||
|
class _Pwnam:
|
||||||
|
pw_uid = 1234
|
||||||
|
|
||||||
|
with monkeypatch.context() as m:
|
||||||
|
m.setattr(installer.pwd, "getpwnam", lambda name: _Pwnam())
|
||||||
|
m.setattr(installer.subprocess, "run", fake_run)
|
||||||
|
created = installer._ensure_user("volumen")
|
||||||
|
assert created is False
|
||||||
|
assert run_log == []
|
||||||
|
|
||||||
|
# If not root, even a missing user doesn't trigger useradd.
|
||||||
|
getpwnam_log.clear()
|
||||||
|
run_log.clear()
|
||||||
|
with monkeypatch.context() as m:
|
||||||
|
m.setattr(installer.pwd, "getpwnam", fake_getpwnam)
|
||||||
|
m.setattr(installer, "_is_root", lambda: False)
|
||||||
|
m.setattr(installer.subprocess, "run", fake_run)
|
||||||
|
created = installer._ensure_user("volumen")
|
||||||
|
assert created is False
|
||||||
|
assert run_log == []
|
||||||
|
|
||||||
|
def test_chown_chmod_skipped_silently_when_not_root(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
posts = tmp_path / "posts"
|
||||||
|
users_file = tmp_path / "users.toml"
|
||||||
|
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
|
||||||
|
# Pretend we're not root — the chown/chmod helpers must no-op rather
|
||||||
|
# than raise, and no subprocess is spawned for useradd/systemctl.
|
||||||
|
monkeypatch.setattr(installer, "_is_root", lambda: False)
|
||||||
|
run_log: list[list[str]] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
installer.subprocess,
|
||||||
|
"run",
|
||||||
|
lambda cmd, *a, **kw: run_log.append(list(cmd)) or object(),
|
||||||
|
)
|
||||||
|
result = installer.system_install(
|
||||||
|
config_path=config_path,
|
||||||
|
content_dir=posts,
|
||||||
|
users_file=users_file,
|
||||||
|
admin_password="TopSecret!",
|
||||||
|
)
|
||||||
|
assert config_path.is_file()
|
||||||
|
assert posts.is_dir()
|
||||||
|
# No subprocess calls touched the system at all.
|
||||||
|
assert run_log == []
|
||||||
|
# Chmod was applied as a no-op (mode unchanged).
|
||||||
|
assert oct(config_path.stat().st_mode & 0o777) != oct(0o600) # default
|
||||||
|
# Result is a valid InstallResult.
|
||||||
|
assert result.config_path == config_path
|
||||||
|
|
||||||
|
def test_install_service_writes_systemd_unit_and_calls_systemctl(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
content_dir = tmp_path / "posts"
|
||||||
|
users_file = tmp_path / "users.toml"
|
||||||
|
systemd_unit = tmp_path / "volumen.service"
|
||||||
|
cmd_log: list[list[str]] = []
|
||||||
|
|
||||||
|
class _Pwnam:
|
||||||
|
pw_uid = 1234
|
||||||
|
|
||||||
|
class _Grnam:
|
||||||
|
gr_gid = 1234
|
||||||
|
|
||||||
|
def fake_getpwnam(name: str) -> object:
|
||||||
|
return _Pwnam()
|
||||||
|
|
||||||
|
def fake_getgrnam(name: str) -> object:
|
||||||
|
return _Grnam()
|
||||||
|
|
||||||
|
def fake_which(name: str) -> str | None:
|
||||||
|
return "/usr/local/bin/volumen" if name == "volumen" else None
|
||||||
|
|
||||||
|
def fake_run(cmd: list[str], *args: object, **kwargs: object) -> object:
|
||||||
|
cmd_log.append(list(cmd))
|
||||||
|
return object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
|
||||||
|
monkeypatch.setattr(installer, "_is_root", lambda: True)
|
||||||
|
monkeypatch.setattr(installer.pwd, "getpwnam", fake_getpwnam)
|
||||||
|
monkeypatch.setattr(installer.grp, "getgrnam", fake_getgrnam)
|
||||||
|
monkeypatch.setattr(installer.shutil, "which", fake_which)
|
||||||
|
monkeypatch.setattr(installer.subprocess, "run", fake_run)
|
||||||
|
monkeypatch.setattr(installer, "SYSTEMD_UNIT_PATH", systemd_unit)
|
||||||
|
result = installer.system_install(
|
||||||
|
config_path=config_path,
|
||||||
|
content_dir=content_dir,
|
||||||
|
users_file=users_file,
|
||||||
|
admin_password="TopSecret!",
|
||||||
|
install_service=True,
|
||||||
|
enable_service=True,
|
||||||
|
)
|
||||||
|
assert systemd_unit.is_file()
|
||||||
|
unit_text = systemd_unit.read_text()
|
||||||
|
assert "/usr/local/bin/volumen" in unit_text
|
||||||
|
assert "serve --config" in unit_text
|
||||||
|
assert f"--config {config_path}" in unit_text
|
||||||
|
assert f"--content {content_dir}" in unit_text
|
||||||
|
assert "User=volumen" in unit_text
|
||||||
|
assert "NoNewPrivileges=true" in unit_text
|
||||||
|
assert "ProtectSystem=strict" in unit_text
|
||||||
|
assert result.systemd_unit_path == systemd_unit
|
||||||
|
assert ["systemctl", "daemon-reload"] in cmd_log
|
||||||
|
assert ["systemctl", "enable", "--now", "volumen"] in cmd_log
|
||||||
|
|
||||||
|
|
||||||
|
# --- 2. user_install ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserInstall:
|
||||||
|
def test_writes_config_under_user_path(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
content_dir = tmp_path / "posts"
|
||||||
|
users_file = tmp_path / "users.toml"
|
||||||
|
monkeypatch.setattr(installer, "_ensure_user", lambda name: False)
|
||||||
|
result = installer.user_install(
|
||||||
|
config_path=config_path,
|
||||||
|
content_dir=content_dir,
|
||||||
|
users_file=users_file,
|
||||||
|
admin_password="TopSecret!",
|
||||||
|
)
|
||||||
|
assert result.service_user is None
|
||||||
|
assert result.systemd_unit_path is None
|
||||||
|
text = config_path.read_text()
|
||||||
|
assert re.search(r'^env = "development"', text, re.MULTILINE) is not None
|
||||||
|
assert re.search(r"^cookie_secure = false", text, re.MULTILINE) is not None
|
||||||
|
assert config_path.exists()
|
||||||
|
assert content_dir.is_dir()
|
||||||
|
|
||||||
|
def test_rejects_install_service(self, tmp_path: Path) -> None:
|
||||||
|
with pytest.raises(installer.InstallerError):
|
||||||
|
installer.user_install(
|
||||||
|
config_path=tmp_path / "config.toml",
|
||||||
|
content_dir=tmp_path / "posts",
|
||||||
|
users_file=tmp_path / "users.toml",
|
||||||
|
admin_password="x",
|
||||||
|
install_service=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- 3. _mutate_config_text --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMutateConfigText:
|
||||||
|
def test_substitutes_six_keys_without_disturbing_comments(self) -> None:
|
||||||
|
text = (
|
||||||
|
'host = "::"\n'
|
||||||
|
'env = "development"\n'
|
||||||
|
"trust_proxy = false\n"
|
||||||
|
"cookie_secure = false\n"
|
||||||
|
'content_dir = "/old"\n'
|
||||||
|
'users_file = "/old/users.toml"\n'
|
||||||
|
'password_hash = ""\n'
|
||||||
|
'session_key = ""\n'
|
||||||
|
)
|
||||||
|
out = installer._mutate_config_text(
|
||||||
|
text,
|
||||||
|
host="::1",
|
||||||
|
env="production",
|
||||||
|
trust_proxy=True,
|
||||||
|
cookie_secure=True,
|
||||||
|
content_dir="/new",
|
||||||
|
users_file="/new/users.toml",
|
||||||
|
)
|
||||||
|
assert 'host = "::1"' in out
|
||||||
|
assert 'env = "production"' in out
|
||||||
|
assert "trust_proxy = true" in out
|
||||||
|
assert "cookie_secure = true" in out
|
||||||
|
assert 'content_dir = "/new"' in out
|
||||||
|
assert 'users_file = "/new/users.toml"' in out
|
||||||
|
|
||||||
|
def test_injects_password_hash_and_session_key(self) -> None:
|
||||||
|
text = 'host = "::"\npassword_hash = ""\nsession_key = ""\n'
|
||||||
|
out = installer._mutate_config_text(
|
||||||
|
text,
|
||||||
|
password_hash="scrypt$abc",
|
||||||
|
session_key="deadbeef" * 16,
|
||||||
|
)
|
||||||
|
assert 'password_hash = "scrypt$abc"' in out
|
||||||
|
assert 'session_key = "deadbeef"' * 4 not in out # sentinel
|
||||||
|
assert 'session_key = "' in out
|
||||||
|
|
||||||
|
|
||||||
|
# --- 4. CLI status -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatus:
|
||||||
|
def test_status_json_reports_all_fields(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
from volumen import installer as inst_mod
|
||||||
|
|
||||||
|
# Pre-create a config so the inspector finds something.
|
||||||
|
posts = tmp_path / "posts"
|
||||||
|
users_file = tmp_path / "users.toml"
|
||||||
|
posts.mkdir()
|
||||||
|
users_file.touch()
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
monkeypatch.setattr(inst_mod, "_ensure_user", lambda name: False)
|
||||||
|
installer.system_install(
|
||||||
|
config_path=config_path,
|
||||||
|
content_dir=posts,
|
||||||
|
users_file=users_file,
|
||||||
|
admin_password="TopSecret!",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(inst_mod, "SYSTEMD_UNIT_PATH", tmp_path / "volumen.service")
|
||||||
|
# Force systemctl to a known "unknown" branch.
|
||||||
|
monkeypatch.setattr(inst_mod.shutil, "which", lambda name: None)
|
||||||
|
# Inject a users.toml entry for "admin".
|
||||||
|
users_file.write_text(
|
||||||
|
'[[users]]\nusername = "admin"\npassword_hash = "scrypt$x"\nrole = "admin"\n'
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
mock.patch.object(
|
||||||
|
sys,
|
||||||
|
"argv",
|
||||||
|
[
|
||||||
|
"volumen",
|
||||||
|
"status",
|
||||||
|
"--config",
|
||||||
|
str(config_path),
|
||||||
|
"--data",
|
||||||
|
str(posts),
|
||||||
|
"--users-file",
|
||||||
|
str(users_file),
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
contextlib.suppress(SystemExit),
|
||||||
|
):
|
||||||
|
main()
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert captured.out.strip().startswith("{")
|
||||||
|
info = json.loads(captured.out)
|
||||||
|
for key in (
|
||||||
|
"config_path",
|
||||||
|
"data_dir",
|
||||||
|
"users_file",
|
||||||
|
"config_exists",
|
||||||
|
"data_dir_exists",
|
||||||
|
"users_file_exists",
|
||||||
|
"password_hash_set",
|
||||||
|
"session_key_ok",
|
||||||
|
"systemd_unit_installed",
|
||||||
|
"service_active",
|
||||||
|
"admin_user_present",
|
||||||
|
"issues",
|
||||||
|
):
|
||||||
|
assert key in info
|
||||||
|
assert info["config_exists"] is True
|
||||||
|
assert info["password_hash_set"] is True
|
||||||
|
assert info["session_key_ok"] is True
|
||||||
|
assert info["admin_user_present"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestCLISystemdLocalRefuse:
|
||||||
|
def test_local_with_systemd_exits_nonzero(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
exit_code = 0
|
||||||
|
with mock.patch.object(
|
||||||
|
sys,
|
||||||
|
"argv",
|
||||||
|
["volumen", "init", "--local", "--systemd", "--admin-password", "x"],
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except SystemExit as exc:
|
||||||
|
exit_code = exc.code if isinstance(exc.code, int) else 1
|
||||||
|
assert exit_code == 2
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "--systemd" in captured.err
|
||||||
@@ -0,0 +1,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('')
|
||||||
|
assert "<figure>" in result
|
||||||
|
|
||||||
|
def test_image_without_title_no_wrapping(self) -> None:
|
||||||
|
result = render("")
|
||||||
|
assert "<figure>" not in result
|
||||||
|
|
||||||
|
def test_figure_includes_figcaption(self) -> None:
|
||||||
|
result = render('')
|
||||||
|
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
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Import smoke tests for the split application modules."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tomllib
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Distribution wheel — make sure admin assets are packaged.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def test_pyproject_declares_web_package_data() -> None:
|
||||||
|
"""Regression: admin panel returned 500 in production because Jinja
|
||||||
|
templates and SVG icons were not declared as package data and the
|
||||||
|
wheel dropped them. Catch accidental removal in pyproject.toml."""
|
||||||
|
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text())
|
||||||
|
package_data = (
|
||||||
|
data.get("tool", {}).get("setuptools", {}).get("package-data", {}).get("volumen", [])
|
||||||
|
)
|
||||||
|
assert any("web/templates" in p and "*.jinja" in p for p in package_data), (
|
||||||
|
f"volumen package-data must include web/templates/*.jinja, got: {package_data}"
|
||||||
|
)
|
||||||
|
assert any("web/static" in p for p in package_data), (
|
||||||
|
f"volumen package-data must include web/static/*.svg, got: {package_data}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_built_wheel_contains_web_templates_and_static(tmp_path: Path) -> None:
|
||||||
|
"""Stronger version of the regression: build a wheel with ``uv build``
|
||||||
|
and assert the admin assets are inside the resulting archive. Skipped
|
||||||
|
when ``uv`` is not on PATH (e.g. minimal CI image)."""
|
||||||
|
uv = shutil.which("uv")
|
||||||
|
if uv is None:
|
||||||
|
pytest.skip("uv not on PATH — cannot build the wheel")
|
||||||
|
|
||||||
|
out_dir = tmp_path / "wheels"
|
||||||
|
out_dir.mkdir()
|
||||||
|
result = subprocess.run(
|
||||||
|
[uv, "build", "--wheel", "--out-dir", str(out_dir)],
|
||||||
|
cwd=str(REPO_ROOT),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"uv build failed (exit {result.returncode}):\n"
|
||||||
|
f"stdout:\n{result.stdout}\n"
|
||||||
|
f"stderr:\n{result.stderr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
wheels = sorted(out_dir.glob("*.whl"))
|
||||||
|
assert len(wheels) == 1, f"Expected exactly one wheel, got {wheels}"
|
||||||
|
with zipfile.ZipFile(wheels[0]) as zf:
|
||||||
|
names = zf.namelist()
|
||||||
|
|
||||||
|
templates = [n for n in names if n.startswith("volumen/web/templates/")]
|
||||||
|
statics = [n for n in names if n.startswith("volumen/web/static/")]
|
||||||
|
assert templates, (
|
||||||
|
f"web/templates missing from wheel {wheels[0].name}; "
|
||||||
|
f"admin panel would 500 on first request. Members: {names}"
|
||||||
|
)
|
||||||
|
assert statics, (
|
||||||
|
f"web/static missing from wheel {wheels[0].name}; "
|
||||||
|
f"admin panel icons would 404. Members: {names}"
|
||||||
|
)
|
||||||
|
assert any(n.endswith(".jinja") for n in templates), templates
|
||||||
|
assert any(n.endswith(".svg") for n in statics), statics
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Coverage tests for ``Post`` properties — non-happy-path branches."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from volumen.post import Post
|
||||||
|
|
||||||
|
|
||||||
|
def _post(metadata: dict[str, object] | None = None, body: str = "") -> Post:
|
||||||
|
return Post(metadata=metadata, body=body)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTagsProperty:
|
||||||
|
def test_tags_default_is_empty(self) -> None:
|
||||||
|
assert _post().tags == []
|
||||||
|
|
||||||
|
def test_tags_list_of_strings(self) -> None:
|
||||||
|
assert _post({"tags": ["python", "blog"]}).tags == ["python", "blog"]
|
||||||
|
|
||||||
|
def test_tags_non_list_coerces_to_empty(self) -> None:
|
||||||
|
# When ``tags`` is set to something that isn't a list, the property
|
||||||
|
# returns an empty list rather than raising.
|
||||||
|
assert _post({"tags": "python"}).tags == []
|
||||||
|
assert _post({"tags": 42}).tags == []
|
||||||
|
assert _post({"tags": None}).tags == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranslationsProperty:
|
||||||
|
def test_default_is_empty(self) -> None:
|
||||||
|
assert _post().translations == {}
|
||||||
|
|
||||||
|
def test_dict_coerces_values_to_strings(self) -> None:
|
||||||
|
out = _post({"translations": {"cs": "cs-slug", "de": "de-slug"}}).translations
|
||||||
|
assert out == {"cs": "cs-slug", "de": "de-slug"}
|
||||||
|
|
||||||
|
def test_non_dict_returns_empty(self) -> None:
|
||||||
|
assert _post({"translations": ["cs", "de"]}).translations == {}
|
||||||
|
assert _post({"translations": "cs"}).translations == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestFediverseCreator:
|
||||||
|
def test_none(self) -> None:
|
||||||
|
assert _post().fediverse_creator is None
|
||||||
|
|
||||||
|
def test_empty_string_is_none(self) -> None:
|
||||||
|
assert _post({"fediverse_creator": ""}).fediverse_creator is None
|
||||||
|
assert _post({"fediverse_creator": " "}).fediverse_creator is None
|
||||||
|
|
||||||
|
def test_valid_fediverse_creator_regex(self) -> None:
|
||||||
|
p = _post({"fediverse_creator": "@alice@example.com"})
|
||||||
|
assert p.fediverse_creator == "@alice@example.com"
|
||||||
|
assert p.valid_fediverse_creator is True
|
||||||
|
|
||||||
|
def test_invalid_fediverse_creator(self) -> None:
|
||||||
|
# No `@host` part → regex doesn't match.
|
||||||
|
p = _post({"fediverse_creator": "alice"})
|
||||||
|
assert p.fediverse_creator == "alice"
|
||||||
|
assert p.valid_fediverse_creator is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestPublishAt:
|
||||||
|
def test_none(self) -> None:
|
||||||
|
assert _post().publish_at is None
|
||||||
|
|
||||||
|
def test_date_object(self) -> None:
|
||||||
|
assert _post({"publish_at": date(2026, 6, 1)}).publish_at == date(2026, 6, 1)
|
||||||
|
|
||||||
|
def test_iso_string(self) -> None:
|
||||||
|
assert _post({"publish_at": "2026-06-01"}).publish_at == date(2026, 6, 1)
|
||||||
|
|
||||||
|
def test_invalid_string_returns_none(self) -> None:
|
||||||
|
assert _post({"publish_at": "not-a-date"}).publish_at is None
|
||||||
|
assert _post({"publish_at": 12345}).publish_at is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduled:
|
||||||
|
def test_past_date_is_not_scheduled(self) -> None:
|
||||||
|
past = (date.today() - timedelta(days=1)).isoformat()
|
||||||
|
assert _post({"publish_at": past}).scheduled is False
|
||||||
|
|
||||||
|
def test_future_date_is_scheduled(self) -> None:
|
||||||
|
future = (date.today() + timedelta(days=1)).isoformat()
|
||||||
|
assert _post({"publish_at": future}).scheduled is True
|
||||||
|
|
||||||
|
def test_no_publish_at(self) -> None:
|
||||||
|
assert _post().scheduled is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadingTime:
|
||||||
|
def test_empty_body_minimum_one_minute(self) -> None:
|
||||||
|
assert _post(body="").reading_time == 1
|
||||||
|
|
||||||
|
def test_short_body(self) -> None:
|
||||||
|
# 5 words → ceil(5/200) = 1.
|
||||||
|
assert _post(body="one two three four five").reading_time == 1
|
||||||
|
|
||||||
|
def test_long_body(self) -> None:
|
||||||
|
body = " ".join(f"word{i}" for i in range(500))
|
||||||
|
# 500 words → ceil(500/200) = 3.
|
||||||
|
assert _post(body=body).reading_time == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestExcerpt:
|
||||||
|
def test_explicit_excerpt(self) -> None:
|
||||||
|
assert _post({"excerpt": "Short summary"}).excerpt == "Short summary"
|
||||||
|
|
||||||
|
def test_derived_from_body(self) -> None:
|
||||||
|
# No explicit excerpt → derived from first non-heading paragraph.
|
||||||
|
p = _post(body="Hello **world**.")
|
||||||
|
assert "Hello" in p.excerpt
|
||||||
|
assert "<" not in p.excerpt # HTML stripped
|
||||||
|
assert "*" not in p.excerpt # Markdown stripped
|
||||||
|
|
||||||
|
def test_derived_empty_when_only_headings(self) -> None:
|
||||||
|
assert _post(body="# Heading\n\n## Subheading").excerpt == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestSummaryAndDetail:
|
||||||
|
def test_summary_includes_all_known_fields(self) -> None:
|
||||||
|
p = _post(
|
||||||
|
{
|
||||||
|
"title": "T",
|
||||||
|
"slug": "s",
|
||||||
|
"date": "2026-01-15",
|
||||||
|
"lang": "en",
|
||||||
|
"tags": ["x"],
|
||||||
|
"author": "A",
|
||||||
|
"fediverse_creator": "@a@example.com",
|
||||||
|
"cover": "media/c.webp",
|
||||||
|
},
|
||||||
|
body="Body.",
|
||||||
|
)
|
||||||
|
s = p.summary()
|
||||||
|
assert s["slug"] == "s"
|
||||||
|
assert s["title"] == "T"
|
||||||
|
assert s["date"] == "2026-01-15"
|
||||||
|
assert s["lang"] == "en"
|
||||||
|
assert s["tags"] == ["x"]
|
||||||
|
assert s["author"] == "A"
|
||||||
|
assert s["fediverse_creator"] == "@a@example.com"
|
||||||
|
assert s["cover"] == "media/c.webp"
|
||||||
|
assert s["url"] == "/api/volumen/posts/s"
|
||||||
|
|
||||||
|
def test_detail_includes_body_and_html(self) -> None:
|
||||||
|
p = _post({"title": "T", "slug": "s"}, body="Hello.")
|
||||||
|
d = p.detail()
|
||||||
|
assert d["body"] == "Hello."
|
||||||
|
assert "html" in d
|
||||||
|
assert "<p>" in d["html"]
|
||||||
@@ -0,0 +1,533 @@
|
|||||||
|
"""Security-focused tests: signature validation, sanitisation, cookies, headers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from volumen.markdown import render
|
||||||
|
from volumen.password import (
|
||||||
|
BLOCK_R,
|
||||||
|
COST_N,
|
||||||
|
KEY_LENGTH,
|
||||||
|
PARALLEL_P,
|
||||||
|
hash_password,
|
||||||
|
needs_rehash,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
from volumen.store import (
|
||||||
|
ALLOWED_IMAGE_EXTENSIONS,
|
||||||
|
detect_image_extension,
|
||||||
|
validate_image_signature,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Image signature validation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestImageSignature:
|
||||||
|
def test_webp_magic_accepted(self) -> None:
|
||||||
|
# Minimal RIFF/WEBP header (12 bytes).
|
||||||
|
good = b"RIFF" + b"\x10\x00\x00\x00" + b"WEBP" + b"VP8 "
|
||||||
|
assert validate_image_signature(good, ".webp") is True
|
||||||
|
|
||||||
|
def test_webp_wrong_magic_rejected(self) -> None:
|
||||||
|
# RIFF but not WEBP at offset 8.
|
||||||
|
bad = b"RIFF" + b"\x10\x00\x00\x00" + b"WAVE" + b"VP8 "
|
||||||
|
assert validate_image_signature(bad, ".webp") is False
|
||||||
|
|
||||||
|
def test_webp_too_short_rejected(self) -> None:
|
||||||
|
assert validate_image_signature(b"RIFF", ".webp") is False
|
||||||
|
|
||||||
|
def test_avif_magic_accepted(self) -> None:
|
||||||
|
# ftyp box with size=0x18, brand=avif.
|
||||||
|
avif = b"\x00\x00\x00\x18ftyp" + b"avif" + b"\x00\x00\x00\x00" + b"avif" + b"mif1"
|
||||||
|
assert validate_image_signature(avif, ".avif") is True
|
||||||
|
|
||||||
|
def test_avif_alt_brand_accepted(self) -> None:
|
||||||
|
avif = b"\x00\x00\x00\x18ftyp" + b"avis" + b"\x00\x00\x00\x00" + b"avis" + b"mif1"
|
||||||
|
assert validate_image_signature(avif, ".avif") is True
|
||||||
|
|
||||||
|
def test_avif_wrong_brand_rejected(self) -> None:
|
||||||
|
avif = b"\x00\x00\x00\x18ftyp" + b"mp42" + b"\x00\x00\x00\x00" + b"mp42" + b"isom"
|
||||||
|
assert validate_image_signature(avif, ".avif") is False
|
||||||
|
|
||||||
|
def test_avif_too_short_rejected(self) -> None:
|
||||||
|
assert validate_image_signature(b"\x00\x00\x00\x18ftyp", ".avif") is False
|
||||||
|
|
||||||
|
def test_unknown_extension_rejected(self) -> None:
|
||||||
|
assert validate_image_signature(b"anything", ".png") is False
|
||||||
|
|
||||||
|
def test_detect_extension_webp(self) -> None:
|
||||||
|
data = b"RIFF" + b"\x00\x00\x00\x00" + b"WEBP"
|
||||||
|
assert detect_image_extension(data) == ".webp"
|
||||||
|
|
||||||
|
def test_detect_extension_avif(self) -> None:
|
||||||
|
data = b"\x00\x00\x00\x18ftypavif" + b"\x00" * 8
|
||||||
|
assert detect_image_extension(data) == ".avif"
|
||||||
|
|
||||||
|
def test_detect_extension_unknown(self) -> None:
|
||||||
|
assert detect_image_extension(b"random") is None
|
||||||
|
|
||||||
|
def test_allowed_extensions_match_policy(self) -> None:
|
||||||
|
assert ALLOWED_IMAGE_EXTENSIONS == (".webp", ".avif")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Markdown XSS sanitisation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarkdownSanitisation:
|
||||||
|
def test_script_tag_stripped(self) -> None:
|
||||||
|
rendered = render("Hello <script>alert(1)</script>world")
|
||||||
|
assert "<script" not in rendered.lower()
|
||||||
|
assert "alert(1)" not in rendered
|
||||||
|
|
||||||
|
def test_inline_event_handler_stripped(self) -> None:
|
||||||
|
rendered = render('<img src="x" onerror="alert(1)">')
|
||||||
|
assert "onerror" not in rendered.lower()
|
||||||
|
assert "alert" not in rendered.lower()
|
||||||
|
|
||||||
|
def test_javascript_url_sanitised(self) -> None:
|
||||||
|
rendered = render("[click me](javascript:alert(1))")
|
||||||
|
assert "javascript:" not in rendered.lower()
|
||||||
|
|
||||||
|
def test_data_image_url_kept(self) -> None:
|
||||||
|
rendered = render("")
|
||||||
|
assert "<img" in rendered
|
||||||
|
|
||||||
|
def test_figure_wrapping_still_works(self) -> None:
|
||||||
|
rendered = render('')
|
||||||
|
assert "<figure>" in rendered
|
||||||
|
assert "caption" in rendered
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Password hashing policy
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPasswordPolicy:
|
||||||
|
def test_strong_hash_passes(self) -> None:
|
||||||
|
encoded = hash_password("a-strong-password")
|
||||||
|
assert verify_password("a-strong-password", encoded) is True
|
||||||
|
|
||||||
|
def test_weak_params_rejected(self) -> None:
|
||||||
|
encoded = f"scrypt$8$1$1${'A' * 24}${'B' * 32}"
|
||||||
|
assert verify_password("anything", encoded) is False
|
||||||
|
|
||||||
|
def test_needs_rehash_for_strong_hash(self) -> None:
|
||||||
|
encoded = hash_password("a-strong-password")
|
||||||
|
assert needs_rehash(encoded) is False
|
||||||
|
|
||||||
|
def test_needs_rehash_for_weak_hash(self) -> None:
|
||||||
|
encoded = f"scrypt$8$1$1${'A' * 24}${'B' * 32}"
|
||||||
|
assert needs_rehash(encoded) is True
|
||||||
|
|
||||||
|
def test_needs_rehash_for_malformed(self) -> None:
|
||||||
|
assert needs_rehash("not-a-hash") is True
|
||||||
|
|
||||||
|
def test_module_constants_match_policy(self) -> None:
|
||||||
|
# Policy floor equals current implementation.
|
||||||
|
assert COST_N >= 16384
|
||||||
|
assert BLOCK_R >= 8
|
||||||
|
assert PARALLEL_P >= 1
|
||||||
|
assert KEY_LENGTH >= 32
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTTP security headers and CSP
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSecurityHeaders:
|
||||||
|
def test_global_security_headers_present(self, client: TestClient) -> None:
|
||||||
|
response = client.get("/api/volumen/site")
|
||||||
|
assert response.headers.get("x-content-type-options") == "nosniff"
|
||||||
|
assert response.headers.get("x-frame-options") == "DENY"
|
||||||
|
assert response.headers.get("referrer-policy") == "strict-origin-when-cross-origin"
|
||||||
|
assert "permissions-policy" in response.headers
|
||||||
|
|
||||||
|
def test_csp_on_public_routes(self, client: TestClient) -> None:
|
||||||
|
response = client.get("/api/volumen/site")
|
||||||
|
csp = response.headers.get("content-security-policy", "")
|
||||||
|
assert "default-src 'self'" in csp
|
||||||
|
# Public routes have script-src but without nonce (nonce is admin-only)
|
||||||
|
assert "script-src 'self'" in csp
|
||||||
|
assert "nonce-" not in csp
|
||||||
|
|
||||||
|
def test_admin_route_has_csp_with_nonce(self, admin_client: TestClient) -> None:
|
||||||
|
response = admin_client.get("/admin/login")
|
||||||
|
csp = response.headers.get("content-security-policy", "")
|
||||||
|
assert "default-src 'self'" in csp
|
||||||
|
assert "'unsafe-inline'" not in csp
|
||||||
|
assert "nonce-" in csp
|
||||||
|
|
||||||
|
def test_admin_csp_has_no_duplicate_directives(self, admin_client: TestClient) -> None:
|
||||||
|
# Regression: nonce-augmented script-src / style-src used to be
|
||||||
|
# appended after the base ones, so the same directive name appeared
|
||||||
|
# twice. Browsers use the FIRST occurrence and silently drop the
|
||||||
|
# nonce, breaking all admin JS. Verify each directive is unique.
|
||||||
|
response = admin_client.get("/admin/login")
|
||||||
|
csp = response.headers.get("content-security-policy", "")
|
||||||
|
directives = [d.strip().split(maxsplit=1)[0] for d in csp.split(";")]
|
||||||
|
assert len(directives) == len(set(directives)), (
|
||||||
|
f"Duplicate CSP directives: {[d for d in directives if directives.count(d) > 1]}"
|
||||||
|
)
|
||||||
|
# And specifically: only one script-src, only one style-src.
|
||||||
|
assert directives.count("script-src") == 1
|
||||||
|
assert directives.count("style-src") == 1
|
||||||
|
|
||||||
|
def test_admin_csp_nonce_appears_in_rendered_html(self, admin_client: TestClient) -> None:
|
||||||
|
# Regression: nonce used to be set AFTER call_next, so templates
|
||||||
|
# rendered with an empty csp_nonce and the matching <script nonce="...">
|
||||||
|
# tag in the response never carried the nonce that CSP demanded.
|
||||||
|
# Browsers then blocked every inline script. Verify the nonce in
|
||||||
|
# the response header matches the nonce on the <script> tags.
|
||||||
|
import re
|
||||||
|
|
||||||
|
response = admin_client.get("/admin/login")
|
||||||
|
csp = response.headers.get("content-security-policy", "")
|
||||||
|
match = re.search(r"nonce-([A-Za-z0-9_-]+)", csp)
|
||||||
|
assert match is not None, "No nonce in CSP"
|
||||||
|
nonce = match.group(1)
|
||||||
|
# At least one inline <script> must carry this exact nonce.
|
||||||
|
script_tags = re.findall(r"<script[^>]*>", response.text)
|
||||||
|
assert script_tags, "No <script> tags rendered"
|
||||||
|
assert any(f'nonce="{nonce}"' in tag for tag in script_tags), (
|
||||||
|
"Rendered <script> tags do not carry the CSP nonce:\n" + "\n".join(script_tags)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_admin_pages_have_no_blocking_inline_styles(self, admin_client: TestClient) -> None:
|
||||||
|
# Regression: with `style-src 'self' 'nonce-...'`, any HTML element
|
||||||
|
# carrying `style="..."` is blocked by the browser — including
|
||||||
|
# style attributes that do *not* start with `--` (CSS custom
|
||||||
|
# properties, which are explicitly allowed by CSP3). Walk every
|
||||||
|
# admin page and assert no offending attribute survives.
|
||||||
|
import re
|
||||||
|
|
||||||
|
def _offending(body: str) -> list[str]:
|
||||||
|
results: list[str] = []
|
||||||
|
for match in re.finditer(r"\sstyle=\"([^\"]*)\"", body):
|
||||||
|
value = match.group(1).strip()
|
||||||
|
# `style="--foo: ..."` is a CSS custom-property declaration
|
||||||
|
# and is allowed by CSP3 without `unsafe-inline`. Anything
|
||||||
|
# else is a style application and must move into a class.
|
||||||
|
if not value.startswith("--"):
|
||||||
|
results.append(match.group(0))
|
||||||
|
return results
|
||||||
|
|
||||||
|
for path in ("/admin/login", "/admin/", "/admin/settings"):
|
||||||
|
response = admin_client.get(path)
|
||||||
|
assert response.status_code == 200, f"{path} returned {response.status_code}"
|
||||||
|
offending = _offending(response.text)
|
||||||
|
assert not offending, (
|
||||||
|
f"{path} renders {len(offending)} non-custom-property inline "
|
||||||
|
f"style attribute(s) that strict CSP will block:\n" + "\n".join(offending)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Also check the post form (requires login) — it had several
|
||||||
|
# one-off utility styles that should now be classes.
|
||||||
|
from tests.test_admin import _login
|
||||||
|
|
||||||
|
_login(admin_client)
|
||||||
|
response = admin_client.get("/admin/posts/new")
|
||||||
|
assert response.status_code == 200
|
||||||
|
offending = _offending(response.text)
|
||||||
|
assert not offending, (
|
||||||
|
"/admin/posts/new renders inline style attributes that strict "
|
||||||
|
"CSP will block:\n" + "\n".join(offending)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_favicon_ico_serves_svg(self, client: TestClient) -> None:
|
||||||
|
# Regression: browsers auto-request /favicon.ico even when the page
|
||||||
|
# declares an explicit <link rel="icon">, and the previous behaviour
|
||||||
|
# was a 404. Serve the packaged icon SVG so the console is clean
|
||||||
|
# and the tab gets a proper icon.
|
||||||
|
response = client.get("/favicon.ico")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"].startswith("image/svg+xml")
|
||||||
|
body = response.content
|
||||||
|
assert body.startswith(b"<") and b"</svg>" in body
|
||||||
|
# And the admin login page must declare the icon explicitly.
|
||||||
|
login = client.get("/admin/login")
|
||||||
|
assert login.status_code == 200
|
||||||
|
assert 'rel="icon"' in login.text
|
||||||
|
assert 'href="/favicon.ico"' in login.text
|
||||||
|
|
||||||
|
def test_admin_layout_renders_version(self, admin_client: TestClient) -> None:
|
||||||
|
# Regression: `version` was not in admin_context, so the sidebar
|
||||||
|
# footer rendered as just "v" with no number behind it.
|
||||||
|
# The sidebar footer only renders on the authenticated layout,
|
||||||
|
# so we log in first.
|
||||||
|
from tests.test_admin import _login
|
||||||
|
from volumen.version import VERSION
|
||||||
|
|
||||||
|
_login(admin_client)
|
||||||
|
response = admin_client.get("/admin/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "sidebar-version" in response.text
|
||||||
|
# The version label is "v{version}" inside a div.sidebar-version.
|
||||||
|
# Refuse the bare "v</div>" rendering that this bug produced.
|
||||||
|
assert "v" + VERSION in response.text, (
|
||||||
|
f"Expected the sidebar version label to include {VERSION}; "
|
||||||
|
f"admin_context is not propagating the package version"
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user