feat(api): POST /files и GET /agent/files/{sha256}, files/ 0700 при старте, уборка в tick раз в сутки (#43)

Не 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 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-16 01:41:54 +04:00
parent 19bba60cac
commit e8707d4762
6 changed files with 179 additions and 9 deletions

View File

@ -14,7 +14,7 @@
| `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` |
| `backup` | копии файла базы, ротация | `paths` |
| `files` | хранилище файлов вложений `<данные>/files/<sha256>` (каталог — параметр, `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`) |

View File

@ -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/<sha256>`, каталог — параметр `Hub.files_dir`) ← `queue`, `api`; `mcp_server` берёт из него только предел `MAX_SIZE`.
- `client` (HTTP-клиент хаба) ← `mcp_server` (stdio, инструменты MCP); `importer` → `mirror`/`settings`; `backup` вызывается из `api` и из `db` (копия перед миграцией схемы).
- Подробная карта модулей и границ — `ARCHITECTURE.md`.

View File

@ -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/<дата-время>-<batch>.sqlite`
и хранит последние `backup_keep` копий (по умолчанию 30).
- Файлы вложений: `files/<sha256>`, одна копия на содержимое, в резервные копии базы не входят. Раз в сутки (первый
раз — через минуту после старта) хаб удаляет файлы старше 30 дней, на которые не ссылается `ensure_attachment` в
очереди или у агента; повтор такой команды после уборки — 422 «файл … не загружен: POST /files», файл загружается
заново.
- Автообновление: если `auto_refresh_minutes > 0` (по умолчанию 60), агент на связи и очередь пуста дольше интервала,
хаб ставит пакет «Автообновление».
- Настройки: `PUT /settings` или инструмент `settings_set`; защищены `settings_version`, `schema_version`, `tokens`.

View File

@ -114,3 +114,11 @@ filename="<имя UTF-8>"; filename*=UTF-8''<percent-encoded>`, `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`.
Обоснование: для агента это тот же случай «файла нет». Цена: нет.

View File

@ -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 = ("<!doctype html><meta charset=\"utf-8\"><title>Хаб Jira</title><p>Веб-приложение не собрано: выполните "
"<code>make web</code> в каталоге jira-hub и перезапустите хаб.</p>").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"),

View File

@ -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"]] == ["Автообновление"]