messagefoundry-webconsole 0.2.15__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. messagefoundry_webconsole/__init__.py +128 -0
  2. messagefoundry_webconsole/_auth.py +841 -0
  3. messagefoundry_webconsole/_html.py +539 -0
  4. messagefoundry_webconsole/_security.py +253 -0
  5. messagefoundry_webconsole/_service.py +22 -0
  6. messagefoundry_webconsole/_static.py +78 -0
  7. messagefoundry_webconsole/mount.py +103 -0
  8. messagefoundry_webconsole/pages/__init__.py +25 -0
  9. messagefoundry_webconsole/pages/_common.py +21 -0
  10. messagefoundry_webconsole/pages/account.py +771 -0
  11. messagefoundry_webconsole/pages/admin.py +480 -0
  12. messagefoundry_webconsole/pages/audit.py +66 -0
  13. messagefoundry_webconsole/pages/config.py +113 -0
  14. messagefoundry_webconsole/pages/connections.py +519 -0
  15. messagefoundry_webconsole/pages/messages.py +689 -0
  16. messagefoundry_webconsole/pages/monitoring.py +853 -0
  17. messagefoundry_webconsole/pages/uploaded_logs.py +252 -0
  18. messagefoundry_webconsole/routes/__init__.py +6 -0
  19. messagefoundry_webconsole/routes/_common.py +45 -0
  20. messagefoundry_webconsole/routes/account.py +451 -0
  21. messagefoundry_webconsole/routes/admin.py +505 -0
  22. messagefoundry_webconsole/routes/audit.py +39 -0
  23. messagefoundry_webconsole/routes/config.py +67 -0
  24. messagefoundry_webconsole/routes/connection_writes.py +248 -0
  25. messagefoundry_webconsole/routes/core.py +1077 -0
  26. messagefoundry_webconsole/routes/monitoring.py +94 -0
  27. messagefoundry_webconsole/routes/monitoring_writes.py +205 -0
  28. messagefoundry_webconsole/routes/oidc.py +172 -0
  29. messagefoundry_webconsole/routes/search.py +204 -0
  30. messagefoundry_webconsole/routes/sso.py +88 -0
  31. messagefoundry_webconsole/routes/status.py +178 -0
  32. messagefoundry_webconsole/routes/uploaded_logs.py +181 -0
  33. messagefoundry_webconsole/static/app.css +345 -0
  34. messagefoundry_webconsole/static/app.js +1506 -0
  35. messagefoundry_webconsole/static/csp-probe.js +9 -0
  36. messagefoundry_webconsole-0.2.15.dist-info/METADATA +63 -0
  37. messagefoundry_webconsole-0.2.15.dist-info/RECORD +40 -0
  38. messagefoundry_webconsole-0.2.15.dist-info/WHEEL +4 -0
  39. messagefoundry_webconsole-0.2.15.dist-info/licenses/LICENSE +662 -0
  40. messagefoundry_webconsole-0.2.15.dist-info/licenses/NOTICE +31 -0
@@ -0,0 +1,88 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """L5c (ADR 0068 §9): the browser Kerberos SSO challenge route — RFC 4559, Kerberos-only single-leg."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import base64
8
+ import binascii
9
+ import logging
10
+
11
+ from fastapi import FastAPI, Request, Response
12
+ from fastapi.responses import HTMLResponse, RedirectResponse
13
+
14
+ from messagefoundry.api._ui_seam import UiDeps
15
+ from messagefoundry.api.security import get_auth
16
+
17
+ from .. import pages
18
+ from .._auth import (
19
+ set_session_cookie,
20
+ )
21
+
22
+ _log = logging.getLogger(__name__)
23
+
24
+
25
+ def register(app: FastAPI, deps: UiDeps) -> None:
26
+ """L5c (ADR 0068 §9): the browser Kerberos SSO challenge route — RFC 4559,
27
+ Kerberos-only SINGLE-LEG, off by default ([auth].kerberos_enabled, experimental)."""
28
+
29
+ @app.get("/ui/sso")
30
+ async def ui_sso(request: Request) -> Response:
31
+ auth = get_auth(request)
32
+ if auth is None or not auth.kerberos_available:
33
+ # Disabled/degraded: redirect WITHOUT auditing (review carve-out, ADR 0068 §9).
34
+ # There is no rate limiter in front of this branch, so auditing a token-bearing
35
+ # attempt here would be an unbounded unauthenticated DB-write amplifier — the
36
+ # exact anti-flood invariant the JSON rate-limit path preserves. The attempt is
37
+ # a no-op (SSO is off); its visibility is the operator-facing serve-time state.
38
+ return RedirectResponse("/ui/login?e=sso_unavailable", status_code=303)
39
+ header = request.headers.get("Authorization", "")
40
+ if not header.startswith("Negotiate "):
41
+ # The RFC 4559 challenge leg — deliberately NOT rate-limited: every
42
+ # unauthenticated SSO navigation produces one 401 before the browser attaches
43
+ # its token; throttling it would self-lock normal traffic (ADR 0068 §9). The
44
+ # HTML body keeps a browser without SSO configured from dead-ending.
45
+ return HTMLResponse(
46
+ pages.sso_challenge(),
47
+ status_code=401,
48
+ headers={"WWW-Authenticate": "Negotiate"},
49
+ )
50
+ # Token-bearing leg. The rate limiter runs FIRST (review fix, ADR 0068 §9): it
51
+ # bounds EVERY downstream audit write to the limiter's rate, so an attacker looping
52
+ # token requests can't amplify into unbounded audit_log growth. The rate-limit
53
+ # reject itself is a _log.warning (NOT an audit) — parity with the JSON
54
+ # _rate_limited path's anti-flood posture — so exhaustion writes zero DB rows.
55
+ client = request.client.host if request.client else None
56
+ if not auth.allow_login_attempt(client):
57
+ _log.warning("SSO rate limit exceeded for %s", client or "<unknown>")
58
+ return RedirectResponse("/ui/login?e=rate_limited", status_code=303)
59
+ # Cross-site hygiene — an audited SSO reject (AUTH-K-AUDIT), now behind the limiter
60
+ # so the audit is bounded: a non-navigation fetch is drive-by ambient-auth probing.
61
+ # A cross-site TOP-LEVEL navigation is allowed (intranet links keep working; the
62
+ # residual harm of a forced navigation is self-login only — server-minted token, no
63
+ # fixation, ADR 0068 §9 threat model).
64
+ mode = request.headers.get("Sec-Fetch-Mode")
65
+ if mode is not None and mode != "navigate":
66
+ await auth.audit_kerberos_reject("non_navigation_fetch")
67
+ return RedirectResponse("/ui/login?e=sso_failed", status_code=303)
68
+ try:
69
+ token_bytes = base64.b64decode(header[len("Negotiate ") :], validate=True)
70
+ except (binascii.Error, ValueError):
71
+ await auth.audit_kerberos_reject("malformed_token")
72
+ return RedirectResponse("/ui/login?e=sso_failed", status_code=303)
73
+ # seed_reauth=False (ADR 0068 §9): the SSO proof is AMBIENT — the session must not
74
+ # be born with a free step-up window; the first sensitive action forces the
75
+ # directory-password step-up at /ui/reauth. ONE session per navigation into this
76
+ # route (the resync side effect fires here, never per page).
77
+ outcome = await auth.authenticate_kerberos(token_bytes, client=client, seed_reauth=False)
78
+ if not outcome.ok or outcome.token is None:
79
+ # authenticate_kerberos audited the reject. NEVER a second 401 — no challenge
80
+ # loops (Kerberos-only single-leg is a hard line; an NTLM NegTokenInit from an
81
+ # IP-hosted/SPN-less URL, an expired ticket, or an unknown principal all land
82
+ # here). The single-leg helper deliberately discards the acceptor's out_token —
83
+ # no mutual-auth response header (SECURITY.md's "no mutual authentication"; ADR
84
+ # 0068 §9 records this as the current posture).
85
+ return RedirectResponse("/ui/login?e=sso_failed", status_code=303)
86
+ resp = RedirectResponse("/ui", status_code=303)
87
+ set_session_cookie(resp, outcome.token, request=request)
88
+ return resp
@@ -0,0 +1,178 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """L1b: read-only engine status page (engine/store metrics, security posture, cluster + DR state).
4
+
5
+ Also serves ``GET /ui/nav-status`` — the metadata-only health rollup polled by the nav's engine-health
6
+ heart + alerts bell (app.js, every page). Worst issue wins; it never raises (a health probe that crashed
7
+ would blank the nav — a store failure IS the "down" signal).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from typing import Any
14
+
15
+ from fastapi import Depends, FastAPI, HTTPException, Request
16
+ from fastapi.responses import HTMLResponse, JSONResponse
17
+
18
+ from messagefoundry.api._ui_seam import UiDeps
19
+ from messagefoundry.api.models import (
20
+ ClusterNodeList,
21
+ ClusterStatus,
22
+ DrStatus,
23
+ SystemStatus,
24
+ )
25
+ from messagefoundry.auth import Identity, Permission
26
+
27
+ from .. import pages
28
+ from .._auth import (
29
+ require_ui,
30
+ )
31
+
32
+ _log = logging.getLogger(__name__)
33
+
34
+ # Free-space thresholds for the engine-health heart (absolute — total disk size isn't exposed, only free
35
+ # bytes). The store's DB grows on this drive, so "DB space" and "local drive space" collapse to one check on
36
+ # the default/embedded backend. Sensible defaults; tune if a deployment wants a different floor.
37
+ _DISK_WARN_BYTES = 5 * 1024**3 # < 5 GiB free → warn (orange)
38
+ _DISK_CRIT_BYTES = 1 * 1024**3 # < 1 GiB free → critical (blinking red)
39
+
40
+ # ADR 0014 operator-alert severities (store.py: "info|warning|critical"), ranked worst-last for the bell.
41
+ _SEVERITY_RANK = {"info": 1, "warning": 2, "critical": 3}
42
+
43
+
44
+ def _worst_severity(severities: list[str]) -> str | None:
45
+ """The highest-ranked severity among active alerts, or ``None`` for an empty list (drives the bell's
46
+ color: critical→red, warning→orange, info→accent, none→gray)."""
47
+ worst: str | None = None
48
+ rank = 0
49
+ for s in severities:
50
+ r = _SEVERITY_RANK.get(s, 0)
51
+ if r > rank:
52
+ rank, worst = r, s
53
+ return worst
54
+
55
+
56
+ def _derive_health(
57
+ sysinfo: SystemStatus | None,
58
+ dr: DrStatus | None,
59
+ cluster: ClusterStatus | None,
60
+ nodes: ClusterNodeList | None,
61
+ ) -> tuple[str, str | None]:
62
+ """Roll infrastructure signals into one engine-health verdict — ``ok`` < ``warn`` < ``down``, worst
63
+ issue wins — plus a short reason for the worst issue (the heart's tooltip). Pure: takes already-fetched
64
+ models (``None`` = that probe failed / not applicable), so it's unit-testable without an engine.
65
+
66
+ - ``sysinfo is None`` → store unreachable → **down** (the strongest "unhealthy").
67
+ - disk free (DB drive, and log drive if metered) < 1 GiB → **down**, < 5 GiB → **warn**.
68
+ - server DB connection pool saturated (``idle == 0``) → **warn**.
69
+ - running on the DR failover box (``dr.active``) → **warn**; a clustered engine with no leader → **down**.
70
+ """
71
+ issues: list[tuple[int, str]] = [] # (level, reason); 1 = warn, 2 = down
72
+ if sysinfo is None:
73
+ issues.append((2, "store unreachable"))
74
+ else:
75
+ frees = [("db", sysinfo.db.disk_free_bytes)]
76
+ if sysinfo.logs is not None:
77
+ frees.append(("logs", sysinfo.logs.disk_free_bytes))
78
+ for label, free in frees:
79
+ if free < _DISK_CRIT_BYTES:
80
+ issues.append((2, f"low disk ({label}): {free / 1024**3:.1f} GiB free"))
81
+ elif free < _DISK_WARN_BYTES:
82
+ issues.append((1, f"low disk ({label}): {free / 1024**3:.1f} GiB free"))
83
+ if sysinfo.pool is not None and sysinfo.pool.idle == 0:
84
+ issues.append((1, "DB connection pool saturated"))
85
+ if dr is not None and dr.enabled and dr.active:
86
+ issues.append((1, "running on the DR failover box"))
87
+ if (
88
+ cluster is not None
89
+ and cluster.clustered
90
+ and nodes is not None
91
+ and nodes.leader_node_id is None
92
+ ):
93
+ issues.append((2, "cluster has no leader"))
94
+
95
+ level = max((lvl for lvl, _ in issues), default=0)
96
+ reason = next((msg for lvl, msg in issues if lvl == level), None)
97
+ return {0: "ok", 1: "warn", 2: "down"}[level], reason
98
+
99
+
100
+ def register(app: FastAPI, deps: UiDeps) -> None:
101
+ """L1b: read-only engine status page (engine/store metrics, effective security posture,
102
+ cluster + DR state). Reuses the monitoring:read JSON handlers — no PHI, no step-up."""
103
+ core = deps.core
104
+
105
+ @app.get("/ui/status", response_class=HTMLResponse)
106
+ async def ui_status(
107
+ request: Request,
108
+ engine: Any = Depends(deps.get_engine),
109
+ identity: Identity = Depends(require_ui(Permission.MONITORING_READ)),
110
+ ) -> HTMLResponse:
111
+ sys_status = await core.system_status(request, engine=engine, _user=identity)
112
+ posture = await core.security_posture(request, engine=engine, identity=identity)
113
+ cluster = await core.cluster_status(engine=engine, _user=identity)
114
+ nodes = await core.cluster_nodes(engine=engine, _user=identity)
115
+ dr = await core.dr_status(engine=engine, _user=identity)
116
+ svc = await core.service_status(request, _user=identity)
117
+ return HTMLResponse(pages.status(sys_status, posture, cluster, nodes, dr, svc))
118
+
119
+ @app.get("/ui/nav-status")
120
+ async def ui_nav_status(
121
+ request: Request,
122
+ engine: Any = Depends(deps.get_engine),
123
+ # activity=False (ASVS 14.3.1): a TIMER-driven background poll on every navved page, not user
124
+ # activity. Refreshing the idle clock here kept an abandoned tab — including every PHI page —
125
+ # signed in indefinitely, which defeated automatic logoff outright.
126
+ identity: Identity = Depends(require_ui(Permission.MONITORING_READ, activity=False)),
127
+ ) -> JSONResponse:
128
+ # The nav heart + alerts bell poll this ~every 15s from every page. Metadata only — disk/pool/
129
+ # cluster posture + an alert COUNT (no bodies, no PHI). It must NEVER raise: a crash would blank the
130
+ # nav, and an unreachable store is itself the "down" verdict, so each probe is guarded and a broad
131
+ # failure degrades to the worst signal rather than a 500.
132
+ try:
133
+ sysinfo: SystemStatus | None = await core.system_status(
134
+ request, engine=engine, _user=identity
135
+ )
136
+ except HTTPException:
137
+ raise # a real authz failure — let require_ui-style semantics surface (never seen as "down")
138
+ except Exception: # noqa: BLE001 - a store/metrics read failure IS the "down" health signal
139
+ _log.warning("nav-status: system_status failed; reporting engine down", exc_info=True)
140
+ sysinfo = None
141
+
142
+ dr: DrStatus | None = None
143
+ cluster: ClusterStatus | None = None
144
+ nodes: ClusterNodeList | None = None
145
+ try:
146
+ dr = await core.dr_status(engine=engine, _user=identity)
147
+ cluster = await core.cluster_status(engine=engine, _user=identity)
148
+ if cluster.clustered:
149
+ nodes = await core.cluster_nodes(engine=engine, _user=identity)
150
+ except HTTPException:
151
+ raise
152
+ except Exception: # noqa: BLE001 - HA/DR posture is best-effort; absence must not fake a problem
153
+ _log.warning("nav-status: HA/DR probe failed; skipping failover checks", exc_info=True)
154
+
155
+ health, reason = _derive_health(sysinfo, dr, cluster, nodes)
156
+
157
+ # Alerts count/severity needs monitoring:diagnose (the ack/resolve tier). The handler is called
158
+ # DIRECTLY here, which skips its own Depends(require(...)) gate — so gate it explicitly on the
159
+ # identity (the /ui/alerts page re-asserts at the route level the same way). Without diagnose,
160
+ # alerts=null so the bell HIDES itself rather than showing a gray "no alerts" the viewer can't trust.
161
+ alerts: dict[str, object] | None = None
162
+ if identity.has(Permission.MONITORING_DIAGNOSE):
163
+ try:
164
+ instances = await core.list_active_alerts(
165
+ engine=engine, identity=identity, limit=200
166
+ )
167
+ active = instances.alerts
168
+ alerts = {
169
+ "count": len(active),
170
+ "severity": _worst_severity([a.severity for a in active]),
171
+ }
172
+ except HTTPException:
173
+ raise
174
+ except Exception: # noqa: BLE001 - a best-effort count; a query failure must not crash the probe
175
+ _log.warning("nav-status: list_active_alerts failed; omitting count", exc_info=True)
176
+ alerts = None
177
+
178
+ return JSONResponse({"health": health, "reason": reason, "alerts": alerts})
@@ -0,0 +1,181 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Offline uploaded-logs /ui routes (BACKLOG #125/#126, ADR 0134).
4
+
5
+ Upload / list / browse / resend / delete over the engine's uploaded-logs CoreHandlers (seam v7). The
6
+ console never touches the filesystem or the store directly — it calls the audited JSON handlers by
7
+ reference and re-asserts the equivalent permission/step-up via ``require_ui*``. The browse GET (which
8
+ decrypts PHI) is step-up-gated + registered as an UNLOCK action (like content search); delete is a
9
+ step-up, body-less, auto-retryable POST behind a confirm step; upload is a same-origin multipart POST
10
+ (no step-up — a body-carrying POST can't survive the re-auth redirect, and browsing PHI is the gated
11
+ surface). Resend is a same-origin POST on top of the already-stepped-up browse page.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from fastapi import Depends, FastAPI, HTTPException, Query, Request
19
+ from fastapi.responses import HTMLResponse, RedirectResponse, Response
20
+
21
+ from messagefoundry.api._ui_seam import UiDeps
22
+ from messagefoundry.api.models import UploadResendRequest
23
+ from messagefoundry.auth import Identity, Permission
24
+
25
+ from .. import pages
26
+ from .._auth import (
27
+ assert_same_origin,
28
+ register_ui_action,
29
+ require_ui,
30
+ require_ui_step_up,
31
+ )
32
+ from ._common import _form_pairs
33
+
34
+ # The browse GET decrypts PHI (step-up), so register it as an UNLOCK form — a stale step-up 303s to
35
+ # /ui/reauth and GET-redirects back to the browse page (the PHI-shaped filter is a GET query, so it is
36
+ # deliberately NOT carried across the redirect). The delete POST is body-less + step-up, so it may be
37
+ # auto-retried after re-auth. Upload/resend are same-origin require_ui POSTs (not registered).
38
+ register_ui_action(
39
+ r"^/ui/uploaded-logs/file/[^/?#]+$", Permission.FILES_BROWSE, auto_retry=False, unlock=True
40
+ )
41
+ register_ui_action(r"^/ui/uploaded-logs/file/[^/?#]+/delete$", Permission.FILES_DELETE)
42
+
43
+
44
+ def register(app: FastAPI, deps: UiDeps) -> None:
45
+ core = deps.core
46
+
47
+ @app.get("/ui/uploaded-logs", response_class=HTMLResponse)
48
+ async def ui_uploaded_logs(
49
+ request: Request,
50
+ engine: Any = Depends(deps.get_engine),
51
+ identity: Identity = Depends(require_ui(Permission.FILES_BROWSE)),
52
+ ) -> HTMLResponse:
53
+ data = await core.list_uploaded_files(request, engine=engine, identity=identity)
54
+ return HTMLResponse(pages.uploaded_logs(data))
55
+
56
+ @app.get("/ui/uploaded-logs/upload", response_class=HTMLResponse)
57
+ async def ui_uploaded_logs_upload_form(
58
+ request: Request,
59
+ _identity: Identity = Depends(require_ui(Permission.FILES_UPLOAD)),
60
+ ) -> HTMLResponse:
61
+ return HTMLResponse(pages.uploaded_logs_upload())
62
+
63
+ @app.post("/ui/uploaded-logs/upload")
64
+ async def ui_uploaded_logs_upload(
65
+ request: Request,
66
+ engine: Any = Depends(deps.get_engine),
67
+ identity: Identity = Depends(require_ui(Permission.FILES_UPLOAD)),
68
+ ) -> Response:
69
+ # Same-origin CSRF defense-in-depth on top of the SameSite cookie. No step-up: a multipart body
70
+ # POST can't survive the re-auth redirect (browsing the PHI is the step-up-gated surface).
71
+ assert_same_origin(request)
72
+ try:
73
+ await core.upload_file(request, engine=engine, identity=identity)
74
+ except HTTPException as exc:
75
+ return HTMLResponse(
76
+ pages.uploaded_logs_upload(error=str(exc.detail)), status_code=exc.status_code
77
+ )
78
+ return RedirectResponse("/ui/uploaded-logs", status_code=303)
79
+
80
+ @app.get("/ui/uploaded-logs/file/{file_id}", response_class=HTMLResponse)
81
+ async def ui_uploaded_log_browse(
82
+ file_id: str,
83
+ request: Request,
84
+ engine: Any = Depends(deps.get_engine),
85
+ identity: Identity = Depends(require_ui_step_up(Permission.FILES_BROWSE)),
86
+ content: str | None = Query(None, max_length=512),
87
+ field_path: str | None = Query(None, max_length=32),
88
+ field_value: str | None = Query(None, max_length=512),
89
+ message_type: str | None = Query(None, max_length=64),
90
+ control_id: str | None = Query(None, max_length=256),
91
+ limit: int = Query(200, ge=1, le=500),
92
+ ) -> Response:
93
+ shared = dict( # noqa: C408
94
+ content=content or "",
95
+ field_path=field_path or "",
96
+ field_value=field_value or "",
97
+ message_type=message_type or "",
98
+ control_id=control_id or "",
99
+ )
100
+
101
+ async def _browse(c: str | None, fp: str | None, fv: str | None) -> Any:
102
+ return await core.browse_uploaded_file(
103
+ request,
104
+ file_id=file_id,
105
+ engine=engine,
106
+ identity=identity,
107
+ content=c,
108
+ field_path=fp,
109
+ field_value=fv,
110
+ target="both",
111
+ message_type=message_type,
112
+ control_id=control_id,
113
+ limit=limit,
114
+ offset=0,
115
+ )
116
+
117
+ try:
118
+ result = await _browse(content, field_path, field_value)
119
+ except HTTPException as exc:
120
+ if exc.status_code == 404: # bad/absent id (incl. path-traversal) → back to the list
121
+ return RedirectResponse("/ui/uploaded-logs", status_code=303)
122
+ if exc.status_code == 400: # bad content criteria → re-render metadata-only + the error
123
+ result = await _browse(None, None, None)
124
+ return HTMLResponse(
125
+ pages.uploaded_log_detail(result, error=str(exc.detail), **shared),
126
+ status_code=400,
127
+ )
128
+ raise
129
+ return HTMLResponse(pages.uploaded_log_detail(result, **shared))
130
+
131
+ @app.post("/ui/uploaded-logs/file/{file_id}/resend")
132
+ async def ui_uploaded_log_resend(
133
+ file_id: str,
134
+ request: Request,
135
+ engine: Any = Depends(deps.get_engine),
136
+ identity: Identity = Depends(require_ui(Permission.FILES_BROWSE)),
137
+ ) -> Response:
138
+ # Same-origin; the browse page it posts from is already step-up-gated, so the operator is fresh.
139
+ assert_same_origin(request)
140
+ form = dict(await _form_pairs(request))
141
+ try:
142
+ body = UploadResendRequest(index=int(form.get("index", "")), to=form.get("to", ""))
143
+ except (ValueError, TypeError):
144
+ raise HTTPException(400, "index and to are required") from None
145
+ await core.resend_uploaded_message(
146
+ request, file_id=file_id, body=body, engine=engine, identity=identity
147
+ )
148
+ return RedirectResponse(f"/ui/uploaded-logs/file/{file_id}", status_code=303)
149
+
150
+ @app.get("/ui/uploaded-logs/file/{file_id}/delete-confirm", response_class=HTMLResponse)
151
+ async def ui_uploaded_log_delete_confirm(
152
+ file_id: str,
153
+ request: Request,
154
+ engine: Any = Depends(deps.get_engine),
155
+ identity: Identity = Depends(require_ui(Permission.FILES_DELETE)),
156
+ ) -> Response:
157
+ # The confirm step (BACKLOG #126). Show the filename so the operator confirms the right file; a
158
+ # bad/absent id (path-traversal) 404s at the browse handler, so read metadata via list here.
159
+ data = await core.list_uploaded_files(request, engine=engine, identity=identity)
160
+ match = next((f for f in data.files if f.file_id == file_id), None)
161
+ if match is None:
162
+ return RedirectResponse("/ui/uploaded-logs", status_code=303)
163
+ return HTMLResponse(pages.uploaded_log_delete_confirm(file_id, match.filename))
164
+
165
+ @app.post("/ui/uploaded-logs/file/{file_id}/delete")
166
+ async def ui_uploaded_log_delete(
167
+ file_id: str,
168
+ request: Request,
169
+ engine: Any = Depends(deps.get_engine),
170
+ identity: Identity = Depends(require_ui_step_up(Permission.FILES_DELETE)),
171
+ ) -> Response:
172
+ assert_same_origin(request)
173
+ try:
174
+ await core.delete_uploaded_file(
175
+ request, file_id=file_id, engine=engine, identity=identity
176
+ )
177
+ except HTTPException as exc:
178
+ if exc.status_code == 404:
179
+ return RedirectResponse("/ui/uploaded-logs", status_code=303)
180
+ raise
181
+ return RedirectResponse("/ui/uploaded-logs", status_code=303)