feat(api): раздача веб-приложения по /app… без токена, 503 без сборки; тест M-7 (#21)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 20:03:28 +04:00
parent 0f2df4c929
commit bc610a7734
3 changed files with 143 additions and 5 deletions

View File

@ -1,6 +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) — без токена: статический файл без данных.""" Без токена — статика без данных: страница доски (GET /, GET /links) и веб-приложение (GET /app…)."""
import hmac import hmac
import json import json
import logging import logging
@ -11,7 +11,7 @@ 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 typing import NamedTuple
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, unquote, urlparse
from . import backup, db, mirror, queue, relay, settings from . import backup, db, mirror, queue, relay, settings
from .commands import CommandError from .commands import CommandError
@ -23,13 +23,20 @@ log = logging.getLogger(__name__)
MAX_BODY = 16 * 1024 * 1024 # предел тела запроса, байт MAX_BODY = 16 * 1024 * 1024 # предел тела запроса, байт
PAGE = Path(__file__).parent / "web" / "links.html" # страница доски связей; читается на каждый запрос PAGE = Path(__file__).parent / "web" / "links.html" # страница доски связей; читается на каждый запрос
WEB_APP = Path(__file__).parent / "web" / "app" # сборка веб-приложения (make web, не в git); читается на каждый запрос
ASSET_TYPES = {".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml",
".png": "image/png", ".ico": "image/x-icon", ".woff2": "font/woff2", ".map": "application/json"}
ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # имена файлов сборки содержат хэш (R-3)
NOT_BUILT = ("<!doctype html><meta charset=\"utf-8\"><title>Хаб Jira</title><p>Веб-приложение не собрано: выполните "
"<code>make web</code> в каталоге jira-hub и перезапустите хаб.</p>").encode("utf-8")
SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит
class Raw(NamedTuple): class Raw(NamedTuple):
"""Не-JSON ответ обработчика (страница доски).""" """Не-JSON ответ обработчика (страница доски, веб-приложение). headers=None — Cache-Control: no-cache."""
content_type: str content_type: str
data: bytes data: bytes
headers: dict | None = None
class ApiError(Exception): class ApiError(Exception):
@ -39,8 +46,9 @@ class ApiError(Exception):
class Hub: class Hub:
def __init__(self, db_path, backups_dir=None): def __init__(self, db_path, backups_dir=None, web_dir=None):
self.db_path = Path(db_path) self.db_path = Path(db_path)
self.web_dir = Path(web_dir) if web_dir else WEB_APP
self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir()
self.claim_wait = 30 self.claim_wait = 30
secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии
@ -155,6 +163,22 @@ def h_page(hub, conn, m, q, body):
return 200, Raw("text/html; charset=utf-8", PAGE.read_bytes()) return 200, Raw("text/html; charset=utf-8", PAGE.read_bytes())
def h_app(hub, conn, m, q, body):
"""Веб-приложение: файлы assets/ сборки как есть, любой другой путь /app… — index.html (маршруты на клиенте).
Путь файла после разрешения обязан лежать внутри assets/, иначе 404; без сборки — 503 с подсказкой."""
index = hub.web_dir / "index.html"
if not index.is_file():
return 503, Raw("text/html; charset=utf-8", NOT_BUILT)
rest = unquote(m.group(1) or "")
if rest.startswith("assets/"):
assets = (hub.web_dir / "assets").resolve()
f = (hub.web_dir / rest).resolve()
if not f.is_relative_to(assets) or not f.is_file():
raise ApiError(404, "not_found", "/app/" + rest)
return 200, Raw(ASSET_TYPES.get(f.suffix, "application/octet-stream"), f.read_bytes(), ASSET_CACHE)
return 200, Raw("text/html; charset=utf-8", index.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:
@ -263,6 +287,7 @@ def h_settings_put(hub, conn, m, q, body):
ROUTES = [ ROUTES = [
("GET", r"^/(links)?$", h_page, None), ("GET", r"^/(links)?$", h_page, None),
("GET", r"^/app(?:/(.*))?$", h_app, 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"),
@ -355,7 +380,8 @@ class Handler(BaseHTTPRequestHandler):
def _send(self, status, payload): def _send(self, status, payload):
if isinstance(payload, Raw): if isinstance(payload, Raw):
ctype, data, extra = payload.content_type, payload.data, {"Cache-Control": "no-cache"} ctype, data = payload.content_type, payload.data
extra = payload.headers if payload.headers is not None else {"Cache-Control": "no-cache"}
else: else:
ctype, extra = "application/json; charset=utf-8", {} ctype, extra = "application/json; charset=utf-8", {}
data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8") data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")

View File

@ -180,3 +180,15 @@ def test_board_last_sync_is_last_successful_full_refresh():
_sync_run(conn, "2026-10-14T13:00:00+00:00", None, "jql", BASE, status="claimed", count=50) _sync_run(conn, "2026-10-14T13:00:00+00:00", None, "jql", BASE, status="claimed", count=50)
assert mirror.board(conn, BASE)["last_sync"] == {"started_at": "2026-10-14T09:59:00+00:00", assert mirror.board(conn, BASE)["last_sync"] == {"started_at": "2026-10-14T09:59:00+00:00",
"finished_at": "2026-10-14T10:00:00+00:00", "issues_count": 198} "finished_at": "2026-10-14T10:00:00+00:00", "issues_count": 198}
def test_board_inside_open_transaction_keeps_it_open():
"""M-7 #20: вызов внутри уже открытой транзакции не открывает и не закрывает свою."""
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("AURORA-1620")], [])
with db.transaction(conn):
assert conn.in_transaction
snap = mirror.board(conn, BASE)
assert conn.in_transaction
assert not conn.in_transaction
assert [i["key"] for i in snap["issues"]] == ["AURORA-1620"]

100
tests/test_web_app.py Normal file
View File

@ -0,0 +1,100 @@
"""Раздача веб-приложения: GET /app… без токена (specs/hub-api «Раздача веб-приложения»)."""
import http.client
import threading
from urllib.parse import urlparse
import pytest
from jira_hub import api
def _get(base, path):
"""Сырой GET: http.client не нормализует путь (../ и %2e%2e доходят до хаба как есть)."""
u = urlparse(base)
c = http.client.HTTPConnection(u.hostname, u.port, timeout=10)
try:
c.request("GET", path)
r = c.getresponse()
return r.status, r, r.read()
finally:
c.close()
def _serve(tmp_path, web_dir):
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups", web_dir=web_dir)
srv = api.make_server(h, "127.0.0.1", 0)
th = threading.Thread(target=srv.serve_forever, daemon=True)
th.start()
return srv, th, f"http://127.0.0.1:{srv.server_port}"
@pytest.fixture
def app_hub(tmp_path):
web = tmp_path / "app"
(web / "assets").mkdir(parents=True)
(web / "index.html").write_text("<!doctype html><div id=root></div>", encoding="utf-8")
(web / "assets" / "index-abc123.js").write_text("console.log('ok')", encoding="utf-8")
(web / "assets" / "index-abc123.css").write_text("body{}", encoding="utf-8")
(web / "assets" / "font.bin").write_bytes(b"\x00\x01")
(tmp_path / "secret.txt").write_text("секрет", encoding="utf-8")
srv, th, base = _serve(tmp_path, web)
yield base
srv.shutdown()
srv.server_close()
th.join(5)
def test_asset_served_with_type_and_immutable_cache(app_hub):
status, r, body = _get(app_hub, "/app/assets/index-abc123.js")
assert status == 200
assert r.getheader("Content-Type") == "text/javascript; charset=utf-8"
assert r.getheader("Cache-Control") == "public, max-age=31536000, immutable"
assert body == b"console.log('ok')"
status, r, _ = _get(app_hub, "/app/assets/index-abc123.css")
assert r.getheader("Content-Type") == "text/css; charset=utf-8"
status, r, _ = _get(app_hub, "/app/assets/font.bin")
assert status == 200 and r.getheader("Content-Type") == "application/octet-stream"
@pytest.mark.parametrize("path", ["/app", "/app/", "/app/board", "/app/issues/AURORA-1620", "/app/issues?sprint=x"])
def test_app_paths_get_index_without_token(app_hub, path):
status, r, body = _get(app_hub, path)
assert status == 200
assert r.getheader("Content-Type") == "text/html; charset=utf-8"
assert r.getheader("Cache-Control") == "no-cache"
assert body == b"<!doctype html><div id=root></div>"
@pytest.mark.parametrize("path", ["/app/assets/../../secret.txt", "/app/assets/%2e%2e/%2e%2e/secret.txt",
"/app/assets/..%2F..%2Fsecret.txt", "/app/assets/missing.js", "/app/assets/"])
def test_asset_outside_or_missing_is_404(app_hub, path):
status, _, body = _get(app_hub, path)
assert status == 404
assert "секрет".encode("utf-8") not in body
def test_not_built_is_503_with_hint(tmp_path):
srv, th, base = _serve(tmp_path, tmp_path / "no-build")
try:
for path in ("/app/board", "/app/assets/index-abc123.js"):
status, r, body = _get(base, path)
assert status == 503
assert r.getheader("Content-Type") == "text/html; charset=utf-8"
assert "make web".encode("utf-8") in body
finally:
srv.shutdown()
srv.server_close()
th.join(5)
def test_other_paths_are_not_app(app_hub):
status, _, _ = _get(app_hub, "/application")
assert status == 404 # не префикс /app/, обычный 404 API
status, _, _ = _get(app_hub, "/board")
assert status == 401 # API по-прежнему под токеном
def test_default_web_dir_is_package_build(tmp_path):
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
assert h.web_dir == api.WEB_APP
assert api.WEB_APP.parts[-3:] == ("jira_hub", "web", "app")