diff --git a/.env.example b/.env.example index 499722e..f2a1d49 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,19 @@ # Часовой пояс напоминаний (0.89): один на инстанс REMINDER_TIMEZONE=Europe/Kyiv +# BugTrail (ТЗ 3.22, 0.92): вставка ссылки на багрепорт в заголовок задачи +# заполняет заголовок и описание. Доступ к трекеру не нужен — share-токен в самой +# ссылке и есть авторизация. Пусто — импорт выключен. +# Внимание: docker-compose эти переменные в контейнер api не пробрасывает (как и +# VAPID_*/REMINDER_TIMEZONE) — там работает дефолт из кода, а выключить импорт на +# проде можно только добавив их в environment сервиса api. +BUGTRAIL_URL=https://bugtrail.gnexus.space +# Панель трекера для ссылок в тексте задачи; пусто — берётся BUGTRAIL_URL. +# В разработке API и панель на разных адресах: +# BUGTRAIL_URL=http://localhost:8001 +# BUGTRAIL_PANEL_URL=http://localhost:5173 +BUGTRAIL_PANEL_URL= + # docker-compose (M7): TLS терминирует внешний reverse-proxy — # он пробрасывает трафик на WEB_PORT (контейнер web слушает :80); # на нём же client_max_body_size >= 12m и без буферизации SSE (/api/, /mcp/) diff --git a/backend/app/api/bugtrail.py b/backend/app/api/bugtrail.py new file mode 100644 index 0000000..b92d60b --- /dev/null +++ b/backend/app/api/bugtrail.py @@ -0,0 +1,46 @@ +"""Черновик задачи из ссылки на багрепорт BugTrail (ТЗ 3.22, 0.92). + +GET /api/bugtrail/draft?url=... — бэкенд достаёт отчёт по share-токену и отдаёт +готовый заголовок с описанием. Ничего не создаёт и в БД не пишет: фронт заполняет +форму, решение создать задачу остаётся за человеком. + +Ходить с бэкенда, а не из браузера, приходится по двум причинам: у трекера узкий +CORS и share-токен незачем светить лишний раз. Адрес трекера берётся из настроек, +из ссылки вынимается только токен (см. services/bugtrail.py). +""" + +from fastapi import APIRouter, HTTPException + +from app.config import get_settings +from app.dependencies import UserIdDep +from app.services import bugtrail + +router = APIRouter(prefix="/api/bugtrail", tags=["bugtrail"]) + + +@router.get("/draft", response_model=bugtrail.BugTrailDraft) +async def get_bugtrail_draft(url: str, user_id: UserIdDep) -> bugtrail.BugTrailDraft: + """Черновик задачи из ссылки на отчёт BugTrail. + + 400 — не наша ссылка, 503 — импорт выключен настройкой, 404 — отчёта нет, + 502 — трекер молчит или ответил не тем. Тихо игнорировать 404/502 нельзя: + человек вставил ссылку именно чтобы получить текст, но и ссылка при этом + остаётся в заголовке — терять введённое нечем. + """ + settings = get_settings() + if not settings.bugtrail_enabled: + raise HTTPException(status_code=503, detail="BugTrail import is not configured") + token = bugtrail.parse_share_token(url) + if token is None: + raise HTTPException(status_code=400, detail="Not a BugTrail report link") + report, status = await bugtrail.fetch_report(token) + if status == "not_found": + raise HTTPException(status_code=404, detail="Report not found") + if report is None: + raise HTTPException(status_code=502, detail="BugTrail is unavailable") + return bugtrail.build_draft( + report, + api_base=settings.bugtrail_url.rstrip("/"), + panel_base=settings.bugtrail_panel, + token=token, + ) diff --git a/backend/app/config.py b/backend/app/config.py index ce10840..98071c9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -55,10 +55,26 @@ # Один на инстанс — упрощение, вопрос о per-user поясе открыт (8.13). reminder_timezone: str = "Europe/Kyiv" + # BugTrail (ТЗ 3.22, 0.92): трекер багов владельца, из ссылки на отчёт + # собирается черновик задачи. `bugtrail_url` — база API (по ней и ходим), + # `_panel_url` — база панели (человеческие ссылки в описании), пусто — + # берётся API-база. Пустой `bugtrail_url` выключает импорт (эндпоинт 503). + bugtrail_url: str = "https://bugtrail.gnexus.space" + bugtrail_panel_url: str = "" + @property def push_enabled(self) -> bool: return bool(self.vapid_public_key and self.vapid_private_key) + @property + def bugtrail_enabled(self) -> bool: + return bool(self.bugtrail_url) + + @property + def bugtrail_panel(self) -> str: + """База панели трекера для ссылок в тексте задачи (fallback — API-база).""" + return (self.bugtrail_panel_url or self.bugtrail_url).rstrip("/") + @model_validator(mode="after") def _session_secret_must_be_set(self) -> "Settings": if self.session_secret == DEFAULT_SESSION_SECRET: diff --git a/backend/app/main.py b/backend/app/main.py index b4c0efe..5ab7163 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,6 +16,7 @@ from app import mcp_server from app.actor import VIA_MCP, agent_actor from app.api import attachments, events, garden, projects, tags, task_events, tasks, xp +from app.api.bugtrail import router as bugtrail_router from app.api.mcp_tokens import router as mcp_tokens_router from app.api.push import router as push_router from app.api.repo_readme import router as repo_readme_router @@ -110,6 +111,7 @@ app.include_router(attachments.router) app.include_router(settings_router) app.include_router(repo_readme_router) +app.include_router(bugtrail_router) app.include_router(mcp_tokens_router) app.include_router(push_router) app.include_router(xp.router) diff --git a/backend/app/services/bugtrail.py b/backend/app/services/bugtrail.py new file mode 100644 index 0000000..210bce8 --- /dev/null +++ b/backend/app/services/bugtrail.py @@ -0,0 +1,318 @@ +"""Черновик задачи из ссылки на багрепорт BugTrail (ТЗ 3.22, 0.92). + +Ссылка из панели трекера (`{panel}/r/{share_token}`) публична by design: share-токен +в пути и есть авторизация. Отдельного доступа к BugTrail не нужно, токен у нас нигде +не хранится и не настраивается — только вынимается из вставленной ссылки. + +Ходим за отчётом **с бэкенда** (у трекера узкий CORS, а share-токен незачем лишний раз +светить в браузере). Хост, к которому обращаемся, берётся **только из настроек**: из +ссылки вынимается лишь share-токен, и он проверяется по charset. Подставить +произвольный адрес нечем — это весь SSRF-барьер фичи (ср. services/favicon.py). + +Отсюда же берётся текст задачи: подписи шагов — слово в слово из русской локали самого +BugTrail (`packages/web/src/i18n/ru.json`), чтобы задача в gntodo читалась так же, как +отчёт в панели. +""" + +from __future__ import annotations + +import json +import re +from typing import Any, Literal +from urllib.parse import urlsplit + +import httpx +from pydantic import BaseModel, ConfigDict, Field + +from app.config import get_settings + +CONNECT_TIMEOUT = 3.0 +READ_TIMEOUT = 4.0 +TOTAL_TIMEOUT = 6.0 +# Тело отчёта — не картинка: в ReportOut входит mouse_track (до 5000 точек у записи +# экрана), поэтому «толстое» тело тут норма, а не признак подделки. Но граница нужна. +MAX_BYTES = 1_000_000 +# Потолки на то, что подставляем в описание: лимита на описание в БД нет вовсе, и +# патологический отчёт не должен раздувать задачу (карточка списка рендерит начало). +MAX_STEPS = 50 +MAX_IMAGES = 20 +MAX_DESCRIPTION = 20_000 + +_UA = "gntodo/1.0 (bugtrail import)" +# token_urlsafe(16) — 22 символа [A-Za-z0-9_-]; берём с запасом: чужая ссылка всё +# равно упирается в белый список хостов, а битый токен вернёт 404 +_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") +# Подписи типов шагов — из report.stepTypes русской локали BugTrail +_STEP_LABELS = { + "click": "Клик", + "input": "Ввод", + "url_change": "Смена URL", + "scroll": "Прокрутка", + "navigation": "Переход", + "note": "Заметка", + "screenshot": "Скриншот", + "console": "Ошибка консоли", +} + +FetchStatus = Literal["ok", "not_found", "unavailable"] + + +class _Step(BaseModel): + model_config = ConfigDict(extra="ignore") + + type: str = "" + offset_ms: int = 0 + data: dict[str, Any] = Field(default_factory=dict) + screenshot_attachment_id: str | None = None + + +class _Attachment(BaseModel): + model_config = ConfigDict(extra="ignore") + + file_id: str = "" + kind: str = "" + filename: str = "" + mime: str = "" + size: int = 0 + + +class Report(BaseModel): + """Ответ трекера (ReportDetailOut) — только то, что попадает в задачу. + + Модель внешнего контракта, поэтому локальная и снисходительная: всё + необязательное (в BugTrail эти же поля бывают `null`), лишнее игнорируем. + """ + + model_config = ConfigDict(extra="ignore") + + title: str | None = None + description: str | None = None + page_url: str | None = None + page_title: str | None = None + share_token: str | None = None + element: dict[str, Any] | None = None + steps: list[_Step] | None = None + attachments: list[_Attachment] | None = None + + +class BugTrailDraft(BaseModel): + """Черновик задачи: заполняем форму, ничего не создаём.""" + + title: str + description: str + + +def parse_share_token(url: str) -> str | None: + """Share-токен из ссылки на отчёт; None — это не ссылка BugTrail. + + Принимаем обе формы: человеческую `{panel}/r/{token}` и API-шную + `{api}/api/reports/{token}`. Хост обязан совпасть с настроенным (панель или + API) — иначе ссылку не разбираем вовсе. + """ + settings = get_settings() + if not settings.bugtrail_enabled: + return None + split = urlsplit(url.strip()) + if split.scheme not in ("http", "https"): + return None + if split.netloc.lower() not in {_host(settings.bugtrail_url), _host(settings.bugtrail_panel)}: + return None + parts = [part for part in split.path.split("/") if part] + if len(parts) == 2 and parts[0] == "r": + token = parts[1] + elif len(parts) == 3 and parts[:2] == ["api", "reports"]: + token = parts[2] + else: + return None + return token if _TOKEN_RE.match(token) else None + + +def _host(base: str) -> str: + """`https://host:port/` → `host:port` в нижнем регистре — для сверки хостов.""" + return urlsplit(base).netloc.lower() + + +def _text(value: Any) -> str: + """Строка из внешних данных; всё прочее (None, числа, словари) — пусто.""" + return value.strip() if isinstance(value, str) else "" + + +async def _read_limited(resp: httpx.Response, limit: int) -> bytes | None: + """Тело ответа не длиннее limit; None — превысило (ср. favicon._read_limited).""" + buf = bytearray() + async for chunk in resp.aiter_bytes(): + buf += chunk + if len(buf) > limit: + return None + return bytes(buf) + + +async def _get_json(client: httpx.AsyncClient, url: str) -> tuple[Any, FetchStatus]: + """JSON по url и что из этого вышло: ok / not_found / unavailable.""" + try: + async with client.stream("GET", url, follow_redirects=False) as resp: + if resp.status_code == 404: + return None, "not_found" + if resp.status_code != 200: + return None, "unavailable" + body = await _read_limited(resp, MAX_BYTES) + except httpx.HTTPError: + return None, "unavailable" + if body is None: + return None, "unavailable" + try: + return json.loads(body), "ok" + except ValueError: # не JSON — трекер ответил не тем + return None, "unavailable" + + +async def fetch_report(token: str) -> tuple[Report | None, FetchStatus]: + """Отчёт по share-токену. Клиент создаётся здесь — тесты бьют по `_get_json`.""" + settings = get_settings() + timeout = httpx.Timeout(TOTAL_TIMEOUT, connect=CONNECT_TIMEOUT, read=READ_TIMEOUT) + url = f"{settings.bugtrail_url.rstrip('/')}/api/reports/{token}" + async with httpx.AsyncClient(timeout=timeout, headers={"User-Agent": _UA}) as client: + payload, status = await _get_json(client, url) + if status != "ok": + return None, status + try: + return Report.model_validate(payload), "ok" + except ValueError: # не наш контракт — считаем трекер недоступным + return None, "unavailable" + + +def build_draft(report: Report, *, api_base: str, panel_base: str, token: str) -> BugTrailDraft: + """Черновик задачи из отчёта: заголовок и markdown-описание. + + `api_base` — для картинок (`/api/reports/by-token/...`), `panel_base` — для + человеческой ссылки на отчёт. В dev это разные адреса, путать нельзя. + """ + share = report.share_token or token + title = _text(report.title) or _text(report.page_title) + blocks: list[str] = [] + + if description := _text(report.description): + blocks.append(description) + + steps = report.steps or [] + if steps: + shots = _shots_by_step(report, api_base=api_base, token=share) + lines: list[str] = [] + for index, step in enumerate(steps[:MAX_STEPS]): + lines.append(_step_line(step, index + 1)) + if shot := shots.get(step.screenshot_attachment_id or ""): + lines.append(shot) + if len(steps) > MAX_STEPS: + lines.append(f"… ещё {len(steps) - MAX_STEPS}") + blocks.append("**Записанные шаги**\n\n" + "\n".join(lines)) + elif element := _element_parts(report.element): + blocks.append(f"**Элемент:** {element}") + + if page := _page_line(report): + blocks.append(page) + + images, files = _attachments(report, api_base=api_base, token=share) + if images or files: + blocks.append("**Вложения**\n\n" + "\n".join([*images, *files])) + + blocks.append(f"**Источник:** BugTrail — {panel_base}/r/{share}") + return BugTrailDraft(title=title or "Багрепорт BugTrail", description=_cut(blocks)) + + +def _step_line(step: _Step, number: int) -> str: + """Строка шага — как в панели BugTrail (`aiPrompt.ts:111`, `stepLine`).""" + data = step.data + parts: list[str] = [] + if element := _element_parts(data.get("element")): + parts.append(element) + if value := _text(data.get("value")): + parts.append(f"«{value}»") + if isinstance(length := data.get("value_length"), int): + parts.append(f"({length} симв.)") + from_url, to_url = _text(data.get("from_url")), _text(data.get("to_url")) + if from_url and to_url: + parts.append(f"{from_url} → {to_url}") + elif to_url: + parts.append(to_url) + x, y = data.get("x"), data.get("y") + if isinstance(x, int | float) and isinstance(y, int | float): + parts.append(f"({x}, {y})") + if text := _text(data.get("text")): + parts.append(text) + label = _STEP_LABELS.get(step.type, step.type) + tail = f": {' '.join(parts)}" if parts else "" + return f"{number}. {step.offset_ms / 1000:.1f} с — {label}{tail}" + + +def _element_parts(element: Any) -> str: + """`