diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 79215c5..79a5d6a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -16,8 +16,8 @@ | `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` | | `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения; `_check_project`: проверки пакета проекта — метка, чужие метки, задачи вне проекта; `_assign_boards`: `board_id` проекта у `ensure_sprint`), выдача (`claim`, `size` у `ensure_attachment` и `boards` у `refresh` — `deliver_params`), отчёты (для `fetch_attachment` в `done` — проверка файла в хранилище и запись `attachment_file`), `request_fetch` (постановка скачивания вложения в одной транзакции, `TooLarge`, `AgentTooOld`; пакет без хвостового `refresh` и без резервной копии), отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `projects`, `attachments`, `db` | | `backup` | копии файла базы, ротация | `paths` | -| `files` | хранилище файлов вложений `<данные>/files/` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` | -| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}` (каталог `Hub.files_dir`, 0700 при старте); раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board`; маршруты `/projects` | `queue`, `mirror`, `projects`, `settings`, `backup`, `files`, `relay`, `release` | +| `files` | хранилище файлов вложений `<данные>/files/` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ, кроме `allow_empty` для файла от агента), `touch` (mtime при выдаче содержимого), `sweep_downloads` (каталоги копий MCP `downloads//`, старше 30 дней), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` | +| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}`, `POST /agent/files` (от агента, пустой допустим; каталог `Hub.files_dir`, 0700 при старте); вложения `GET /attachments/{id}`, `POST /attachments/{id}/fetch` (`queue.request_fetch`: `TooLarge` → 413, `AgentTooOld` → 409, нет → 404 `no_attachment`), `GET /attachments/{id}/content` (`Content-Disposition: filename*=UTF-8''…`, `nosniff`, `files.touch`); уборка раз в сутки трогает и `Hub.downloads_dir`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board`; маршруты `/projects` | `queue`, `mirror`, `projects`, `settings`, `backup`, `files`, `attachments`, `relay`, `release` | | `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` | | `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` | | `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) | diff --git a/CLAUDE.md b/CLAUDE.md index e5d691f..43a6223 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ - Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента. - Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь. - Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed). -- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite` (зеркало, очередь, журнал изменений `issue_change`), `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней); каталог данных, `backups/`, `release/` и `files/` — права 0700. Порт по умолчанию 8790. +- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite` (зеркало, очередь, журнал изменений `issue_change`), `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней), `downloads/` (копии вложений для MCP `/<имя>`, уборка 30 дней); каталог данных, `backups/`, `release/` и `files/` — права 0700. Порт по умолчанию 8790. - Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное. ## Архитектура diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 8b2886d..080b978 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -13,9 +13,9 @@ from datetime import timedelta from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import NamedTuple -from urllib.parse import parse_qs, unquote, urlparse +from urllib.parse import parse_qs, quote, unquote, urlparse -from . import backup, db, files, history, mirror, projects, queue, relay, release, settings +from . import attachments, backup, db, files, history, mirror, projects, queue, relay, release, settings from .commands import CommandError from .paths import backups_dir as default_backups_dir from .paths import secure_dir @@ -31,6 +31,7 @@ ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # им NOT_BUILT = ("Хаб Jira

Веб-приложение не собрано: выполните " "make web в каталоге jira-hub и перезапустите хаб.

").encode("utf-8") SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит +MIME_RE = re.compile(r"[\w!#$&^.+-]+/[\w!#$&^.+-]+") # тип/подтип без параметров и пробелов: заголовок не внушить SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений») @@ -54,6 +55,7 @@ class Hub: self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release" self.files_dir = Path(files_dir) if files_dir else self.db_path.parent / "files" + self.downloads_dir = self.db_path.parent / "downloads" # копии вложений для MCP; каталог создаёт MCP self.last_sweep = None # время прошлой уборки files/, в памяти (решение 15) # последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version # неизвестен, а не null (hub-hygiene-d, решение 7) @@ -96,6 +98,12 @@ class Hub: removed = files.sweep(conn, self.files_dir, now) if removed: log.info("уборка файлов: удалено %d", len(removed)) + try: + gone = files.sweep_downloads(self.downloads_dir, now) + if gone: + log.info("уборка копий вложений: удалено %d", len(gone)) + except Exception: # noqa: BLE001 — копии MCP не должны мешать основной уборке + log.exception("уборка копий вложений: сбой") return removed def note_agent_update(self, body): @@ -355,9 +363,9 @@ def h_batch_cancel(hub, conn, m, q, body): raise ApiError(404, "not_found", "batch") -def h_files_post(hub, conn, m, q, body): - """Загрузка файла вложения: {content: base64} → {file: sha256, size}. Пробельные символы (переносы строк base64) - отбрасываются до строгой проверки (решение 14).""" +def _put_file(hub, body, allow_empty=False): + """Общая часть POST /files и POST /agent/files: {content: base64} → {file: sha256, size}. Пробельные символы + (переносы строк base64) отбрасываются до строгой проверки (решение 14).""" content = body.get("content") if not isinstance(content, str): raise ApiError(400, "bad_request", "content — строка base64") @@ -366,7 +374,7 @@ def h_files_post(hub, conn, m, q, body): except ValueError: # binascii.Error — подкласс ValueError raise ApiError(400, "bad_request", "content — не base64") from None try: - sha, size = files.put(hub.files_dir, data) + sha, size = files.put(hub.files_dir, data, allow_empty=allow_empty) except files.EmptyFile: raise ApiError(422, "empty_file", "пустой файл") from None except files.TooLarge: @@ -374,6 +382,55 @@ def h_files_post(hub, conn, m, q, body): return 200, {"file": sha, "size": size} +def h_files_post(hub, conn, m, q, body): + return _put_file(hub, body) + + +def h_agent_files_post(hub, conn, m, q, body): + """Файл от агента (fetch_attachment, решение 5): как POST /files, но пустой файл принимается (решение 13).""" + return _put_file(hub, body, allow_empty=True) + + +def _attachment(hub, conn, m): + att = attachments.get(conn, hub.files_dir, m.group(1)) + if att is None: + raise ApiError(404, "no_attachment", f"вложения #{m.group(1)} нет в зеркале — обновите задачу") + return att + + +def h_attachment_get(hub, conn, m, q, body): + return 200, _attachment(hub, conn, m) + + +def h_attachment_fetch(hub, conn, m, q, body): + author = body.get("author") or "api" + if not isinstance(author, str): + raise ApiError(422, "bad_request", "author — строка") + try: + return 200, queue.request_fetch(conn, hub.files_dir, m.group(1), author) + except KeyError: + raise ApiError(404, "no_attachment", f"вложения #{m.group(1)} нет в зеркале — обновите задачу") from None + except queue.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (решение 17) + raise ApiError(413, "too_large", str(e)) from None + except queue.AgentTooOld as e: + raise ApiError(409, "agent_too_old", str(e)) from None + except CommandError as e: + raise ApiError(422, "bad_batch", str(e)) from None + + +def h_attachment_content(hub, conn, m, q, body): + """Содержимое скачанного вложения; mime — из вложения, негодный → octet-stream; имя — filename* (RFC 5987).""" + att = _attachment(hub, conn, m) + row = conn.execute("SELECT file FROM attachment_file WHERE attachment_id = ?", (m.group(1),)).fetchone() + data = files.read(hub.files_dir, row["file"]) if att["state"] == "ready" and row else None + if data is None: + raise ApiError(404, "not_fetched", "вложение не скачано — запросите скачивание") + files.touch(hub.files_dir, row["file"]) + mime = att["mime_type"] if isinstance(att["mime_type"], str) and MIME_RE.fullmatch(att["mime_type"]) else "application/octet-stream" + return 200, Raw(mime, data, {"Content-Disposition": "attachment; filename*=UTF-8''" + quote(att["filename"], safe=""), + "X-Content-Type-Options": "nosniff", "Cache-Control": "no-cache"}) + + def h_command_retry(hub, conn, m, q, body): if not isinstance(body.get("author") or "api", str): raise ApiError(422, "bad_retry", "author — строка") @@ -496,6 +553,7 @@ ROUTES = [ ("GET", r"^/agent/known-keys$", h_known_keys, "agent"), ("GET", r"^/agent/release$", h_agent_release, "agent"), ("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"), + ("POST", r"^/agent/files$", h_agent_files_post, "agent"), ("GET", r"^/issues$", h_issues, "client"), ("GET", r"^/issues/([^/]+)$", h_issue, "client"), ("GET", r"^/board$", h_board, "client"), @@ -506,6 +564,9 @@ ROUTES = [ ("GET", r"^/batches/(\d+)$", h_batch_get, "client"), ("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"), ("POST", r"^/files$", h_files_post, "client"), + ("GET", r"^/attachments/(\d+)$", h_attachment_get, "client"), + ("POST", r"^/attachments/(\d+)/fetch$", h_attachment_fetch, "client"), + ("GET", r"^/attachments/(\d+)/content$", h_attachment_content, "client"), ("POST", r"^/commands/(\d+)/retry$", h_command_retry, "client"), ("POST", r"^/commands/(\d+)/cancel$", h_command_cancel, "client"), ("POST", r"^/refresh$", h_refresh, "client"), diff --git a/src/jira_hub/files.py b/src/jira_hub/files.py index abb81c7..e553181 100644 --- a/src/jira_hub/files.py +++ b/src/jira_hub/files.py @@ -4,6 +4,7 @@ import hashlib import os import re +import shutil import tempfile import time from pathlib import Path @@ -65,12 +66,12 @@ def _intact(target, sha) -> bool: return False -def put(root, data: bytes) -> tuple[str, int]: +def put(root, data: bytes, allow_empty: bool = False) -> tuple[str, int]: """Сохранить содержимое, вернуть (sha256, размер). Целая копия уже есть (тот же sha256 содержимого) — только обновить mtime (уборка считает возраст от последней загрузки); оборванная или испорченная — переписать (решение 13). Запись атомарная: временный файл mkstemp в том же каталоге, fsync, os.replace; при сбое временный файл удаляется - (решение 11).""" - if not data: + (решение 11). allow_empty — пустое содержимое принимается (файл от агента: в Jira бывают пустые вложения, решение 13).""" + if not data and not allow_empty: raise EmptyFile("пустой файл") if len(data) > MAX_SIZE: raise TooLarge(f"файл больше {MAX_SIZE} байт ({len(data)})") @@ -97,6 +98,37 @@ def put(root, data: bytes) -> tuple[str, int]: return sha, len(data) +def touch(root, sha) -> None: + """Обновить mtime файла (выдача содержимого продлевает ему жизнь, решение 14); файла нет или sha негоден — ничего.""" + if not is_sha(sha): + return + try: + os.utime(path(root, sha)) + except FileNotFoundError: + pass + + +def sweep_downloads(root, now: float | None = None) -> list[str]: + """Удалить каталоги копий MCP //, в которых ничего не менялось KEEP_DAYS (новее из mtime каталога + и его файлов); вернуть имена. Файлы прямо в корне и сбой удаления одного каталога уборку не прерывают.""" + root = Path(root) + if not root.is_dir(): + return [] + cutoff = (time.time() if now is None else now) - KEEP_DAYS * 86400 + removed = [] + for d in sorted(root.iterdir()): + try: + if d.is_symlink() or not d.is_dir(): + continue + newest = max([d.stat().st_mtime] + [f.stat().st_mtime for f in d.iterdir() if f.is_file()]) + if newest < cutoff: + shutil.rmtree(d) + removed.append(d.name) + except OSError: + continue + return removed + + def sweep(conn, root, now: float | None = None) -> list[str]: """Удалить файлы старше KEEP_DAYS (по mtime), на которые не ссылается ни одна ensure_attachment в queued или claimed; вернуть имена удалённых. Правило одно для всех обычных файлов каталога: брошенные временные .*.tmp уходят diff --git a/tests/test_api.py b/tests/test_api.py index 50fe134..932fe32 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1030,3 +1030,142 @@ def test_issue_has_attachments(hub): got = c.get("/issues/A-1") assert got["attachments"][0]["attachment_id"] == "7" and got["attachments"][0]["state"] == "none" assert got["description_attachment_ids"] == ["7"] + + +# --- вложения из Jira по запросу (#83) ---------------------------------------------- + +ATT_NAME = "скрин 1.png" + + +def _seed_attachment(hub, mime="image/png", size=3, name=ATT_NAME): + conn = hub["hub"].conn() + try: + from jira_hub import mirror + mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [ + {"id": "31337", "filename": name, "size": size, "mime_type": mime, "created": "2026-10-20T10:00:00.000+0300"}]}], []) + finally: + conn.close() + + +def _content(hub, path): + req = urllib.request.Request(hub["base"] + path, headers={"Authorization": "Bearer " + hub["client"].token}) + return urllib.request.urlopen(req) + + +def _fetch_done(hub, data=b"abc"): + """Запрос скачивания, claim агентом, загрузка файла и отчёт done.""" + out = hub["client"].post("/attachments/31337/fetch", {"author": "web"}) + cmd = hub["agent"].post("/agent/claim", {})["command"] + up = hub["agent"].post("/agent/files", {"content": _b64(data)}) + hub["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": up}) + return out, cmd, up + + +def test_agent_files_post(hub): + a = hub["agent"] + out = a.post("/agent/files", {"content": _b64(b"abc")}) + assert out == {"file": hashlib.sha256(b"abc").hexdigest(), "size": 3} + with pytest.raises(HubError) as e: + hub["client"].post("/agent/files", {"content": _b64(b"abc")}) + assert e.value.status == 401 + assert a.post("/agent/files", {"content": ""}) == {"file": hashlib.sha256(b"").hexdigest(), "size": 0} + with pytest.raises(HubError) as e: + a.post("/agent/files", {"content": _b64(b"\0" * (11 * 1024 * 1024))}) + assert e.value.status == 413 and e.value.error == "too_large" + with pytest.raises(HubError) as e: + a.post("/agent/files", {"content": "***"}) + assert e.value.status == 400 + + +def test_attachment_get_404(hub): + for method, path in (("get", "/attachments/1"), ("post", "/attachments/1/fetch"), ("get", "/attachments/1/content")): + with pytest.raises(HubError) as e: + getattr(hub["client"], method)(path) + assert e.value.status == 404 and e.value.error == "no_attachment" + + +def test_attachment_fetch_flow(hub): + _seed_attachment(hub) + out, cmd, up = _fetch_done(hub) + assert out["state"] == "fetching" and out["command_id"] and out["batch_id"] + assert cmd["type"] == "fetch_attachment" and cmd["pause_seconds"] == 2 + got = hub["client"].get("/attachments/31337") + assert got["state"] == "ready" and got["filename"] == ATT_NAME and got["key"] == "A-1" + resp = _content(hub, "/attachments/31337/content") + assert resp.status == 200 and resp.read() == b"abc" + assert resp.headers["Content-Type"] == "image/png" + assert resp.headers["Content-Disposition"] == "attachment; filename*=UTF-8''%D1%81%D0%BA%D1%80%D0%B8%D0%BD%201.png" + assert resp.headers["X-Content-Type-Options"] == "nosniff" + assert resp.headers["Content-Length"] == "3" + + +def test_attachment_content_updates_mtime(hub): + _seed_attachment(hub) + _, _, up = _fetch_done(hub) + p = hub["hub"].files_dir / up["file"] + old = time.time() - 10 * 86400 + os.utime(p, (old, old)) + _content(hub, "/attachments/31337/content").read() + assert p.stat().st_mtime > time.time() - 60 + + +def test_attachment_content_special_name(hub): + name = "a:b/c d\u00e9.txt" + _seed_attachment(hub, name=name, mime="text/plain") + _fetch_done(hub) + resp = _content(hub, "/attachments/31337/content") + assert resp.headers["Content-Disposition"] == "attachment; filename*=UTF-8''a%3Ab%2Fc%20d%C3%A9.txt" + + +def test_attachment_content_not_fetched(hub): + _seed_attachment(hub) + with pytest.raises(HubError) as e: + hub["client"].get("/attachments/31337/content") + assert e.value.status == 404 and e.value.error == "not_fetched" + + +def test_attachment_content_bad_mime(hub): + _seed_attachment(hub, mime="weird") + _fetch_done(hub) + assert _content(hub, "/attachments/31337/content").headers["Content-Type"] == "application/octet-stream" + + +def test_attachment_fetch_twice_same_command(hub): + _seed_attachment(hub) + a = hub["client"].post("/attachments/31337/fetch", {}) + b = hub["client"].post("/attachments/31337/fetch", {}) + assert a["command_id"] == b["command_id"] and b["state"] == "fetching" + + +def test_attachment_fetch_too_large_413(hub): + _seed_attachment(hub, size=25_000_000) + with pytest.raises(HubError) as e: + hub["client"].post("/attachments/31337/fetch", {}) + assert e.value.status == 413 and e.value.error == "too_large" + assert e.value.detail == "файл больше 10 МБ (23,8 МБ) — скачайте в Jira" + assert hub["client"].get("/batches")["batches"] == [] + + +def test_attachment_fetch_agent_too_old_409(hub): + _seed_attachment(hub) + hub["agent"].post("/agent/claim", {"agent_version": 13}) + with pytest.raises(HubError) as e: + hub["client"].post("/attachments/31337/fetch", {}) + assert e.value.status == 409 and e.value.error == "agent_too_old" + assert e.value.detail == "агент v13 не умеет скачивать вложения — нужен v14" + + +def test_sweep_files_downloads(hub): + h = hub["hub"] + d = h.downloads_dir / "31337" + d.mkdir(parents=True) + (d / "a.png").write_bytes(b"x") + old = time.time() - 31 * 86400 + os.utime(d, (old, old)) + os.utime(d / "a.png", (old, old)) + conn = h.conn() + try: + h.sweep_files(conn) + finally: + conn.close() + assert not d.exists() diff --git a/tests/test_files.py b/tests/test_files.py index 470412d..c7c978d 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -155,3 +155,37 @@ def test_put_rewrites_corrupted_copy_of_same_size(root): (root / ABC).write_bytes(b"xyz") # тот же размер, другое содержимое assert files.put(root, b"abc") == (ABC, 3) assert (root / ABC).read_bytes() == b"abc" + + +# --- allow_empty, touch, sweep_downloads (#83) --------------------------------- + +def test_put_allow_empty(root): + assert files.put(root, b"", allow_empty=True) == (hashlib.sha256(b"").hexdigest(), 0) + with pytest.raises(files.EmptyFile): + files.put(root, b"") + + +def test_touch_updates_mtime(root): + sha, _ = files.put(root, b"abc") + old = time.time() - 5 * DAY + os.utime(root / sha, (old, old)) + files.touch(root, sha) + assert (root / sha).stat().st_mtime > time.time() - 60 + files.touch(root, "0" * 64) # файла нет — не ошибка + files.touch(root, "../x") # негодный sha — не ошибка + + +def test_sweep_downloads(tmp_path): + root = tmp_path / "downloads" + old, fresh = root / "31337", root / "31338" + for d in (old, fresh): + d.mkdir(parents=True) + (d / "a.png").write_bytes(b"x") + (root / "loose.txt").write_text("x") + t = time.time() - 31 * DAY + os.utime(old / "a.png", (t, t)) + os.utime(old, (t, t)) + os.utime(root / "loose.txt", (t, t)) + assert files.sweep_downloads(root) == ["31337"] + assert not old.exists() and fresh.exists() and (root / "loose.txt").exists() + assert files.sweep_downloads(tmp_path / "нет") == []