"""Debug sessions (E16): Start → reproduce → Stop, the screenshots, and the evidence under review. The session itself — what tags a request, its cap, its level — is `app/debugsession.py`. These routes are the user's handle on their OWN sessions only: another user's id 404s (`owned_or_404`), including for an admin, who reads everyone's lines in the diagnostics console instead. The shared demo account cannot record: its log would interleave every visitor under one account. Screenshots arrive as a raw image body (`image/png`, `image/jpeg`, `image/webp`), not a multipart form — one image per request, capped while it arrives, and kept only if its bytes really are one of those types, whatever the Content-Type claimed. """ import json from datetime import datetime, timezone from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request, Response from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app import audit, debugsession, logs, logsink from app.audit import AuditAction from app.auth import require_human from app.db import get_db from app.issues import bundle, gitea from app.ratelimit import RateLimiter from app.refusal import refused from app.models import DebugScreenshot, DebugSession, User from app.routes._common import capped_body, issue_refused, not_found, owned_or_404 from app.utils import iso router = APIRouter(prefix="/api/debug-sessions", tags=["diagnostics"]) log = logs.get_logger(__name__) # Sessions listed in Settings → Diagnosztika, newest first: enough to reach last week's unsent draft. LIST_MAX = 20 class StartIn(BaseModel): level: debugsession.SessionLevel def _view(db: Session, session: DebugSession) -> dict: return debugsession.serialize(session, debugsession.screenshot_count(db, session)) or {} @router.post("") def start_session( body: StartIn, user: User = Depends(require_human), db: Session = Depends(get_db), ) -> dict: """Start recording, or join the recording already running (another tab started it).""" try: session = debugsession.start(db, user, body.level) db.commit() except IntegrityError: # A concurrent Start won the one-open-per-user index: join that one. db.rollback() session = debugsession.open_session(db, user) if session is None: raise refused(409, "debug_session_conflict") from None # The rest of THIS request is part of the session too. level, until = debugsession.level_for(user, session) logs.set_user(user.id, level, until, session.id) log.info( "debug session started", extra=logs.event("debug.session.started", level=session.level, until=iso(session.until)), ) return {"ok": True, "session": _view(db, session)} @router.post("/{sid}/stop") def stop_session( sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: session = owned_or_404(db, DebugSession, sid, user, what="debug_session") if session.ended_at is None: # Logged BEFORE the session closes, while this request still carries its tag: the last line # of the evidence is the Stop itself. log.info("debug session stopped", extra=logs.event("debug.session.stopped")) # Past its cap it had already ended on its own; a late Stop must not rename that. live = session.until > datetime.now(timezone.utc) reason = debugsession.EndReason.STOPPED if live else debugsession.EndReason.EXPIRED debugsession.end(session, reason) db.commit() return {"ok": True, "session": _view(db, session)} @router.get("") def list_sessions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict: sessions = db.scalars( select(DebugSession) .where(DebugSession.user_id == user.id) .order_by(DebugSession.started_at.desc()) .limit(LIST_MAX) ).all() return {"items": [_view(db, s) for s in sessions]} @router.get("/{sid}") def get_session( sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: return _view(db, owned_or_404(db, DebugSession, sid, user, what="debug_session")) @router.get("/{sid}/events") def session_events( sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: """The rows an issue from this session would carry — browser and server, in time order.""" session = owned_or_404(db, DebugSession, sid, user, what="debug_session") rows, total = debugsession.bundle(db, session) return {"items": [logsink.serialize(r) for r in rows], "total": total} # --- the draft and its export --------------------------------------------------------------- # The composer's own fields (steps, description…) are its business, kept whole so it reopens as it # was — bounded here so a draft cannot become a place to store anything else. FORM_MAX_BYTES = 64 * 1024 class DraftIn(BaseModel): title: str = Field(max_length=200) # The rendered markdown the export and the issue carry. body: str = Field(max_length=50_000) # Log rows taken out of the evidence in the review. excluded: list[int] = Field(default_factory=list, max_length=debugsession.BUNDLE_MAX) form: dict[str, Any] = Field(default_factory=dict) @router.put("/{sid}/draft") def save_draft( sid: str, body: DraftIn, user: User = Depends(require_human), db: Session = Depends(get_db), ) -> dict: session = owned_or_404(db, DebugSession, sid, user, what="debug_session") if session.issue_url is not None: # Sent: the issue is on the tracker now, and a draft edited after it would say otherwise. raise refused(409, "issue_already_sent") if len(json.dumps(body.form, ensure_ascii=False).encode()) > FORM_MAX_BYTES: raise HTTPException(status_code=413, detail={"code": "too_large"}) debugsession.save_draft(session, body.model_dump()) db.commit() return {"ok": True, "session": _view(db, session)} @router.get("/{sid}/export") def export_session( sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) ) -> Response: """The issue as a zip — the text, the two logs and the screenshots — for an instance without an issue tracker, or for sending it by hand. Built from the SAVED draft: what the composer shows is what it saved.""" session = owned_or_404(db, DebugSession, sid, user, what="debug_session") data = bundle.zipped(bundle.files(db, session)) name = f"siftlode-debug-{session.started_at:%Y%m%d-%H%M}-{session.id[:6]}.zip" return Response( content=data, media_type="application/zip", headers={ "Content-Disposition": f'attachment; filename="{name}"', "Cache-Control": "no-store", }, ) # --- the issue tracker (S4) ----------------------------------------------------------------- # Issues a user may OPEN per hour — finishing a half-sent one is not counted. The composer sends one # at a time; this is room for a bad afternoon, not a pace anything legitimate keeps up. _new_issues = RateLimiter(max_events=5, window_seconds=3600) @router.post("/{sid}/issue") def send_issue( sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: """Send the session's issue to the tracker — from the SAVED draft, as the export is built — or finish a send that stopped half-way (the issue exists; only what is missing is sent).""" session = owned_or_404(db, DebugSession, sid, user, what="debug_session") new = session.issue_url is None if not new and not gitea.pending(session): return {"ok": True, "session": _view(db, session)} if new: if not ((session.draft or {}).get("title") or "").strip(): raise refused(422, "issue_title_required") if not _new_issues.allow(str(user.id)): raise refused(429, "issues_rate_limited") try: gitea.deliver(db, session) except gitea.GiteaError as exc: # Keep what did happen: an issue created before an upload failed stays linked, and the # retry sends only the files still missing. _record_created(db, user, session, new) db.commit() raise issue_refused(exc) from None _record_created(db, user, session, new) db.commit() return {"ok": True, "session": _view(db, session)} def _record_created(db: Session, user: User, session: DebugSession, new: bool) -> None: if new and session.issue_url: audit.record( db, user.id, AuditAction.ISSUE_CREATE, target_type="debug_session", target_id=session.id, summary=session.issue_url, ) class PreviewIn(BaseModel): text: str = Field(max_length=50_000) @router.post("/{sid}/preview") def preview_issue( sid: str, body: PreviewIn, user: User = Depends(require_human), db: Session = Depends(get_db), ) -> dict: """The body as the tracker will render it. Gitea sanitises its own output; the composer still shows it in a sandboxed frame, never in the app's own document.""" owned_or_404(db, DebugSession, sid, user, what="debug_session") try: return {"ok": True, "html": gitea.preview(db, body.text)} except gitea.GiteaError as exc: raise issue_refused(exc) from None # --- screenshots --------------------------------------------------------------------------- def _shot_view(shot: DebugScreenshot) -> dict: return {"id": shot.id, "mime": shot.mime, "size": shot.size, "created_at": iso(shot.created_at)} @router.get("/{sid}/screenshots") def list_screenshots( sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: session = owned_or_404(db, DebugSession, sid, user, what="debug_session") return {"items": [_shot_view(s) for s in debugsession.screenshots(db, session)]} def _upload_target( sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) ) -> DebugSession: return owned_or_404(db, DebugSession, sid, user, what="debug_session") async def _image_body(request: Request) -> bytes: return await capped_body(request, debugsession.SCREENSHOT_MAX_BYTES) @router.post("/{sid}/screenshots") def add_screenshot( # The owner check FIRST: dependencies resolve in order, so a stranger's upload is refused # before a byte of it is read. session: DebugSession = Depends(_upload_target), data: bytes = Depends(_image_body), db: Session = Depends(get_db), ) -> dict: try: shot = debugsession.add_screenshot(db, session, data) except debugsession.ScreenshotRefused as exc: raise _screenshot_refused(exc.code) from None db.commit() return {"ok": True, "screenshot": _shot_view(shot)} def _screenshot_refused(code: str) -> HTTPException: # Literal reasons, one per case: the frontend's contract test reads them to require a sentence # for each in both languages. if code == "already_sent": return refused(409, "screenshot_already_sent") if code == "too_many": return refused(422, "screenshot_too_many", limit=debugsession.SCREENSHOTS_MAX) if code == "empty": return refused(422, "screenshot_empty") return refused(422, "screenshot_not_an_image") def _session_screenshot(db: Session, sid: str, shot_id: int, user: User) -> DebugScreenshot: session = owned_or_404(db, DebugSession, sid, user, what="debug_session") shot = db.get(DebugScreenshot, shot_id) if shot is None or shot.session_id != session.id: raise not_found("screenshot") return shot @router.get("/{sid}/screenshots/{shot_id}") def get_screenshot( sid: str, shot_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) ) -> Response: shot = _session_screenshot(db, sid, shot_id, user) # `nosniff`: the type was checked against the bytes at upload, and the browser must not guess. return Response( content=shot.data, media_type=shot.mime, headers={"Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff"}, ) @router.delete("/{sid}/screenshots/{shot_id}") def delete_screenshot( sid: str, shot_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: shot = _session_screenshot(db, sid, shot_id, user) db.delete(shot) db.commit() return {"ok": True, "deleted": shot_id}