Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
20 changes: 13 additions & 7 deletions app/auth/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_-]+$")
Expand Down Expand Up @@ -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
Expand Down
43 changes: 33 additions & 10 deletions app/dashboard/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 ─────────────────────────
Expand Down Expand Up @@ -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/<job_id>/stream")
Expand All @@ -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()),
Expand Down
67 changes: 56 additions & 11 deletions app/static/js/dashboard.js
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -44,6 +46,7 @@
let _renderTimer = null;
let _renderT0 = null;
let _expiryDays = 14;
let _renderDone = false;

// ── Export helpers ─────────────────────────────────────────────
function slugify(title) {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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();
Expand All @@ -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) => {
Expand All @@ -296,6 +310,7 @@
});

es.addEventListener("done", async (e) => {
_renderDone = true;
es.close();
_activeEs = null;
stopTimer();
Expand All @@ -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.");
Expand All @@ -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.");
};
}

Expand All @@ -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 }),
Expand Down Expand Up @@ -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 }),
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
1 change: 1 addition & 0 deletions app/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

{% block content %}{% endblock %}

<script>window.APP_ROOT = {{ request.script_root | tojson }};</script>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
Expand Down
Loading