The send, the draft and the screenshots take the session's row lock, so an autosave or a paste during a send waits for it and then sees it, and two sends cannot open two issues. A save keeps the delivery's progress; a failed edit that shows the screenshots is finished by the retry; an answer that is not the tracker's API (a redirect, a proxy page) is refused, not a crash that loses the issue's record. Only an attempt that opened an issue counts toward the pace. Screenshots: deleting one after the send is refused, a user's storage is capped across sessions, new sessions and zip exports are paced, images are stored uncompressed in the zip. One liveness rule, one session view, one image type table. Logging: a session's elevation is tracked apart from the user's own, so the browser's own requests (no header) no longer lower the floor a session needs; Stop ends it at once. Composer: an image pasted anywhere is taken; what is still unsaved is saved on close, and "saved" is shown only when it is; the draft is held while it is sent; a failed screenshot read says so instead of reading as none; focus moves to what stays; the preview follows the text size. Pill: the step prompt and a running Stop survive the pill moving between hosts, the prompt keeps the dialog's Tab trap, the toast region lets clicks through, other tabs hear of a Start or Stop, the last seconds are sent before the cap, a capture with no frame is an error.
364 lines
14 KiB
Python
364 lines
14 KiB
Python
"""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 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
|
|
|
|
|
|
_view = debugsession.view
|
|
|
|
# New sessions a user may open per hour (joining the running one is not counted): a session is a
|
|
# place to keep screenshots, and this — with the per-user storage cap — bounds how many there are.
|
|
_new_sessions = RateLimiter(max_events=20, window_seconds=3600)
|
|
|
|
|
|
@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)."""
|
|
joining = debugsession.open_session(db, user) is not None
|
|
if not joining and not _new_sessions.allow(str(user.id)):
|
|
raise refused(429, "debug_session_rate_limited", limit=_new_sessions.max_events)
|
|
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"))
|
|
debugsession.stop(session)
|
|
db.commit()
|
|
logs.end_session_elevation(session.id)
|
|
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
|
|
# Zips a user may build per minute: each holds every screenshot in memory while it is built.
|
|
_exports = RateLimiter(max_events=6, window_seconds=60)
|
|
|
|
|
|
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 len(json.dumps(body.form, ensure_ascii=False).encode()) > FORM_MAX_BYTES:
|
|
raise HTTPException(status_code=413, detail={"code": "too_large"})
|
|
# Under the lock: a save that arrives while the issue is being sent waits for the send, and then
|
|
# sees it — the draft of a sent issue cannot change after it.
|
|
debugsession.lock(db, 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")
|
|
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")
|
|
if not _exports.allow(str(user.id)):
|
|
raise refused(429, "export_rate_limited")
|
|
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, or an attempt refused before the issue
|
|
# was opened, 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)
|
|
|
|
|
|
def _issues_rate_limited() -> HTTPException:
|
|
return refused(429, "issues_rate_limited", limit=_new_issues.max_events)
|
|
|
|
|
|
@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")
|
|
# Held to the end: a second send (another tab, a retry after a timeout) waits for this one and
|
|
# then finds the issue it opened, instead of opening another; an autosave or a screenshot waits
|
|
# too, and then finds the issue sent.
|
|
debugsession.lock(db, session)
|
|
new = session.issue_url is None
|
|
if not new and not debugsession.unfinished(session):
|
|
return {"ok": True, "session": _view(db, session)}
|
|
key = str(user.id)
|
|
if new:
|
|
if not ((session.draft or {}).get("title") or "").strip():
|
|
raise refused(422, "issue_title_required")
|
|
if _new_issues.exhausted(key):
|
|
raise _issues_rate_limited()
|
|
|
|
def count_new_issue() -> None:
|
|
if not _new_issues.allow(key):
|
|
raise _issues_rate_limited()
|
|
|
|
try:
|
|
gitea.deliver(db, session, before_create=count_new_issue)
|
|
except gitea.GiteaError as exc:
|
|
# Keep what did happen: an issue created before an upload failed stays linked, and the
|
|
# retry does only what is still missing.
|
|
_record_created(db, user, session, new)
|
|
db.commit()
|
|
raise issue_refused(exc) from None
|
|
except HTTPException:
|
|
raise # the pace, refused before anything was opened
|
|
except Exception:
|
|
# Anything else after the issue was opened must not lose the record that it was: a retry
|
|
# would open a second one.
|
|
_record_created(db, user, session, new)
|
|
db.commit()
|
|
raise
|
|
_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 == "storage_full":
|
|
mb = debugsession.SCREENSHOT_USER_MAX_BYTES // (1024 * 1024)
|
|
return refused(422, "screenshot_storage_full", limit=mb)
|
|
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:
|
|
return _screenshot_of(
|
|
db, owned_or_404(db, DebugSession, sid, user, what="debug_session"), shot_id
|
|
)
|
|
|
|
|
|
def _screenshot_of(db: Session, session: DebugSession, shot_id: int) -> DebugScreenshot:
|
|
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:
|
|
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
|
|
shot = _screenshot_of(db, session, shot_id)
|
|
try:
|
|
debugsession.remove_screenshot(db, session, shot)
|
|
except debugsession.ScreenshotRefused as exc:
|
|
raise _screenshot_refused(exc.code) from None
|
|
db.commit()
|
|
return {"ok": True, "deleted": shot_id}
|