- admin config group "Bug reports": Gitea address, repository, token (secret),
and "Check the connection" — the repository must answer, take issues and
be PRIVATE (a report carries its reporter's log); checked on every send too
- POST /api/debug-sessions/{id}/issue sends the SAVED draft: create the issue,
upload each file on its own request (the measured API), then show the
screenshots inline; progress is kept in the draft, so a send that stops
half-way keeps its issue and the retry sends only what is missing; 5 new
issues an hour per user; audited as issue_create
- the composer: Create issue (only where a tracker is set up, naming the
repository), a partial-send banner with retry, and a Preview — the tracker's
own rendering in a sandboxed frame, or the zip's markdown without one
- the recorder's refusals answer in the localized family (debug_session_*,
screenshot_*, issue(s)_*), not bare codes the UI could only call generic
- a tracker refusal logs the status only: its message can quote the title back
319 lines
12 KiB
Python
319 lines
12 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 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}
|