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:
+51
-17
@@ -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
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
Reference in New Issue
Block a user