diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py
index ce5c2b2..87e6a86 100644
--- a/src/jira_hub/api.py
+++ b/src/jira_hub/api.py
@@ -1,6 +1,6 @@
"""HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос).
Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md).
-Страница доски (GET /, GET /links) — без токена: статический файл без данных."""
+Без токена — статика без данных: страница доски (GET /, GET /links) и веб-приложение (GET /app…)."""
import hmac
import json
import logging
@@ -11,7 +11,7 @@ import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
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 .commands import CommandError
@@ -23,13 +23,20 @@ log = logging.getLogger(__name__)
MAX_BODY = 16 * 1024 * 1024 # предел тела запроса, байт
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 = ("
Хаб JiraВеб-приложение не собрано: выполните "
+ "make web в каталоге jira-hub и перезапустите хаб.
").encode("utf-8")
SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит
class Raw(NamedTuple):
- """Не-JSON ответ обработчика (страница доски)."""
+ """Не-JSON ответ обработчика (страница доски, веб-приложение). headers=None — Cache-Control: no-cache."""
content_type: str
data: bytes
+ headers: dict | None = None
class ApiError(Exception):
@@ -39,8 +46,9 @@ class ApiError(Exception):
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.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.claim_wait = 30
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())
+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):
fields = {k: v for k, v in body.items() if k != "updated_by"}
try:
@@ -263,6 +287,7 @@ def h_settings_put(hub, conn, m, q, body):
ROUTES = [
("GET", r"^/(links)?$", h_page, None),
+ ("GET", r"^/app(?:/(.*))?$", h_app, None),
("POST", r"^/agent/claim$", h_claim, "agent"),
("GET", r"^/agent/settings$", h_agent_settings, "agent"),
("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"),
@@ -355,7 +380,8 @@ class Handler(BaseHTTPRequestHandler):
def _send(self, status, payload):
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:
ctype, extra = "application/json; charset=utf-8", {}
data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
diff --git a/tests/test_mirror.py b/tests/test_mirror.py
index 836a8d7..e45e428 100644
--- a/tests/test_mirror.py
+++ b/tests/test_mirror.py
@@ -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)
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}
+
+
+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"]
diff --git a/tests/test_web_app.py b/tests/test_web_app.py
new file mode 100644
index 0000000..1a57bbc
--- /dev/null
+++ b/tests/test_web_app.py
@@ -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("", 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""
+
+
+@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")