bugcap 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bugcap/__init__.py +1 -0
- bugcap/agent_api.py +221 -0
- bugcap/backends.py +133 -0
- bugcap/capture.py +84 -0
- bugcap/cli.py +942 -0
- bugcap/config.py +86 -0
- bugcap/dashboard/__init__.py +3 -0
- bugcap/dashboard/api.py +105 -0
- bugcap/dashboard/media.py +73 -0
- bugcap/dashboard/server.py +213 -0
- bugcap/dashboard/static/app.css +43 -0
- bugcap/dashboard/static/app.js +179 -0
- bugcap/dashboard/static/index.html +45 -0
- bugcap/drafts.py +83 -0
- bugcap/errors.py +30 -0
- bugcap/ghcli.py +175 -0
- bugcap/ingest.py +210 -0
- bugcap/live.py +292 -0
- bugcap/live_session.py +125 -0
- bugcap/logs.py +74 -0
- bugcap/mcp_server.py +221 -0
- bugcap/paths.py +116 -0
- bugcap/recorder.py +390 -0
- bugcap/refs.py +213 -0
- bugcap/repo.py +138 -0
- bugcap/service.py +418 -0
- bugcap/store.py +485 -0
- bugcap/sync.py +424 -0
- bugcap/tomlio.py +37 -0
- bugcap/validation.py +74 -0
- bugcap-0.2.2.dist-info/METADATA +225 -0
- bugcap-0.2.2.dist-info/RECORD +36 -0
- bugcap-0.2.2.dist-info/WHEEL +5 -0
- bugcap-0.2.2.dist-info/entry_points.txt +2 -0
- bugcap-0.2.2.dist-info/licenses/LICENSE +21 -0
- bugcap-0.2.2.dist-info/top_level.txt +1 -0
bugcap/config.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Global (per-user) config: object store settings etc. Secrets never live here."""
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from . import tomlio
|
|
5
|
+
from .paths import config_path
|
|
6
|
+
|
|
7
|
+
KNOWN_KEYS = {
|
|
8
|
+
"storage.bucket": "Bucket name (S3 / R2 / any S3-compatible)",
|
|
9
|
+
"storage.endpoint_url": "Custom endpoint, e.g. https://<account>.r2.cloudflarestorage.com",
|
|
10
|
+
"storage.region": "Region (use 'auto' for R2)",
|
|
11
|
+
"storage.prefix": "Key prefix inside the bucket, e.g. bugcap/",
|
|
12
|
+
"storage.public_base_url": "Public URL base (R2 public bucket / CDN). If unset, presigned URLs are used",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load() -> dict[str, Any]:
|
|
17
|
+
return tomlio.load(config_path())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get(key: str) -> Any:
|
|
21
|
+
section, _, name = key.partition(".")
|
|
22
|
+
return load().get(section, {}).get(name)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def set_value(key: str, value: str) -> None:
|
|
26
|
+
section, _, name = key.partition(".")
|
|
27
|
+
if not name:
|
|
28
|
+
raise ValueError("config keys look like 'storage.bucket'")
|
|
29
|
+
data = load()
|
|
30
|
+
data.setdefault(section, {})[name] = value
|
|
31
|
+
tomlio.save(config_path(), data)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def unset(key: str) -> None:
|
|
35
|
+
section, _, name = key.partition(".")
|
|
36
|
+
data = load()
|
|
37
|
+
data.get(section, {}).pop(name, None)
|
|
38
|
+
tomlio.save(config_path(), data)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --- sync defaults and image-commit consent (roadmap item 8) -----------------
|
|
42
|
+
|
|
43
|
+
def get_sync_defaults() -> dict[str, str]:
|
|
44
|
+
"""Global `[sync]` defaults: images_repo, images_path, images_branch."""
|
|
45
|
+
section = load().get("sync", {})
|
|
46
|
+
return {
|
|
47
|
+
k: section[k]
|
|
48
|
+
for k in ("images_repo", "images_path", "images_branch")
|
|
49
|
+
if section.get(k)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _consent_set() -> list[str]:
|
|
54
|
+
raw = load().get("consent", {}).get("images_repos", "")
|
|
55
|
+
return [s.strip() for s in str(raw).split(",") if s.strip()]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def consent_has(slug: str) -> bool:
|
|
59
|
+
"""Whether the user has already approved committing images to this (private) repo."""
|
|
60
|
+
return slug in _consent_set()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def consent_add(slug: str) -> None:
|
|
64
|
+
"""Remember approval to commit images to a private repo (comma-separated, flat key)."""
|
|
65
|
+
repos = _consent_set()
|
|
66
|
+
if slug not in repos:
|
|
67
|
+
repos.append(slug)
|
|
68
|
+
data = load()
|
|
69
|
+
data.setdefault("consent", {})["images_repos"] = ",".join(repos)
|
|
70
|
+
tomlio.save(config_path(), data)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
DEFAULT_MAX_UPLOAD_MB = 25
|
|
74
|
+
HARD_MAX_UPLOAD_MB = 100 # GitHub rejects larger files in a normal commit (see research.md)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_max_upload_mb() -> float:
|
|
78
|
+
"""`[sync] max_upload_mb`: per-file size above which media is not uploaded to GitHub."""
|
|
79
|
+
raw = load().get("sync", {}).get("max_upload_mb")
|
|
80
|
+
try:
|
|
81
|
+
value = float(raw)
|
|
82
|
+
except (TypeError, ValueError):
|
|
83
|
+
return float(DEFAULT_MAX_UPLOAD_MB)
|
|
84
|
+
if value <= 0:
|
|
85
|
+
return float(DEFAULT_MAX_UPLOAD_MB)
|
|
86
|
+
return min(value, float(HARD_MAX_UPLOAD_MB))
|
bugcap/dashboard/api.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""JSON handlers for the dashboard. Each is a thin wrapper over `service`/`store`; the only
|
|
2
|
+
logic here is shaping responses and mapping ServiceError codes to HTTP statuses."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .. import service
|
|
6
|
+
from ..errors import ServiceError
|
|
7
|
+
from ..store import Report
|
|
8
|
+
|
|
9
|
+
STATUS_FOR_CODE = {
|
|
10
|
+
"bad_query": 400, "invalid_status": 400, "invalid_label": 400, "invalid_title": 400,
|
|
11
|
+
"bad_token": 403, "not_found": 404, "invalid_reference": 422,
|
|
12
|
+
"duplicate_label": 409, "referenced_image": 409,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def http_status(exc: ServiceError) -> int:
|
|
17
|
+
return STATUS_FOR_CODE.get(exc.code, 400)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _summary(report: Report) -> dict:
|
|
21
|
+
return {
|
|
22
|
+
"id": report.id, "title": report.title, "status": report.status, "tags": report.tags,
|
|
23
|
+
"repo": report.repo, "created_at": report.created_at, "media_count": len(report.media),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def list_reports(store, query: dict) -> dict:
|
|
28
|
+
def one(name):
|
|
29
|
+
values = query.get(name)
|
|
30
|
+
return values[-1] if values else None
|
|
31
|
+
|
|
32
|
+
def number(name, default):
|
|
33
|
+
raw = one(name)
|
|
34
|
+
if raw is None:
|
|
35
|
+
return default
|
|
36
|
+
try:
|
|
37
|
+
return int(raw)
|
|
38
|
+
except ValueError:
|
|
39
|
+
raise ServiceError("bad_query", f"{name} must be a number")
|
|
40
|
+
|
|
41
|
+
total, items = service.list_reports_query(
|
|
42
|
+
store, repo=one("repo"), tags=query.get("tag") or [], status=one("status"), q=one("q"),
|
|
43
|
+
limit=number("limit", 200), offset=number("offset", 0),
|
|
44
|
+
)
|
|
45
|
+
return {"total": total, "items": items}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _links(synced_refs: dict) -> list:
|
|
49
|
+
"""Clickable targets derived from sync refs (currently the GitHub issue)."""
|
|
50
|
+
links = []
|
|
51
|
+
issue = (synced_refs or {}).get("github.issue", "")
|
|
52
|
+
slug, sep, number = issue.rpartition("#")
|
|
53
|
+
if sep and number.isdigit() and slug.count("/") == 1:
|
|
54
|
+
links.append({"label": f"GitHub issue {issue}", "url": f"https://github.com/{slug}/issues/{number}"})
|
|
55
|
+
return links
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_report(store, report_id: int) -> dict:
|
|
59
|
+
view = service.get_report_view(store, report_id)
|
|
60
|
+
report = view.report
|
|
61
|
+
media = [
|
|
62
|
+
{
|
|
63
|
+
"id": m.id, "index": m.idx, "label": m.label, "kind": m.kind, "mime": m.mime,
|
|
64
|
+
"size_bytes": m.size_bytes, "url": f"/media/{m.id}",
|
|
65
|
+
}
|
|
66
|
+
for m in view.media
|
|
67
|
+
]
|
|
68
|
+
frames = {
|
|
69
|
+
str(m.id): [{"frame_no": f.frame_no, "url": f"/media/{m.id}/frames/{f.frame_no}"} for f in m.frames]
|
|
70
|
+
for m in view.media if m.kind == "frames"
|
|
71
|
+
}
|
|
72
|
+
data = _summary(report)
|
|
73
|
+
data.update({
|
|
74
|
+
"notes_raw": report.notes,
|
|
75
|
+
"notes_segments": view.segments,
|
|
76
|
+
"notes_html_segments": view.segments,
|
|
77
|
+
"media": media,
|
|
78
|
+
"frames": frames,
|
|
79
|
+
"synced_refs": report.synced_refs,
|
|
80
|
+
"links": _links(report.synced_refs),
|
|
81
|
+
})
|
|
82
|
+
return data
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def set_status(store, report_id: int, body: dict) -> dict:
|
|
86
|
+
status = body.get("status")
|
|
87
|
+
if not isinstance(status, str):
|
|
88
|
+
raise ServiceError("bad_query", "body must be {\"status\": \"...\"}")
|
|
89
|
+
return _summary(service.set_status(store, report_id, status))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def set_tags(store, report_id: int, body: dict) -> dict:
|
|
93
|
+
add, remove = body.get("add", []), body.get("remove", [])
|
|
94
|
+
for value in (add, remove):
|
|
95
|
+
if not isinstance(value, list) or not all(isinstance(t, str) for t in value):
|
|
96
|
+
raise ServiceError("bad_query", "add and remove must be lists of strings")
|
|
97
|
+
return {"tags": service.set_tags(store, report_id, add=add, remove=remove)}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def set_notes(store, report_id: int, body: dict) -> dict:
|
|
101
|
+
notes = body.get("notes")
|
|
102
|
+
if not isinstance(notes, str):
|
|
103
|
+
raise ServiceError("bad_query", "body must be {\"notes\": \"...\"}")
|
|
104
|
+
service.set_notes(store, report_id, notes)
|
|
105
|
+
return get_report(store, report_id)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Serve stored media by row id, never by a client-supplied path."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from ..paths import resolve_data_path
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger("bugcap.dashboard")
|
|
10
|
+
_RANGE = re.compile(r"^bytes=(\d*)-(\d*)$")
|
|
11
|
+
CHUNK = 64 * 1024
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_range(header: str | None, size: int):
|
|
15
|
+
"""None = no (usable) range, 'invalid' = unsatisfiable, else (start, end) inclusive."""
|
|
16
|
+
if not header:
|
|
17
|
+
return None
|
|
18
|
+
match = _RANGE.match(header.strip())
|
|
19
|
+
if not match or (match.group(1) == "" and match.group(2) == ""):
|
|
20
|
+
return None
|
|
21
|
+
first, last = match.groups()
|
|
22
|
+
if first == "": # suffix: last N bytes
|
|
23
|
+
n = int(last)
|
|
24
|
+
if n == 0 or size == 0:
|
|
25
|
+
return "invalid"
|
|
26
|
+
return max(0, size - n), size - 1
|
|
27
|
+
start = int(first)
|
|
28
|
+
end = int(last) if last else size - 1
|
|
29
|
+
if start >= size or (last and int(last) < start):
|
|
30
|
+
return "invalid"
|
|
31
|
+
return start, min(end, size - 1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def serve_media(handler, rel_path, mime: str, range_header: str | None) -> None:
|
|
35
|
+
"""`rel_path` is the stored data-relative path; it must resolve inside images/ or media/."""
|
|
36
|
+
try:
|
|
37
|
+
path = resolve_data_path(rel_path)
|
|
38
|
+
if not path.is_file():
|
|
39
|
+
raise ValueError("missing file")
|
|
40
|
+
except (ValueError, OSError, TypeError):
|
|
41
|
+
log.warning("media path rejected: %r", rel_path)
|
|
42
|
+
handler.send_json(404, {"code": "not_found", "message": "media not found"})
|
|
43
|
+
return
|
|
44
|
+
size = path.stat().st_size
|
|
45
|
+
rng = parse_range(range_header, size)
|
|
46
|
+
if rng == "invalid":
|
|
47
|
+
handler.send_response(416)
|
|
48
|
+
handler.send_security_headers()
|
|
49
|
+
handler.send_header("Content-Range", f"bytes */{size}")
|
|
50
|
+
handler.send_header("Content-Length", "0")
|
|
51
|
+
handler.end_headers()
|
|
52
|
+
return
|
|
53
|
+
start, end = rng if rng else (0, size - 1)
|
|
54
|
+
handler.send_response(206 if rng else 200)
|
|
55
|
+
handler.send_security_headers()
|
|
56
|
+
handler.send_header("Content-Type", mime)
|
|
57
|
+
handler.send_header("Accept-Ranges", "bytes")
|
|
58
|
+
handler.send_header("Cache-Control", "private, max-age=3600")
|
|
59
|
+
handler.send_header("Content-Length", str(end - start + 1) if size else "0")
|
|
60
|
+
if rng:
|
|
61
|
+
handler.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
|
62
|
+
handler.end_headers()
|
|
63
|
+
if not size:
|
|
64
|
+
return
|
|
65
|
+
remaining = end - start + 1
|
|
66
|
+
with open(path, "rb") as fh:
|
|
67
|
+
fh.seek(start)
|
|
68
|
+
while remaining > 0:
|
|
69
|
+
chunk = fh.read(min(CHUNK, remaining))
|
|
70
|
+
if not chunk:
|
|
71
|
+
break
|
|
72
|
+
handler.wfile.write(chunk)
|
|
73
|
+
remaining -= len(chunk)
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Local dashboard server: standard-library HTTP, loopback only by default.
|
|
2
|
+
|
|
3
|
+
Protections: Host and Origin checks (DNS rebinding / cross-site), a per-process token on every
|
|
4
|
+
write (CSRF), media served by row id with containment checks, a static allowlist, and a CSP
|
|
5
|
+
that forbids external origins."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hmac
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import secrets
|
|
12
|
+
import sys
|
|
13
|
+
import webbrowser
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
15
|
+
from importlib import resources
|
|
16
|
+
from urllib.parse import parse_qs, urlparse
|
|
17
|
+
|
|
18
|
+
from ..errors import ServiceError
|
|
19
|
+
from ..store import Store
|
|
20
|
+
from . import api
|
|
21
|
+
from .media import serve_media
|
|
22
|
+
|
|
23
|
+
LOOPBACK = ("127.0.0.1", "localhost")
|
|
24
|
+
STATIC_ALLOWLIST = {"app.js": "text/javascript", "app.css": "text/css"}
|
|
25
|
+
CSP = (
|
|
26
|
+
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; "
|
|
27
|
+
"media-src 'self' blob:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
|
|
28
|
+
)
|
|
29
|
+
MAX_BODY = 1024 * 1024
|
|
30
|
+
MUTATING = ("POST", "PUT", "PATCH", "DELETE")
|
|
31
|
+
_REPORT = re.compile(r"^/api/reports/(\d+)$")
|
|
32
|
+
_REPORT_ACTION = re.compile(r"^/api/reports/(\d+)/(status|tags|notes)$")
|
|
33
|
+
_MEDIA = re.compile(r"^/media/(\d+)$")
|
|
34
|
+
_FRAME = re.compile(r"^/media/(\d+)/frames/(\d+)$")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DashboardServer(ThreadingHTTPServer):
|
|
38
|
+
daemon_threads = True
|
|
39
|
+
|
|
40
|
+
def __init__(self, address, token: str, exposed: bool = False):
|
|
41
|
+
super().__init__(address, Handler)
|
|
42
|
+
self.token = token
|
|
43
|
+
self.exposed = exposed
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def port(self) -> int:
|
|
47
|
+
return self.server_address[1]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Handler(BaseHTTPRequestHandler):
|
|
51
|
+
server: DashboardServer
|
|
52
|
+
server_version = "bugcap"
|
|
53
|
+
|
|
54
|
+
def log_message(self, fmt, *args): # keep the terminal quiet; no tokens in logs
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
# --- response helpers ------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def send_security_headers(self) -> None:
|
|
60
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
61
|
+
self.send_header("Content-Security-Policy", CSP)
|
|
62
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
63
|
+
|
|
64
|
+
def send_json(self, status: int, payload) -> None:
|
|
65
|
+
body = json.dumps(payload).encode()
|
|
66
|
+
self.send_response(status)
|
|
67
|
+
self.send_security_headers()
|
|
68
|
+
self.send_header("Content-Type", "application/json")
|
|
69
|
+
self.send_header("Cache-Control", "no-store")
|
|
70
|
+
self.send_header("Content-Length", str(len(body)))
|
|
71
|
+
self.end_headers()
|
|
72
|
+
self.wfile.write(body)
|
|
73
|
+
|
|
74
|
+
def _error(self, exc: ServiceError) -> None:
|
|
75
|
+
self.send_json(api.http_status(exc), exc.as_dict())
|
|
76
|
+
|
|
77
|
+
# --- access rules ----------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def _allowed_origins(self) -> set:
|
|
80
|
+
port = self.server.port
|
|
81
|
+
return {f"http://127.0.0.1:{port}", f"http://localhost:{port}"}
|
|
82
|
+
|
|
83
|
+
def _check_access(self) -> bool:
|
|
84
|
+
host = self.headers.get("Host", "")
|
|
85
|
+
if not self.server.exposed and host not in {f"127.0.0.1:{self.server.port}", f"localhost:{self.server.port}"}:
|
|
86
|
+
self.send_json(400, {"code": "bad_host", "message": "unexpected Host header"})
|
|
87
|
+
return False
|
|
88
|
+
origin = self.headers.get("Origin")
|
|
89
|
+
if origin is not None:
|
|
90
|
+
allowed = self._allowed_origins() | ({f"http://{host}"} if self.server.exposed else set())
|
|
91
|
+
if origin not in allowed:
|
|
92
|
+
self.send_json(403, {"code": "bad_origin", "message": "cross-origin request refused"})
|
|
93
|
+
return False
|
|
94
|
+
if self.command in MUTATING:
|
|
95
|
+
sent = self.headers.get("X-Bugcap-Token", "")
|
|
96
|
+
if not hmac.compare_digest(sent.encode(), self.server.token.encode()):
|
|
97
|
+
self._error(ServiceError("bad_token", "missing or wrong X-Bugcap-Token"))
|
|
98
|
+
return False
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
# --- routing ---------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def _dispatch(self) -> None:
|
|
104
|
+
if not self._check_access():
|
|
105
|
+
return
|
|
106
|
+
parsed = urlparse(self.path)
|
|
107
|
+
path, query = parsed.path, parse_qs(parsed.query)
|
|
108
|
+
try:
|
|
109
|
+
self._route(path, query)
|
|
110
|
+
except ServiceError as exc:
|
|
111
|
+
self._error(exc)
|
|
112
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
113
|
+
pass
|
|
114
|
+
except Exception: # never leak internals
|
|
115
|
+
self.send_json(500, {"code": "internal", "message": "internal error"})
|
|
116
|
+
|
|
117
|
+
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _dispatch
|
|
118
|
+
|
|
119
|
+
def _body(self) -> dict:
|
|
120
|
+
try:
|
|
121
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
122
|
+
except ValueError:
|
|
123
|
+
length = -1
|
|
124
|
+
if not 0 < length <= MAX_BODY:
|
|
125
|
+
raise ServiceError("bad_query", "a JSON body is required")
|
|
126
|
+
try:
|
|
127
|
+
data = json.loads(self.rfile.read(length))
|
|
128
|
+
except ValueError:
|
|
129
|
+
raise ServiceError("bad_query", "body is not valid JSON")
|
|
130
|
+
if not isinstance(data, dict):
|
|
131
|
+
raise ServiceError("bad_query", "body must be a JSON object")
|
|
132
|
+
return data
|
|
133
|
+
|
|
134
|
+
def _route(self, path: str, query: dict) -> None:
|
|
135
|
+
method = self.command
|
|
136
|
+
if method == "GET":
|
|
137
|
+
if path == "/":
|
|
138
|
+
return self._static("index.html", "text/html; charset=utf-8")
|
|
139
|
+
if path.startswith("/static/") and path[len("/static/"):] in STATIC_ALLOWLIST:
|
|
140
|
+
name = path[len("/static/"):]
|
|
141
|
+
return self._static(name, STATIC_ALLOWLIST[name] + "; charset=utf-8")
|
|
142
|
+
if path == "/api/session":
|
|
143
|
+
return self.send_json(200, {"token": self.server.token})
|
|
144
|
+
if path == "/api/reports":
|
|
145
|
+
with Store() as store:
|
|
146
|
+
return self.send_json(200, api.list_reports(store, query))
|
|
147
|
+
match = _REPORT.match(path)
|
|
148
|
+
if match:
|
|
149
|
+
with Store() as store:
|
|
150
|
+
return self.send_json(200, api.get_report(store, int(match.group(1))))
|
|
151
|
+
match = _MEDIA.match(path)
|
|
152
|
+
if match:
|
|
153
|
+
return self._media(int(match.group(1)), None)
|
|
154
|
+
match = _FRAME.match(path)
|
|
155
|
+
if match:
|
|
156
|
+
return self._media(int(match.group(1)), int(match.group(2)))
|
|
157
|
+
else:
|
|
158
|
+
match = _REPORT_ACTION.match(path)
|
|
159
|
+
handlers = {("POST", "status"): api.set_status, ("POST", "tags"): api.set_tags,
|
|
160
|
+
("PUT", "notes"): api.set_notes}
|
|
161
|
+
if match and (method, match.group(2)) in handlers:
|
|
162
|
+
body = self._body()
|
|
163
|
+
with Store() as store:
|
|
164
|
+
return self.send_json(200, handlers[(method, match.group(2))](store, int(match.group(1)), body))
|
|
165
|
+
self.send_json(404, {"code": "not_found", "message": "no such route"})
|
|
166
|
+
|
|
167
|
+
def _static(self, name: str, content_type: str) -> None:
|
|
168
|
+
data = (resources.files("bugcap.dashboard") / "static" / name).read_bytes()
|
|
169
|
+
self.send_response(200)
|
|
170
|
+
self.send_security_headers()
|
|
171
|
+
self.send_header("Content-Type", content_type)
|
|
172
|
+
self.send_header("Cache-Control", "no-cache")
|
|
173
|
+
self.send_header("Content-Length", str(len(data)))
|
|
174
|
+
self.end_headers()
|
|
175
|
+
self.wfile.write(data)
|
|
176
|
+
|
|
177
|
+
def _media(self, media_id: int, frame_no) -> None:
|
|
178
|
+
with Store() as store:
|
|
179
|
+
media = store.get_media(media_id)
|
|
180
|
+
if media is None:
|
|
181
|
+
return self.send_json(404, {"code": "not_found", "message": "media not found"})
|
|
182
|
+
if frame_no is None:
|
|
183
|
+
if media.kind == "frames" or not media.path:
|
|
184
|
+
return self.send_json(404, {"code": "not_found", "message": "media not found"})
|
|
185
|
+
return serve_media(self, media.path, media.mime, self.headers.get("Range"))
|
|
186
|
+
frame = next((f for f in media.frames if f.frame_no == frame_no), None)
|
|
187
|
+
if frame is None:
|
|
188
|
+
return self.send_json(404, {"code": "not_found", "message": "frame not found"})
|
|
189
|
+
serve_media(self, frame.path, media.mime, self.headers.get("Range"))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def make_server(host: str = "127.0.0.1", port: int = 8765, token: str | None = None,
|
|
193
|
+
explicit_host: bool = False) -> DashboardServer:
|
|
194
|
+
"""Bind the server (not yet serving). Non-loopback hosts need `explicit_host=True`."""
|
|
195
|
+
if host not in LOOPBACK and not explicit_host:
|
|
196
|
+
raise ValueError(f"refusing to bind {host}: the dashboard is local-only unless --host is given")
|
|
197
|
+
return DashboardServer((host, port), token or secrets.token_urlsafe(32), exposed=host not in LOOPBACK)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def serve(host: str = "127.0.0.1", port: int = 8765, open_browser: bool = False,
|
|
201
|
+
explicit_host: bool = False) -> None:
|
|
202
|
+
server = make_server(host, port, explicit_host=explicit_host)
|
|
203
|
+
shown = "127.0.0.1" if host in LOOPBACK else host
|
|
204
|
+
url = f"http://{shown}:{server.port}/"
|
|
205
|
+
print(f"bugcap dashboard: {url} (Ctrl+C to stop)", flush=True)
|
|
206
|
+
if open_browser:
|
|
207
|
+
webbrowser.open(url)
|
|
208
|
+
try:
|
|
209
|
+
server.serve_forever()
|
|
210
|
+
except KeyboardInterrupt:
|
|
211
|
+
print("", file=sys.stderr)
|
|
212
|
+
finally:
|
|
213
|
+
server.server_close()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #fafafa; --fg: #1b1b1f; --muted: #62646c; --card: #ffffff; --line: #dcdce2;
|
|
3
|
+
--accent: #2457d6; --err: #b00020;
|
|
4
|
+
color-scheme: light;
|
|
5
|
+
}
|
|
6
|
+
@media (prefers-color-scheme: dark) {
|
|
7
|
+
:root:not([data-theme="light"]) {
|
|
8
|
+
--bg: #14151a; --fg: #ececf1; --muted: #9ea0aa; --card: #1d1f26; --line: #33353f;
|
|
9
|
+
--accent: #7ea2ff; --err: #ff6b81; color-scheme: dark;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
:root[data-theme="dark"] {
|
|
13
|
+
--bg: #14151a; --fg: #ececf1; --muted: #9ea0aa; --card: #1d1f26; --line: #33353f;
|
|
14
|
+
--accent: #7ea2ff; --err: #ff6b81; color-scheme: dark;
|
|
15
|
+
}
|
|
16
|
+
* { box-sizing: border-box; }
|
|
17
|
+
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; }
|
|
18
|
+
.bar { display: flex; gap: 12px; align-items: baseline; padding: 12px 16px; border-bottom: 1px solid var(--line); }
|
|
19
|
+
.bar h1 { font-size: 18px; margin: 0; }
|
|
20
|
+
main { max-width: 960px; margin: 0 auto; padding: 16px; }
|
|
21
|
+
.muted { color: var(--muted); }
|
|
22
|
+
.error { color: var(--err); }
|
|
23
|
+
.filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px; margin-bottom: 12px; }
|
|
24
|
+
input, select, textarea, button {
|
|
25
|
+
font: inherit; color: var(--fg); background: var(--card); border: 1px solid var(--line);
|
|
26
|
+
border-radius: 6px; padding: 6px 8px; max-width: 100%;
|
|
27
|
+
}
|
|
28
|
+
button { cursor: pointer; }
|
|
29
|
+
button:hover { border-color: var(--accent); }
|
|
30
|
+
textarea { width: 100%; }
|
|
31
|
+
.reports { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
|
32
|
+
.reports li { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; cursor: pointer; }
|
|
33
|
+
.reports li:hover { border-color: var(--accent); }
|
|
34
|
+
.reports .t { font-weight: 600; }
|
|
35
|
+
.badge { font-size: 12px; border: 1px solid var(--line); border-radius: 999px; padding: 0 8px; margin-left: 6px; color: var(--muted); }
|
|
36
|
+
.row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
|
|
37
|
+
.tags .badge { margin: 0 4px 0 0; cursor: pointer; }
|
|
38
|
+
.inline { display: inline-flex; gap: 4px; }
|
|
39
|
+
.notes { white-space: pre-wrap; background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; }
|
|
40
|
+
.notes img, .media img, .media video { max-width: 100%; height: auto; display: block; margin: 8px 0; border-radius: 6px; }
|
|
41
|
+
.media figure { margin: 0 0 16px; }
|
|
42
|
+
.media figcaption { font-size: 13px; color: var(--muted); }
|
|
43
|
+
.ref-missing { color: var(--muted); font-style: italic; }
|