Record the acting user and use noun-based transaction actions.

Every request-driven kanta transaction now passes user= from the
Remote-User header the SSO/forward-auth proxy sets (the editor socket
reads it from the WebSocket headers; the translator worker keeps its
client key). Action labels are short identifiers naming the object, not
sentences: page / page:{lang} / page:title / page:{lang}:title /
page:language / page:slug / page:delete / structure:reorder / settings /
translate:reset for admin actions, translate:{lang}[ :title ] for worker
submissions (title results identified via the job kind, now tracked in
the connection state).
This commit is contained in:
2026-09-03 15:24:32 +00:00
parent 65fec6c519
commit d7d03754d1
3 changed files with 59 additions and 21 deletions
+51 -17
View File
@@ -15,6 +15,7 @@ from datetime import UTC, datetime
from fastapi import ( from fastapi import (
APIRouter, APIRouter,
HTTPException, HTTPException,
Request,
WebSocket, WebSocket,
WebSocketDisconnect, WebSocketDisconnect,
) )
@@ -96,7 +97,9 @@ async def list_pages(lang: str | None = None) -> list[dict]:
@router.put("/_api/pages/{path:path}", status_code=204) @router.put("/_api/pages/{path:path}", status_code=204)
async def save_page(path: str, page: PageIn, lang: str | None = None) -> None: async def save_page(
path: str, page: PageIn, request: Request, lang: str | None = None
) -> None:
"""Create or replace the page at a slug path ("" or "/" = front page). """Create or replace the page at a slug path ("" or "/" = front page).
Missing ancestors are created as content-less category labels. Giving Missing ancestors are created as content-less category labels. Giving
@@ -120,12 +123,16 @@ async def save_page(path: str, page: PageIn, lang: str | None = None) -> None:
node = chain[-1] if chain else None node = chain[-1] if chain else None
if node is None or node.chunks is None: if node is None or node.chunks is None:
raise HTTPException(404, "no such page") raise HTTPException(404, "no such page")
with kanta.transaction("save translation", extra=path): with kanta.transaction(
f"page:{lang}", user=request.headers.get("remote-user"), extra=path
):
# Patches alone make the translated version exist. # Patches alone make the translated version exist.
if i18n.add_patch(data, node, path, lang, page.markdown): if i18n.add_patch(data, node, path, lang, page.markdown):
_invalidate_pages() _invalidate_pages()
return return
with kanta.transaction("save page", extra=path): with kanta.transaction(
"page", user=request.headers.get("remote-user"), extra=path
):
node = _ensure(data.menu, path) node = _ensure(data.menu, path)
node.title = page.title node.title = page.title
node.chunks = store_chunks(data.chunks, page.markdown) node.chunks = store_chunks(data.chunks, page.markdown)
@@ -137,7 +144,7 @@ async def save_page(path: str, page: PageIn, lang: str | None = None) -> None:
@router.delete("/_api/pages/{path:path}", status_code=204) @router.delete("/_api/pages/{path:path}", status_code=204)
async def delete_page(path: str) -> None: async def delete_page(path: str, request: Request) -> None:
"""Delete a node by slug path. """Delete a node by slug path.
A category (node with children) loses only its landing page and stays A category (node with children) loses only its landing page and stays
@@ -145,7 +152,9 @@ async def delete_page(path: str) -> None:
""" """
path = path.strip("/") path = path.strip("/")
_check_reserved(path) _check_reserved(path)
with kanta.transaction("delete page", extra=path): with kanta.transaction(
"page:delete", user=request.headers.get("remote-user"), extra=path
):
if not _remove_page(data.menu, path): if not _remove_page(data.menu, path):
raise HTTPException(404, "no such page") raise HTTPException(404, "no such page")
_invalidate_pages() _invalidate_pages()
@@ -182,7 +191,7 @@ class StructureOp(BaseModel):
@router.post("/_api/structure", status_code=204) @router.post("/_api/structure", status_code=204)
async def update_structure(op: StructureOp) -> None: async def update_structure(op: StructureOp, request: Request) -> None:
"""Apply one structure operation (see StructureOp).""" """Apply one structure operation (see StructureOp)."""
path = op.path.strip("/") path = op.path.strip("/")
chain = resolve(data.menu, path) chain = resolve(data.menu, path)
@@ -195,7 +204,9 @@ async def update_structure(op: StructureOp) -> None:
# what "the original" means for the node — its language is part of # what "the original" means for the node — its language is part of
# every render, so a change invalidates everywhere. # every render, so a change invalidates everywhere.
language = i18n.base_tag(op.language) language = i18n.base_tag(op.language)
with kanta.transaction("set page language", extra=path): with kanta.transaction(
"page:language", user=request.headers.get("remote-user"), extra=path
):
if language != node.language: if language != node.language:
node.language = language node.language = language
_invalidate_pages() _invalidate_pages()
@@ -203,7 +214,9 @@ async def update_structure(op: StructureOp) -> None:
if op.title is not None and lang and lang != i18n.primary_lang(data.menu, path): if op.title is not None and lang and lang != i18n.primary_lang(data.menu, path):
# Translated title (i18n.set_title_translation): original title, # Translated title (i18n.set_title_translation): original title,
# slugs and hierarchy stay untouched. # slugs and hierarchy stay untouched.
with kanta.transaction("translate title", extra=path): with kanta.transaction(
f"page:{lang}:title", user=request.headers.get("remote-user"), extra=path
):
if i18n.set_title_translation(data, node, lang, op.title): if i18n.set_title_translation(data, node, lang, op.title):
_invalidate_pages() _invalidate_pages()
return return
@@ -220,7 +233,18 @@ async def update_structure(op: StructureOp) -> None:
raise HTTPException(400, "target path exists") raise HTTPException(400, "target path exists")
if not tslug and node.children: if not tslug and node.children:
raise HTTPException(400, "the front page cannot have children") raise HTTPException(400, "the front page cannot have children")
with kanta.transaction("update structure", extra=path): # One structure call can combine a title set, a move/rename and a
# reorder; the action names the most significant of them.
action = (
"page:slug"
if target is not None and target != path
else "page:title"
if op.title is not None
else "structure:reorder"
)
with kanta.transaction(
action, user=request.headers.get("remote-user"), extra=path
):
if op.title is not None: if op.title is not None:
node.title = op.title node.title = op.title
if target is not None and target != path: if target is not None and target != path:
@@ -281,9 +305,11 @@ class SettingsIn(BaseModel):
@router.put("/_api/settings", status_code=204) @router.put("/_api/settings", status_code=204)
async def put_settings(settings: SettingsIn) -> None: async def put_settings(settings: SettingsIn, request: Request) -> None:
"""Update site-wide settings; invalidates cached pages and ETags.""" """Update site-wide settings; invalidates cached pages and ETags."""
with kanta.transaction("update settings"): with kanta.transaction(
"settings", user=request.headers.get("remote-user")
):
data.brand = settings.brand data.brand = settings.brand
data.brand_html = settings.brand_html data.brand_html = settings.brand_html
data.theme = settings.theme data.theme = settings.theme
@@ -302,14 +328,16 @@ async def put_settings(settings: SettingsIn) -> None:
@router.delete("/_api/translations", status_code=204) @router.delete("/_api/translations", status_code=204)
async def delete_translations() -> None: async def delete_translations(request: Request) -> None:
"""Drop all machine translations (Data.trans) so the dispatcher """Drop all machine translations (Data.trans) so the dispatcher
re-translates everything from scratch (a "refresh translations" action: re-translates everything from scratch (a translate:reset action:
the invalidation hook re-offers every fragment to connected the invalidation hook re-offers every fragment to connected
translators). User patches are kept; the availability index translators). User patches are kept; the availability index
(node.langs) is rebuilt from them — patches alone still make a language (node.langs) is rebuilt from them — patches alone still make a language
exist on a page.""" exist on a page."""
with kanta.transaction("refresh translations"): with kanta.transaction(
"translate:reset", user=request.headers.get("remote-user")
):
i18n.clear_translations(data) i18n.clear_translations(data)
_invalidate_pages() _invalidate_pages()
# Fragments rejected this run (segment validation) stay skipped no # Fragments rejected this run (segment validation) stay skipped no
@@ -326,7 +354,7 @@ class ToggleTaskIn(BaseModel):
@router.post("/_api/toggle-task") @router.post("/_api/toggle-task")
async def toggle_task_endpoint(body: ToggleTaskIn) -> dict[str, str]: async def toggle_task_endpoint(body: ToggleTaskIn, request: Request) -> dict[str, str]:
"""Toggle the Nth task-list checkbox in a page's Markdown source. """Toggle the Nth task-list checkbox in a page's Markdown source.
If ``markdown`` is provided the source is left untouched and the toggled If ``markdown`` is provided the source is left untouched and the toggled
@@ -348,7 +376,9 @@ async def toggle_task_endpoint(body: ToggleTaskIn) -> dict[str, str]:
new_markdown = toggle_task(node_markdown(data, node) or "", body.index) new_markdown = toggle_task(node_markdown(data, node) or "", body.index)
if new_markdown is None: if new_markdown is None:
raise HTTPException(400, "invalid task index") raise HTTPException(400, "invalid task index")
with kanta.transaction("toggle task", extra=path): with kanta.transaction(
"page", user=request.headers.get("remote-user"), extra=path
):
# Re-chunk like any save: only the chunk containing the toggled # Re-chunk like any save: only the chunk containing the toggled
# checkbox gets a new hash, the rest keep theirs. # checkbox gets a new hash, the rest keep theirs.
node.chunks = store_chunks(data.chunks, new_markdown) node.chunks = store_chunks(data.chunks, new_markdown)
@@ -554,7 +584,11 @@ async def editor_ws(ws: WebSocket) -> None:
} }
) )
continue continue
with kanta.transaction("editor save", extra=path): with kanta.transaction(
f"page:{lang}" if translated else "page",
user=ws.headers.get("remote-user"),
extra=path,
):
if move_from != path: if move_from != path:
same_menu = ( same_menu = (
move_from.rpartition("/")[0] == path.rpartition("/")[0] move_from.rpartition("/")[0] == path.rpartition("/")[0]
+3 -3
View File
@@ -268,19 +268,19 @@ async def put_favicon(request: Request) -> dict[str, str]:
raise HTTPException(400, "empty file") raise HTTPException(400, "empty file")
ext = _ext(request.headers.get("x-filename", "favicon.ico")) ext = _ext(request.headers.get("x-filename", "favicon.ico"))
stored = await asyncio.to_thread(store_image, body, ext, FAVICON_MAXSIZE) stored = await asyncio.to_thread(store_image, body, ext, FAVICON_MAXSIZE)
with kanta.transaction("upload favicon"): with kanta.transaction("settings", user=request.headers.get("remote-user")):
data.favicon = stored data.favicon = stored
_invalidate_pages() _invalidate_pages()
return {"path": f"/_f/{stored}"} return {"path": f"/_f/{stored}"}
@router.delete("/_api/settings/favicon", status_code=204) @router.delete("/_api/settings/favicon", status_code=204)
async def delete_favicon() -> None: async def delete_favicon(request: Request) -> None:
"""Clear the custom favicon (back to the build's /favicon.ico). """Clear the custom favicon (back to the build's /favicon.ico).
The blob stays in the content-addressed store; only the reference goes. The blob stays in the content-addressed store; only the reference goes.
""" """
with kanta.transaction("clear favicon"): with kanta.transaction("settings", user=request.headers.get("remote-user")):
data.favicon = "" data.favicon = ""
_invalidate_pages() _invalidate_pages()
+5 -1
View File
@@ -209,6 +209,7 @@ class _Connection:
#: and link marks). #: and link marks).
self.spans: list[Span] = [] self.spans: list[Span] = []
self.original: str = "" # its full source text (for the splicing) self.original: str = "" # its full source text (for the splicing)
self.kind: str = "" # "chunk" | "title" (for the transaction action)
class Dispatcher: class Dispatcher:
@@ -307,6 +308,7 @@ class Dispatcher:
state.inflight = (job.lang, job.key) # before the await: no double-assign state.inflight = (job.lang, job.key) # before the await: no double-assign
state.spans = spans state.spans = spans
state.original = original state.original = original
state.kind = job.kind
try: try:
await ws.send_text(msgspec.json.encode(job).decode()) await ws.send_text(msgspec.json.encode(job).decode())
except Exception: # send failed: the receive loop cleans up except Exception: # send failed: the receive loop cleans up
@@ -356,6 +358,7 @@ class Dispatcher:
await ws.close(code=1002) await ws.close(code=1002)
return return
texts, spans, original = msg.texts, state.spans, state.original texts, spans, original = msg.texts, state.spans, state.original
kind, state.kind = state.kind, ""
state.inflight = None state.inflight = None
state.spans = [] state.spans = []
state.original = "" state.original = ""
@@ -379,7 +382,8 @@ class Dispatcher:
self.schedule() self.schedule()
continue continue
with self.db.transaction( with self.db.transaction(
"translator results", user=clientkey, extra=lang f"translate:{lang}{':title' if kind == 'title' else ''}",
user=clientkey,
): ):
paths = store_results( paths = store_results(
self.data, lang, [TransResult(key=msg.key, text=text)] self.data, lang, [TransResult(key=msg.key, text=text)]