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.
- messagefoundry_webconsole/__init__.py +128 -0
- messagefoundry_webconsole/_auth.py +841 -0
- messagefoundry_webconsole/_html.py +539 -0
- messagefoundry_webconsole/_security.py +253 -0
- messagefoundry_webconsole/_service.py +22 -0
- messagefoundry_webconsole/_static.py +78 -0
- messagefoundry_webconsole/mount.py +103 -0
- messagefoundry_webconsole/pages/__init__.py +25 -0
- messagefoundry_webconsole/pages/_common.py +21 -0
- messagefoundry_webconsole/pages/account.py +771 -0
- messagefoundry_webconsole/pages/admin.py +480 -0
- messagefoundry_webconsole/pages/audit.py +66 -0
- messagefoundry_webconsole/pages/config.py +113 -0
- messagefoundry_webconsole/pages/connections.py +519 -0
- messagefoundry_webconsole/pages/messages.py +689 -0
- messagefoundry_webconsole/pages/monitoring.py +853 -0
- messagefoundry_webconsole/pages/uploaded_logs.py +252 -0
- messagefoundry_webconsole/routes/__init__.py +6 -0
- messagefoundry_webconsole/routes/_common.py +45 -0
- messagefoundry_webconsole/routes/account.py +451 -0
- messagefoundry_webconsole/routes/admin.py +505 -0
- messagefoundry_webconsole/routes/audit.py +39 -0
- messagefoundry_webconsole/routes/config.py +67 -0
- messagefoundry_webconsole/routes/connection_writes.py +248 -0
- messagefoundry_webconsole/routes/core.py +1077 -0
- messagefoundry_webconsole/routes/monitoring.py +94 -0
- messagefoundry_webconsole/routes/monitoring_writes.py +205 -0
- messagefoundry_webconsole/routes/oidc.py +172 -0
- messagefoundry_webconsole/routes/search.py +204 -0
- messagefoundry_webconsole/routes/sso.py +88 -0
- messagefoundry_webconsole/routes/status.py +178 -0
- messagefoundry_webconsole/routes/uploaded_logs.py +181 -0
- messagefoundry_webconsole/static/app.css +345 -0
- messagefoundry_webconsole/static/app.js +1506 -0
- messagefoundry_webconsole/static/csp-probe.js +9 -0
- messagefoundry_webconsole-0.2.15.dist-info/METADATA +63 -0
- messagefoundry_webconsole-0.2.15.dist-info/RECORD +40 -0
- messagefoundry_webconsole-0.2.15.dist-info/WHEEL +4 -0
- messagefoundry_webconsole-0.2.15.dist-info/licenses/LICENSE +662 -0
- 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)
|