From e8707d47620c300935ed3bae726adf4e0d2909e1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 16 Oct 2026 01:41:54 +0400 Subject: [PATCH] =?UTF-8?q?feat(api):=20POST=20/files=20=D0=B8=20GET=20/ag?= =?UTF-8?q?ent/files/{sha256},=20files/=200700=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=B5,=20=D1=83=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=20=D0=B2=20tick=20=D1=80=D0=B0=D0=B7=20?= =?UTF-8?q?=D0=B2=20=D1=81=D1=83=D1=82=D0=BA=D0=B8=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Не base64 — 400 bad_request, пустой — 422 empty_file, больше 10 МБ — 413 too_large; агенту — {file, size, content} в base64, нет файла — 404 no_file; уборка отдельно от автообновления (решения 14–16). Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 2 +- CLAUDE.md | 3 +- README.md | 10 +- .../changes/bug-and-attachments/design.md | 8 ++ src/jira_hub/api.py | 61 +++++++++- tests/test_api.py | 104 +++++++++++++++++- 6 files changed, 179 insertions(+), 9 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b61da5d..54e232f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -14,7 +14,7 @@ | `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `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`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `relay`, `release` | +| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}` (каталог `Hub.files_dir`, 0700 при старте); раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `files`, `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 39d4356..e3be21b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,12 +9,13 @@ - Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента. - Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь. - Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed). -- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite`, `backups/`, `release/` (опубликованная версия агента); каталог данных, `backups/` и `release/` — права 0700. Порт по умолчанию 8790. +- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite`, `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней); каталог данных, `backups/`, `release/` и `files/` — права 0700. Порт по умолчанию 8790. - Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное. ## Архитектура - `src/jira_hub/` — весь код пакета, без разделения на ядро и адаптеры; чистая логика (`commands`, `mirror`, `queue`, `settings`) принимает соединение SQLite параметром. - `db` (схема, миграции) ← `settings`, `mirror`, `commands` ← `queue` ← `api` (HTTP, маршруты в `ROUTES`, фоновый `tick`, реле общей папки `relay` (поток в `serve`, настройка `relay_dir`)) ← `cli`. +- `files` (хранилище вложений `files/`, каталог — параметр `Hub.files_dir`) ← `queue`, `api`; `mcp_server` берёт из него только предел `MAX_SIZE`. - `client` (HTTP-клиент хаба) ← `mcp_server` (stdio, инструменты MCP); `importer` → `mirror`/`settings`; `backup` вызывается из `api` и из `db` (копия перед миграцией схемы). - Подробная карта модулей и границ — `ARCHITECTURE.md`. diff --git a/README.md b/README.md index 4fd2dce..5c846b6 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Python для хаба uv ставит свой (3.13, `.python-version`; сис Первый `uv sync` или `uv run` после смены сам пересоздаёт `.venv`. Данные лежат в `~/.local/share/jira-hub/` (каталог переопределяется переменной `JIRA_HUB_HOME`): -`hub.sqlite` и `backups/`. Пакет `mcp` закреплён версией `<2` (рулинг R-10): MCP SDK 2.x ломает API. +`hub.sqlite`, `backups/`, `release/` и `files/` (файлы вложений по sha256; каталоги — 0700). Пакет `mcp` закреплён версией `<2` (рулинг R-10): MCP SDK 2.x ломает API. ## Первый запуск @@ -128,7 +128,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py ## HTTP API Все маршруты, кроме веб-приложения (`GET /app…`) и перенаправления старых адресов (`GET /`, `GET /links`), требуют `Authorization: Bearer <токен>`; ответы JSON, -ошибки `{error, detail}` (400/401/404/409/422). +ошибки `{error, detail}` (400/401/404/409/413/422). | Маршрут | Токен | Назначение | |---|---|---| @@ -139,6 +139,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py | `POST /agent/state` | агент | загрузка состояния задач пачками | | `GET /agent/known-keys` | агент | ключи зеркала | | `GET /agent/release` | агент | опубликованная версия агента `{version, sha256, content}`; 404 `no_release` | +| `GET /agent/files/{sha256}` | агент | файл вложения `{file, size, content}` (`content` — base64); 404 `no_file` | | `GET /issues` | клиент | фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `limit`, `offset` | | `GET /issues/{key}` | клиент | задача целиком | | `GET /board` | клиент | снимок для доски: все задачи с `fact_status`/`work_kind`, все связи, `last_sync` — последний успешный полный прогон | @@ -148,6 +149,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py | `POST /batches/{id}/cancel` | клиент | отмена пакета | | `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет) / отмена (`force` для взятой) | | `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` | +| `POST /files` | клиент | загрузить файл вложения `{content: base64}` → `{file: sha256, size}`; не base64 — 400, пустой — 422 `empty_file`, больше 10 МБ (10 485 760 байт) — 413 `too_large`; повтор того же содержимого — тот же `file` | | `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок | | `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`; ответ — все настройки после записи, как у GET) | @@ -223,6 +225,10 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub - Резервные копии: перед первой записывающей командой пакета хаб копирует базу в `backups/<дата-время>-.sqlite` и хранит последние `backup_keep` копий (по умолчанию 30). +- Файлы вложений: `files/`, одна копия на содержимое, в резервные копии базы не входят. Раз в сутки (первый + раз — через минуту после старта) хаб удаляет файлы старше 30 дней, на которые не ссылается `ensure_attachment` в + очереди или у агента; повтор такой команды после уборки — 422 «файл … не загружен: POST /files», файл загружается + заново. - Автообновление: если `auto_refresh_minutes > 0` (по умолчанию 60), агент на связи и очередь пуста дольше интервала, хаб ставит пакет «Автообновление». - Настройки: `PUT /settings` или инструмент `settings_set`; защищены `settings_version`, `schema_version`, `tokens`. diff --git a/openspec/changes/bug-and-attachments/design.md b/openspec/changes/bug-and-attachments/design.md index 1161912..680b93b 100644 --- a/openspec/changes/bug-and-attachments/design.md +++ b/openspec/changes/bug-and-attachments/design.md @@ -114,3 +114,11 @@ filename="<имя UTF-8>"; filename*=UTF-8''`, `Content-Type: a 13. 15.10.2026 — повторная загрузка: копия того же размера есть — только `os.utime`; другого размера (оборванная запись) — файл переписывается; содержимое по sha256 не перепроверяется. Обоснование: имя — sha256 содержимого, агент сверяет sha256 при скачивании. Цена: подменённый на диске файл того же размера хаб не заметит (заметит агент). +14. 15.10.2026 — `POST /files` отбрасывает пробельные символы (переносы строк) в `content` до строгой проверки base64. + Обоснование: GNU `base64` и многие библиотеки переносят строку через 76 символов; спека говорит только «не base64 — + 400». Цена: строка с пробелами внутри тоже принимается. +15. 15.10.2026 — время последней уборки — в памяти процесса (`Hub.last_sweep`); первая уборка — на первом тике после + старта (через 60 с). Обоснование: без новой настройки и файла-метки, уборка дешёвая. Цена: при нескольких + перезапусках хаба в сутки уборок больше одной (спека уточнена: «в пределах работы процесса», рулинг R-1). +16. 15.10.2026 — `GET /agent/files/{id}` с id не из 64 hex нижнего регистра — 404 `no_file`, а не общий `not_found`. + Обоснование: для агента это тот же случай «файла нет». Цена: нет. diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 1d5efce..ef6419e 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -1,6 +1,7 @@ """HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос). Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md). Без токена — статика без данных: веб-приложение (GET /app…) и перенаправление старых адресов доски (GET /, GET /links).""" +import base64 import hmac import json import logging @@ -13,7 +14,7 @@ from pathlib import Path from typing import NamedTuple from urllib.parse import parse_qs, unquote, urlparse -from . import backup, db, mirror, queue, relay, release, settings +from . import backup, db, files, mirror, queue, relay, release, settings from .commands import CommandError from .paths import backups_dir as default_backups_dir from .paths import secure_dir @@ -29,6 +30,7 @@ ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # им NOT_BUILT = ("Хаб Jira

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

").encode("utf-8") SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит +SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений») class Raw(NamedTuple): @@ -45,16 +47,19 @@ class ApiError(Exception): class Hub: - def __init__(self, db_path, backups_dir=None, release_dir=None, web_dir=None): + def __init__(self, db_path, backups_dir=None, release_dir=None, web_dir=None, files_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.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.last_sweep = None # время прошлой уборки files/, в памяти (решение 15) # последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version # неизвестен, а не null (hub-hygiene-d, решение 7) self.agent_update = {"update_state": None, "launcher_version": None, "reported": False} self.claim_wait = 30 secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии + secure_dir(self.files_dir) # files/ — 0700 при каждом старте, как backups/ conn = self.conn() settings.ensure_defaults(conn) conn.close() @@ -63,17 +68,35 @@ class Hub: return db.connect(self.db_path) def tick(self): - """Один шаг фонового автообновления; любой сбой логируется и не прерывает цикл.""" + """Один шаг фоновых работ: автообновление и уборка файлов вложений (не чаще раза в сутки). Сбой каждой + логируется и не мешает ни другой, ни следующему тику.""" conn = None try: conn = self.conn() queue.maybe_auto_refresh(conn) except Exception: # noqa: BLE001 log.exception("автообновление: сбой тика") + try: + if conn is not None: + self.sweep_files(conn) + except Exception: # noqa: BLE001 + log.exception("уборка файлов: сбой") finally: - if conn: + if conn is not None: conn.close() + def sweep_files(self, conn, now=None): + """Уборка files/ (files.sweep) не чаще раза в SWEEP_EVERY; время прошлой — в памяти процесса, после + перезапуска хаба первая уборка — на первом тике (решение 15). None — ещё рано.""" + now = time.time() if now is None else now + if self.last_sweep is not None and now - self.last_sweep < SWEEP_EVERY: + return None + self.last_sweep = now + removed = files.sweep(conn, self.files_dir, now) + if removed: + log.info("уборка файлов: удалено %d", len(removed)) + return removed + def note_agent_update(self, body): """update_state и launcher_version из claim: хранятся в памяти, агент шлёт их в каждом claim (R-3). Значение неверного типа игнорируется — claim из-за него не отвергается; сам claim отмечается (reported).""" @@ -141,6 +164,15 @@ def h_agent_release(hub, conn, m, q, body): return 200, rel +def h_agent_file(hub, conn, m, q, body): + """Файл вложения агенту: {file, size, content}, content — base64 (проходит и реле папки, и HTTP). id не из 64 hex + нижнего регистра — тот же 404 no_file (решение 16).""" + data = files.read(hub.files_dir, m.group(1)) + if data is None: + raise ApiError(404, "no_file", f"файла {m.group(1)} нет в хранилище хаба") + return 200, {"file": m.group(1), "size": len(data), "content": base64.b64encode(data).decode("ascii")} + + def h_agent_settings(hub, conn, m, q, body): return 200, settings.for_agent(conn) @@ -258,6 +290,25 @@ 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).""" + content = body.get("content") + if not isinstance(content, str): + raise ApiError(400, "bad_request", "content — строка base64") + try: + data = base64.b64decode("".join(content.split()), validate=True) + except ValueError: # binascii.Error — подкласс ValueError + raise ApiError(400, "bad_request", "content — не base64") from None + try: + sha, size = files.put(hub.files_dir, data) + except files.EmptyFile: + raise ApiError(422, "empty_file", "пустой файл") from None + except files.TooLarge: + raise ApiError(413, "too_large", f"файл больше {files.MAX_SIZE} байт ({len(data)})") from None + return 200, {"file": sha, "size": size} + + def h_command_retry(hub, conn, m, q, body): if not isinstance(body.get("author") or "api", str): raise ApiError(422, "bad_retry", "author — строка") @@ -344,6 +395,7 @@ ROUTES = [ ("POST", r"^/agent/state$", h_agent_state, "agent"), ("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"), ("GET", r"^/issues$", h_issues, "client"), ("GET", r"^/issues/([^/]+)$", h_issue, "client"), ("GET", r"^/board$", h_board, "client"), @@ -352,6 +404,7 @@ ROUTES = [ ("GET", r"^/batches$", h_batches_get, "client"), ("GET", r"^/batches/(\d+)$", h_batch_get, "client"), ("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"), + ("POST", r"^/files$", h_files_post, "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/tests/test_api.py b/tests/test_api.py index dc3bdf9..c4cddd5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,13 +1,17 @@ +import base64 +import hashlib import json import os +import stat import threading +import time import urllib.error import urllib.request from datetime import timedelta import pytest -from jira_hub import api, db, queue, release, settings +from jira_hub import api, db, files, queue, release, settings from jira_hub.client import HubClient, HubError @@ -606,3 +610,101 @@ def test_bug_without_setting_is_422(hub): "temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}}]}) assert e.value.status == 422 and e.value.detail == "команда 1: нет настройки issue_types.bug" assert hub["client"].get("/batches")["batches"] == [] + + +def _b64(data: bytes) -> str: + return base64.b64encode(data).decode("ascii") + + +def test_files_upload_and_agent_download(hub, tmp_path): + """Спека hub-api «Загрузка файла», «Повторная загрузка», «Скачивание файла вложения».""" + c, a = hub["client"], hub["agent"] + sha = hashlib.sha256(b"abc").hexdigest() + out = c.post("/files", {"content": _b64(b"abc")}) + assert out == {"file": sha, "size": 3} and sha.startswith("ba7816bf") + assert (tmp_path / "files" / sha).read_bytes() == b"abc" + assert c.post("/files", {"content": _b64(b"abc")}) == out + assert [p.name for p in (tmp_path / "files").iterdir()] == [sha] + got = a.get(f"/agent/files/{sha}") + assert got["file"] == sha and got["size"] == 3 + assert hashlib.sha256(base64.b64decode(got["content"])).hexdigest() == sha + with pytest.raises(HubError) as e: + c.get(f"/agent/files/{sha}") + assert e.value.status == 401 + with pytest.raises(HubError) as e: + a.post("/files", {"content": _b64(b"abc")}) + assert e.value.status == 401 + with pytest.raises(HubError) as e: + a.get("/agent/files/" + "0" * 64) + assert (e.value.status, e.value.error) == (404, "no_file") + + +@pytest.mark.parametrize("body, status, error", [ + ({}, 400, "bad_request"), ({"content": 5}, 400, "bad_request"), ({"content": None}, 400, "bad_request"), + ({"content": "не base64!"}, 400, "bad_request"), ({"content": "YWJj="}, 400, "bad_request"), + ({"content": ""}, 422, "empty_file"), +]) +def test_files_bad_upload(hub, tmp_path, body, status, error): + with pytest.raises(HubError) as e: + hub["client"].post("/files", body) + assert (e.value.status, e.value.error) == (status, error) + assert list((tmp_path / "files").iterdir()) == [] + + +def test_files_base64_with_line_breaks(hub): + data = bytes(range(256)) * 4 + s = _b64(data) + wrapped = "\n".join(s[i:i + 76] for i in range(0, len(s), 76)) + "\n" # как GNU base64 + assert hub["client"].post("/files", {"content": wrapped}) == {"file": hashlib.sha256(data).hexdigest(), "size": 1024} + + +def test_files_too_large_is_413(hub, tmp_path): + """Спека hub-api «Слишком большой файл»: 11 МБ — 413, тело запроса (≈ 15 МБ base64) до 16 МБ проходит.""" + with pytest.raises(HubError) as e: + hub["client"].post("/files", {"content": _b64(b"\0" * (11 * 1024 * 1024))}) + assert (e.value.status, e.value.error) == (413, "too_large") + assert list((tmp_path / "files").iterdir()) == [] + + +@pytest.mark.parametrize("bad", ["..", "%2E%2E", hashlib.sha256(b"abc").hexdigest().upper(), "0" * 63, "x" * 64]) +def test_agent_file_bad_id_is_no_file(hub, bad): + hub["client"].post("/files", {"content": _b64(b"abc")}) + with pytest.raises(HubError) as e: + hub["agent"].get(f"/agent/files/{bad}") + assert (e.value.status, e.value.error) == (404, "no_file") + + +def test_files_dir_closed_on_every_start(tmp_path): + h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + d = tmp_path / "files" + assert h.files_dir == d and stat.S_IMODE(d.stat().st_mode) == 0o700 + os.chmod(d, 0o755) + api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + assert stat.S_IMODE(d.stat().st_mode) == 0o700 + + +def test_tick_sweeps_files_once_a_day(hub, tmp_path, monkeypatch): + h = hub["hub"] + sha = hub["client"].post("/files", {"content": _b64(b"old")})["file"] + old = time.time() - 31 * 86400 + os.utime(tmp_path / "files" / sha, (old, old)) + calls = [] + real = files.sweep + monkeypatch.setattr(api.files, "sweep", lambda *a, **k: calls.append(1) or real(*a, **k)) + h.tick() + assert len(calls) == 1 and not (tmp_path / "files" / sha).exists() + h.tick() + assert len(calls) == 1 # второй тик в те же сутки — без уборки + h.last_sweep -= api.SWEEP_EVERY + h.tick() + assert len(calls) == 2 + + +def test_tick_sweep_failure_logged_auto_refresh_still_runs(hub, monkeypatch, caplog): + def broken(*args, **kwargs): + raise OSError("диск недоступен") + monkeypatch.setattr(api.files, "sweep", broken) + hub["agent"].post("/agent/claim", {"agent_version": 9}) # агент на связи — тик ставит автообновление + hub["hub"].tick() # не бросает + assert "уборка файлов: сбой" in caplog.text + assert [b["title"] for b in hub["client"].get("/batches")["batches"]] == ["Автообновление"]