feat(api): страница доски связей по GET / и /links без токена (#17)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 17:56:17 +04:00
parent c35151b841
commit 9bfb8be549
3 changed files with 49 additions and 4 deletions

View File

@ -1,5 +1,6 @@
"""HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос). """HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос).
Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md).""" Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md).
Страница доски (GET /, GET /links) — без токена: статический файл без данных."""
import hmac import hmac
import json import json
import logging import logging
@ -9,6 +10,7 @@ import time
import traceback import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from typing import NamedTuple
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from . import backup, db, mirror, queue, relay, settings from . import backup, db, mirror, queue, relay, settings
@ -20,9 +22,16 @@ from .queue import ClaimedError
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
MAX_BODY = 16 * 1024 * 1024 # предел тела запроса, байт MAX_BODY = 16 * 1024 * 1024 # предел тела запроса, байт
PAGE = Path(__file__).parent / "web" / "links.html" # страница доски связей; читается на каждый запрос
SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит
class Raw(NamedTuple):
"""Не-JSON ответ обработчика (страница доски)."""
content_type: str
data: bytes
class ApiError(Exception): class ApiError(Exception):
def __init__(self, status, error, detail=None): def __init__(self, status, error, detail=None):
super().__init__(error) super().__init__(error)
@ -142,6 +151,10 @@ def h_board(hub, conn, m, q, body):
return 200, mirror.board(conn, settings.get(conn, "base_jql")) return 200, mirror.board(conn, settings.get(conn, "base_jql"))
def h_page(hub, conn, m, q, body):
return 200, Raw("text/html; charset=utf-8", PAGE.read_bytes())
def h_note_put(hub, conn, m, q, body): def h_note_put(hub, conn, m, q, body):
fields = {k: v for k, v in body.items() if k != "updated_by"} fields = {k: v for k, v in body.items() if k != "updated_by"}
try: try:
@ -249,6 +262,7 @@ def h_settings_put(hub, conn, m, q, body):
ROUTES = [ ROUTES = [
("GET", r"^/(links)?$", h_page, None),
("POST", r"^/agent/claim$", h_claim, "agent"), ("POST", r"^/agent/claim$", h_claim, "agent"),
("GET", r"^/agent/settings$", h_agent_settings, "agent"), ("GET", r"^/agent/settings$", h_agent_settings, "agent"),
("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"), ("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"),
@ -318,7 +332,8 @@ class Handler(BaseHTTPRequestHandler):
match = rx.match(url.path) match = rx.match(url.path)
if not match: if not match:
continue continue
self._auth(scope) if scope is not None:
self._auth(scope)
try: try:
body = json.loads(raw.decode("utf-8")) if raw else {} body = json.loads(raw.decode("utf-8")) if raw else {}
except ValueError as e: # битый JSON или не UTF-8 except ValueError as e: # битый JSON или не UTF-8
@ -339,10 +354,16 @@ class Handler(BaseHTTPRequestHandler):
self._send(500, {"error": "internal", "detail": str(e)}) self._send(500, {"error": "internal", "detail": str(e)})
def _send(self, status, payload): def _send(self, status, payload):
data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8") if isinstance(payload, Raw):
ctype, data, extra = payload.content_type, payload.data, {"Cache-Control": "no-cache"}
else:
ctype, extra = "application/json; charset=utf-8", {}
data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data))) self.send_header("Content-Length", str(len(data)))
for name, value in extra.items():
self.send_header(name, value)
self.end_headers() self.end_headers()
self.wfile.write(data) self.wfile.write(data)

View File

@ -1,3 +1,5 @@
<!doctype html>
<html lang="ru">
<meta charset="utf-8"> <meta charset="utf-8">
<title>Спринт 2 линк</title> <title>Спринт 2 линк</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@500&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@500&display=swap">

View File

@ -1,5 +1,7 @@
import json import json
import threading import threading
import urllib.error
import urllib.request
import pytest import pytest
@ -363,3 +365,23 @@ def test_board_route(hub):
with pytest.raises(HubError) as e: with pytest.raises(HubError) as e:
client.get("/board") client.get("/board")
assert e.value.status == 401 assert e.value.status == 401
def _raw_get(base, path):
with urllib.request.urlopen(base + path, timeout=10) as resp:
return resp.status, resp.headers, resp.read().decode("utf-8")
def test_page_served_without_token(hub):
hub["agent"].post("/agent/state", {"issues": [{"key": "ZZTEST-9001", "summary": "тема из зеркала", "labels": []}],
"links": []})
for path in ("/", "/links"):
status, headers, body = _raw_get(hub["base"], path)
assert status == 200
assert headers["Content-Type"] == "text/html; charset=utf-8"
assert headers["Cache-Control"] == "no-cache"
assert body.lstrip().lower().startswith("<!doctype html>")
assert "ZZTEST-9001" not in body and "тема из зеркала" not in body
with pytest.raises(urllib.error.HTTPError) as e:
urllib.request.urlopen(hub["base"] + "/links/x", timeout=10)
assert e.value.code == 404