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/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))
@@ -0,0 +1,3 @@
1
+ from .server import make_server, serve
2
+
3
+ __all__ = ["make_server", "serve"]
@@ -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; }