feat(api): раздача веб-приложения по /app… без токена, 503 без сборки; тест M-7 (#21)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0f2df4c929
commit
bc610a7734
@ -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")
|
||||||
|
|||||||
@ -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
100
tests/test_web_app.py
Normal 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")
|
||||||
Loading…
x
Reference in New Issue
Block a user