This commit is contained in:
@@ -8,9 +8,18 @@ __pycache__/
|
||||
.ruff_cache/
|
||||
dist/
|
||||
|
||||
# setuptools / uv build output
|
||||
/build/
|
||||
/src/*.egg-info/
|
||||
|
||||
# Test / coverage
|
||||
/coverage/
|
||||
/tmp/
|
||||
/.coverage
|
||||
/.coverage.*
|
||||
|
||||
# Transient log files (pytest, dev servers, scratch)
|
||||
/*.log
|
||||
|
||||
# mkdocs / Sphinx build output
|
||||
/site/
|
||||
|
||||
+16
-7
@@ -10,12 +10,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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
|
||||
|
||||
- **CSP nonce chybí pro `style-src` v admin routách** — přidán `style-src 'self' 'nonce-{nonce}'` do CSP hlavičky pro `/admin` routy. Bez nonce prohlížeč blokoval všechny inline `<style>` tagy v admin šablonách.
|
||||
- **`web/static/` SVG soubory chybí v distribučním kole** — přidána sekce `[tool.setuptools.package-data]` do `pyproject.toml`, aby `volumen-icon.svg` a `volumen-logo.svg` byly součástí instalovaného wheelu. Admin panel dříve vracel 500 na `/admin/icon.svg` a `/admin/logo.svg`.
|
||||
- **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
|
||||
|
||||
@@ -387,8 +401,3 @@ admin — no database, no external services.
|
||||
production.
|
||||
- Configurable, stable `session_key` so sessions survive restarts.
|
||||
- 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
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "volumen"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
description = "A small, file-based Markdown blog engine with a built-in admin."
|
||||
readme = "README.md"
|
||||
keywords = ["blog", "markdown", "fastapi", "cms", "static-blog", "toml", "rss", "json-feed", "engine"]
|
||||
@@ -62,7 +62,7 @@ dev = [
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
volumen = ["web/static/*"]
|
||||
volumen = ["web/templates/*.jinja", "web/static/*.svg"]
|
||||
|
||||
|
||||
[tool.ruff]
|
||||
|
||||
+30
-5
@@ -8,6 +8,7 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
@@ -84,6 +85,19 @@ def create_app(config: Config, store: Store) -> FastAPI:
|
||||
if _STATIC_DIR.is_dir():
|
||||
app.mount("/admin/static", StaticFiles(directory=str(_STATIC_DIR)), name="admin_static")
|
||||
|
||||
# Browsers auto-request `/favicon.ico` even when the page declares an
|
||||
# explicit icon link, so serve the admin icon from the same source as
|
||||
# `/admin/icon.svg` (the packaged SVG in web/static/). Modern browsers
|
||||
# accept SVG content here as long as the Content-Type is set correctly.
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
async def favicon() -> FileResponse:
|
||||
icon = _STATIC_DIR / "volumen-icon.svg"
|
||||
return FileResponse(
|
||||
icon,
|
||||
media_type="image/svg+xml",
|
||||
headers={"Cache-Control": "public, max-age=86400"},
|
||||
)
|
||||
|
||||
from .admin_auth_routes import router as admin_auth_router
|
||||
from .admin_media_routes import router as admin_media_router
|
||||
from .admin_post_routes import router as admin_post_router
|
||||
@@ -152,6 +166,13 @@ class GlobalSecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
self._cookie_secure = cookie_secure
|
||||
|
||||
async def dispatch(self, request: Request, call_next): # type: ignore[override]
|
||||
# Generate nonce BEFORE call_next so templates can access
|
||||
# request.state.csp_nonce during rendering.
|
||||
nonce: str | None = None
|
||||
if request.url.path.startswith("/admin"):
|
||||
nonce = secrets.token_urlsafe(18)
|
||||
request.state.csp_nonce = nonce
|
||||
|
||||
response = await call_next(request)
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
@@ -161,12 +182,16 @@ class GlobalSecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
# Always set base CSP
|
||||
response.headers["Content-Security-Policy"] = "; ".join(_BASE_CSP_DIRECTIVES)
|
||||
|
||||
# For admin routes, augment with nonce-based script-src
|
||||
if request.url.path.startswith("/admin"):
|
||||
nonce = secrets.token_urlsafe(18)
|
||||
request.state.csp_nonce = nonce
|
||||
# For admin routes, replace base script-src / style-src with
|
||||
# nonce-based versions (do NOT append — browsers use the first
|
||||
# occurrence when a directive is duplicated).
|
||||
if nonce is not None:
|
||||
csp = "; ".join(
|
||||
_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"style-src 'self' 'nonce-{nonce}'",
|
||||
|
||||
@@ -117,6 +117,8 @@ def admin_context(
|
||||
**extra: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the standard Jinja2 template context for admin routes."""
|
||||
from .version import VERSION
|
||||
|
||||
username: str = request.session.get("user", "")
|
||||
record = users_obj.find(username) if username else None
|
||||
csrf_val = csrf_token(request)
|
||||
@@ -126,11 +128,13 @@ def admin_context(
|
||||
"config": config,
|
||||
"store": store,
|
||||
"users": users_obj,
|
||||
"users_exist": users_obj.any,
|
||||
"csrf_token": csrf_val,
|
||||
"current_user": username or None,
|
||||
"current_role": record.role if record else None,
|
||||
"current_user_record": record,
|
||||
"csp_nonce": nonce,
|
||||
"version": VERSION,
|
||||
}
|
||||
ctx.update(extra)
|
||||
return ctx
|
||||
|
||||
@@ -61,10 +61,10 @@ class Users:
|
||||
self._snapshot = snapshot
|
||||
if os.path.isfile(self._path):
|
||||
self._cached = self._read_file()
|
||||
elif self._bootstrap_hash:
|
||||
self._cached = [User("admin", self._bootstrap_hash, "admin")]
|
||||
else:
|
||||
self._cached = []
|
||||
if not self._cached and self._bootstrap_hash:
|
||||
self._cached = [User("admin", self._bootstrap_hash, "admin")]
|
||||
return list(self._cached)
|
||||
|
||||
@property
|
||||
|
||||
@@ -12,16 +12,16 @@
|
||||
<h1>{{ "New post" if mode == "new" else "Edit post" }}</h1>
|
||||
<p class="lede">{{ "Draft a new article in Markdown." if mode == "new" else "Update and publish this post." }}</p>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<div class="page-head-actions">
|
||||
{% if mode == "edit" %}
|
||||
<a class="btn btn-ghost" href="/api/volumen/posts/{{ post.slug }}" target="_blank">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" 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
|
||||
</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-ghost" href="/admin/">Cancel</a>
|
||||
<button type="submit" form="post-form" class="btn btn-primary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
@@ -101,7 +101,7 @@
|
||||
<p class="help">Auto-generated from the first paragraph if left empty</p>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 1.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
|
||||
<div class="form-options">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="draft" {{ 'checked' if post.draft else '' }}> Draft
|
||||
</label>
|
||||
@@ -135,7 +135,7 @@
|
||||
</div>
|
||||
|
||||
<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="visual">Visual</button>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>volumen admin</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico">
|
||||
<style nonce="{{ csp_nonce | default('') }}">
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
@@ -250,6 +251,7 @@
|
||||
aspect-ratio: 16 / 9; background: var(--surface-2);
|
||||
background-size: cover; background-position: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background-image: var(--cover-url);
|
||||
}
|
||||
.post-cover.placeholder {
|
||||
display: grid; place-items: center; color: var(--fg-subtle);
|
||||
@@ -339,7 +341,7 @@
|
||||
.editor-tabs {
|
||||
display: inline-flex; gap: 2px; background: var(--surface-2);
|
||||
padding: 3px; border: 1px solid var(--border); border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.editor-tabs button {
|
||||
font: inherit; font-size: 0.8rem; font-weight: 550;
|
||||
@@ -810,6 +812,25 @@
|
||||
border-color: var(--accent-hover);
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -888,7 +909,7 @@
|
||||
<div class="name">{{ display_name }}</div>
|
||||
<div class="role">{{ current_role }}</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 }}">
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
<h3>No posts yet</h3>
|
||||
<p>Create your first post to get started.</p>
|
||||
<a class="btn btn-primary" href="/admin/posts/new" style="margin-top: 0.75rem;">Create post</a>
|
||||
<a class="btn btn-primary mt-3" href="/admin/posts/new">Create post</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="post-grid" id="post-grid">
|
||||
@@ -48,7 +48,7 @@
|
||||
data-slug="{{ post.slug }}"
|
||||
data-status="{{ status }}">
|
||||
{% 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 %}
|
||||
<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>
|
||||
@@ -67,12 +67,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-actions">
|
||||
<span class="btn btn-sm btn-ghost" style="cursor: default;">Edit</span>
|
||||
<span class="spacer" style="flex: 1;"></span>
|
||||
<span class="btn btn-sm btn-ghost btn-static">Edit</span>
|
||||
<span class="flex-spacer"></span>
|
||||
<form method="post" action="/admin/posts/{{ post.slug }}/delete"
|
||||
onsubmit="return confirm('Delete this post?');" style="margin: 0;">
|
||||
onsubmit="return confirm('Delete this post?');" class="form-inline">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-sm btn-ghost" 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>
|
||||
</button>
|
||||
</form>
|
||||
@@ -82,7 +82,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
<script nonce="{{ csp_nonce | default('') }}">
|
||||
(function () {
|
||||
var search = document.getElementById("post-search");
|
||||
var filterButtons = document.querySelectorAll("#post-filters .filter-chip");
|
||||
@@ -97,7 +97,7 @@
|
||||
var status = card.dataset.status;
|
||||
var matchesSearch = !q || title.indexOf(q) !== -1 || slug.indexOf(q) !== -1;
|
||||
var matchesFilter = currentFilter === "all" || status === currentFilter;
|
||||
card.style.display = matchesSearch && matchesFilter ? "" : "none";
|
||||
card.classList.toggle("is-hidden", !(matchesSearch && matchesFilter));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<div class="surface-head">
|
||||
<div>
|
||||
<h2>Account</h2>
|
||||
<p class="lede">Signed in as <strong style="color: var(--fg);">{{ current_user }}</strong> · {{ current_role }}</p>
|
||||
<p class="lede">Signed in as <strong class="text-fg-strong">{{ current_user }}</strong> · {{ current_role }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
</div>
|
||||
</form>
|
||||
{% if current_user_record and current_user_record.photo %}
|
||||
<form id="remove-photo-form" method="post" action="/admin/settings/photo/remove" style="display: none;">
|
||||
<form id="remove-photo-form" method="post" action="/admin/settings/photo/remove" class="form-hidden">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -94,7 +94,7 @@
|
||||
</div>
|
||||
<button type="submit" class="btn">Save name</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/settings/fediverse" class="settings-inline-form" style="margin-top: 1rem;">
|
||||
<form method="post" action="/admin/settings/fediverse" class="settings-inline-form form-spaced">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<div class="field">
|
||||
<label for="fediverse_creator">Fediverse handle</label>
|
||||
@@ -126,7 +126,7 @@
|
||||
</div>
|
||||
<button type="submit" class="btn">Update password</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/settings/username" class="settings-inline-form" style="margin-top: 1rem;">
|
||||
<form method="post" action="/admin/settings/username" class="settings-inline-form form-spaced">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
@@ -159,7 +159,7 @@
|
||||
<div class="user-info">
|
||||
<div class="user-name">
|
||||
{{ user.name or user.username }}
|
||||
{% if user.username == current_user %}<span class="badge badge-accent" style="margin-left: 0.4rem;">you</span>{% endif %}
|
||||
{% if user.username == current_user %}<span class="badge badge-accent badge-inline">you</span>{% endif %}
|
||||
</div>
|
||||
<div class="user-username">@{{ user.username }}</div>
|
||||
</div>
|
||||
@@ -167,7 +167,7 @@
|
||||
{% if user.username == current_user %}
|
||||
<span class="badge badge-neutral">{{ user.role }}</span>
|
||||
{% else %}
|
||||
<form method="post" action="/admin/settings/users/{{ user.username }}/role" style="margin: 0;">
|
||||
<form method="post" action="/admin/settings/users/{{ user.username }}/role" class="form-inline">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<select class="select select-sm" name="role" onchange="this.form.submit()">
|
||||
{% for role in roles %}
|
||||
@@ -178,7 +178,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if user.username != current_user %}
|
||||
<form method="post" action="/admin/settings/users/{{ user.username }}/delete" onsubmit="return confirm('Remove this user?');" style="margin: 0;">
|
||||
<form method="post" action="/admin/settings/users/{{ user.username }}/delete" onsubmit="return confirm('Remove this user?');" class="form-inline">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-sm btn-ghost btn-danger">Remove</button>
|
||||
</form>
|
||||
@@ -188,7 +188,7 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<div class="field-row-3">
|
||||
@@ -217,7 +217,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script nonce="{{ csp_nonce | default('') }}">
|
||||
(function () {
|
||||
var sections = document.querySelectorAll(".settings-panels .surface[id]");
|
||||
var navLinks = document.querySelectorAll(".settings-nav a[href^='#']");
|
||||
|
||||
+44
-2
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
@@ -27,6 +28,29 @@ class TestAdminLogin:
|
||||
assert response.status_code == 200
|
||||
assert "Sign in" in response.text
|
||||
|
||||
def test_login_page_omits_warning_when_users_exist(self, admin_client: TestClient) -> None:
|
||||
# Regression: `users_exist` was missing from the admin template
|
||||
# context, so the "No users configured" warning was shown on every
|
||||
# login page render regardless of whether authentication worked.
|
||||
response = admin_client.get("/admin/login")
|
||||
assert response.status_code == 200
|
||||
assert "No users configured" not in response.text
|
||||
|
||||
def test_login_page_shows_warning_when_no_users(self, config, store) -> None:
|
||||
# No bootstrap_hash, no users.toml → `users_exist` must be False
|
||||
# so the warning block is rendered.
|
||||
from volumen.app import create_app
|
||||
|
||||
users_path = config.users_file
|
||||
# Make sure no stale users file exists.
|
||||
if os.path.exists(users_path):
|
||||
os.remove(users_path)
|
||||
app = create_app(config, store)
|
||||
client = TestClient(app)
|
||||
response = client.get("/admin/login")
|
||||
assert response.status_code == 200
|
||||
assert "No users configured" in response.text
|
||||
|
||||
def test_login_with_valid_credentials(self, admin_client: TestClient) -> None:
|
||||
resp = admin_client.get("/admin/login")
|
||||
token = _csrf_from_body(resp.text)
|
||||
@@ -424,13 +448,31 @@ class TestOrphanedSession:
|
||||
def test_orphaned_session_is_rejected(
|
||||
self, admin_client: TestClient, admin_config_dir: Path
|
||||
) -> None:
|
||||
import tomli_w
|
||||
|
||||
from volumen.password import hash_password as hash_pw
|
||||
|
||||
_login(admin_client)
|
||||
resp = admin_client.get("/admin/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
# 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.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)
|
||||
assert resp.status_code in (302, 307, 303)
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tomllib
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from volumen.admin_auth_routes import router as auth_router
|
||||
from volumen.admin_media_routes import router as media_router
|
||||
from volumen.admin_post_routes import router as post_router
|
||||
@@ -21,3 +29,68 @@ def test_split_modules_import_and_construct_app(config, store) -> None:
|
||||
|
||||
assert app.state.config is config
|
||||
assert app.state.store is store
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Distribution wheel — make sure admin assets are packaged.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def test_pyproject_declares_web_package_data() -> None:
|
||||
"""Regression: admin panel returned 500 in production because Jinja
|
||||
templates and SVG icons were not declared as package data and the
|
||||
wheel dropped them. Catch accidental removal in pyproject.toml."""
|
||||
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text())
|
||||
package_data = (
|
||||
data.get("tool", {}).get("setuptools", {}).get("package-data", {}).get("volumen", [])
|
||||
)
|
||||
assert any("web/templates" in p and "*.jinja" in p for p in package_data), (
|
||||
f"volumen package-data must include web/templates/*.jinja, got: {package_data}"
|
||||
)
|
||||
assert any("web/static" in p for p in package_data), (
|
||||
f"volumen package-data must include web/static/*.svg, got: {package_data}"
|
||||
)
|
||||
|
||||
|
||||
def test_built_wheel_contains_web_templates_and_static(tmp_path: Path) -> None:
|
||||
"""Stronger version of the regression: build a wheel with ``uv build``
|
||||
and assert the admin assets are inside the resulting archive. Skipped
|
||||
when ``uv`` is not on PATH (e.g. minimal CI image)."""
|
||||
uv = shutil.which("uv")
|
||||
if uv is None:
|
||||
pytest.skip("uv not on PATH — cannot build the wheel")
|
||||
|
||||
out_dir = tmp_path / "wheels"
|
||||
out_dir.mkdir()
|
||||
result = subprocess.run(
|
||||
[uv, "build", "--wheel", "--out-dir", str(out_dir)],
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"uv build failed (exit {result.returncode}):\n"
|
||||
f"stdout:\n{result.stdout}\n"
|
||||
f"stderr:\n{result.stderr}"
|
||||
)
|
||||
|
||||
wheels = sorted(out_dir.glob("*.whl"))
|
||||
assert len(wheels) == 1, f"Expected exactly one wheel, got {wheels}"
|
||||
with zipfile.ZipFile(wheels[0]) as zf:
|
||||
names = zf.namelist()
|
||||
|
||||
templates = [n for n in names if n.startswith("volumen/web/templates/")]
|
||||
statics = [n for n in names if n.startswith("volumen/web/static/")]
|
||||
assert templates, (
|
||||
f"web/templates missing from wheel {wheels[0].name}; "
|
||||
f"admin panel would 500 on first request. Members: {names}"
|
||||
)
|
||||
assert statics, (
|
||||
f"web/static missing from wheel {wheels[0].name}; "
|
||||
f"admin panel icons would 404. Members: {names}"
|
||||
)
|
||||
assert any(n.endswith(".jinja") for n in templates), templates
|
||||
assert any(n.endswith(".svg") for n in statics), statics
|
||||
|
||||
@@ -166,6 +166,117 @@ class TestSecurityHeaders:
|
||||
assert "'unsafe-inline'" not in csp
|
||||
assert "nonce-" in csp
|
||||
|
||||
def test_admin_csp_has_no_duplicate_directives(self, admin_client: TestClient) -> None:
|
||||
# Regression: nonce-augmented script-src / style-src used to be
|
||||
# appended after the base ones, so the same directive name appeared
|
||||
# twice. Browsers use the FIRST occurrence and silently drop the
|
||||
# nonce, breaking all admin JS. Verify each directive is unique.
|
||||
response = admin_client.get("/admin/login")
|
||||
csp = response.headers.get("content-security-policy", "")
|
||||
directives = [d.strip().split(maxsplit=1)[0] for d in csp.split(";")]
|
||||
assert len(directives) == len(set(directives)), (
|
||||
f"Duplicate CSP directives: {[d for d in directives if directives.count(d) > 1]}"
|
||||
)
|
||||
# And specifically: only one script-src, only one style-src.
|
||||
assert directives.count("script-src") == 1
|
||||
assert directives.count("style-src") == 1
|
||||
|
||||
def test_admin_csp_nonce_appears_in_rendered_html(self, admin_client: TestClient) -> None:
|
||||
# Regression: nonce used to be set AFTER call_next, so templates
|
||||
# rendered with an empty csp_nonce and the matching <script nonce="...">
|
||||
# tag in the response never carried the nonce that CSP demanded.
|
||||
# Browsers then blocked every inline script. Verify the nonce in
|
||||
# the response header matches the nonce on the <script> tags.
|
||||
import re
|
||||
|
||||
response = admin_client.get("/admin/login")
|
||||
csp = response.headers.get("content-security-policy", "")
|
||||
match = re.search(r"nonce-([A-Za-z0-9_-]+)", csp)
|
||||
assert match is not None, "No nonce in CSP"
|
||||
nonce = match.group(1)
|
||||
# At least one inline <script> must carry this exact nonce.
|
||||
script_tags = re.findall(r"<script[^>]*>", response.text)
|
||||
assert script_tags, "No <script> tags rendered"
|
||||
assert any(f'nonce="{nonce}"' in tag for tag in script_tags), (
|
||||
"Rendered <script> tags do not carry the CSP nonce:\n" + "\n".join(script_tags)
|
||||
)
|
||||
|
||||
def test_admin_pages_have_no_blocking_inline_styles(self, admin_client: TestClient) -> None:
|
||||
# Regression: with `style-src 'self' 'nonce-...'`, any HTML element
|
||||
# carrying `style="..."` is blocked by the browser — including
|
||||
# style attributes that do *not* start with `--` (CSS custom
|
||||
# properties, which are explicitly allowed by CSP3). Walk every
|
||||
# admin page and assert no offending attribute survives.
|
||||
import re
|
||||
|
||||
def _offending(body: str) -> list[str]:
|
||||
results: list[str] = []
|
||||
for match in re.finditer(r"\sstyle=\"([^\"]*)\"", body):
|
||||
value = match.group(1).strip()
|
||||
# `style="--foo: ..."` is a CSS custom-property declaration
|
||||
# and is allowed by CSP3 without `unsafe-inline`. Anything
|
||||
# else is a style application and must move into a class.
|
||||
if not value.startswith("--"):
|
||||
results.append(match.group(0))
|
||||
return results
|
||||
|
||||
for path in ("/admin/login", "/admin/", "/admin/settings"):
|
||||
response = admin_client.get(path)
|
||||
assert response.status_code == 200, f"{path} returned {response.status_code}"
|
||||
offending = _offending(response.text)
|
||||
assert not offending, (
|
||||
f"{path} renders {len(offending)} non-custom-property inline "
|
||||
f"style attribute(s) that strict CSP will block:\n" + "\n".join(offending)
|
||||
)
|
||||
|
||||
# Also check the post form (requires login) — it had several
|
||||
# one-off utility styles that should now be classes.
|
||||
from tests.test_admin import _login
|
||||
|
||||
_login(admin_client)
|
||||
response = admin_client.get("/admin/posts/new")
|
||||
assert response.status_code == 200
|
||||
offending = _offending(response.text)
|
||||
assert not offending, (
|
||||
"/admin/posts/new renders inline style attributes that strict "
|
||||
"CSP will block:\n" + "\n".join(offending)
|
||||
)
|
||||
|
||||
def test_favicon_ico_serves_svg(self, client: TestClient) -> None:
|
||||
# Regression: browsers auto-request /favicon.ico even when the page
|
||||
# declares an explicit <link rel="icon">, and the previous behaviour
|
||||
# was a 404. Serve the packaged icon SVG so the console is clean
|
||||
# and the tab gets a proper icon.
|
||||
response = client.get("/favicon.ico")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("image/svg+xml")
|
||||
body = response.content
|
||||
assert body.startswith(b"<") and b"</svg>" in body
|
||||
# And the admin login page must declare the icon explicitly.
|
||||
login = client.get("/admin/login")
|
||||
assert login.status_code == 200
|
||||
assert 'rel="icon"' in login.text
|
||||
assert 'href="/favicon.ico"' in login.text
|
||||
|
||||
def test_admin_layout_renders_version(self, admin_client: TestClient) -> None:
|
||||
# Regression: `version` was not in admin_context, so the sidebar
|
||||
# footer rendered as just "v" with no number behind it.
|
||||
# The sidebar footer only renders on the authenticated layout,
|
||||
# so we log in first.
|
||||
from tests.test_admin import _login
|
||||
from volumen.version import VERSION
|
||||
|
||||
_login(admin_client)
|
||||
response = admin_client.get("/admin/")
|
||||
assert response.status_code == 200
|
||||
assert "sidebar-version" in response.text
|
||||
# The version label is "v{version}" inside a div.sidebar-version.
|
||||
# Refuse the bare "v</div>" rendering that this bug produced.
|
||||
assert "v" + VERSION in response.text, (
|
||||
f"Expected the sidebar version label to include {VERSION}; "
|
||||
f"admin_context is not propagating the package version"
|
||||
)
|
||||
|
||||
def test_https_only_cookie_unset_when_untrusted(self, client: TestClient) -> None:
|
||||
# Default trust_proxy=false — X-Forwarded-Proto is ignored.
|
||||
response = client.get("/admin/login")
|
||||
|
||||
@@ -23,6 +23,34 @@ class TestBootstrap:
|
||||
users = Users(path=users_path)
|
||||
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:
|
||||
def test_find_existing_user(self, users: Users) -> None:
|
||||
|
||||
Reference in New Issue
Block a user