diff --git a/Dockerfile b/Dockerfile index 9ee0355..de78c52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,4 +74,4 @@ ENV PLAYWRIGHT_CHROMIUM_NO_SANDBOX=1 EXPOSE 80 ENTRYPOINT ["./entrypoint.sh"] -CMD ["gunicorn", "--bind", "0.0.0.0:80", "--workers", "1", "--worker-class", "gthread", "--threads", "8", "--timeout", "180", "wsgi:application"] +CMD ["gunicorn", "--bind", "0.0.0.0:80", "--workers", "1", "--worker-class", "gthread", "--threads", "8", "--timeout", "600", "wsgi:application"] diff --git a/app/auth/decorators.py b/app/auth/decorators.py index a597398..e25cae9 100644 --- a/app/auth/decorators.py +++ b/app/auth/decorators.py @@ -4,7 +4,7 @@ import time from functools import wraps -from flask import redirect, session, url_for +from flask import jsonify, redirect, request, session, url_for _JWT_RE = re.compile(r"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$") @@ -43,14 +43,20 @@ def validate_jwt_format(token: str) -> tuple[bool, str, int | None]: def require_token(f): - """Redirect to onboarding if no token in session or the JWT exp has passed.""" + """Redirect to onboarding if no token in session or the JWT exp has passed. + API routes (paths containing /api/) receive a JSON 401 instead of a redirect.""" @wraps(f) def decorated(*args, **kwargs): - if not session.get("gw_token"): - return redirect(url_for("onboarding.index")) - exp = session.get("gw_token_exp") - if exp is not None and time.time() > exp: - clear_token() + missing = not session.get("gw_token") + if not missing: + exp = session.get("gw_token_exp") + if exp is not None and time.time() > exp: + clear_token() + missing = True + + if missing: + if "/api/" in request.path: + return jsonify({"error": "session_expired"}), 401 return redirect(url_for("onboarding.index")) return f(*args, **kwargs) return decorated diff --git a/app/dashboard/routes.py b/app/dashboard/routes.py index 4ad1da7..aeb528e 100644 --- a/app/dashboard/routes.py +++ b/app/dashboard/routes.py @@ -122,7 +122,7 @@ def view_report_pdf(report_id: int): _purge_old_jobs() job_id = str(uuid.uuid4()) - _render_jobs[job_id] = {"q": queue.Queue(), "pdf": None, "error": None, "done": False, "created_at": time.monotonic()} + _render_jobs[job_id] = {"q": queue.Queue(), "events": [], "pdf": None, "error": None, "done": False, "created_at": time.monotonic()} threading.Thread( target=_run_view, @@ -139,7 +139,9 @@ def _run_view(job_id: str, report_id: int, template, gw_url: str, gw_token: str, t0 = time.monotonic() def emit(event: str, data: dict) -> None: - q.put((event, data)) + event_id = len(job["events"]) + job["events"].append((event_id, event, data)) + q.put(None) try: # ── Stage 1: Generate report JSON ───────────────────────── @@ -197,12 +199,17 @@ def emit(self, record: logging.LogRecord) -> None: # type: ignore[override] job["done"] = True emit("done", {"success": True, "elapsed": elapsed, "pdf_hash": job["pdf_hash"]}) - except Exception as exc: + except BaseException as exc: elapsed = round(time.monotonic() - t0, 1) job["error"] = str(exc) job["done"] = True - emit("render_error", {"message": str(exc)}) - emit("done", {"success": False, "elapsed": elapsed}) + try: + emit("render_error", {"message": str(exc)}) + emit("done", {"success": False, "elapsed": elapsed}) + except Exception: + pass + if not isinstance(exc, Exception): + raise @bp.route("/api/render//stream") @@ -213,16 +220,32 @@ def render_stream(job_id: str): return jsonify({"error": "Unknown job"}), 404 def generate(): + last_id_str = request.headers.get("Last-Event-Id") + next_idx = 0 + if last_id_str: + try: + next_idx = int(last_id_str) + 1 + except ValueError: + pass + + events = job["events"] q = job["q"] + while True: + while next_idx < len(events): + eid, ename, edata = events[next_idx] + yield f"id: {eid}\nevent: {ename}\ndata: {_json.dumps(edata)}\n\n" + next_idx += 1 + if ename == "done": + return + + if job["done"]: + return + try: - event, data = q.get(timeout=90) + q.get(timeout=30) except queue.Empty: yield ": heartbeat\n\n" - continue - yield f"event: {event}\ndata: {_json.dumps(data)}\n\n" - if event == "done": - break return Response( stream_with_context(generate()), diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index dcd5dfd..4d1d91a 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -1,6 +1,8 @@ (function () { "use strict"; + const _apiBase = (window.APP_ROOT || "") + "/dashboard"; + // ── Export refs ──────────────────────────────────────────────── const exportFilename = document.getElementById("export-filename"); const exportOwnerPw = document.getElementById("export-owner-pw"); @@ -44,6 +46,7 @@ let _renderTimer = null; let _renderT0 = null; let _expiryDays = 14; + let _renderDone = false; // ── Export helpers ───────────────────────────────────────────── function slugify(title) { @@ -75,6 +78,11 @@ updateDownloadState(); }); + // ── Session expiry ───────────────────────────────────────────── + function _handleSessionExpired() { + window.location.href = (window.APP_ROOT || "") + "/"; + } + // ── Utilities ────────────────────────────────────────────────── function showFlash(category, msg, ttl = 5000) { const el = document.createElement("div"); @@ -105,7 +113,7 @@ if (pdfDownloadBtn) pdfDownloadBtn.classList.add("btn--disabled"); try { - const resp = await fetch(`/dashboard/api/render/${_activeJobId}/pdf/download`, { + const resp = await fetch(`${_apiBase}/api/render/${_activeJobId}/pdf/download`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -241,13 +249,19 @@ async function startPdfRender() { openPdfPanel(); startTimer(); + _renderDone = false; let jobId; try { - const resp = await fetch(`/dashboard/api/report/${_activeReportId}/view`, { + const resp = await fetch(`${_apiBase}/api/report/${_activeReportId}/view`, { method: "POST", headers: { "Content-Type": "application/json" }, }); + if (resp.status === 401) { + stopTimer(); + _handleSessionExpired(); + return; + } const body = await resp.json(); if (!resp.ok || body.error) { stopTimer(); @@ -270,7 +284,7 @@ return; } - const es = new EventSource(`/dashboard/api/render/${jobId}/stream`); + const es = new EventSource(`${_apiBase}/api/render/${jobId}/stream`); _activeEs = es; es.addEventListener("stage", (e) => { @@ -296,6 +310,7 @@ }); es.addEventListener("done", async (e) => { + _renderDone = true; es.close(); _activeEs = null; stopTimer(); @@ -315,7 +330,8 @@ statusbarElapsed.textContent = `${d.elapsed}s`; try { - const resp = await fetch(`/dashboard/api/render/${jobId}/pdf`); + const resp = await fetch(`${_apiBase}/api/render/${jobId}/pdf`); + if (resp.status === 401) { _handleSessionExpired(); return; } if (!resp.ok) { const body = await resp.json().catch(() => ({})); addStatusMsg("error", body.error || "Could not retrieve PDF."); @@ -342,16 +358,45 @@ } }); - es.onerror = () => { - if (es.readyState === EventSource.CLOSED) return; + es.onerror = async () => { + if (_renderDone) return; + if (es.readyState !== EventSource.CLOSED) return; + // CLOSED means the server returned a non-SSE response (auth failure, crash, + // etc.). CONNECTING means a transient drop — the browser reconnects + // automatically with Last-Event-Id so we let it. es.close(); _activeEs = null; + try { + const r = await fetch(`${_apiBase}/api/render/${jobId}/pdf`); + if (r.status === 401) { _handleSessionExpired(); return; } + if (r.ok) { + _renderDone = true; + stopTimer(); + const elapsed = ((Date.now() - _renderT0) / 1000).toFixed(1); + pdfProgress.hidden = true; + pdfStatusbar.hidden = false; + statusbarStage.textContent = "Done"; + statusbarElapsed.textContent = `${elapsed}s`; + _currentPdfUrl = URL.createObjectURL(await r.blob()); + pdfIframe.src = _currentPdfUrl; + pdfIframe.hidden = false; + pdfCloseBtn.hidden = false; + if (vwConfigured) { + exportSteps.hidden = false; + updateDownloadState(); + } else { + pdfDownloadBtn.hidden = false; + updateDownloadState(); + } + return; + } + } catch (_) {} stopTimer(); pdfProgress.hidden = true; pdfStatusbar.hidden = false; pdfCloseBtn.hidden = false; statusbarStage.textContent = "Connection lost"; - addStatusMsg("error", "SSE connection closed unexpectedly."); + addStatusMsg("error", "Connection to render server lost."); }; } @@ -360,7 +405,7 @@ btn.addEventListener("click", async () => { const name = btn.dataset.template; try { - const resp = await fetch("/dashboard/api/template/select", { + const resp = await fetch(`${_apiBase}/api/template/select`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), @@ -409,7 +454,7 @@ vwModalSubmit.disabled = true; vwModalSubmit.textContent = "Connecting…"; try { - const resp = await fetch("/dashboard/api/vault/connect", { + const resp = await fetch(`${_apiBase}/api/vault/connect`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, master_password: masterPw }), @@ -493,7 +538,7 @@ `Owner: ${exportOwnerPw.value}`, ].join("\n"); try { - const resp = await fetch("/dashboard/api/vault/credential", { + const resp = await fetch(`${_apiBase}/api/vault/credential`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -529,7 +574,7 @@ const title = _activeReportTitle || exportFilename.value || `report-${_activeReportId}.pdf`; const text = exportUserPw.value; try { - const resp = await fetch("/dashboard/api/vault/send", { + const resp = await fetch(`${_apiBase}/api/vault/send`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/app/templates/base.html b/app/templates/base.html index 8c90fd3..40774a2 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -22,6 +22,7 @@ {% block content %}{% endblock %} + {% block scripts %}{% endblock %} diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 10c4ddb..4d07fd7 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -1,5 +1,6 @@ import base64 import json +import queue from unittest.mock import MagicMock, patch import pytest @@ -95,6 +96,117 @@ def test_view_report_no_template(auth_client): assert resp.status_code == 400 -def test_view_report_requires_session(app): +def test_api_route_returns_json_401_without_session(app): resp = app.test_client().post("/dashboard/api/report/10/view") + assert resp.status_code == 401 + assert resp.content_type == "application/json" + assert resp.get_json()["error"] == "session_expired" + + +def test_non_api_route_redirects_without_session(app): + resp = app.test_client().get("/dashboard/") assert resp.status_code == 302 + + +# ── SSE stream helpers ────────────────────────────────────────────────────── + + +def _parse_sse(text): + events = [] + current = {} + for line in text.splitlines(): + if line.startswith("id:"): + current["id"] = int(line[3:].strip()) + elif line.startswith("event:"): + current["event"] = line[6:].strip() + elif line.startswith("data:"): + current["data"] = json.loads(line[5:].strip()) + elif line == "" and current: + events.append(current) + current = {} + if current: + events.append(current) + return events + + +def _make_done_job(events_data): + events = [(i, name, data) for i, (name, data) in enumerate(events_data)] + return { + "q": queue.Queue(), + "events": events, + "pdf": b"fake-pdf", + "pdf_hash": "abc123", + "error": None, + "done": True, + "created_at": 0, + } + + +_FAKE_SSE_EVENTS = [ + ("stage", {"stage": "generate", "label": "Fetching report data…"}), + ("stage", {"stage": "weasyprint", "label": "Generating PDF…"}), + ("done", {"success": True, "elapsed": 5.0, "pdf_hash": "abc123"}), +] + + +# ── SSE stream tests ──────────────────────────────────────────────────────── + + +def test_render_stream_streams_all_events(auth_client): + job_id = "job-all" + fake_job = _make_done_job(_FAKE_SSE_EVENTS) + with patch.dict("app.dashboard.routes._render_jobs", {job_id: fake_job}): + resp = auth_client.get(f"/dashboard/api/render/{job_id}/stream") + assert resp.status_code == 200 + assert "text/event-stream" in resp.content_type + events = _parse_sse(resp.data.decode()) + assert len(events) == 3 + assert events[0]["id"] == 0 and events[0]["event"] == "stage" + assert events[2]["id"] == 2 and events[2]["event"] == "done" + + +def test_render_stream_replays_from_last_event_id(auth_client): + job_id = "job-replay" + fake_job = _make_done_job(_FAKE_SSE_EVENTS) + with patch.dict("app.dashboard.routes._render_jobs", {job_id: fake_job}): + resp = auth_client.get( + f"/dashboard/api/render/{job_id}/stream", + headers={"Last-Event-Id": "0"}, + ) + assert resp.status_code == 200 + events = _parse_sse(resp.data.decode()) + assert len(events) == 2 + assert events[0]["id"] == 1 + assert events[1]["id"] == 2 and events[1]["event"] == "done" + + +def test_render_stream_empty_when_fully_caught_up(auth_client): + job_id = "job-caught-up" + fake_job = _make_done_job(_FAKE_SSE_EVENTS) + last_id = len(_FAKE_SSE_EVENTS) - 1 + with patch.dict("app.dashboard.routes._render_jobs", {job_id: fake_job}): + resp = auth_client.get( + f"/dashboard/api/render/{job_id}/stream", + headers={"Last-Event-Id": str(last_id)}, + ) + assert resp.status_code == 200 + assert _parse_sse(resp.data.decode()) == [] + + +def test_render_stream_invalid_last_event_id_starts_from_beginning(auth_client): + job_id = "job-bad-id" + fake_job = _make_done_job(_FAKE_SSE_EVENTS) + with patch.dict("app.dashboard.routes._render_jobs", {job_id: fake_job}): + resp = auth_client.get( + f"/dashboard/api/render/{job_id}/stream", + headers={"Last-Event-Id": "not-a-number"}, + ) + assert resp.status_code == 200 + events = _parse_sse(resp.data.decode()) + assert len(events) == 3 + assert events[0]["id"] == 0 + + +def test_render_stream_unknown_job_returns_404(auth_client): + resp = auth_client.get("/dashboard/api/render/nonexistent/stream") + assert resp.status_code == 404