From d7d03754d1191f94689ee72d076b19ecc959c91f Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Thu, 3 Sep 2026 15:24:32 +0000 Subject: [PATCH] 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). --- pagerite/api.py | 68 ++++++++++++++++++++++++++++++++----------- pagerite/files.py | 6 ++-- pagerite/translate.py | 6 +++- 3 files changed, 59 insertions(+), 21 deletions(-) diff --git a/pagerite/api.py b/pagerite/api.py index b238698..0117232 100644 --- a/pagerite/api.py +++ b/pagerite/api.py @@ -15,6 +15,7 @@ from datetime import UTC, datetime from fastapi import ( APIRouter, HTTPException, + Request, WebSocket, WebSocketDisconnect, ) @@ -96,7 +97,9 @@ async def list_pages(lang: str | None = None) -> list[dict]: @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). 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 if node is None or node.chunks is None: 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. if i18n.add_patch(data, node, path, lang, page.markdown): _invalidate_pages() 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.title = page.title 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) -async def delete_page(path: str) -> None: +async def delete_page(path: str, request: Request) -> None: """Delete a node by slug path. 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("/") _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): raise HTTPException(404, "no such page") _invalidate_pages() @@ -182,7 +191,7 @@ class StructureOp(BaseModel): @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).""" path = op.path.strip("/") 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 # every render, so a change invalidates everywhere. 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: node.language = language _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): # Translated title (i18n.set_title_translation): original title, # 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): _invalidate_pages() return @@ -220,7 +233,18 @@ async def update_structure(op: StructureOp) -> None: raise HTTPException(400, "target path exists") if not tslug and node.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: node.title = op.title if target is not None and target != path: @@ -281,9 +305,11 @@ class SettingsIn(BaseModel): @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.""" - with kanta.transaction("update settings"): + with kanta.transaction( + "settings", user=request.headers.get("remote-user") + ): data.brand = settings.brand data.brand_html = settings.brand_html data.theme = settings.theme @@ -302,14 +328,16 @@ async def put_settings(settings: SettingsIn) -> None: @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 - 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 translators). User patches are kept; the availability index (node.langs) is rebuilt from them — patches alone still make a language exist on a page.""" - with kanta.transaction("refresh translations"): + with kanta.transaction( + "translate:reset", user=request.headers.get("remote-user") + ): i18n.clear_translations(data) _invalidate_pages() # Fragments rejected this run (segment validation) stay skipped no @@ -326,7 +354,7 @@ class ToggleTaskIn(BaseModel): @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. 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) if new_markdown is None: 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 # checkbox gets a new hash, the rest keep theirs. node.chunks = store_chunks(data.chunks, new_markdown) @@ -554,7 +584,11 @@ async def editor_ws(ws: WebSocket) -> None: } ) 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: same_menu = ( move_from.rpartition("/")[0] == path.rpartition("/")[0] diff --git a/pagerite/files.py b/pagerite/files.py index bf779b6..1b2e657 100644 --- a/pagerite/files.py +++ b/pagerite/files.py @@ -268,19 +268,19 @@ async def put_favicon(request: Request) -> dict[str, str]: raise HTTPException(400, "empty file") ext = _ext(request.headers.get("x-filename", "favicon.ico")) 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 _invalidate_pages() return {"path": f"/_f/{stored}"} @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). 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 = "" _invalidate_pages() diff --git a/pagerite/translate.py b/pagerite/translate.py index 9098ca3..00b914a 100644 --- a/pagerite/translate.py +++ b/pagerite/translate.py @@ -209,6 +209,7 @@ class _Connection: #: and link marks). self.spans: list[Span] = [] self.original: str = "" # its full source text (for the splicing) + self.kind: str = "" # "chunk" | "title" (for the transaction action) class Dispatcher: @@ -307,6 +308,7 @@ class Dispatcher: state.inflight = (job.lang, job.key) # before the await: no double-assign state.spans = spans state.original = original + state.kind = job.kind try: await ws.send_text(msgspec.json.encode(job).decode()) except Exception: # send failed: the receive loop cleans up @@ -356,6 +358,7 @@ class Dispatcher: await ws.close(code=1002) return texts, spans, original = msg.texts, state.spans, state.original + kind, state.kind = state.kind, "" state.inflight = None state.spans = [] state.original = "" @@ -379,7 +382,8 @@ class Dispatcher: self.schedule() continue with self.db.transaction( - "translator results", user=clientkey, extra=lang + f"translate:{lang}{':title' if kind == 'title' else ''}", + user=clientkey, ): paths = store_results( self.data, lang, [TransResult(key=msg.key, text=text)]