fix(admin): ship templates in wheel, fix CSP and bootstrap login
Test / test (push) Successful in 1m1s

This commit is contained in:
2026-07-27 10:27:41 +02:00
parent 98f7f3c9e3
commit 38544b84cf
15 changed files with 361 additions and 41 deletions
BIN
View File
Binary file not shown.
+9
View File
@@ -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/
+14 -7
View File
@@ -10,12 +10,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [development] ## [development]
### 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 ## [0.4.1] — 2026-07-27
### Fixed ### Fixed
- **CSP nonce chybí pro `style-src` v admin routách** — přidán `style-src 'self' 'nonce-{nonce}'` do CSP hlavičky pro `/admin` routy. Bez nonce prohlížeč blokoval všechny inline `<style>` tagy v admin šablonách. - **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 soubory chybí v distribučním kole** — přidána sekce `[tool.setuptools.package-data]` do `pyproject.toml`, aby `volumen-icon.svg` a `volumen-logo.svg` byly součástí instalovaného wheelu. Admin panel dříve vracel 500 na `/admin/icon.svg` a `/admin/logo.svg`. - **`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
@@ -387,8 +399,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
+1 -1
View File
@@ -62,7 +62,7 @@ dev = [
where = ["src"] where = ["src"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
volumen = ["web/static/*"] volumen = ["web/templates/*.jinja", "web/static/*.svg"]
[tool.ruff] [tool.ruff]
+30 -5
View File
@@ -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,12 +182,16 @@ 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( csp = "; ".join(
_BASE_CSP_DIRECTIVES 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"script-src 'self' 'nonce-{nonce}'",
f"style-src 'self' 'nonce-{nonce}'", f"style-src 'self' 'nonce-{nonce}'",
+4
View File
@@ -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
+2 -2
View File
@@ -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
+5 -5
View File
@@ -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>
+23 -2
View File
@@ -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>
+8 -8
View File
@@ -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
View File
@@ -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)
+73
View File
@@ -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
+111
View File
@@ -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")
+28
View File
@@ -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: