Compare commits
5
Commits
v0.4.0
..
bce4f5e223
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bce4f5e223 | ||
|
|
a66a36abbc | ||
|
|
38544b84cf | ||
|
|
98f7f3c9e3 | ||
|
|
d9b7a53ac2 |
@@ -1,3 +1,6 @@
|
|||||||
|
# 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
|
name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -7,26 +10,27 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: fedora
|
runs-on: fedora
|
||||||
|
permissions:
|
||||||
|
releases: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Python and uv
|
- name: Set up Python and uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.14"
|
python-version: "3.14" # match requires-python in pyproject.toml
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: "uv.lock"
|
cache-dependency-glob: "uv.lock"
|
||||||
|
|
||||||
- name: Resolve and verify version
|
- name: Resolve and verify version
|
||||||
id: version
|
id: version
|
||||||
env:
|
env:
|
||||||
REF: ${{ gitea.ref }}
|
VERSION: ${{ gitea.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${REF##*/}"
|
if ! echo "$VERSION" | grep -qE '^v[0-9]+(\.[0-9]+){0,2}([-+].*)?$'; then
|
||||||
if [[ ! "$VERSION" =~ ^v[0-9]+(\.[0-9]+){0,2}([-+].*)?$ ]]; then
|
echo "ERROR: expected a semver tag like v1.2.3, got: '$VERSION'"
|
||||||
echo "ERROR: expected a semver tag like v1.2.3, got: '$VERSION' (ref: '$REF')"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -81,6 +85,7 @@ jobs:
|
|||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BODY="$(python -c 'import json,sys; print(json.dumps(sys.stdin.read()))' < release-body.md)"
|
BODY="$(python -c 'import json,sys; print(json.dumps(sys.stdin.read()))' < release-body.md)"
|
||||||
@@ -90,18 +95,12 @@ jobs:
|
|||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-X POST \
|
-X POST \
|
||||||
"${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \
|
"${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \
|
||||||
-d "{
|
-d "{\"tag_name\":\"${GITEA_REF_NAME}\",\"name\":\"${GITEA_REF_NAME}\",\"body\":${BODY},\"draft\":false,\"prerelease\":false}")"
|
||||||
\"tag_name\": \"$GITHUB_REF_NAME\",
|
|
||||||
\"name\": \"$GITHUB_REF_NAME\",
|
|
||||||
\"body\": ${BODY},
|
|
||||||
\"draft\": false,
|
|
||||||
\"prerelease\": false
|
|
||||||
}")"
|
|
||||||
|
|
||||||
http_code="$(echo "$response" | tail -1)"
|
http_code="$(echo "$response" | tail -1)"
|
||||||
payload="$(echo "$response" | sed '$d')"
|
payload="$(echo "$response" | sed '$d')"
|
||||||
echo "HTTP ${http_code}"
|
|
||||||
|
|
||||||
|
echo "HTTP ${http_code}"
|
||||||
if [ "$http_code" != "201" ]; then
|
if [ "$http_code" != "201" ]; then
|
||||||
echo "Failed to create release: ${payload}"
|
echo "Failed to create release: ${payload}"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -114,10 +113,10 @@ jobs:
|
|||||||
- name: Upload wheel and sdist assets
|
- name: Upload wheel and sdist assets
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
|
|
||||||
RELEASE_ID: ${{ steps.create.outputs.release_id }}
|
|
||||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
|
||||||
|
RELEASE_ID: ${{ steps.create.outputs.release_id }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
for ASSET in "volumen-${VERSION_NO_V}-py3-none-any.whl" \
|
for ASSET in "volumen-${VERSION_NO_V}-py3-none-any.whl" \
|
||||||
@@ -140,13 +139,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Release $GITHUB_REF_NAME is live with wheel + sdist."
|
|
||||||
|
|
||||||
# Publish to PyPI (only on tag push). Requires `PYPI_TOKEN` in repo
|
|
||||||
# secrets (project-scoped upload token from https://pypi.org/manage/account/publishing/).
|
|
||||||
- name: Publish to PyPI
|
- name: Publish to PyPI
|
||||||
if: ${{ startsWith(gitea.ref_name, 'v') }}
|
|
||||||
env:
|
env:
|
||||||
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||||
run: |
|
run: uv publish
|
||||||
uv publish
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
# 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
|
name: Test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -11,12 +14,12 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: fedora
|
runs-on: fedora
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Python and uv
|
- name: Set up Python and uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.14"
|
python-version: "3.14" # match requires-python in pyproject.toml
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: "uv.lock"
|
cache-dependency-glob: "uv.lock"
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,18 @@ __pycache__/
|
|||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# setuptools / uv build output
|
||||||
|
/build/
|
||||||
|
/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
|
# mkdocs / Sphinx build output
|
||||||
/site/
|
/site/
|
||||||
|
|||||||
+21
-5
@@ -10,6 +10,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [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
|
## [0.4.0] — 2026-07-26
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -380,8 +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://sourcedock.dev/petrbalvin/volumen/releases/tag/v0.1.0
|
|
||||||
[0.2.0]: https://sourcedock.dev/petrbalvin/volumen/releases/tag/v0.2.0
|
|
||||||
[0.3.0]: https://sourcedock.dev/petrbalvin/volumen/releases/tag/v0.3.0
|
|
||||||
[0.4.0]: https://sourcedock.dev/petrbalvin/volumen/releases/tag/v0.4.0
|
|
||||||
|
|||||||
+5
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "volumen"
|
name = "volumen"
|
||||||
version = "0.4.0"
|
version = "0.4.2"
|
||||||
description = "A small, file-based Markdown blog engine with a built-in admin."
|
description = "A small, file-based Markdown blog engine with a built-in admin."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
keywords = ["blog", "markdown", "fastapi", "cms", "static-blog", "toml", "rss", "json-feed", "engine"]
|
keywords = ["blog", "markdown", "fastapi", "cms", "static-blog", "toml", "rss", "json-feed", "engine"]
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
authors = [{name = "Petr Balvín", email = "petrbalvin@yandex.com"}]
|
authors = [{name = "Petr Balvín", email = "opensource@petrbalvin.org"}]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 4 - Beta",
|
"Development Status :: 4 - Beta",
|
||||||
"Environment :: Web Environment",
|
"Environment :: Web Environment",
|
||||||
@@ -61,6 +61,9 @@ dev = [
|
|||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
volumen = ["web/templates/*.jinja", "web/static/*.svg"]
|
||||||
|
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
|
|||||||
+36
-5
@@ -8,6 +8,7 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
@@ -84,6 +85,19 @@ def create_app(config: Config, store: Store) -> FastAPI:
|
|||||||
if _STATIC_DIR.is_dir():
|
if _STATIC_DIR.is_dir():
|
||||||
app.mount("/admin/static", StaticFiles(directory=str(_STATIC_DIR)), name="admin_static")
|
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_auth_routes import router as admin_auth_router
|
||||||
from .admin_media_routes import router as admin_media_router
|
from .admin_media_routes import router as admin_media_router
|
||||||
from .admin_post_routes import router as admin_post_router
|
from .admin_post_routes import router as admin_post_router
|
||||||
@@ -152,6 +166,13 @@ class GlobalSecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
self._cookie_secure = cookie_secure
|
self._cookie_secure = cookie_secure
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next): # type: ignore[override]
|
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 = await call_next(request)
|
||||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
@@ -161,11 +182,21 @@ class GlobalSecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
# Always set base CSP
|
# Always set base CSP
|
||||||
response.headers["Content-Security-Policy"] = "; ".join(_BASE_CSP_DIRECTIVES)
|
response.headers["Content-Security-Policy"] = "; ".join(_BASE_CSP_DIRECTIVES)
|
||||||
|
|
||||||
# For admin routes, augment with nonce-based script-src
|
# For admin routes, replace base script-src / style-src with
|
||||||
if request.url.path.startswith("/admin"):
|
# nonce-based versions (do NOT append — browsers use the first
|
||||||
nonce = secrets.token_urlsafe(18)
|
# occurrence when a directive is duplicated).
|
||||||
request.state.csp_nonce = nonce
|
if nonce is not None:
|
||||||
csp = "; ".join(_BASE_CSP_DIRECTIVES + (f"script-src 'self' 'nonce-{nonce}'",))
|
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
|
response.headers["Content-Security-Policy"] = csp
|
||||||
|
|
||||||
if self._cookie_secure:
|
if self._cookie_secure:
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ def admin_context(
|
|||||||
**extra: Any,
|
**extra: Any,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build the standard Jinja2 template context for admin routes."""
|
"""Build the standard Jinja2 template context for admin routes."""
|
||||||
|
from .version import VERSION
|
||||||
|
|
||||||
username: str = request.session.get("user", "")
|
username: str = request.session.get("user", "")
|
||||||
record = users_obj.find(username) if username else None
|
record = users_obj.find(username) if username else None
|
||||||
csrf_val = csrf_token(request)
|
csrf_val = csrf_token(request)
|
||||||
@@ -126,11 +128,13 @@ def admin_context(
|
|||||||
"config": config,
|
"config": config,
|
||||||
"store": store,
|
"store": store,
|
||||||
"users": users_obj,
|
"users": users_obj,
|
||||||
|
"users_exist": users_obj.any,
|
||||||
"csrf_token": csrf_val,
|
"csrf_token": csrf_val,
|
||||||
"current_user": username or None,
|
"current_user": username or None,
|
||||||
"current_role": record.role if record else None,
|
"current_role": record.role if record else None,
|
||||||
"current_user_record": record,
|
"current_user_record": record,
|
||||||
"csp_nonce": nonce,
|
"csp_nonce": nonce,
|
||||||
|
"version": VERSION,
|
||||||
}
|
}
|
||||||
ctx.update(extra)
|
ctx.update(extra)
|
||||||
return ctx
|
return ctx
|
||||||
|
|||||||
@@ -61,10 +61,10 @@ class Users:
|
|||||||
self._snapshot = snapshot
|
self._snapshot = snapshot
|
||||||
if os.path.isfile(self._path):
|
if os.path.isfile(self._path):
|
||||||
self._cached = self._read_file()
|
self._cached = self._read_file()
|
||||||
elif self._bootstrap_hash:
|
|
||||||
self._cached = [User("admin", self._bootstrap_hash, "admin")]
|
|
||||||
else:
|
else:
|
||||||
self._cached = []
|
self._cached = []
|
||||||
|
if not self._cached and self._bootstrap_hash:
|
||||||
|
self._cached = [User("admin", self._bootstrap_hash, "admin")]
|
||||||
return list(self._cached)
|
return list(self._cached)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -12,16 +12,16 @@
|
|||||||
<h1>{{ "New post" if mode == "new" else "Edit post" }}</h1>
|
<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>
|
<p class="lede">{{ "Draft a new article in Markdown." if mode == "new" else "Update and publish this post." }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
<div class="page-head-actions">
|
||||||
{% if mode == "edit" %}
|
{% if mode == "edit" %}
|
||||||
<a class="btn btn-ghost" href="/api/volumen/posts/{{ post.slug }}" target="_blank">
|
<a class="btn btn-ghost" href="/api/volumen/posts/{{ post.slug }}" target="_blank">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
<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
|
Preview
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="btn btn-ghost" href="/admin/">Cancel</a>
|
<a class="btn btn-ghost" href="/admin/">Cancel</a>
|
||||||
<button type="submit" form="post-form" class="btn btn-primary">
|
<button type="submit" form="post-form" class="btn btn-primary">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
<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
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
<p class="help">Auto-generated from the first paragraph if left empty</p>
|
<p class="help">Auto-generated from the first paragraph if left empty</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: flex; gap: 1.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
|
<div class="form-options">
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input type="checkbox" name="draft" {{ 'checked' if post.draft else '' }}> Draft
|
<input type="checkbox" name="draft" {{ 'checked' if post.draft else '' }}> Draft
|
||||||
</label>
|
</label>
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="surface full">
|
<div class="surface full">
|
||||||
<div class="editor-tabs" style="margin-bottom: 0.75rem;">
|
<div class="editor-tabs">
|
||||||
<button type="button" data-tab="markdown" class="active">Markdown</button>
|
<button type="button" data-tab="markdown" class="active">Markdown</button>
|
||||||
<button type="button" data-tab="visual">Visual</button>
|
<button type="button" data-tab="visual">Visual</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>volumen admin</title>
|
<title>volumen admin</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.ico">
|
||||||
<style nonce="{{ csp_nonce | default('') }}">
|
<style nonce="{{ csp_nonce | default('') }}">
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
@@ -250,6 +251,7 @@
|
|||||||
aspect-ratio: 16 / 9; background: var(--surface-2);
|
aspect-ratio: 16 / 9; background: var(--surface-2);
|
||||||
background-size: cover; background-position: center;
|
background-size: cover; background-position: center;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
background-image: var(--cover-url);
|
||||||
}
|
}
|
||||||
.post-cover.placeholder {
|
.post-cover.placeholder {
|
||||||
display: grid; place-items: center; color: var(--fg-subtle);
|
display: grid; place-items: center; color: var(--fg-subtle);
|
||||||
@@ -339,7 +341,7 @@
|
|||||||
.editor-tabs {
|
.editor-tabs {
|
||||||
display: inline-flex; gap: 2px; background: var(--surface-2);
|
display: inline-flex; gap: 2px; background: var(--surface-2);
|
||||||
padding: 3px; border: 1px solid var(--border); border-radius: var(--radius);
|
padding: 3px; border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
.editor-tabs button {
|
.editor-tabs button {
|
||||||
font: inherit; font-size: 0.8rem; font-weight: 550;
|
font: inherit; font-size: 0.8rem; font-weight: 550;
|
||||||
@@ -810,6 +812,25 @@
|
|||||||
border-color: var(--accent-hover);
|
border-color: var(--accent-hover);
|
||||||
color: var(--on-accent);
|
color: var(--on-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------
|
||||||
|
* Utility classes that replace inline `style="..."` attributes.
|
||||||
|
* Strict CSP blocks inline style attributes, so anything that needs
|
||||||
|
* a one-off look lives here.
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
.page-head-actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||||
|
.form-options { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-top: 0.5rem; }
|
||||||
|
.form-inline { margin: 0; }
|
||||||
|
.form-hidden { display: none; }
|
||||||
|
.form-spaced { margin-top: 1rem; }
|
||||||
|
.flex-spacer { flex: 1; }
|
||||||
|
.btn-static { cursor: default; }
|
||||||
|
.btn-danger-text { color: var(--danger); }
|
||||||
|
.text-fg-strong { color: var(--fg); }
|
||||||
|
.badge-inline { margin-left: 0.4rem; }
|
||||||
|
.h3-section { margin: 0 0 0.75rem; }
|
||||||
|
.mt-3 { margin-top: 0.75rem; }
|
||||||
|
.is-hidden { display: none; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -888,7 +909,7 @@
|
|||||||
<div class="name">{{ display_name }}</div>
|
<div class="name">{{ display_name }}</div>
|
||||||
<div class="role">{{ current_role }}</div>
|
<div class="role">{{ current_role }}</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/admin/logout" style="margin: 0;">
|
<form method="post" action="/admin/logout" class="form-inline">
|
||||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
<button type="submit" class="icon-btn" title="Log out" aria-label="Log out">
|
<button type="submit" class="icon-btn" title="Log out" aria-label="Log out">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<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>
|
<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>
|
<h3>No posts yet</h3>
|
||||||
<p>Create your first post to get started.</p>
|
<p>Create your first post to get started.</p>
|
||||||
<a class="btn btn-primary" href="/admin/posts/new" style="margin-top: 0.75rem;">Create post</a>
|
<a class="btn btn-primary mt-3" href="/admin/posts/new">Create post</a>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="post-grid" id="post-grid">
|
<div class="post-grid" id="post-grid">
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
data-slug="{{ post.slug }}"
|
data-slug="{{ post.slug }}"
|
||||||
data-status="{{ status }}">
|
data-status="{{ status }}">
|
||||||
{% if post.cover %}
|
{% if post.cover %}
|
||||||
<div class="post-cover" style="background-image: url('{{ post.cover }}');"></div>
|
<div class="post-cover" style="--cover-url: url('{{ post.cover }}');"></div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="post-cover placeholder">
|
<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>
|
<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>
|
||||||
@@ -67,12 +67,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="post-actions">
|
<div class="post-actions">
|
||||||
<span class="btn btn-sm btn-ghost" style="cursor: default;">Edit</span>
|
<span class="btn btn-sm btn-ghost btn-static">Edit</span>
|
||||||
<span class="spacer" style="flex: 1;"></span>
|
<span class="flex-spacer"></span>
|
||||||
<form method="post" action="/admin/posts/{{ post.slug }}/delete"
|
<form method="post" action="/admin/posts/{{ post.slug }}/delete"
|
||||||
onsubmit="return confirm('Delete this post?');" style="margin: 0;">
|
onsubmit="return confirm('Delete this post?');" class="form-inline">
|
||||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
<button type="submit" class="btn btn-sm btn-ghost" style="color: var(--danger);" onclick="event.preventDefault(); event.stopPropagation(); this.closest('form').submit();">
|
<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>
|
<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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script nonce="{{ csp_nonce | default('') }}">
|
||||||
(function () {
|
(function () {
|
||||||
var search = document.getElementById("post-search");
|
var search = document.getElementById("post-search");
|
||||||
var filterButtons = document.querySelectorAll("#post-filters .filter-chip");
|
var filterButtons = document.querySelectorAll("#post-filters .filter-chip");
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
var status = card.dataset.status;
|
var status = card.dataset.status;
|
||||||
var matchesSearch = !q || title.indexOf(q) !== -1 || slug.indexOf(q) !== -1;
|
var matchesSearch = !q || title.indexOf(q) !== -1 || slug.indexOf(q) !== -1;
|
||||||
var matchesFilter = currentFilter === "all" || status === currentFilter;
|
var matchesFilter = currentFilter === "all" || status === currentFilter;
|
||||||
card.style.display = matchesSearch && matchesFilter ? "" : "none";
|
card.classList.toggle("is-hidden", !(matchesSearch && matchesFilter));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
<div class="surface-head">
|
<div class="surface-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Account</h2>
|
<h2>Account</h2>
|
||||||
<p class="lede">Signed in as <strong style="color: var(--fg);">{{ current_user }}</strong> · {{ current_role }}</p>
|
<p class="lede">Signed in as <strong class="text-fg-strong">{{ current_user }}</strong> · {{ current_role }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% if current_user_record and current_user_record.photo %}
|
{% if current_user_record and current_user_record.photo %}
|
||||||
<form id="remove-photo-form" method="post" action="/admin/settings/photo/remove" style="display: none;">
|
<form id="remove-photo-form" method="post" action="/admin/settings/photo/remove" class="form-hidden">
|
||||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn">Save name</button>
|
<button type="submit" class="btn">Save name</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="/admin/settings/fediverse" class="settings-inline-form" style="margin-top: 1rem;">
|
<form method="post" action="/admin/settings/fediverse" class="settings-inline-form form-spaced">
|
||||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="fediverse_creator">Fediverse handle</label>
|
<label for="fediverse_creator">Fediverse handle</label>
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn">Update password</button>
|
<button type="submit" class="btn">Update password</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="/admin/settings/username" class="settings-inline-form" style="margin-top: 1rem;">
|
<form method="post" action="/admin/settings/username" class="settings-inline-form form-spaced">
|
||||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
@@ -159,7 +159,7 @@
|
|||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<div class="user-name">
|
<div class="user-name">
|
||||||
{{ user.name or user.username }}
|
{{ user.name or user.username }}
|
||||||
{% if user.username == current_user %}<span class="badge badge-accent" style="margin-left: 0.4rem;">you</span>{% endif %}
|
{% if user.username == current_user %}<span class="badge badge-accent badge-inline">you</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="user-username">@{{ user.username }}</div>
|
<div class="user-username">@{{ user.username }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,7 +167,7 @@
|
|||||||
{% if user.username == current_user %}
|
{% if user.username == current_user %}
|
||||||
<span class="badge badge-neutral">{{ user.role }}</span>
|
<span class="badge badge-neutral">{{ user.role }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="/admin/settings/users/{{ user.username }}/role" style="margin: 0;">
|
<form method="post" action="/admin/settings/users/{{ user.username }}/role" class="form-inline">
|
||||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
<select class="select select-sm" name="role" onchange="this.form.submit()">
|
<select class="select select-sm" name="role" onchange="this.form.submit()">
|
||||||
{% for role in roles %}
|
{% for role in roles %}
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if user.username != current_user %}
|
{% if user.username != current_user %}
|
||||||
<form method="post" action="/admin/settings/users/{{ user.username }}/delete" onsubmit="return confirm('Remove this user?');" style="margin: 0;">
|
<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 }}">
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
<button type="submit" class="btn btn-sm btn-ghost btn-danger">Remove</button>
|
<button type="submit" class="btn btn-sm btn-ghost btn-danger">Remove</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="add-user-form">
|
<div class="add-user-form">
|
||||||
<h3 style="margin: 0 0 0.75rem;">Add user</h3>
|
<h3 class="h3-section">Add user</h3>
|
||||||
<form method="post" action="/admin/settings/users">
|
<form method="post" action="/admin/settings/users">
|
||||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
<div class="field-row-3">
|
<div class="field-row-3">
|
||||||
@@ -217,7 +217,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script nonce="{{ csp_nonce | default('') }}">
|
||||||
(function () {
|
(function () {
|
||||||
var sections = document.querySelectorAll(".settings-panels .surface[id]");
|
var sections = document.querySelectorAll(".settings-panels .surface[id]");
|
||||||
var navLinks = document.querySelectorAll(".settings-nav a[href^='#']");
|
var navLinks = document.querySelectorAll(".settings-nav a[href^='#']");
|
||||||
|
|||||||
+44
-2
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -27,6 +28,29 @@ class TestAdminLogin:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Sign in" in response.text
|
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:
|
def test_login_with_valid_credentials(self, admin_client: TestClient) -> None:
|
||||||
resp = admin_client.get("/admin/login")
|
resp = admin_client.get("/admin/login")
|
||||||
token = _csrf_from_body(resp.text)
|
token = _csrf_from_body(resp.text)
|
||||||
@@ -424,13 +448,31 @@ class TestOrphanedSession:
|
|||||||
def test_orphaned_session_is_rejected(
|
def test_orphaned_session_is_rejected(
|
||||||
self, admin_client: TestClient, admin_config_dir: Path
|
self, admin_client: TestClient, admin_config_dir: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
|
import tomli_w
|
||||||
|
|
||||||
|
from volumen.password import hash_password as hash_pw
|
||||||
|
|
||||||
_login(admin_client)
|
_login(admin_client)
|
||||||
resp = admin_client.get("/admin/")
|
resp = admin_client.get("/admin/")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
# Clear users file to simulate deleted user
|
# 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"
|
users_path = admin_config_dir / "users.toml"
|
||||||
users_path.write_text("")
|
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)
|
resp = admin_client.get("/admin/", follow_redirects=False)
|
||||||
assert resp.status_code in (302, 307, 303)
|
assert resp.status_code in (302, 307, 303)
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
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_auth_routes import router as auth_router
|
||||||
from volumen.admin_media_routes import router as media_router
|
from volumen.admin_media_routes import router as media_router
|
||||||
from volumen.admin_post_routes import router as post_router
|
from volumen.admin_post_routes import router as post_router
|
||||||
@@ -21,3 +29,68 @@ def test_split_modules_import_and_construct_app(config, store) -> None:
|
|||||||
|
|
||||||
assert app.state.config is config
|
assert app.state.config is config
|
||||||
assert app.state.store is store
|
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
|
||||||
|
|||||||
@@ -166,6 +166,117 @@ class TestSecurityHeaders:
|
|||||||
assert "'unsafe-inline'" not in csp
|
assert "'unsafe-inline'" not in csp
|
||||||
assert "nonce-" 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:
|
def test_https_only_cookie_unset_when_untrusted(self, client: TestClient) -> None:
|
||||||
# Default trust_proxy=false — X-Forwarded-Proto is ignored.
|
# Default trust_proxy=false — X-Forwarded-Proto is ignored.
|
||||||
response = client.get("/admin/login")
|
response = client.get("/admin/login")
|
||||||
|
|||||||
@@ -23,6 +23,34 @@ class TestBootstrap:
|
|||||||
users = Users(path=users_path)
|
users = Users(path=users_path)
|
||||||
assert not users.any
|
assert not users.any
|
||||||
|
|
||||||
|
def test_bootstrap_used_when_users_file_is_empty(
|
||||||
|
self, users_path: str, bootstrap_hash: str
|
||||||
|
) -> None:
|
||||||
|
# Regression: `volumen init` calls `users_file.touch(exist_ok=True)`
|
||||||
|
# which leaves a zero-byte users.toml. The bootstrap admin must
|
||||||
|
# still be usable so the very first login after `volumen init`
|
||||||
|
# works in production.
|
||||||
|
with open(users_path, "w") as fh:
|
||||||
|
fh.write("")
|
||||||
|
users = Users(path=users_path, bootstrap_hash=bootstrap_hash)
|
||||||
|
assert users.any
|
||||||
|
assert users.authenticate("admin", "volumen-test-password") is not None
|
||||||
|
|
||||||
|
def test_bootstrap_ignored_when_users_present(
|
||||||
|
self, users_path: str, bootstrap_hash: str
|
||||||
|
) -> None:
|
||||||
|
# Bootstrap must NOT shadow a populated users file — otherwise the
|
||||||
|
# configured `admin` user could log in with the bootstrap password
|
||||||
|
# even after their stored hash was rotated.
|
||||||
|
writer = Users(path=users_path)
|
||||||
|
writer.add("alice", "alice-test-password")
|
||||||
|
writer.add("bob", "bob-test-password")
|
||||||
|
|
||||||
|
reader = Users(path=users_path, bootstrap_hash=bootstrap_hash)
|
||||||
|
usernames = {u.username for u in reader.all}
|
||||||
|
# Only alice + bob from disk; no synthetic admin from bootstrap.
|
||||||
|
assert usernames == {"alice", "bob"}
|
||||||
|
|
||||||
|
|
||||||
class TestFind:
|
class TestFind:
|
||||||
def test_find_existing_user(self, users: Users) -> None:
|
def test_find_existing_user(self, users: Users) -> None:
|
||||||
|
|||||||
@@ -897,7 +897,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "volumen"
|
name = "volumen"
|
||||||
version = "0.4.0"
|
version = "0.4.1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastapi", extra = ["standard"] },
|
{ name = "fastapi", extra = ["standard"] },
|
||||||
|
|||||||
Reference in New Issue
Block a user