diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 42ac859..94cde57 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -67,6 +67,13 @@ ADR-007 §6 licensing guard). - z≥12 — GeoJSON через `GET /api/gps-tracks?bbox=...&activity=...&source=...`. - z<8 — слой скрыт (защита от шторма запросов). +Скачивание одного трека из popup карты (ET-011): +`GET /api/gps-tracks/{track_id}/download` — отдаёт GPX 1.1 с +правильным `Content-Disposition` и UTF-8 именем по RFC 5987. Разрешено +только для источников с `download_allowed: true` в +`config/gps_sources.yaml` (MVP: только `osm`). Cap 200000 точек → +413 Payload Too Large. См. ADR-014 / ADR-015. + Health/observability: `GET /api/gps-tracks/health` — состояние БД, число треков по источникам, последний прогон. diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index c601608..3033041 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -17,3 +17,5 @@ | ADR-011 | ttrails.ru — licensing: БЛОКИРОВАН до завершения ToS/robots ревью | proposed | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md) | | ADR-012 | Wikiloc — licensing: accepted с rate-limit 10s, graceful-stop на 403/429, обезличенное сохранение (без user/description) | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-012-wikiloc-licensing.md) | | ADR-013 | Активация EnduroRussia + Wikiloc — конфиг-only изменения поверх pipeline ET-008 (URL-fix, новая запись wikiloc, регионы, стили, атрибуция) | accepted | 2026-06-01 | [ET-009](../../work-items/ET-009/06-adr/ADR-013-source-activation.md) | +| ADR-014 | GPX-download эндпоинт публичного трека: `xml.etree.ElementTree`-builder + fetch+Blob на клиенте | accepted | 2026-06-03 | [ET-011](../../work-items/ET-011/06-adr/ADR-014-gpx-download-endpoint.md) | +| ADR-015 | Политика реэкспорта публичных треков: per-source `download_allowed` в `gps_sources.yaml`, default-deny (whitelist `osm` для MVP) | accepted | 2026-06-03 | [ET-011](../../work-items/ET-011/06-adr/ADR-015-source-redistribution-policy.md) | diff --git a/docs/work-items/ET-011/06-adr/ADR-014-gpx-download-endpoint.md b/docs/work-items/ET-011/06-adr/ADR-014-gpx-download-endpoint.md new file mode 100644 index 0000000..a5dcc8d --- /dev/null +++ b/docs/work-items/ET-011/06-adr/ADR-014-gpx-download-endpoint.md @@ -0,0 +1,503 @@ +--- +type: adr +work_item_id: ET-011 +adr_id: ADR-014 +title: "ADR-014: Эндпоинт скачивания GPX из popup трека — `xml.etree.ElementTree`-builder + fetch+Blob на клиенте" +status: accepted +created_at: 2026-06-03 +updated_at: 2026-06-03 +authors: + - "agent:architect" +supersedes: [] +superseded_by: [] +labels: + - "ET-011:download" + - "minor-change" +--- + +# ADR-014 — Endpoint и формат скачивания публичного GPS-трека + +## Статус + +**Accepted.** Архитектурное решение для ET-011. + +## Контекст + +ET-008 ввёл публичный слой GPS-треков (`/api/gps-tracks/*`) и popup при +клике (`gps_tracks.js::_renderTrackPopupHtml`, l. 463). В popup +показывается метаинформация (название, активность, длина, точки, дата, +источники), но **нет действия «забрать трек к себе»**: пользователь +видит трек, но не может одним тапом скачать его GPX. + +ET-011 расширяет popup кнопкой «Скачать GPX» и добавляет новый эндпоинт +`GET /api/gps-tracks/{track_id}/download`, который собирает GPX 1.1 из +геометрии трека (WKB LineString в `tracks.geom`) и отдаёт файл с +правильным `Content-Disposition` и UTF-8 именем по RFC 5987. + +Существующие активы, которые переиспользуем: + +- `src/api/gps_tracks/mvt.py::_wkb_to_coords()` — парсинг WKB LineString + в `[[lon, lat], ...]` (см. `endpoint.py:55–57`, уже используется в + GeoJSON-эндпоинте). +- `src/api/gps_tracks/db.py::open_db/init_db` — открытие БД, спрайт уже + используется во всех роутах. +- `src/web/app.js::downloadGPX()` (l. 1236–1249) — рабочий + desktop+iOS-mobile паттерн `Blob + URL.createObjectURL + a.download`. + Используется для скачивания **построенного** маршрута; для + публичного трека механика та же, но содержимое приходит с сервера. +- `showToast(...)` (используется по всему `gps_tracks.js`) — UX для + ошибок. + +## Альтернативы и решения + +### Решение A — Транспорт от backend до файла на диске пользователя + +| Опция | Плюсы | Минусы | +|---|---|---| +| A1: `` — браузер сам качает | Один клик, нулевая JS-логика | Невозможно перехватить статус 4xx/5xx и показать toast (REQ-F-05 — обязателен); ошибочный JSON отрисуется в новой вкладке | +| A2 (**выбрано**): `fetch()` → `response.blob()` → `URL.createObjectURL` → `` → `click()` → `revokeObjectURL` | Можно проверить статус и заголовки; toast при ошибке; реальный размер для UI; единый паттерн с `app.js::downloadGPX()` уже в проде | Чуть больше JS-кода; нужно прочесть `Content-Disposition` из ответа | +| A3: ServiceWorker-перехват | Универсальный, контроль над прогресс-баром | Overkill: ET-008 без SW, добавлять ради одной кнопки — лишняя зависимость и риск (PH-9 PWA — отдельная фаза) | + +**Обоснование A2.** REQ-F-05 фиксирует обязательную обработку 403/404/5xx +через `showToast` — это требует чтения HTTP-статуса. Без `fetch` это +невозможно. Тот же `fetch+Blob` паттерн уже работает в `downloadGPX()` +для построенного маршрута на iOS Safari, Android Chrome и desktop — то +есть R-1 в BRD (iOS Safari `Content-Disposition`) уже митигирован +через `a.download` от blob-URL. + +Имя файла на клиенте читается из `Content-Disposition` заголовка +(`filename*=UTF-8''`). При наличии расширенного +параметра — декодируем и используем; иначе fallback к ASCII `filename=`. +Если оба отсутствуют (defensive) — `track-.gpx`. Парсер хедера — +тривиальная regex на клиенте (~10 строк). + +### Решение B — Backend: как собирать GPX + +| Опция | Плюсы | Минусы | +|---|---|---| +| B1 (**выбрано**): `xml.etree.ElementTree` (stdlib) | Корректное XML-экранирование атрибутов и текста (защита от багов с `<`, `&`, `"` в `tracks.name`); без новых зависимостей; небольшие GPX в 50k точек собираются за ≤ 100 ms | Сериализация в строку через `tostring(root, encoding="unicode")` — один проход; в стрессе ≥ 200k уже cap-обрезано REQ-NF-02 | +| B2: `lxml.etree` | Чуть быстрее (~1.5×); встроенная XSD-валидация | Новая транзитивная зависимость в runtime-образе; собранный XML тот же; для теста XSD-валидации `lxml` всё равно понадобится — но **только** в `tests/`, не в runtime | +| B3: f-string шаблоны | Простота, копирует паттерн `app.js::generateGPX()` | Ручное XML-экранирование (`&`, `<` в названии трека) — типичный источник CVE; для UTF-8 имён почти всегда работает, но один спецсимвол — broken XML и провал AC-5 | + +**Обоснование B1.** Стандартная библиотека Python 3.12 содержит +`xml.etree.ElementTree` (для **сборки** доверенного XML, не для парсинга +input'а). Корректно экранирует `&`, `<`, `>`, `"` в текстовых узлах и +атрибутах. Тест UT-03 валидирует результат против `gpx.xsd` через +`lxml.etree.XMLSchema` — `lxml` добавляется **только** в test-deps +(`requirements-dev.txt`), runtime-образ не растёт. + +Для **парсинга** внешних GPX (collector в ET-008) используется +`defusedxml.ElementTree` (защита от XXE/billion-laughs); тут парсинг +не нужен — мы только генерируем. + +### Решение C — In-memory ответ vs StreamingResponse + +| Опция | Плюсы | Минусы | +|---|---|---| +| C1 (**выбрано**): `Response(content=xml_str, media_type=..., headers=...)` | Простота; gzip из starlette middleware (если включён) работает сразу; для 50k точек XML ~5 МБ — нагрузка нормальная | Весь XML в памяти worker'а; при 200k точек (cap REQ-NF-02) ≈ 20 МБ на 1 запрос | +| C2: `StreamingResponse` через генератор по `trkpt` | Меньше памяти на пик; first-byte быстрее | Сложнее правильно поставить `Content-Disposition`, `Content-Length` неизвестен (gzip-middleware всё равно стримит); REQ-NF-01 = 300 ms p95 у нас и так с запасом | + +**Обоснование C1.** Cap REQ-NF-02 (200k точек → 413) ограничивает +память по одному запросу до ~20 МБ XML. Параллельные скачивания на +test-сервере (1 worker uvicorn в проекте, реально 2–4 во время нагрузки) +дадут пик ≤ 80 МБ — это меньше, чем уже использует MVT-кэш ET-008 в +норме. Стриминг сэкономит ~50 ms first-byte, что несущественно для +файла-скачивания (browser показывает прогресс в downloads, а не на +странице). + +### Решение D — Поведение popup после клика + +| Опция | Плюсы | Минусы | +|---|---|---| +| D1 (**выбрано**): popup остаётся открытым после клика | Пользователь видит результат (toast / индикатор); консистентно с тем, что popup в проекте закрывается только по клику вне popup или повторному клику в карту (см. `closeOnClick: true` в `gps_tracks.js:528`) | Если пользователь хочет скачать и сразу закрыть — нужен один лишний тап вне popup (привычный жест) | +| D2: автозакрытие сразу при клике | Чище визуально | Toast об ошибке окажется без контекста («что я пытался скачать?») | + +**Обоснование D1.** Согласуется с REQ-F-05.1 рекомендацией («не +закрывать»). Если запрос > 200 ms — на кнопке появляется CSS-класс +`.is-loading` (визуальный spinner через `::after` псевдоэлемент в CSS, +без новых SVG). При успехе класс снимается, toast — опционально +(скачивание визуально само себя анонсирует через download-bar браузера). + +### Решение E — Где живёт код сборки GPX + +| Опция | Плюсы | Минусы | +|---|---|---| +| E1 (**выбрано**): новый модуль `src/api/gps_tracks/export.py` | Единая ответственность; легко тестируется в unit; не загромождает `endpoint.py` (роутер уже 311 строк) | Один новый файл (минимальная цена) | +| E2: функция в `endpoint.py` | Совсем рядом с route | Раздувает endpoint-модуль; затрудняет повторное использование (например, для будущего bulk-export через `gps-collector` CLI) | +| E3: функция в `db.py` | DB и export — концептуально связаны | DB-модуль становится дом всему — нарушение single-responsibility | + +**Обоснование E1.** В `export.py` живут две публичные функции: +- `build_gpx(track_row, sources, external_urls) -> str` — собирает XML. +- `safe_filename(name: str | None, track_id: int) -> tuple[str, str]` — + возвращает `(ascii_fallback, utf8_for_filename_star)`. + +Обе чистые, без I/O — легко тестируются. + +### Решение F — Sanitization имени файла + +Один проход: +1. Если `name` пустой / None — заменить на `track-`. +2. Заменить `/ \ : * ? " < > |` на `_`. +3. Заменить `\x00..\x1f` (управляющие) и `\x7f` на `_`. +4. Триммить пробелы и точки в начале/конце (Windows-нюанс). +5. Триммить до 80 символов по **байтам в UTF-8** (не code-point — чтобы + `filename*` не превысил RFC-предел в 254 байта на параметр). +6. Если результат пуст после санитизации — `track-`. +7. ASCII-fallback: транслит **не делаем** (BRD §A2), вместо этого — + keep ASCII-printable (`32–126`), остальное в `_`; если пустота — + `track-`. +8. Кодирование UTF-8 для `filename*`: `urllib.parse.quote(name, + safe='', encoding='utf-8')`. + +Возврат: `(ascii_fallback="…", utf8_quoted="…")`. Сборка хедера: + +``` +Content-Disposition: attachment; filename="{ascii}.gpx"; filename*=UTF-8''{utf8_quoted}.gpx +``` + +Расширение `.gpx` (или `.kml` в Q-2-future) **не** санитизируется, но +не входит в счётчик 80 байт. + +### Решение G — Структура GPX 1.1 + +См. TRZ REQ-F-03 — следуем буквально. Тонкости, которые архитектор +фиксирует: + +- `` паттерн — тот же, что + `app.js::downloadGPX()` для построенного маршрута. Этот паттерн в + проде работает на iOS Safari (проверено в ET-006 / PH-3). + - При downloads с `a.download` от blob-URL iOS Safari 13+ корректно + сохраняет файл с указанным именем в downloads. + - E2E-01/02 (Playwright) проверяет на desktop + mobile viewport; + iOS-specific quirk проверяется ручным smoke на физическом iPhone + (BRD §8 R-1). +- **Наследник от:** существующий `downloadGPX()` (PH-3 / ET-006 patterns). + +## R-2 — Кириллица в имени файла ломается в downloaders некоторых браузеров + +- **Описание:** Headers `Content-Disposition: filename="<кириллица>.gpx"` + без RFC 5987 ASCII-fallback ломаются в старых Edge, не-Unicode + Windows-устройствах. +- **Вероятность / Влияние:** С / Н. +- **Митигация:** + - **Архитектурное решение (ADR-014 §F)**: всегда отдаём ОБА + параметра: ASCII-fallback `filename=` + UTF-8 `filename*=UTF-8''`. + Современные браузеры читают `filename*`, древние — ASCII-fallback + (= `track-.gpx`). + - Тест IT-06 проверяет наличие обоих параметров. + - UT-04 проверяет санитизацию (запрещённые символы → `_`, длина ≤ 80 + байт UTF-8). + +## R-3 — Утечка proprietary metadata через merged GPX (ADR-015 §B trade-off) + +- **Описание:** Трек с `sources=["osm", "wikiloc"]` (после dedup-merge) + проходит license-guard по правилу ANY (есть OSM ⇒ download разрешён). + Но `tracks.name` / `tracks.description` могут быть взяты из Wikiloc + (если у Wikiloc был выше source_priority). В скачанный GPX попадает + proprietary текст. +- **Вероятность / Влияние:** С / С. +- **Митигация:** + - **Архитектурное решение (ADR-014 §G)**: `` ставим + только для OSM (`license = openstreetmap.org/copyright`); для не- + OSM `` опускаем. Это защищает от ложной атрибуции. + - **Архитектурное ограничение (ADR-015 §B)**: per-field source + tracking не вводим (требует ALTER TABLE — out of ET-011 scope). + - **Compensation**: `source_priority` в ET-009 фиксирует osm=100 > + enduro_russia=80 > wikiloc=70. При merge OSM-метаданные перекрывают + остальные. На практике для треков с `"osm" ∈ sources` `name` и + `description` уже от OSM. + - **Эскалация**: если в Build review-стадии review-агент найдёт + конкретный случай утечки (например, фикстура с `wikiloc.description + = "<длинный proprietary текст>"`) — возврат в Analysis для + расширения схемы. + +## R-4 — Запрос на трек 200000+ точек срывает worker по timeout + +- **Описание:** Сборка `xml.etree.ElementTree` для 200000 trkpt в строку + занимает 400–500 мс CPU. Несколько параллельных таких запросов могут + превысить uvicorn `--timeout-keep-alive` или nginx + `proxy_read_timeout`. +- **Вероятность / Влияние:** Н / С. +- **Митигация:** + - **Архитектурное решение (REQ-NF-02, ADR-014 §H)**: cap 200000 → + 413 ДО сборки XML. + - Проверка делается через `tracks.points_count` (read-only field в + схеме ET-008, indexed PK lookup — < 1 ms). + - Тест IT-04 проверяет 413 для фиктивной записи `points_count=300000`. + - В случае массового тяжёлого трафика — отдельный rate-limit + middleware (out of scope, см. `07-infra-requirements.md` §3.2). + +## R-5 — Массовые скачивания одного трека забивают RAM сервера + +- **Описание:** Cap 200k → ~20 МБ XML per request. 10 параллельных + скачиваний = 200 МБ heap. test-сервер имеет ~1 ГБ свободно у + контейнера app. +- **Вероятность / Влияние:** Н / С. +- **Митигация:** + - 200 МБ < free RAM × запас 5×. Не блокирующий. + - Если в проде проявится — переключение на `StreamingResponse` + (ADR-014 §C опция C2). Это не меняет API-контракт и тесты, можно + делать без нового ADR. + - Garbage collection после `Response(...)` корректно освобождает heap + (Python ссылается только на raw bytes для отправки в TCP). + +## R-6 — Кнопка «Скачать» появляется для треков с `download_allowed: false` → 403 после клика + +- **Описание:** Frontend (ADR-014 §3.b) показывает кнопку **всегда**. + При клике на трек EnduroRussia/Wikiloc/ttrails backend возвращает + 403. Пользователь думает «функция сломана». +- **Вероятность / Влияние:** В / Н. +- **Митигация:** + - **Сознательный компромисс** (ADR-014 §«Отрицательные»): прятать + кнопку требует знать `download_allowed` на клиенте — расширение + MVT/GeoJSON-контракта на новое поле. Не делаем в ET-011. + - **Toast с CTA**: при 403 → `showToast('Источник запрещает + скачивание. Откройте трек на сайте источника.')` + кликабельная + ссылка на `external_urls[0]` (см. ADR-015 §5). + - **Release-notes** (если ведутся): «Качаем пока только OSM-треки». + - При негативном UX-фидбэке в проде — расширение GeoJSON-properties + флагом `downloadable: bool` в отдельной итерации. + +## R-7 — Сборка GPX-XML без экранирования спецсимволов в `tracks.name` + +- **Описание:** Имя трека может содержать `&`, `<`, `>`, `"` — + обязательные для XML escape-symbols. Если builder использует f-string + templates без escape — broken XML, провал AC-5 (XSD validation). +- **Вероятность / Влияние:** В (если бы выбрали f-string) / В. +- **Митигация:** + - **Архитектурное решение (ADR-014 §B)**: `xml.etree.ElementTree` + автоматически экранирует текст и атрибуты при сериализации. + - Тест UT-01 (см. test-plan) использует `name = "Trail & "` + или подобные кейсы. + - Тест UT-03 / IT-07 валидирует против XSD. + +## R-8 — Валидация по XSD требует `lxml` в test-deps + +- **Описание:** `xml.etree.ElementTree` (stdlib) **не** умеет валидацию + по XSD. Для UT-03 / IT-07 нужен `lxml.etree.XMLSchema`. +- **Вероятность / Влияние:** Случилось / Н. +- **Митигация:** + - **Архитектурное решение (ADR-014 §B, §5)**: добавить `lxml` в + `requirements-dev.txt` (только для тестов). + - Если `lxml` уже присутствует через `defusedxml` транзитивно — + нет действия. + - Альтернатива: `xmllint --schema` через subprocess — добавляет + C-зависимость в CI image, более хрупкая. `lxml` через pip проще. + +## R-9 — Юридическая ошибка в whitelist `download_allowed` + +- **Описание:** Архитектор закрыл BRD Q-1 как «только OSM» (default). + Если Owner после merge'a определит, что EnduroRussia/Wikiloc разрешено + отдавать — нужен update ADR-015 + правка `gps_sources.yaml`. В + обратную сторону: если кто-то ошибочно выставит `download_allowed: + true` для proprietary источника — нарушение ToS. +- **Вероятность / Влияние:** С / В. +- **Митигация:** + - **Default-deny** в Pydantic-модели (ADR-015 §«Решение C»): отсутствие + поля = `false`. + - **Документация в ADR-015 §«Решение D»** — явный whitelist с + юридическим обоснованием для каждого источника. + - **Code review check** при изменении `gps_sources.yaml`: любая + смена `download_allowed: false → true` требует ссылки на обновлённый + licensing-ADR. + - **Integration test IT-05** фиксирует поведение для запрещённого + источника (страж-тест). +- **Наследник от:** ET-008 R-9 (regression of accepted ADR to proposed). + +## R-10 — Регрессия существующих эндпоинтов `/api/gps-tracks/*` + +- **Описание:** Расширение `endpoint.py::create_gps_router` новым + route и аргументом `sources_config_path` может случайно сломать + существующий контракт (`""`, `/tiles`, `/health`, `/cache/clear`). +- **Вероятность / Влияние:** Н / С. +- **Митигация:** + - **Архитектурное решение**: новый аргумент `sources_config_path` + опциональный, default — `None` (= `{"osm"}` whitelist). Старые + тесты, вызывающие `create_gps_router(db_path)`, продолжают работать. + - **Тест IT-08** — smoke-проверка, что GET `""`, `/tiles/...`, + `/health` отвечают так же, как до ET-011. + - **AC-15** — регрессионный пункт acceptance для UI: sheet-gpx, + sheet-route, фильтры публичных треков работают как раньше. + +## R-11 — Frontend парсинг `Content-Disposition` некорректен на каком-то браузере + +- **Описание:** Если `_parseFilenameFromCD()` (см. ADR-014 §3.b) не + справляется с экзотическими header-форматами (например, кавычки в + `filename="track \"name\".gpx"`), файл сохраняется с дефолтным именем. +- **Вероятность / Влияние:** Н / Н. +- **Митигация:** + - Backend контролирует header — мы сами знаем, что отдаём + `filename=".gpx"` без escaped quotes (санитизация + в `safe_filename` заменяет `"` на `_`). + - Fallback `track-.gpx` если парсинг не удался — файл всё равно + сохраняется. + +## R-12 — XSD-фикстура `gpx.xsd` устаревает + +- **Описание:** `gpx.xsd` от topografix может обновиться (хотя + спецификация GPX 1.1 заморожена с 2004 года). Снимок 2026-06 будет + валиден неопределённое время. +- **Вероятность / Влияние:** Н / Н. +- **Митигация:** + - GPX 1.1 — frozen spec; topografix не выпускают новые версии 1.1. + - Снимок коммитится один раз; если что-то изменится — refresh. + +## R-13 — Race-condition: трек удалён из БД между HEAD и GET + +- **Описание:** Если в момент tap'а на popup трек удалили из БД + (например, через ad-hoc `DELETE`), эндпоинт вернёт 404. Popup уже + показал кнопку, пользователь увидит «Трек не найден» в toast. +- **Вероятность / Влияние:** Н / Н. +- **Митигация:** + - Принято as-is. Toast «Трек не найден» — корректный UX. + - В проекте нет ручного `DELETE FROM tracks` в нормальном потоке; + GC pipeline (ET-008) удаляет orphan-записи раз в месяц. + +## R-14 — Кнопка «Скачать» некорректно тапается на ультра-маленьких viewport + +- **Описание:** REQ-NF-04 требует ≥ 32×32 CSS px тапабельной зоны. + При CSS-typo или ошибке в стилях кнопка может вписаться в padding'и + popup'а, сжимаясь. +- **Вероятность / Влияние:** Н / С. +- **Митигация:** + - **Архитектурное решение (ADR-014 §3.c)**: `width: 32px; height: + 32px` в `.track-popup-download-btn`. + - **E2E-02 (mobile)** проверяет bounding box ≥ 32×32 px. + - **TC-UI-02 (Playwright UI test cases)** — визуальная проверка на + iPhone SE (375×667). + +## R-15 — Tooltip не объявляется screen-reader'у + +- **Описание:** REQ-F-01 / AC-14: tooltip «Скачать GPX». Если builder + забудет `aria-label` — screen-reader пользователь не услышит + название действия. +- **Вероятность / Влияние:** Н / С. +- **Митигация:** + - **Архитектурное решение (ADR-014 §3.a)**: явно прописываем + `aria-label="Скачать GPX"` И `title="Скачать GPX"` на `