feat(p4): web UI — PocketID OIDC with a fail-closed group check, review flows
Six pages (inbox, search, note, edit, browse, health), HTMX vendored, markdown rendered with raw HTML disabled, CSP + origin check on every POST. The group check rejects a missing scope, a missing claim and a wrong-typed claim separately, each with its own test (§14.21). /healthz, /readyz and /metrics stay open and carry no note content; readiness ignores Bifrost (§14.17).
This commit is contained in:
1 parent
cc536b80ab
commit
5853b7b1fa
20 files changed
+1662
-2
No files matched your search
Binary file not shown.
+8
-2
@@ -56,8 +56,12 @@ def utcnow() -> str:
|
||||
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def parse(text: str) -> tuple[CommentedMap, str]:
|
||||
"""Split a note into (frontmatter, body). Both are NFC-normalised."""
|
||||
def parse(text: str, *, strict: bool = False) -> tuple[CommentedMap, str]:
|
||||
"""Split a note into (frontmatter, body). Both are NFC-normalised.
|
||||
|
||||
strict=True re-raises broken YAML — used where a human just typed it. The
|
||||
scanner stays lenient so one bad file cannot stop the whole vault indexing.
|
||||
"""
|
||||
text = nfc(text.replace("\r\n", "\n"))
|
||||
m = _FM_RE.match(text)
|
||||
if not m:
|
||||
@@ -67,6 +71,8 @@ def parse(text: str) -> tuple[CommentedMap, str]:
|
||||
try:
|
||||
data = _yaml().load(raw)
|
||||
except Exception: # a hand-edited file with broken YAML must not kill the scan
|
||||
if strict:
|
||||
raise
|
||||
data = None
|
||||
if data is None:
|
||||
data = CommentedMap()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Web UI (§10)."""
|
||||
@@ -0,0 +1,497 @@
|
||||
"""Web UI on :8081 (§10).
|
||||
|
||||
Auth is in-app OIDC against PocketID with a fail-closed group check. /healthz,
|
||||
/readyz and /metrics stay open — they are probed and scraped in-cluster — and
|
||||
carry no note content.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import difflib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
from fastapi import FastAPI, Form, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from prometheus_client import CONTENT_TYPE_LATEST, CollectorRegistry, Gauge, generate_latest
|
||||
|
||||
from mdmem.errors import MdmemError, VersionConflict
|
||||
from mdmem.notes import parse as parse_note
|
||||
from mdmem.service import MemoryService
|
||||
from mdmem.ui.auth import (
|
||||
FLOW_COOKIE,
|
||||
SESSION_COOKIE,
|
||||
AuthError,
|
||||
FlowCodec,
|
||||
OidcClient,
|
||||
Principal,
|
||||
SessionCodec,
|
||||
authorize_claims,
|
||||
new_pkce,
|
||||
)
|
||||
from mdmem.ui.render import render_markdown
|
||||
|
||||
log = logging.getLogger("mdmem.ui")
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
CSP = (
|
||||
"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "
|
||||
"script-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
||||
)
|
||||
OPEN_PATHS = ("/healthz", "/readyz", "/metrics")
|
||||
OPEN_PREFIXES = ("/ui/auth/", "/ui/static/")
|
||||
|
||||
|
||||
def build_ui_app(svc: MemoryService) -> FastAPI: # noqa: C901 - one place, many routes
|
||||
settings = svc.settings
|
||||
if settings.ui_auth_disabled:
|
||||
# §10 allows shipping the UI without OIDC, but then it must not be reachable
|
||||
# through an HTTPRoute — port-forward only (§14.19).
|
||||
log.error(
|
||||
"UI_AUTH_DISABLED is set: /ui is unauthenticated. Never put an HTTPRoute in "
|
||||
"front of this listener — reach it with kubectl port-forward only (§10, §14.19)."
|
||||
)
|
||||
elif not settings.session_secret:
|
||||
raise RuntimeError(
|
||||
"SESSION_SECRET is not set and UI_AUTH_DISABLED is off; refusing to start a UI "
|
||||
"whose sessions cannot survive a restart"
|
||||
)
|
||||
templates = Jinja2Templates(directory=str(HERE / "templates"))
|
||||
templates.env.globals["render_markdown"] = render_markdown
|
||||
session_secret = settings.session_secret or secrets.token_hex(32)
|
||||
sessions = SessionCodec(session_secret, settings.session_ttl_hours)
|
||||
flows = FlowCodec(session_secret)
|
||||
oidc = OidcClient(settings)
|
||||
|
||||
app = FastAPI(title="mdmem", docs_url=None, redoc_url=None, openapi_url=None)
|
||||
app.mount("/ui/static", StaticFiles(directory=str(HERE / "static")), name="static")
|
||||
|
||||
registry = CollectorRegistry()
|
||||
g_notes = Gauge("mdmem_notes", "notes by lifecycle state", ["state"], registry=registry)
|
||||
g_chunks = Gauge("mdmem_chunks", "indexed chunks", registry=registry)
|
||||
g_pending = Gauge("mdmem_vec_pending", "chunks waiting for an embedding", registry=registry)
|
||||
g_embed_ok = Gauge("mdmem_embed_ok", "1 when the embedding backend answered", registry=registry)
|
||||
g_embed_ms = Gauge("mdmem_embed_last_ms", "latency of the last embedding call", registry=registry)
|
||||
g_searches = Gauge("mdmem_searches_total", "searches served", registry=registry)
|
||||
g_writes = Gauge("mdmem_writes_total", "notes written", registry=registry)
|
||||
g_unresolved = Gauge("mdmem_unresolved_links", "wikilinks with no target", registry=registry)
|
||||
|
||||
# -- middleware --------------------------------------------------------
|
||||
@app.middleware("http")
|
||||
async def security(request: Request, call_next): # type: ignore[no-untyped-def]
|
||||
path = request.url.path
|
||||
is_open = path in OPEN_PATHS or path.startswith(OPEN_PREFIXES)
|
||||
|
||||
if request.method in ("POST", "PUT", "DELETE", "PATCH") and not is_open:
|
||||
origin = request.headers.get("origin")
|
||||
if origin is not None and not _origin_allowed(origin, settings, request):
|
||||
return JSONResponse({"error": "bad origin"}, status_code=403)
|
||||
|
||||
if not is_open:
|
||||
principal = _principal(request)
|
||||
if principal is None:
|
||||
if settings.ui_auth_disabled:
|
||||
request.state.principal = Principal(sub="local", name="local", email="")
|
||||
else:
|
||||
nxt = request.url.path
|
||||
return RedirectResponse(f"/ui/auth/login?next={nxt}", status_code=302)
|
||||
else:
|
||||
request.state.principal = principal
|
||||
|
||||
response = await call_next(request)
|
||||
response.headers["Content-Security-Policy"] = CSP
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
return response
|
||||
|
||||
def _principal(request: Request) -> Principal | None:
|
||||
return sessions.read(request.cookies.get(SESSION_COOKIE))
|
||||
|
||||
def _ctx(request: Request, **kw: Any) -> dict[str, Any]:
|
||||
who = getattr(request.state, "principal", None)
|
||||
counts = svc.health().counts
|
||||
return {"request": request, "user": who, "counts": counts, **kw}
|
||||
|
||||
# -- health / metrics --------------------------------------------------
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> JSONResponse:
|
||||
"""Deliberately dumb: a liveness probe must not depend on the index (§13a.3)."""
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
@app.get("/readyz")
|
||||
async def readyz() -> JSONResponse:
|
||||
ok, checks = svc.ready()
|
||||
return JSONResponse({"ready": ok, "checks": checks}, status_code=200 if ok else 503)
|
||||
|
||||
@app.get("/metrics")
|
||||
async def metrics() -> Response:
|
||||
h = svc.health()
|
||||
for state, n in h.counts.items():
|
||||
g_notes.labels(state=state).set(n)
|
||||
g_chunks.set(h.chunks)
|
||||
g_pending.set(h.vec_pending)
|
||||
g_embed_ok.set(1 if h.embed_ok else 0)
|
||||
g_embed_ms.set(h.embed_last_ms or 0)
|
||||
g_searches.set(svc.metrics["search"])
|
||||
g_writes.set(svc.metrics["writes"])
|
||||
g_unresolved.set(h.unresolved_links)
|
||||
return Response(generate_latest(registry), media_type=CONTENT_TYPE_LATEST)
|
||||
|
||||
# -- auth --------------------------------------------------------------
|
||||
@app.get("/ui/auth/login")
|
||||
async def login(request: Request, next: str = "/ui/inbox") -> Response:
|
||||
if settings.ui_auth_disabled:
|
||||
return RedirectResponse(next, status_code=302)
|
||||
state = secrets.token_urlsafe(24)
|
||||
verifier, challenge = new_pkce()
|
||||
try:
|
||||
url = await oidc.authorize_url(state, challenge)
|
||||
except Exception as exc:
|
||||
log.error("OIDC discovery failed", extra={"fields": {"error": str(exc)[:200]}})
|
||||
return PlainTextResponse("identity provider unreachable", status_code=503)
|
||||
resp = RedirectResponse(url, status_code=302)
|
||||
resp.set_cookie(
|
||||
FLOW_COOKIE,
|
||||
flows.issue(state, verifier, next if next.startswith("/ui/") else "/ui/inbox"),
|
||||
max_age=600,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
path="/ui/auth",
|
||||
)
|
||||
return resp
|
||||
|
||||
@app.get("/ui/auth/callback")
|
||||
async def callback(request: Request, code: str = "", state: str = "") -> Response:
|
||||
flow = flows.read(request.cookies.get(FLOW_COOKIE))
|
||||
if not flow or not code or not secrets.compare_digest(flow["state"], state):
|
||||
return PlainTextResponse("invalid login state; start again", status_code=400)
|
||||
try:
|
||||
tokens = await oidc.exchange(code, flow["verifier"])
|
||||
claims = await oidc.claims(tokens)
|
||||
principal = authorize_claims(claims, settings)
|
||||
except AuthError as exc:
|
||||
return PlainTextResponse(f"access denied: {exc.reason}", status_code=exc.status)
|
||||
except Exception as exc:
|
||||
log.error("login failed", extra={"fields": {"error": str(exc)[:200]}})
|
||||
return PlainTextResponse("login failed", status_code=502)
|
||||
resp = RedirectResponse(flow.get("next") or "/ui/inbox", status_code=302)
|
||||
resp.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
sessions.issue(principal),
|
||||
max_age=settings.session_ttl_hours * 3600,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
resp.delete_cookie(FLOW_COOKIE, path="/ui/auth")
|
||||
log.info("login", extra={"fields": {"sub": principal.sub}})
|
||||
return resp
|
||||
|
||||
@app.get("/ui/auth/logout")
|
||||
async def logout() -> Response:
|
||||
resp = RedirectResponse("/ui/auth/login", status_code=302)
|
||||
resp.delete_cookie(SESSION_COOKIE, path="/")
|
||||
return resp
|
||||
|
||||
# -- pages -------------------------------------------------------------
|
||||
@app.get("/")
|
||||
async def root() -> Response:
|
||||
return RedirectResponse("/ui/inbox", status_code=302)
|
||||
|
||||
@app.get("/ui/inbox", response_class=HTMLResponse)
|
||||
async def inbox(request: Request) -> Response:
|
||||
items = []
|
||||
for row in svc.list_notes(state="inbox", limit=200, order="created")[::-1]:
|
||||
note = svc.get(row["id"])
|
||||
entry = {"note": note, "diff": None, "target": None, "duplicates": []}
|
||||
if note.get("supersede_chain"):
|
||||
entry["supersedes"] = note["supersede_chain"]
|
||||
revises = svc.get_note(row["id"]).revises
|
||||
if revises:
|
||||
try:
|
||||
target = svc.get(revises)
|
||||
entry["target"] = target
|
||||
entry["diff"] = _diff(target["body"], note["body"], target["title"])
|
||||
except MdmemError:
|
||||
entry["target"] = None
|
||||
for dup_id in note["possible_duplicates"]:
|
||||
try:
|
||||
entry["duplicates"].append(svc.get(dup_id))
|
||||
except MdmemError:
|
||||
continue
|
||||
items.append(entry)
|
||||
return templates.TemplateResponse(request, "inbox.html", _ctx(request, items=items))
|
||||
|
||||
@app.post("/ui/n/{note_id}/approve")
|
||||
async def approve(request: Request, note_id: str, body: str = Form(default="")) -> Response:
|
||||
try:
|
||||
await svc.approve(note_id, edited_body=body or None)
|
||||
except MdmemError as exc:
|
||||
return _error(request, templates, exc)
|
||||
return RedirectResponse("/ui/inbox", status_code=303)
|
||||
|
||||
@app.post("/ui/n/{note_id}/reject")
|
||||
async def reject(request: Request, note_id: str, reason: str = Form(default="")) -> Response:
|
||||
try:
|
||||
await svc.reject(note_id, reason)
|
||||
except MdmemError as exc:
|
||||
return _error(request, templates, exc)
|
||||
return RedirectResponse("/ui/inbox", status_code=303)
|
||||
|
||||
@app.post("/ui/n/{note_id}/merge")
|
||||
async def merge(request: Request, note_id: str, target: str = Form(...)) -> Response:
|
||||
try:
|
||||
await svc.merge_into(note_id, target)
|
||||
except MdmemError as exc:
|
||||
return _error(request, templates, exc)
|
||||
return RedirectResponse("/ui/inbox", status_code=303)
|
||||
|
||||
@app.get("/ui/search", response_class=HTMLResponse)
|
||||
async def search_page(
|
||||
request: Request,
|
||||
q: str = Query(default=""),
|
||||
type: str = Query(default=""),
|
||||
tag: str = Query(default=""),
|
||||
state: str = Query(default=""),
|
||||
since: str = Query(default=""),
|
||||
until: str = Query(default=""),
|
||||
superseded: bool = Query(default=False),
|
||||
) -> Response:
|
||||
resp = None
|
||||
if q.strip():
|
||||
resp = await svc.search(
|
||||
q,
|
||||
k=20,
|
||||
types=[type] if type else None,
|
||||
tags=[tag] if tag else None,
|
||||
since=since or None,
|
||||
until=until or None,
|
||||
include_inbox=state != "active",
|
||||
include_archive=state == "archive",
|
||||
include_superseded=superseded,
|
||||
debug=True,
|
||||
max_chars=100_000,
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"search.html",
|
||||
_ctx(
|
||||
request,
|
||||
q=q,
|
||||
resp=resp,
|
||||
type=type,
|
||||
tag=tag,
|
||||
state=state,
|
||||
since=since,
|
||||
until=until,
|
||||
superseded=superseded,
|
||||
),
|
||||
)
|
||||
|
||||
@app.get("/ui/n/{note_id}", response_class=HTMLResponse)
|
||||
async def note_page(request: Request, note_id: str) -> Response:
|
||||
try:
|
||||
note = svc.get(note_id)
|
||||
except MdmemError as exc:
|
||||
return _error(request, templates, exc)
|
||||
history = svc.vault.history(note_id)
|
||||
resolve = {link["title"]: link["id"] for link in note["links"] if link.get("id")}
|
||||
return templates.TemplateResponse(
|
||||
request, "note.html", _ctx(request, note=note, history=history, resolve=resolve)
|
||||
)
|
||||
|
||||
@app.get("/ui/n/{note_id}/history/{stamp}", response_class=HTMLResponse)
|
||||
async def history_diff(request: Request, note_id: str, stamp: str) -> Response:
|
||||
try:
|
||||
old = svc.vault.read_history(note_id, stamp)
|
||||
except Exception as exc:
|
||||
return PlainTextResponse(str(exc), status_code=404)
|
||||
current = svc.get_note(note_id).to_text()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partial_diff.html",
|
||||
{"request": request, "diff": _diff(old, current, f"{stamp} → jetzt")},
|
||||
)
|
||||
|
||||
@app.get("/ui/n/{note_id}/edit", response_class=HTMLResponse)
|
||||
async def edit_page(request: Request, note_id: str) -> Response:
|
||||
try:
|
||||
note = svc.get_note(note_id)
|
||||
except MdmemError as exc:
|
||||
return _error(request, templates, exc)
|
||||
fm_text = note.to_text().split("---\n")[1] if note.to_text().startswith("---") else ""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"edit.html",
|
||||
_ctx(request, note=note, version=note.file_hash, frontmatter=fm_text, conflict=None),
|
||||
)
|
||||
|
||||
@app.post("/ui/n/{note_id}/edit", response_class=HTMLResponse)
|
||||
async def edit_save(
|
||||
request: Request,
|
||||
note_id: str,
|
||||
body: str = Form(...),
|
||||
frontmatter: str = Form(default=""),
|
||||
expected_version: str = Form(...),
|
||||
) -> Response:
|
||||
fm: dict[str, Any] | None = None
|
||||
if frontmatter.strip():
|
||||
try:
|
||||
fm, _ = parse_note(f"---\n{frontmatter.strip()}\n---\n", strict=True)
|
||||
except Exception as exc:
|
||||
return PlainTextResponse(f"frontmatter is not valid YAML: {exc}", status_code=400)
|
||||
try:
|
||||
await svc.edit(note_id, body=body, expected_version=expected_version, frontmatter=fm)
|
||||
except VersionConflict as exc:
|
||||
current = svc.get_note(note_id)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"edit.html",
|
||||
_ctx(
|
||||
request,
|
||||
note=current,
|
||||
version=current.file_hash,
|
||||
frontmatter=frontmatter,
|
||||
conflict={
|
||||
"message": exc.message,
|
||||
"diff": _diff(current.body, body, "auf der Platte → dein Entwurf"),
|
||||
"yours": body,
|
||||
},
|
||||
),
|
||||
status_code=409,
|
||||
)
|
||||
except MdmemError as exc:
|
||||
return _error(request, templates, exc)
|
||||
return RedirectResponse(f"/ui/n/{note_id}", status_code=303)
|
||||
|
||||
@app.post("/ui/preview", response_class=HTMLResponse)
|
||||
async def preview(request: Request, body: str = Form(default="")) -> Response:
|
||||
return HTMLResponse(render_markdown(body))
|
||||
|
||||
@app.post("/ui/n/{note_id}/state")
|
||||
async def set_state(request: Request, note_id: str, state: str = Form(...)) -> Response:
|
||||
try:
|
||||
await svc.set_state(note_id, state)
|
||||
except MdmemError as exc:
|
||||
return _error(request, templates, exc)
|
||||
return RedirectResponse(f"/ui/n/{note_id}", status_code=303)
|
||||
|
||||
@app.post("/ui/n/{note_id}/delete")
|
||||
async def delete(request: Request, note_id: str, confirm: str = Form(default="")) -> Response:
|
||||
if confirm != note_id:
|
||||
return PlainTextResponse("confirmation did not match the note id", status_code=400)
|
||||
try:
|
||||
await svc.hard_delete(note_id)
|
||||
except MdmemError as exc:
|
||||
return _error(request, templates, exc)
|
||||
return RedirectResponse("/ui/browse", status_code=303)
|
||||
|
||||
@app.get("/ui/browse", response_class=HTMLResponse)
|
||||
async def browse(
|
||||
request: Request,
|
||||
state: str = Query(default=""),
|
||||
order: str = Query(default="updated"),
|
||||
tag: str = Query(default=""),
|
||||
) -> Response:
|
||||
rows = svc.list_notes(state=state or None, limit=2000, order=order)
|
||||
if tag:
|
||||
rows = [r for r in rows if tag in r["tags"]]
|
||||
cloud: dict[str, int] = {}
|
||||
for r in svc.list_notes(limit=5000):
|
||||
for t in r["tags"]:
|
||||
cloud[t] = cloud.get(t, 0) + 1
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"browse.html",
|
||||
_ctx(
|
||||
request,
|
||||
rows=rows,
|
||||
order=order,
|
||||
state=state,
|
||||
tag=tag,
|
||||
cloud=sorted(cloud.items(), key=lambda kv: -kv[1]),
|
||||
),
|
||||
)
|
||||
|
||||
@app.get("/ui/health", response_class=HTMLResponse)
|
||||
async def health_page(request: Request) -> Response:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"health.html",
|
||||
_ctx(
|
||||
request,
|
||||
health=svc.health(),
|
||||
unresolved=svc.unresolved_links()[:100],
|
||||
calls=svc.audit.recent(100),
|
||||
settings=settings,
|
||||
),
|
||||
)
|
||||
|
||||
@app.post("/ui/health/reindex")
|
||||
async def reindex(request: Request, full: str = Form(default="")) -> Response:
|
||||
await svc.reindex(full=bool(full))
|
||||
return RedirectResponse("/ui/health", status_code=303)
|
||||
|
||||
@app.post("/ui/health/reembed")
|
||||
async def reembed(request: Request) -> Response:
|
||||
def _mark() -> None:
|
||||
with svc.db.write() as w:
|
||||
w.execute("UPDATE chunks SET vec_pending=1")
|
||||
w.execute("DELETE FROM chunks_vec")
|
||||
|
||||
await asyncio.to_thread(_mark)
|
||||
await svc.embed_pending()
|
||||
return RedirectResponse("/ui/health", status_code=303)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _origin_allowed(origin: str, settings: Any, request: Request) -> bool:
|
||||
allowed = set(settings.ui_allowed_origins)
|
||||
if settings.ui_base_url:
|
||||
allowed.add(settings.ui_base_url)
|
||||
if not allowed: # not configured: fall back to same-host
|
||||
host = request.headers.get("host", "")
|
||||
return urlparse(origin).netloc == host
|
||||
return origin.rstrip("/") in {a.rstrip("/") for a in allowed}
|
||||
|
||||
|
||||
def _diff(old: str, new: str, label: str) -> str:
|
||||
return "\n".join(
|
||||
difflib.unified_diff(
|
||||
(old or "").splitlines(),
|
||||
(new or "").splitlines(),
|
||||
fromfile=f"{label} (aktiv)",
|
||||
tofile="Vorschlag",
|
||||
lineterm="",
|
||||
n=3,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _error(request: Request, templates: Jinja2Templates, exc: MdmemError) -> Response:
|
||||
status = {"NOT_FOUND": 404, "VERSION_CONFLICT": 409}.get(exc.code, 400)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"error.html",
|
||||
{"request": request, "error": exc.as_dict(), "user": None, "counts": {}},
|
||||
status_code=status,
|
||||
)
|
||||
|
||||
|
||||
def query_string(**kw: Any) -> str: # pragma: no cover - template helper
|
||||
return urlencode({k: v for k, v in kw.items() if v})
|
||||
|
||||
|
||||
def json_dumps(value: Any) -> str: # pragma: no cover - template helper
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
@@ -0,0 +1,225 @@
|
||||
"""PocketID OIDC with a group check that fails closed (§10, §14.21).
|
||||
|
||||
There is no forward-auth middleware in this cluster, so the UI authenticates
|
||||
itself, exactly like trek/karakeep/open-webui do. Authorisation is a group
|
||||
claim: membership is managed in PocketID, so access changes need no redeploy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
from mdmem.config import Settings
|
||||
|
||||
log = logging.getLogger("mdmem.auth")
|
||||
|
||||
SESSION_COOKIE = "mdmem_session"
|
||||
FLOW_COOKIE = "mdmem_oidc_flow"
|
||||
FLOW_TTL_S = 600
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
def __init__(self, reason: str, status: int = 403) -> None:
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
self.status = status
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Principal:
|
||||
sub: str
|
||||
name: str
|
||||
email: str
|
||||
|
||||
|
||||
def _b64(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def new_pkce() -> tuple[str, str]:
|
||||
verifier = _b64(secrets.token_bytes(64))
|
||||
challenge = _b64(hashlib.sha256(verifier.encode()).digest())
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
def authorize_claims(claims: dict[str, Any], settings: Settings) -> Principal:
|
||||
"""Fail closed: the only accepted outcome is 'the required group is present'.
|
||||
|
||||
A missing scope, a missing claim, or a claim of the wrong type are all
|
||||
rejections — never 'the token is valid, so allow' (§14.21).
|
||||
"""
|
||||
sub = str(claims.get("sub") or "")
|
||||
claim_name = settings.oidc_groups_claim
|
||||
required = settings.oidc_required_group
|
||||
seen = sorted(claims.keys())
|
||||
|
||||
if not required:
|
||||
raise AuthError("OIDC_REQUIRED_GROUP is not configured; refusing to authorise")
|
||||
if claim_name not in claims:
|
||||
log.warning(
|
||||
"login rejected: group claim absent",
|
||||
extra={"fields": {"sub": sub, "claim": claim_name, "claims_seen": seen}},
|
||||
)
|
||||
raise AuthError(f"the {claim_name!r} claim is absent — is the groups scope configured?")
|
||||
|
||||
groups = claims.get(claim_name)
|
||||
if isinstance(groups, str):
|
||||
groups = [groups]
|
||||
if not isinstance(groups, list) or not all(isinstance(g, str) for g in groups):
|
||||
log.warning(
|
||||
"login rejected: group claim has the wrong type",
|
||||
extra={"fields": {"sub": sub, "claim": claim_name, "type": type(groups).__name__}},
|
||||
)
|
||||
raise AuthError(f"the {claim_name!r} claim is not a list of strings")
|
||||
if required not in groups:
|
||||
log.warning(
|
||||
"login rejected: required group missing",
|
||||
extra={"fields": {"sub": sub, "claim": claim_name, "required": required}},
|
||||
)
|
||||
raise AuthError(f"membership in {required!r} is required")
|
||||
|
||||
return Principal(
|
||||
sub=sub,
|
||||
name=str(claims.get("name") or claims.get("preferred_username") or sub),
|
||||
email=str(claims.get("email") or ""),
|
||||
)
|
||||
|
||||
|
||||
class SessionCodec:
|
||||
def __init__(self, secret: str, ttl_hours: int) -> None:
|
||||
if not secret:
|
||||
raise RuntimeError("SESSION_SECRET is not set; refusing to issue sessions")
|
||||
self._s = URLSafeTimedSerializer(secret, salt="mdmem-session")
|
||||
self.ttl_s = ttl_hours * 3600
|
||||
|
||||
def issue(self, principal: Principal) -> str:
|
||||
return self._s.dumps(
|
||||
{
|
||||
"sub": principal.sub,
|
||||
"name": principal.name,
|
||||
"email": principal.email,
|
||||
"group_ok": True,
|
||||
"iat": int(time.time()),
|
||||
}
|
||||
)
|
||||
|
||||
def read(self, token: str | None) -> Principal | None:
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
data = self._s.loads(token, max_age=self.ttl_s)
|
||||
except (BadSignature, SignatureExpired):
|
||||
return None
|
||||
if not data.get("group_ok"):
|
||||
return None
|
||||
return Principal(sub=data.get("sub", ""), name=data.get("name", ""), email=data.get("email", ""))
|
||||
|
||||
|
||||
class FlowCodec:
|
||||
"""Signed, short-lived carrier for the PKCE verifier and CSRF state."""
|
||||
|
||||
def __init__(self, secret: str) -> None:
|
||||
self._s = URLSafeTimedSerializer(secret, salt="mdmem-oidc-flow")
|
||||
|
||||
def issue(self, state: str, verifier: str, next_url: str) -> str:
|
||||
return self._s.dumps({"state": state, "verifier": verifier, "next": next_url})
|
||||
|
||||
def read(self, token: str | None) -> dict[str, str] | None:
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
return self._s.loads(token, max_age=FLOW_TTL_S)
|
||||
except (BadSignature, SignatureExpired):
|
||||
return None
|
||||
|
||||
|
||||
class OidcClient:
|
||||
def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None:
|
||||
self.settings = settings
|
||||
self._client = client or httpx.AsyncClient(timeout=15.0)
|
||||
self._meta: dict[str, Any] | None = None
|
||||
self._jwks: dict[str, Any] | None = None
|
||||
|
||||
async def metadata(self) -> dict[str, Any]:
|
||||
if self._meta is None:
|
||||
url = f"{self.settings.oidc_issuer}/.well-known/openid-configuration"
|
||||
r = await self._client.get(url)
|
||||
r.raise_for_status()
|
||||
self._meta = r.json()
|
||||
return self._meta
|
||||
|
||||
async def jwks(self) -> dict[str, Any]:
|
||||
if self._jwks is None:
|
||||
meta = await self.metadata()
|
||||
r = await self._client.get(meta["jwks_uri"])
|
||||
r.raise_for_status()
|
||||
self._jwks = r.json()
|
||||
return self._jwks
|
||||
|
||||
async def authorize_url(self, state: str, challenge: str) -> str:
|
||||
meta = await self.metadata()
|
||||
from urllib.parse import urlencode
|
||||
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": self.settings.oidc_client_id,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"scope": self.settings.oidc_scope,
|
||||
"state": state,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
return f"{meta['authorization_endpoint']}?{urlencode(params)}"
|
||||
|
||||
@property
|
||||
def redirect_uri(self) -> str:
|
||||
return f"{self.settings.ui_base_url}/ui/auth/callback"
|
||||
|
||||
async def exchange(self, code: str, verifier: str) -> dict[str, Any]:
|
||||
meta = await self.metadata()
|
||||
r = await self._client.post(
|
||||
meta["token_endpoint"],
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"client_id": self.settings.oidc_client_id,
|
||||
"client_secret": self.settings.oidc_client_secret,
|
||||
"code_verifier": verifier,
|
||||
},
|
||||
headers={"content-type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
raise AuthError(f"token exchange failed: HTTP {r.status_code}", status=502)
|
||||
return r.json()
|
||||
|
||||
async def claims(self, token_response: dict[str, Any]) -> dict[str, Any]:
|
||||
from authlib.jose import JsonWebKey, JsonWebToken
|
||||
|
||||
id_token = token_response.get("id_token")
|
||||
if not id_token:
|
||||
raise AuthError("the token response carried no id_token", status=502)
|
||||
keys = JsonWebKey.import_key_set(await self.jwks())
|
||||
jwt = JsonWebToken(["RS256", "ES256"])
|
||||
claims = jwt.decode(id_token, keys)
|
||||
claims.validate(leeway=60)
|
||||
data = dict(claims)
|
||||
if data.get("iss", "").rstrip("/") != self.settings.oidc_issuer.rstrip("/"):
|
||||
raise AuthError("id_token issuer mismatch")
|
||||
aud = data.get("aud")
|
||||
auds = aud if isinstance(aud, list) else [aud]
|
||||
if self.settings.oidc_client_id not in auds:
|
||||
raise AuthError("id_token audience mismatch")
|
||||
return data
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Markdown rendering with raw HTML disabled — no stored XSS from note text (§10)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
|
||||
from markdown_it import MarkdownIt
|
||||
|
||||
_WIKILINK_RE = re.compile(r"\[\[([^\[\]|#]+?)(?:#([^\[\]|]*))?(?:\|([^\[\]]*))?\]\]")
|
||||
|
||||
# linkify is deliberately off: it needs linkify-it-py and would turn bare text in
|
||||
# untrusted notes into clickable links.
|
||||
_md = MarkdownIt("commonmark", {"html": False, "linkify": False, "typographer": False})
|
||||
_md.enable("table")
|
||||
_md.enable("strikethrough")
|
||||
|
||||
|
||||
def _wikilinks_to_markdown(text: str, resolve: dict[str, str] | None = None) -> str:
|
||||
resolve = resolve or {}
|
||||
|
||||
def sub(m: re.Match[str]) -> str:
|
||||
target = m.group(1).strip()
|
||||
label = (m.group(3) or target).strip()
|
||||
note_id = resolve.get(target)
|
||||
href = f"/ui/n/{note_id}" if note_id else f"/ui/search?q={html.escape(target)}"
|
||||
safe_label = label.replace("]", "\\]").replace("[", "\\[")
|
||||
return f"[{safe_label}]({href})"
|
||||
|
||||
return _WIKILINK_RE.sub(sub, text)
|
||||
|
||||
|
||||
def render_markdown(text: str, resolve: dict[str, str] | None = None) -> str:
|
||||
return _md.render(_wikilinks_to_markdown(text or "", resolve))
|
||||
|
||||
|
||||
def render_inline(text: str) -> str:
|
||||
return _md.renderInline(html.escape(text or ""))
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toStrinLine truncated
|
||||
@@ -0,0 +1,72 @@
|
||||
:root {
|
||||
--bg: #fbfaf7; --fg: #1d1c1a; --muted: #6b675f; --line: #e2ded5;
|
||||
--card: #ffffff; --accent: #2f5d50; --warn: #8a5a00; --danger: #8c2f2f;
|
||||
--add: #1f6f43; --del: #8c2f2f;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #16171a; --fg: #e8e6e1; --muted: #9b968c; --line: #2c2e33;
|
||||
--card: #1d1f23; --accent: #7fbfab; --warn: #d0a24c; --danger: #e08585;
|
||||
--add: #7fc7a0; --del: #e08585;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--fg);
|
||||
font: 16px/1.55 -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||
}
|
||||
header {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 1rem;
|
||||
padding: .6rem 1.2rem; border-bottom: 1px solid var(--line); position: sticky; top: 0;
|
||||
background: var(--bg); z-index: 5;
|
||||
}
|
||||
nav { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
|
||||
nav a { color: var(--fg); text-decoration: none; }
|
||||
nav a:hover { text-decoration: underline; }
|
||||
.brand { font-weight: 700; letter-spacing: .02em; color: var(--accent); }
|
||||
main { max-width: 62rem; margin: 0 auto; padding: 1.5rem 1.2rem 5rem; }
|
||||
h1 { font-size: 1.5rem; } h2 { font-size: 1.15rem; }
|
||||
h3 { font-size: .95rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
|
||||
h1 small { color: var(--muted); font-weight: 400; font-size: .8rem; margin-left: .5rem; }
|
||||
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 1rem 1.1rem; margin: 0 0 1.2rem; }
|
||||
.meta, .hint { color: var(--muted); font-size: .82rem; }
|
||||
.summary { font-style: italic; color: var(--muted); }
|
||||
.snippet { white-space: pre-wrap; }
|
||||
.pill { font-size: .72rem; padding: .1rem .45rem; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); vertical-align: middle; }
|
||||
.pill.ok { color: var(--add); border-color: var(--add); }
|
||||
.pill.warn { color: var(--warn); border-color: var(--warn); }
|
||||
.badge { background: var(--line); border-radius: 999px; padding: 0 .4rem; font-size: .75rem; }
|
||||
.tag { display: inline-block; font-size: .78rem; background: var(--line); border-radius: 4px; padding: .05rem .4rem; margin: 0 .25rem .25rem 0; color: var(--fg); text-decoration: none; }
|
||||
.unresolved { color: var(--warn); text-decoration: underline dotted; }
|
||||
.rel { color: var(--muted); font-size: .75rem; }
|
||||
.empty { color: var(--muted); }
|
||||
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 1rem; }
|
||||
.cols ul, .history { list-style: none; padding: 0; margin: .2rem 0; }
|
||||
form { display: inline; }
|
||||
.filters, .editor { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin-bottom: 1rem; }
|
||||
.editor { flex-direction: column; align-items: stretch; }
|
||||
.editor label { display: flex; flex-direction: column; gap: .3rem; font-size: .85rem; color: var(--muted); }
|
||||
input, select, textarea, button, .button {
|
||||
font: inherit; padding: .4rem .6rem; border-radius: 6px; border: 1px solid var(--line);
|
||||
background: var(--card); color: var(--fg);
|
||||
}
|
||||
textarea { width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .88rem; }
|
||||
button, .button { cursor: pointer; text-decoration: none; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
button.danger, summary.danger { color: var(--danger); }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin-top: 1rem; }
|
||||
.actions details { width: 100%; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .9rem; display: block; overflow-x: auto; }
|
||||
th, td { text-align: left; padding: .35rem .5rem; border-bottom: 1px solid var(--line); }
|
||||
th a { color: var(--muted); text-decoration: none; }
|
||||
tr.superseded td { opacity: .55; text-decoration: line-through; }
|
||||
pre.diff { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: .6rem; overflow-x: auto; font-size: .82rem; }
|
||||
pre.diff .add { color: var(--add); } pre.diff .del { color: var(--del); } pre.diff .hunk { color: var(--muted); }
|
||||
.body { overflow-wrap: anywhere; }
|
||||
.body pre, .body code { background: var(--line); border-radius: 4px; padding: .05rem .25rem; }
|
||||
.body pre { padding: .6rem; overflow-x: auto; }
|
||||
.body.small { font-size: .85rem; }
|
||||
.dupes { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: .8rem; }
|
||||
.dupe { border: 1px dashed var(--line); border-radius: 8px; padding: .6rem; }
|
||||
.conflict { border: 1px solid var(--warn); border-radius: 8px; padding: .8rem; margin-bottom: 1rem; }
|
||||
.cloud { line-height: 2; }
|
||||
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}mdmem{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/ui/static/style.css">
|
||||
<script src="/ui/static/htmx.min.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<a class="brand" href="/ui/inbox">mdmem</a>
|
||||
<a href="/ui/inbox">Posteingang <span class="badge">{{ counts.get('inbox', 0) }}</span></a>
|
||||
<a href="/ui/search">Suche</a>
|
||||
<a href="/ui/browse">Alle <span class="badge">{{ counts.get('active', 0) }}</span></a>
|
||||
<a href="/ui/health">Health</a>
|
||||
</nav>
|
||||
<div class="who">
|
||||
{% if user %}<span>{{ user.name }}</span> <a href="/ui/auth/logout">abmelden</a>{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Alle Notizen — mdmem{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Alle Notizen <small>{{ rows|length }}</small></h1>
|
||||
<p class="cloud">{% for t, n in cloud %}<a class="tag" href="/ui/browse?tag={{ t }}">#{{ t }} <span class="badge">{{ n }}</span></a>{% endfor %}</p>
|
||||
<form method="get" class="filters">
|
||||
<select name="state">
|
||||
<option value="">alle Zustände</option>
|
||||
{% for s in ['inbox','active','archive'] %}<option value="{{ s }}" {% if state == s %}selected{% endif %}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
<input type="text" name="tag" value="{{ tag }}" placeholder="Tag">
|
||||
<button type="submit">Filtern</button>
|
||||
</form>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th><a href="/ui/browse?order=title&state={{ state }}&tag={{ tag }}">Titel</a></th>
|
||||
<th><a href="/ui/browse?order=type&state={{ state }}&tag={{ tag }}">Typ</a></th>
|
||||
<th><a href="/ui/browse?order=state&state={{ state }}&tag={{ tag }}">Zustand</a></th>
|
||||
<th>Tags</th>
|
||||
<th><a href="/ui/browse?order=updated&state={{ state }}&tag={{ tag }}">Aktualisiert</a></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr class="{{ 'superseded' if r.superseded_by else '' }}">
|
||||
<td><a href="/ui/n/{{ r.id }}">{{ r.title }}</a>{% if r.revises %} <span class="pill warn">Revision</span>{% endif %}</td>
|
||||
<td>{{ r.type }}</td>
|
||||
<td>{{ r.state }}</td>
|
||||
<td>{% for t in r.tags %}<span class="tag">#{{ t }}</span>{% endfor %}</td>
|
||||
<td>{{ r.updated }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ note.title }} bearbeiten — mdmem{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ note.title }} bearbeiten</h1>
|
||||
{% if conflict %}
|
||||
<div class="conflict">
|
||||
<h2>Konflikt</h2>
|
||||
<p>{{ conflict.message }}</p>
|
||||
{% set diff = conflict.diff %}
|
||||
{% include "partial_diff.html" with context %}
|
||||
<p>Speichere erneut, um deine Fassung über die aktuelle zu legen — das Formular trägt jetzt die neue Version.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/ui/n/{{ note.id }}/edit" class="editor">
|
||||
<input type="hidden" name="expected_version" value="{{ version }}">
|
||||
<label>Frontmatter (YAML)
|
||||
<textarea name="frontmatter" rows="10" spellcheck="false">{{ frontmatter }}</textarea>
|
||||
</label>
|
||||
<label>Text
|
||||
<textarea name="body" rows="18" spellcheck="false"
|
||||
hx-post="/ui/preview" hx-trigger="keyup changed delay:400ms"
|
||||
hx-target="#preview">{% if conflict %}{{ conflict.yours }}{% else %}{{ note.body }}{% endif %}</textarea>
|
||||
</label>
|
||||
<button class="primary" type="submit">Speichern</button>
|
||||
<a class="button" href="/ui/n/{{ note.id }}">Abbrechen</a>
|
||||
</form>
|
||||
<h3>Vorschau</h3>
|
||||
<div id="preview" class="body">{{ render_markdown(note.body, {}) | safe }}</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Fehler — mdmem{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ error.error }}</h1>
|
||||
<p>{{ error.message }}</p>
|
||||
{% if error.details %}<pre>{{ error.details }}</pre>{% endif %}
|
||||
<p><a href="/ui/inbox">zurück zum Posteingang</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Health — mdmem{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Health</h1>
|
||||
<div class="cols">
|
||||
<section>
|
||||
<h3>Bestand</h3>
|
||||
<ul>
|
||||
{% for s, n in health.counts.items() %}<li>{{ s }}: {{ n }}</li>{% endfor %}
|
||||
<li>Chunks: {{ health.chunks }}</li>
|
||||
<li>ohne Vektor: {{ health.vec_pending }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Embeddings</h3>
|
||||
<ul>
|
||||
<li>Modell: {{ health.embed_model }}</li>
|
||||
<li>Dimension: {{ health.embed_dim }}</li>
|
||||
<li>Status: <span class="pill {{ 'ok' if health.embed_ok else 'warn' }}">{{ 'ok' if health.embed_ok else 'nur lexikalisch' }}</span></li>
|
||||
<li>letzte Latenz: {{ health.embed_last_ms or '–' }} ms</li>
|
||||
<li>Fehler: {{ health.embed_errors }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Verlinkung</h3>
|
||||
<ul>
|
||||
<li>unaufgelöste Links: {{ health.unresolved_links }}</li>
|
||||
<li>verwaiste Notizen: {{ health.orphans }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% if health.secrets_in_vault %}
|
||||
<div class="conflict">
|
||||
<h3>Mögliche Zugangsdaten im Vault</h3>
|
||||
<ul>{% for f in health.secrets_in_vault %}<li>{{ f }}</li>{% endfor %}</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="actions">
|
||||
<form method="post" action="/ui/health/reindex"><button type="submit">Index auffrischen</button></form>
|
||||
<form method="post" action="/ui/health/reindex"><input type="hidden" name="full" value="1"><button type="submit">Index neu bauen</button></form>
|
||||
<form method="post" action="/ui/health/reembed"><button type="submit">Alles neu einbetten</button></form>
|
||||
</div>
|
||||
|
||||
<h3>Unaufgelöste Links</h3>
|
||||
<ul>{% for u in unresolved %}<li><a href="/ui/n/{{ u.src }}">{{ u.title }}</a> → <span class="unresolved">{{ u.target }}</span> ({{ u.rel }})</li>{% else %}<li class="empty">keine</li>{% endfor %}</ul>
|
||||
|
||||
<h3>Letzte MCP-Aufrufe</h3>
|
||||
<table>
|
||||
<thead><tr><th>Zeit</th><th>Tool</th><th>ok</th><th>ms</th><th>ids</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in calls %}
|
||||
<tr><td>{{ c.ts }}</td><td>{{ c.tool }}</td><td>{{ c.get('ok', '') }}</td><td>{{ c.get('ms', '') }}</td>
|
||||
<td>{% for i in c.get('ids', []) %}<a href="/ui/n/{{ i }}">{{ i[:8] }}</a> {% endfor %}</td></tr>
|
||||
{% else %}<tr><td colspan="5" class="empty">keine</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,63 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Posteingang — mdmem{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Posteingang <small>{{ items|length }} unbestätigt</small></h1>
|
||||
{% if not items %}<p class="empty">Nichts zu prüfen.</p>{% endif %}
|
||||
{% for item in items %}
|
||||
{% set n = item.note %}
|
||||
<article class="card" id="n-{{ n.id }}">
|
||||
<h2><a href="/ui/n/{{ n.id }}">{{ n.title }}</a>
|
||||
<span class="pill">{{ n.type }}</span>
|
||||
{% if item.target %}<span class="pill warn">Revision von „{{ item.target.title }}“</span>{% endif %}
|
||||
{% if n.supersede_chain %}<span class="pill warn">ersetzt {{ n.supersede_chain|length }}</span>{% endif %}
|
||||
</h2>
|
||||
<p class="meta">
|
||||
beobachtet {{ n.observed_at or '–' }} ·
|
||||
Quelle {{ n.source.get('harness', 'unbekannt') if n.source else 'unbekannt' }}
|
||||
<span class="hint" title="selbstberichtet, nicht verifiziert">(selbstberichtet)</span>
|
||||
</p>
|
||||
{% if n.summary %}<p class="summary">{{ n.summary }}</p>{% endif %}
|
||||
<div class="body">{{ render_markdown(n.body, {}) | safe }}</div>
|
||||
{% if n.keywords %}<p class="kw">{% for k in n.keywords %}<span class="tag">{{ k }}</span>{% endfor %}</p>{% endif %}
|
||||
|
||||
{% if item.diff %}
|
||||
<h3>Änderung gegenüber der aktiven Notiz</h3>
|
||||
{% set diff = item.diff %}
|
||||
{% include "partial_diff.html" with context %}
|
||||
{% endif %}
|
||||
|
||||
{% if item.duplicates %}
|
||||
<h3>Mögliche Duplikate</h3>
|
||||
<div class="dupes">
|
||||
{% for d in item.duplicates %}
|
||||
<div class="dupe">
|
||||
<a href="/ui/n/{{ d.id }}">{{ d.title }}</a>
|
||||
<div class="body small">{{ render_markdown(d.body, {}) | safe }}</div>
|
||||
<form method="post" action="/ui/n/{{ n.id }}/merge">
|
||||
<input type="hidden" name="target" value="{{ d.id }}">
|
||||
<button type="submit">in diese Notiz zusammenführen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="actions">
|
||||
<form method="post" action="/ui/n/{{ n.id }}/approve">
|
||||
<button class="primary" type="submit">Übernehmen</button>
|
||||
</form>
|
||||
<details>
|
||||
<summary>Bearbeiten & übernehmen</summary>
|
||||
<form method="post" action="/ui/n/{{ n.id }}/approve">
|
||||
<textarea name="body" rows="8">{{ n.body }}</textarea>
|
||||
<button class="primary" type="submit">Bearbeitet übernehmen</button>
|
||||
</form>
|
||||
</details>
|
||||
<form method="post" action="/ui/n/{{ n.id }}/reject">
|
||||
<input type="text" name="reason" placeholder="Grund (optional)">
|
||||
<button class="danger" type="submit">Ablehnen</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,66 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ note.title }} — mdmem{% endblock %}
|
||||
{% block content %}
|
||||
<article class="card">
|
||||
<h1>{{ note.title }}
|
||||
<span class="pill">{{ note.type }}</span>
|
||||
<span class="pill {% if note.state == 'active' %}ok{% elif note.state == 'inbox' %}warn{% endif %}">{{ note.state }}</span>
|
||||
</h1>
|
||||
<p class="meta">id {{ note.id }} · Version {{ note.version[:12] }} · aktualisiert {{ note.updated }}</p>
|
||||
{% if note.summary %}<p class="summary">{{ note.summary }}</p>{% endif %}
|
||||
<div class="body">{{ render_markdown(note.body, resolve) | safe }}</div>
|
||||
{% if note.keywords %}<p class="kw">{% for k in note.keywords %}<span class="tag">{{ k }}</span>{% endfor %}</p>{% endif %}
|
||||
{% if note.tags %}<p class="kw">{% for t in note.tags %}<a class="tag" href="/ui/browse?tag={{ t }}">#{{ t }}</a>{% endfor %}</p>{% endif %}
|
||||
|
||||
<div class="cols">
|
||||
<section>
|
||||
<h3>Ausgehende Links</h3>
|
||||
<ul>{% for l in note.links %}
|
||||
<li>{% if l.resolved %}<a href="/ui/n/{{ l.id }}">{{ l.title }}</a>{% else %}<span class="unresolved">{{ l.title }}</span>{% endif %} <span class="rel">{{ l.rel }}</span></li>
|
||||
{% else %}<li class="empty">keine</li>{% endfor %}</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Backlinks</h3>
|
||||
<ul>{% for l in note.backlinks %}
|
||||
<li><a href="/ui/n/{{ l.id }}">{{ l.title }}</a> <span class="rel">{{ l.rel }}</span></li>
|
||||
{% else %}<li class="empty">keine</li>{% endfor %}</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Ersetzungskette</h3>
|
||||
<ul>{% for l in note.supersede_chain %}
|
||||
<li><a href="/ui/n/{{ l.id }}">{{ l.title }}</a> <span class="rel">{{ l.rel }}</span></li>
|
||||
{% else %}<li class="empty">keine</li>{% endfor %}</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% if note.flags %}
|
||||
<h3>Meldungen</h3>
|
||||
<ul>{% for f in note.flags %}<li>{{ f.created }} · {{ f.source }} · {{ f.reason }}</li>{% endfor %}</ul>
|
||||
{% endif %}
|
||||
|
||||
<h3>Verlauf</h3>
|
||||
<ul class="history">
|
||||
{% for h in history|reverse %}
|
||||
<li><button hx-get="/ui/n/{{ note.id }}/history/{{ h }}" hx-target="#diff-{{ loop.index }}">{{ h }}</button>
|
||||
<div id="diff-{{ loop.index }}"></div></li>
|
||||
{% else %}<li class="empty">keine früheren Fassungen</li>{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="actions">
|
||||
<a class="button" href="/ui/n/{{ note.id }}/edit">Bearbeiten</a>
|
||||
{% if note.state != 'archive' %}
|
||||
<form method="post" action="/ui/n/{{ note.id }}/state"><input type="hidden" name="state" value="archive"><button type="submit">Archivieren</button></form>
|
||||
{% else %}
|
||||
<form method="post" action="/ui/n/{{ note.id }}/state"><input type="hidden" name="state" value="active"><button type="submit">Wiederherstellen</button></form>
|
||||
{% endif %}
|
||||
<details>
|
||||
<summary class="danger">Endgültig löschen</summary>
|
||||
<form method="post" action="/ui/n/{{ note.id }}/delete">
|
||||
<p>Datei, Verlauf und Index werden entfernt. Zum Bestätigen die id eingeben:</p>
|
||||
<input type="text" name="confirm" placeholder="{{ note.id }}">
|
||||
<button class="danger" type="submit">Löschen</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,2 @@
|
||||
<pre class="diff">{% for line in diff.split('\n') %}<span class="{% if line.startswith('+') and not line.startswith('+++') %}add{% elif line.startswith('-') and not line.startswith('---') %}del{% elif line.startswith('@@') %}hunk{% endif %}">{{ line }}</span>
|
||||
{% endfor %}</pre>
|
||||
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Suche — mdmem{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Suche</h1>
|
||||
<form method="get" action="/ui/search" class="filters">
|
||||
<input type="search" name="q" value="{{ q }}" placeholder="Suchbegriff" autofocus>
|
||||
<select name="type">
|
||||
<option value="">alle Typen</option>
|
||||
{% for t in ['fact','preference','decision','procedure','entity','event','note'] %}
|
||||
<option value="{{ t }}" {% if type == t %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="text" name="tag" value="{{ tag }}" placeholder="Tag">
|
||||
<select name="state">
|
||||
<option value="">inbox + aktiv</option>
|
||||
<option value="active" {% if state == 'active' %}selected{% endif %}>nur aktiv</option>
|
||||
<option value="archive" {% if state == 'archive' %}selected{% endif %}>inkl. Archiv</option>
|
||||
</select>
|
||||
<input type="text" name="since" value="{{ since }}" placeholder="seit (ISO)">
|
||||
<input type="text" name="until" value="{{ until }}" placeholder="bis (ISO)">
|
||||
<label><input type="checkbox" name="superseded" value="1" {% if superseded %}checked{% endif %}> ersetzte zeigen</label>
|
||||
<button type="submit">Suchen</button>
|
||||
</form>
|
||||
|
||||
{% if resp is not none %}
|
||||
<p class="meta">{{ resp.results|length }} Treffer · Fusion {{ resp.fusion }}{% if resp.truncated %} · gekürzt{% endif %}</p>
|
||||
{% for r in resp.results %}
|
||||
<article class="card">
|
||||
<h2><a href="/ui/n/{{ r.id }}">{{ r.title }}</a>
|
||||
<span class="pill">{{ r.type }}</span>
|
||||
<span class="pill {% if r.verified %}ok{% else %}warn{% endif %}">{{ 'geprüft' if r.verified else 'ungeprüft' }}</span>
|
||||
</h2>
|
||||
<p class="snippet">{{ r.snippet }}</p>
|
||||
<p class="meta" title="lex_rank/sem_rank/cosine/fused">
|
||||
score {{ '%.4f'|format(r.score) }}
|
||||
{% if r.debug %}
|
||||
· via {{ r.debug.via }}
|
||||
· lex {{ r.debug.lex_rank or '–' }} (bm25 {{ r.debug.bm25 or '–' }})
|
||||
· sem {{ r.debug.sem_rank or '–' }} (cos {{ r.debug.cos or '–' }})
|
||||
· fused {{ '%.6f'|format(r.debug.fused) }}
|
||||
{% endif %}
|
||||
· {{ r.updated or '' }}
|
||||
</p>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% if not resp.results %}<p class="empty">Keine Treffer.</p>{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,32 @@
|
||||
from mdmem.ui.render import render_markdown
|
||||
|
||||
|
||||
def test_raw_html_is_escaped_not_rendered():
|
||||
out = render_markdown("<script>alert(1)</script> und <img src=x onerror=y>")
|
||||
assert "<script>" not in out and "<script>" in out
|
||||
assert "onerror" not in out or "<img" in out
|
||||
|
||||
|
||||
def test_html_block_is_escaped():
|
||||
out = render_markdown("<div onclick='x'>hi</div>")
|
||||
assert "<div" not in out
|
||||
|
||||
|
||||
def test_markdown_still_works():
|
||||
out = render_markdown("# Titel\n\n**fett** und `code`")
|
||||
assert "<h1>" in out and "<strong>fett</strong>" in out and "<code>code</code>" in out
|
||||
|
||||
|
||||
def test_wikilink_resolves_to_a_note_page():
|
||||
out = render_markdown("siehe [[evo-x2]]", {"evo-x2": "01JABC"})
|
||||
assert '<a href="/ui/n/01JABC">evo-x2</a>' in out
|
||||
|
||||
|
||||
def test_unresolved_wikilink_points_at_search():
|
||||
out = render_markdown("siehe [[nirgendwo]]")
|
||||
assert "/ui/search?q=nirgendwo" in out
|
||||
|
||||
|
||||
def test_wikilink_alias_and_injection_attempt():
|
||||
out = render_markdown("[[target|<b>label</b>]]", {"target": "01J"})
|
||||
assert "<b>label</b>" not in out
|
||||
@@ -0,0 +1,205 @@
|
||||
"""The group check must fail closed — the degradation paths are the risk (§10, §14.21, P4)."""
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from mdmem.config import Settings
|
||||
from mdmem.embed import FakeEmbedder
|
||||
from mdmem.service import MemoryService
|
||||
from mdmem.ui.app import build_ui_app
|
||||
from mdmem.ui.auth import SESSION_COOKIE, AuthError, Principal, SessionCodec, authorize_claims
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def auth_settings(settings) -> Settings:
|
||||
return replace(
|
||||
settings,
|
||||
session_secret="x" * 32,
|
||||
oidc_issuer="https://pocketid.example",
|
||||
oidc_client_id="client-uuid",
|
||||
oidc_groups_claim="groups",
|
||||
oidc_required_group="mdmem_admins",
|
||||
ui_base_url="https://memory.cloud.traunseenet.com",
|
||||
ui_allowed_origins=("https://memory.cloud.traunseenet.com",),
|
||||
)
|
||||
|
||||
|
||||
def test_member_of_the_required_group_is_admitted(auth_settings):
|
||||
p = authorize_claims({"sub": "u1", "name": "Markus", "groups": ["other", "mdmem_admins"]}, auth_settings)
|
||||
assert isinstance(p, Principal) and p.sub == "u1"
|
||||
|
||||
|
||||
def test_group_absent_is_rejected(auth_settings):
|
||||
with pytest.raises(AuthError, match="mdmem_admins"):
|
||||
authorize_claims({"sub": "u1", "groups": ["something_else"]}, auth_settings)
|
||||
|
||||
|
||||
def test_claim_absent_is_rejected(auth_settings):
|
||||
"""A missing groups scope must not degrade to authenticated-only."""
|
||||
with pytest.raises(AuthError, match="absent"):
|
||||
authorize_claims({"sub": "u1", "email": "a@b.c"}, auth_settings)
|
||||
|
||||
|
||||
def test_claim_of_the_wrong_type_is_rejected(auth_settings):
|
||||
with pytest.raises(AuthError, match="list of strings"):
|
||||
authorize_claims({"sub": "u1", "groups": {"a": 1}}, auth_settings)
|
||||
with pytest.raises(AuthError, match="list of strings"):
|
||||
authorize_claims({"sub": "u1", "groups": [1, 2]}, auth_settings)
|
||||
|
||||
|
||||
def test_a_single_string_claim_is_accepted_as_one_group(auth_settings):
|
||||
assert authorize_claims({"sub": "u1", "groups": "mdmem_admins"}, auth_settings).sub == "u1"
|
||||
|
||||
|
||||
def test_unconfigured_required_group_refuses_everything(auth_settings):
|
||||
s = replace(auth_settings, oidc_required_group="")
|
||||
with pytest.raises(AuthError, match="OIDC_REQUIRED_GROUP"):
|
||||
authorize_claims({"sub": "u1", "groups": ["anything"]}, s)
|
||||
|
||||
|
||||
def test_rejection_logs_the_sub_and_claim_names_but_not_the_values(auth_settings, caplog):
|
||||
with caplog.at_level("WARNING"):
|
||||
with pytest.raises(AuthError):
|
||||
authorize_claims({"sub": "u1", "groups": ["secret_group_name"]}, auth_settings)
|
||||
record = caplog.records[-1]
|
||||
assert record.fields["sub"] == "u1"
|
||||
assert "secret_group_name" not in str(record.fields)
|
||||
|
||||
|
||||
def test_session_cookie_round_trip_and_expiry(auth_settings):
|
||||
codec = SessionCodec(auth_settings.session_secret, auth_settings.session_ttl_hours)
|
||||
token = codec.issue(Principal(sub="u1", name="M", email="m@x"))
|
||||
assert codec.read(token).sub == "u1"
|
||||
assert codec.read("garbage") is None
|
||||
assert codec.read(None) is None
|
||||
|
||||
|
||||
def test_a_session_signed_with_another_secret_is_refused(auth_settings):
|
||||
other = SessionCodec("y" * 32, 12).issue(Principal(sub="u1", name="M", email=""))
|
||||
assert SessionCodec(auth_settings.session_secret, 12).read(other) is None
|
||||
|
||||
|
||||
def test_session_secret_is_mandatory():
|
||||
with pytest.raises(RuntimeError, match="SESSION_SECRET"):
|
||||
SessionCodec("", 12)
|
||||
|
||||
|
||||
# -- routing ---------------------------------------------------------------
|
||||
@pytest.fixture()
|
||||
async def app_and_svc(auth_settings):
|
||||
svc = MemoryService.build(auth_settings, embedder=FakeEmbedder(auth_settings.embed_dim))
|
||||
await svc.start(watch=False)
|
||||
yield build_ui_app(svc), svc
|
||||
await svc.stop()
|
||||
|
||||
|
||||
def _client(app):
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="https://memory.cloud.traunseenet.com"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/ui/inbox", "/ui/search", "/ui/browse", "/ui/health", "/ui/n/x"])
|
||||
async def test_every_ui_path_redirects_an_anonymous_visitor_to_login(app_and_svc, path):
|
||||
app, _ = app_and_svc
|
||||
async with _client(app) as c:
|
||||
r = await c.get(path)
|
||||
assert r.status_code == 302 and r.headers["location"].startswith("/ui/auth/login")
|
||||
|
||||
|
||||
async def test_a_valid_session_gets_in(app_and_svc):
|
||||
app, svc = app_and_svc
|
||||
codec = SessionCodec(svc.settings.session_secret, 12)
|
||||
cookie = codec.issue(Principal(sub="u1", name="Markus", email=""))
|
||||
async with _client(app) as c:
|
||||
r = await c.get("/ui/inbox", cookies={SESSION_COOKIE: cookie})
|
||||
assert r.status_code == 200 and "Posteingang" in r.text
|
||||
|
||||
|
||||
async def test_probes_and_metrics_stay_open(app_and_svc):
|
||||
app, _ = app_and_svc
|
||||
async with _client(app) as c:
|
||||
for path in ("/healthz", "/readyz", "/metrics"):
|
||||
assert (await c.get(path)).status_code == 200
|
||||
|
||||
|
||||
async def test_metrics_carry_no_note_content(app_and_svc):
|
||||
app, svc = app_and_svc
|
||||
await svc.write_note(title="Geheimer Titel", body="sensibler Text", type="fact")
|
||||
async with _client(app) as c:
|
||||
body = (await c.get("/metrics")).text
|
||||
assert "Geheimer Titel" not in body and "sensibler Text" not in body
|
||||
assert "mdmem_notes" in body
|
||||
|
||||
|
||||
async def test_readyz_is_green_while_embeddings_are_down(app_and_svc):
|
||||
app, svc = app_and_svc
|
||||
svc.embed_ok = False
|
||||
async with _client(app) as c:
|
||||
r = await c.get("/readyz")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["checks"]["embeddings"].startswith("degraded")
|
||||
|
||||
|
||||
async def test_security_headers_are_set(app_and_svc):
|
||||
app, svc = app_and_svc
|
||||
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
|
||||
async with _client(app) as c:
|
||||
r = await c.get("/ui/inbox", cookies={SESSION_COOKIE: cookie})
|
||||
assert r.headers["content-security-policy"].startswith("default-src 'self'")
|
||||
assert r.headers["x-frame-options"] == "DENY"
|
||||
|
||||
|
||||
async def test_post_from_a_foreign_origin_is_refused(app_and_svc):
|
||||
app, svc = app_and_svc
|
||||
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
|
||||
res = await svc.write_note(title="T", body="b", type="note")
|
||||
async with _client(app) as c:
|
||||
r = await c.post(
|
||||
f"/ui/n/{res['id']}/approve",
|
||||
headers={"origin": "https://evil.example.com"},
|
||||
cookies={SESSION_COOKIE: cookie},
|
||||
data={},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
assert svc.get_note(res["id"]).state == "inbox"
|
||||
|
||||
|
||||
async def test_post_from_the_configured_origin_is_allowed(app_and_svc):
|
||||
app, svc = app_and_svc
|
||||
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
|
||||
res = await svc.write_note(title="T", body="b", type="note")
|
||||
async with _client(app) as c:
|
||||
r = await c.post(
|
||||
f"/ui/n/{res['id']}/approve",
|
||||
headers={"origin": "https://memory.cloud.traunseenet.com"},
|
||||
cookies={SESSION_COOKIE: cookie},
|
||||
data={},
|
||||
)
|
||||
assert r.status_code in (303, 302)
|
||||
assert svc.get_note(res["id"]).state == "active"
|
||||
|
||||
|
||||
async def test_ui_refuses_to_start_without_a_session_secret(settings):
|
||||
svc = MemoryService.build(replace(settings, session_secret=""), embedder=FakeEmbedder(settings.embed_dim))
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="SESSION_SECRET"):
|
||||
build_ui_app(svc)
|
||||
finally:
|
||||
await svc.stop()
|
||||
|
||||
|
||||
async def test_auth_disabled_is_loudly_logged_and_lets_requests_through(settings, caplog):
|
||||
s = replace(settings, ui_auth_disabled=True, session_secret="")
|
||||
svc = MemoryService.build(s, embedder=FakeEmbedder(s.embed_dim))
|
||||
await svc.start(watch=False)
|
||||
try:
|
||||
with caplog.at_level("ERROR"):
|
||||
app = build_ui_app(svc)
|
||||
assert any("UI_AUTH_DISABLED" in r.message for r in caplog.records)
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") as c:
|
||||
assert (await c.get("/ui/inbox")).status_code == 200
|
||||
finally:
|
||||
await svc.stop()
|
||||
@@ -0,0 +1,247 @@
|
||||
"""End-to-end review flows through the UI (§10, P4)."""
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from mdmem.embed import FakeEmbedder
|
||||
from mdmem.service import MemoryService
|
||||
from mdmem.ui.app import build_ui_app
|
||||
from mdmem.ui.auth import SESSION_COOKIE, Principal, SessionCodec
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
async def ui(settings):
|
||||
s = replace(
|
||||
settings,
|
||||
session_secret="x" * 32,
|
||||
ui_base_url="https://memory.test",
|
||||
ui_allowed_origins=("https://memory.test",),
|
||||
)
|
||||
svc = MemoryService.build(s, embedder=FakeEmbedder(s.embed_dim))
|
||||
await svc.start(watch=False)
|
||||
app = build_ui_app(svc)
|
||||
cookie = SessionCodec(s.session_secret, 12).issue(Principal("u1", "Markus", ""))
|
||||
client = httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url="https://memory.test",
|
||||
cookies={SESSION_COOKIE: cookie},
|
||||
follow_redirects=False,
|
||||
)
|
||||
yield client, svc
|
||||
await client.aclose()
|
||||
await svc.stop()
|
||||
|
||||
|
||||
async def test_inbox_lists_unreviewed_notes(ui):
|
||||
client, svc = ui
|
||||
await svc.write_note(title="Neue Erkenntnis", body="Inhalt", type="fact", summary="kurz")
|
||||
r = await client.get("/ui/inbox")
|
||||
assert r.status_code == 200
|
||||
assert "Neue Erkenntnis" in r.text and "kurz" in r.text
|
||||
|
||||
|
||||
async def test_approve_moves_the_note_to_active(ui):
|
||||
client, svc = ui
|
||||
res = await svc.write_note(title="Zu prüfen", body="Inhalt", type="fact")
|
||||
r = await client.post(f"/ui/n/{res['id']}/approve", data={})
|
||||
assert r.status_code == 303
|
||||
assert svc.get_note(res["id"]).state == "active"
|
||||
assert (await svc.search("Inhalt")).results[0].verified is True
|
||||
|
||||
|
||||
async def test_edit_and_approve_saves_the_edited_body(ui):
|
||||
client, svc = ui
|
||||
res = await svc.write_note(title="Roh", body="alter Text", type="fact")
|
||||
await client.post(f"/ui/n/{res['id']}/approve", data={"body": "geprüfter Text"})
|
||||
note = svc.get_note(res["id"])
|
||||
assert note.state == "active" and note.body.strip() == "geprüfter Text"
|
||||
|
||||
|
||||
async def test_reject_archives_with_a_reason(ui):
|
||||
client, svc = ui
|
||||
res = await svc.write_note(title="Unsinn", body="nope", type="note")
|
||||
await client.post(f"/ui/n/{res['id']}/reject", data={"reason": "falsch"})
|
||||
note = svc.get_note(res["id"])
|
||||
assert note.state == "archive"
|
||||
assert note.fm["rejected"] is True and note.fm["rejected_reason"] == "falsch"
|
||||
assert (await svc.search("nope")).results == []
|
||||
|
||||
|
||||
async def test_revision_shows_a_diff_and_merges_into_the_target(ui):
|
||||
client, svc = ui
|
||||
base = await svc.write_note(title="Fakt", body="Der Wert ist 10", type="fact")
|
||||
await svc.approve(base["id"])
|
||||
version = svc.get_note(base["id"]).file_hash
|
||||
proposal = await svc.update_note(base["id"], version, body="Der Wert ist 12")
|
||||
|
||||
page = await client.get("/ui/inbox")
|
||||
assert "Revision von" in page.text
|
||||
assert "-Der Wert ist 10" in page.text and "+Der Wert ist 12" in page.text
|
||||
|
||||
r = await client.post(f"/ui/n/{proposal['id']}/approve", data={})
|
||||
assert r.status_code == 303
|
||||
assert svc.get_note(base["id"]).body.strip() == "Der Wert ist 12"
|
||||
assert svc.get_note(proposal["id"]).state == "archive"
|
||||
|
||||
|
||||
async def test_duplicates_are_offered_for_merging(ui):
|
||||
client, svc = ui
|
||||
first = await svc.write_note(
|
||||
title="Bifrost ist das Gateway", body="Bifrost ist das Gateway für Modelle", type="fact"
|
||||
)
|
||||
await svc.approve(first["id"])
|
||||
second = await svc.write_note(
|
||||
title="Bifrost ist das Gateway", body="Bifrost ist das Gateway für Modelle", type="fact"
|
||||
)
|
||||
assert second["possible_duplicates"], "an identical note should be flagged as a duplicate"
|
||||
|
||||
page = await client.get("/ui/inbox")
|
||||
assert "Mögliche Duplikate" in page.text
|
||||
|
||||
await client.post(f"/ui/n/{second['id']}/merge", data={"target": first["id"]})
|
||||
assert svc.get_note(second["id"]).state == "archive"
|
||||
assert svc.get_note(first["id"]).body.count("Bifrost ist das Gateway") == 2
|
||||
|
||||
|
||||
async def test_note_page_shows_links_backlinks_and_history(ui):
|
||||
client, svc = ui
|
||||
target = await svc.write_note(title="evo-x2", body="die Kiste", type="entity")
|
||||
src = await svc.write_note(title="llama-swap", body="läuft auf [[evo-x2]]", type="fact")
|
||||
await svc.edit(
|
||||
src["id"], body="läuft auf [[evo-x2]] (v2)", expected_version=svc.get_note(src["id"]).file_hash
|
||||
)
|
||||
|
||||
page = await client.get(f"/ui/n/{src['id']}")
|
||||
assert "evo-x2" in page.text and "Backlinks" in page.text
|
||||
assert "Verlauf" in page.text and "keine früheren Fassungen" not in page.text
|
||||
|
||||
back = await client.get(f"/ui/n/{target['id']}")
|
||||
assert "llama-swap" in back.text
|
||||
|
||||
|
||||
async def test_edit_conflict_shows_a_diff_and_keeps_both_versions(ui):
|
||||
client, svc = ui
|
||||
res = await svc.write_note(title="Konflikt", body="original", type="note")
|
||||
stale = svc.get_note(res["id"]).file_hash
|
||||
await svc.edit(res["id"], body="von woanders geändert", expected_version=stale)
|
||||
|
||||
r = await client.post(
|
||||
f"/ui/n/{res['id']}/edit",
|
||||
data={"body": "meine Fassung", "frontmatter": "", "expected_version": stale},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
assert "Konflikt" in r.text
|
||||
assert "meine Fassung" in r.text and "von woanders geändert" in r.text
|
||||
assert svc.get_note(res["id"]).body.strip() == "von woanders geändert"
|
||||
|
||||
|
||||
async def test_edit_saves_body_and_frontmatter(ui):
|
||||
client, svc = ui
|
||||
res = await svc.write_note(title="Alt", body="alt", type="note", keywords=["a"])
|
||||
version = svc.get_note(res["id"]).file_hash
|
||||
page = await client.get(f"/ui/n/{res['id']}/edit")
|
||||
assert page.status_code == 200 and "Frontmatter" in page.text
|
||||
|
||||
r = await client.post(
|
||||
f"/ui/n/{res['id']}/edit",
|
||||
data={
|
||||
"body": "neuer Text",
|
||||
"frontmatter": f"id: {res['id']}\ntitle: Neu\ntype: note\nkeywords: [a, b]\n",
|
||||
"expected_version": version,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 303
|
||||
note = svc.get_note(res["id"])
|
||||
assert note.title == "Neu" and note.keywords == ["a", "b"] and note.body.strip() == "neuer Text"
|
||||
|
||||
|
||||
async def test_invalid_frontmatter_is_rejected_with_400(ui):
|
||||
client, svc = ui
|
||||
res = await svc.write_note(title="T", body="b", type="note")
|
||||
version = svc.get_note(res["id"]).file_hash
|
||||
r = await client.post(
|
||||
f"/ui/n/{res['id']}/edit",
|
||||
data={"body": "x", "frontmatter": "title: [unclosed", "expected_version": version},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
async def test_live_preview_renders_markdown_without_html(ui):
|
||||
client, _ = ui
|
||||
r = await client.post("/ui/preview", data={"body": "**fett** <script>x</script>"})
|
||||
assert "<strong>fett</strong>" in r.text and "<script>" not in r.text
|
||||
|
||||
|
||||
async def test_archive_and_restore(ui):
|
||||
client, svc = ui
|
||||
res = await svc.write_note(title="Hin und her", body="x", type="note")
|
||||
await svc.approve(res["id"])
|
||||
await client.post(f"/ui/n/{res['id']}/state", data={"state": "archive"})
|
||||
assert svc.get_note(res["id"]).state == "archive"
|
||||
await client.post(f"/ui/n/{res['id']}/state", data={"state": "active"})
|
||||
assert svc.get_note(res["id"]).state == "active"
|
||||
|
||||
|
||||
async def test_hard_delete_requires_the_id_and_removes_everything(ui):
|
||||
client, svc = ui
|
||||
res = await svc.write_note(title="Weg", body="v1", type="note")
|
||||
await svc.edit(res["id"], body="v2", expected_version=svc.get_note(res["id"]).file_hash)
|
||||
|
||||
bad = await client.post(f"/ui/n/{res['id']}/delete", data={"confirm": "nope"})
|
||||
assert bad.status_code == 400 and svc.vault.find(res["id"]) is not None
|
||||
|
||||
ok = await client.post(f"/ui/n/{res['id']}/delete", data={"confirm": res["id"]})
|
||||
assert ok.status_code == 303
|
||||
assert svc.vault.find(res["id"]) is None
|
||||
assert svc.vault.history(res["id"]) == []
|
||||
assert svc.db.read().execute("SELECT count(*) FROM notes").fetchone()[0] == 0
|
||||
|
||||
|
||||
async def test_browse_filters_by_tag_and_shows_the_cloud(ui):
|
||||
client, svc = ui
|
||||
a = await svc.write_note(title="Mit Tag", body="x", type="note", tags=["homelab"])
|
||||
await svc.write_note(title="Ohne Tag", body="y", type="note")
|
||||
r = await client.get("/ui/browse?tag=homelab")
|
||||
assert "Mit Tag" in r.text and "Ohne Tag" not in r.text
|
||||
assert "#homelab" in r.text
|
||||
assert a["id"] in r.text
|
||||
|
||||
|
||||
async def test_search_page_shows_debug_scores(ui):
|
||||
client, svc = ui
|
||||
await svc.write_note(title="Suchbar", body="Bifrost Gateway", type="fact")
|
||||
r = await client.get("/ui/search?q=Bifrost")
|
||||
assert "Suchbar" in r.text and "via" in r.text and "fused" in r.text
|
||||
|
||||
|
||||
async def test_health_page_reports_state_and_reindexes(ui):
|
||||
client, svc = ui
|
||||
await svc.write_note(title="Eins", body="x", type="note")
|
||||
r = await client.get("/ui/health")
|
||||
assert "Embeddings" in r.text and svc.settings.embed_model in r.text
|
||||
|
||||
r2 = await client.post("/ui/health/reindex", data={"full": "1"})
|
||||
assert r2.status_code == 303
|
||||
|
||||
r3 = await client.post("/ui/health/reembed", data={})
|
||||
assert r3.status_code == 303
|
||||
assert svc.db.read().execute("SELECT count(*) FROM chunks WHERE vec_pending=1").fetchone()[0] == 0
|
||||
|
||||
|
||||
async def test_note_body_cannot_inject_script_into_the_page(ui):
|
||||
client, svc = ui
|
||||
await svc.write_note(
|
||||
title="Böse", body="<script>alert('xss')</script>\n\n<img src=x onerror=alert(1)>", type="note"
|
||||
)
|
||||
r = await client.get("/ui/inbox")
|
||||
assert "<script>alert" not in r.text
|
||||
assert "<img" not in r.text # escaped, so no live tag reaches the DOM
|
||||
assert "<img src=x onerror=alert(1)>" in r.text
|
||||
|
||||
|
||||
async def test_unknown_note_returns_404(ui):
|
||||
client, _ = ui
|
||||
r = await client.get("/ui/n/01JZZZZZZZZZZZZZZZZZZZZZZZ")
|
||||
assert r.status_code == 404 and "NOT_FOUND" in r.text
|
||||
Reference in new issue
Block a user