architect(ET): auto-commit from architect run_id=64
Some checks failed
CI / lint (push) Failing after 4s
CI / test (push) Successful in 6s
CI / build (push) Has been skipped

This commit is contained in:
2026-06-03 20:44:55 +00:00
parent 2bf08a10e3
commit 6fe2ecf12b
7 changed files with 1883 additions and 0 deletions

View File

@@ -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` — состояние БД,
число треков по источникам, последний прогон.

View File

@@ -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) |

View File

@@ -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:5557`, уже используется в
GeoJSON-эндпоинте).
- `src/api/gps_tracks/db.py::open_db/init_db` — открытие БД, спрайт уже
используется во всех роутах.
- `src/web/app.js::downloadGPX()` (l. 12361249) — рабочий
desktop+iOS-mobile паттерн `Blob + URL.createObjectURL + a.download`.
Используется для скачивания **построенного** маршрута; для
публичного трека механика та же, но содержимое приходит с сервера.
- `showToast(...)` (используется по всему `gps_tracks.js`) — UX для
ошибок.
## Альтернативы и решения
### Решение A — Транспорт от backend до файла на диске пользователя
| Опция | Плюсы | Минусы |
|---|---|---|
| A1: `<a href="/api/.../download" download="...">` — браузер сам качает | Один клик, нулевая JS-логика | Невозможно перехватить статус 4xx/5xx и показать toast (REQ-F-05 — обязателен); ошибочный JSON отрисуется в новой вкладке |
| A2 (**выбрано**): `fetch()``response.blob()``URL.createObjectURL``<a download="...">``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''<percent-encoded>`). При наличии расширенного
параметра — декодируем и используем; иначе fallback к ASCII `filename=`.
Если оба отсутствуют (defensive) — `track-<id>.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-экранирование (`&amp;`, `&lt;` в названии трека) — типичный источник 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 в проекте, реально 24 во время нагрузки)
дадут пик ≤ 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-<id>`.
2. Заменить `/ \ : * ? " < > |` на `_`.
3. Заменить `\x00..\x1f` (управляющие) и `\x7f` на `_`.
4. Триммить пробелы и точки в начале/конце (Windows-нюанс).
5. Триммить до 80 символов по **байтам в UTF-8** (не code-point — чтобы
`filename*` не превысил RFC-предел в 254 байта на параметр).
6. Если результат пуст после санитизации — `track-<id>`.
7. ASCII-fallback: транслит **не делаем** (BRD §A2), вместо этого —
keep ASCII-printable (`32126`), остальное в `_`; если пустота —
`track-<id>`.
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 — следуем буквально. Тонкости, которые архитектор
фиксирует:
- `<metadata><time>` — формат `YYYY-MM-DDTHH:MM:SSZ` (UTC, ISO-8601 c
`Z`). Если `tracks.created_at` в БД хранится с offset — нормализуем в
UTC. Если NULL — элемент пропускается.
- `<trk><name>` — `tracks.name` или `"Без названия"` (REQ-F-03 уже
предписывает).
- `<trk><type>` — `tracks.activity_type` буквально (`"enduro"`,
`"moto"`, `"bicycle"`, `"hike"`, `"offroad"`, `"other"`). GPX-схема
это допускает (свободный текст).
- Координаты в `<trkpt>` — формат `lat="%.6f" lon="%.6f"` (точность
≈ 0.11 м, достаточно для эндуро-навигации; экономит ~30% размера vs
default Python float repr).
- `<copyright>` — только для OSM (license URL фиксирован
`https://www.openstreetmap.org/copyright`). Для остальных источников
— `<copyright author="Enduro Trails"><license>{external_urls[0]}</license></copyright>`
если есть первый URL, иначе блок опускаем. Это сохраняет атрибуцию
даже когда `download_allowed: true` для не-OSM источника (см.
ADR-015).
- Корневой `<gpx>` без `<wpt>`, без `<rte>` — только `<metadata>` и
один `<trk>`.
### Решение H — Запрос к БД
Один SQL-запрос на эндпоинт:
```sql
SELECT id, name, description, activity_type, user, created_at,
length_m, points_count, geom, sources_json, external_urls_json
FROM tracks WHERE id = ?
```
Проверки в порядке:
1. `row is None` → 404 `{"detail": "track_not_found"}`.
2. `format not in {"gpx"}` → 400 `{"detail": "unsupported_format"}`.
3. `row.points_count > 200000` → 413 `{"detail": "track_too_large"}`.
4. License-check (ADR-015): первый разрешённый source ⇒ pass; иначе
403 `{"detail": "source_forbidden", "external_urls": [...]}`.
5. `coords = _wkb_to_coords(geom)` — переиспользуем из `mvt.py`.
6. `build_gpx(...)` → 200.
Шаг 3 раньше шага 5 — отказываем без чтения geom (защита от patho).
Шаг 4 раньше шага 5 — отказываем без сборки XML (экономия CPU).
### Решение I — Где регистрируется route
Внутри `create_gps_router(db_path)` в `endpoint.py`, рядом с
существующими `@router.get(...)`. Декоратор: `@router.get("/{track_id}/download")`.
`track_id: int = Path(..., ge=1)` — встроенная FastAPI-валидация
защищает от path-traversal и SQL-инъекций (REQ-NF-07).
### Решение J — Логирование (REQ-F-07)
Используем стандартный `logging.getLogger("uvicorn.access")` — отдельный
формат не вводим. Перед `return Response(...)` добавляем:
```python
logger.info(
"track_download id=%d sources=%s size_bytes=%d",
track_id, sources_csv, len(xml_bytes),
)
```
IP клиента не пишем (это уже в uvicorn access-log). Это минимальный
журнал для REQ-NF-06 без отдельной таблицы / без файла.
## Решение
### 1. Новый модуль `src/api/gps_tracks/export.py`
Публичный API:
```python
def build_gpx(
*,
track_id: int,
name: str | None,
description: str | None,
activity_type: str | None,
user: str | None,
created_at: str | None,
sources: list[str],
external_urls: list[str],
coords: list[tuple[float, float]], # (lon, lat)
) -> str:
"""Собирает GPX 1.1 как XML-строку (с XML-declaration)."""
def safe_filename(name: str | None, track_id: int) -> tuple[str, str]:
"""Возвращает (ascii_fallback, utf8_percent_quoted) без расширения."""
```
Реализация — на `xml.etree.ElementTree` (stdlib).
### 2. Новый route в `endpoint.py::create_gps_router`
```python
ALLOWED_FORMATS = {"gpx"} # KML отложено (BRD Q-2)
MAX_POINTS_FOR_DOWNLOAD = 200_000 # REQ-NF-02
@router.get("/{track_id}/download")
async def download_track(
track_id: int = Path(..., ge=1),
format: str = Query("gpx"),
):
if format not in ALLOWED_FORMATS:
raise HTTPException(400, "unsupported_format")
# ... SELECT, проверки 404/413/403, build_gpx, Response
```
`Path` и `Query` импортируются дополнительно из `fastapi`.
### 3. Изменения в `src/web/gps_tracks.js`
a. `_renderTrackPopupHtml(props)` — добавить в конец template, **перед**
`sourcesHtml`, блок `actionsHtml`:
```html
<div class="track-popup-actions">
<button type="button"
class="track-popup-download-btn"
aria-label="Скачать GPX"
title="Скачать GPX"
data-track-id="${props.id}">
<svg …><!-- тот же icon-set, что и в sheet-route::downloadGPX --></svg>
</button>
</div>
```
SVG-иконка — точная копия из `index.html:135137` (download-arrow).
b. Обработчик клика делегируется на popup-контейнер (event-delegation):
```js
new maplibregl.Popup({…})
.setLngLat(e.lngLat)
.setHTML(_renderTrackPopupHtml(feature.properties))
.addTo(map);
// после .addTo: получить .getElement(), повесить click-listener.
```
Внутри listener'а:
```js
async function _downloadPublicTrack(trackId, btnEl) {
btnEl.classList.add('is-loading');
try {
const resp = await fetch(`/api/gps-tracks/${trackId}/download`);
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
_handleDownloadError(resp.status, body);
return;
}
const blob = await resp.blob();
const filename = _parseFilenameFromCD(resp.headers.get('Content-Disposition'))
|| `track-${trackId}.gpx`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
} catch (err) {
if (typeof showToast === 'function') showToast('Не удалось скачать. Попробуйте ещё раз.');
} finally {
btnEl.classList.remove('is-loading');
}
}
```
`_parseFilenameFromCD(cd)`:
- читаем `filename*=UTF-8''<percent-encoded>` → `decodeURIComponent`;
- если нет — `filename="…"`;
- если нет — `null`.
`_handleDownloadError(status, body)`:
- 403 → toast «Источник запрещает скачивание. Откройте трек на сайте источника.» + если `body.external_urls?.length` — `window.open(...)` по нажатию на toast (опционально, как ссылка в самом toast'е).
- 404 → toast «Трек не найден.»
- 413 → toast «Трек слишком большой для скачивания.»
- иначе → «Не удалось скачать. Попробуйте ещё раз.»
c. CSS (в `app.css`) — стиль кнопки.
```css
.track-popup-actions { margin-top: 8px; display: flex; gap: 8px; }
.track-popup-download-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 32px; height: 32px; /* REQ-NF-04: ≥ 32×32 CSS px */
border: none; border-radius: 6px; cursor: pointer;
background: var(--accent, #3b82f6); color: #fff;
}
.track-popup-download-btn svg { width: 18px; height: 18px; }
.track-popup-download-btn.is-loading { opacity: 0.6; pointer-events: none; }
/* тёмная тема — переменные --accent уже определены в стилях ET-005/PH-5 */
```
Точные цвета определит builder с оглядкой на текущую палитру —
ADR не фиксирует hex.
### 4. Конвенция размещения нового кода
| Файл | Действие | Размер |
|---|---|---|
| `src/api/gps_tracks/export.py` | **новый** | ≈ 130 строк |
| `src/api/gps_tracks/endpoint.py` | +1 route ≈ 50 строк | без рефакторинга существующего |
| `src/web/gps_tracks.js` | +1 функция `_downloadPublicTrack`, +1 helper `_parseFilenameFromCD`, +1 helper `_handleDownloadError`, правка `_renderTrackPopupHtml` (+10 строк HTML), правка `_setupGpsClickHandler` (event-delegation, +10 строк) | ≈ 80 строк |
| `src/web/app.css` | +CSS-блок `.track-popup-actions`, `.track-popup-download-btn`, `.is-loading` | ≈ 15 строк |
| `tests/api/test_gps_tracks_gpx_builder.py` | **новый** — UT-01..05 | ≈ 200 строк |
| `tests/api/test_gps_tracks_filename.py` | **новый** — UT-04 cases | ≈ 80 строк |
| `tests/api/test_gps_tracks_download.py` | **новый** — IT-01..08 | ≈ 250 строк |
| `tests/fixtures/gpx-1.1/gpx.xsd` | **новый** — XSD-схема topografix (~30 КБ) | one-shot file |
| `tests/web/test_track_download.spec.ts` | **новый** — E2E-01..04 | ≈ 200 строк |
### 5. Зависимости
- Runtime: **без изменений**. `xml.etree.ElementTree`, `urllib.parse`
— stdlib Python 3.12.
- Test-only: добавить `lxml` в `requirements-dev.txt` для XSD-валидации
(если ещё не присутствует через транзитивные).
### 6. Контракт API
Новый эндпоинт:
```
GET /api/gps-tracks/{track_id}/download[?format=gpx]
```
| Статус | Body | Headers |
|---|---|---|
| 200 | XML (GPX 1.1) | `Content-Type: application/gpx+xml; charset=utf-8`<br>`Content-Disposition: attachment; filename="…"; filename*=UTF-8''…`<br>`Access-Control-Allow-Origin: *` (наследуется из CORS middleware) |
| 400 | `{"detail": "unsupported_format"}` | стандартные |
| 403 | `{"detail": "source_forbidden", "external_urls": [...]}` | стандартные |
| 404 | `{"detail": "track_not_found"}` | стандартные |
| 413 | `{"detail": "track_too_large"}` | стандартные |
| 500 | `{"detail": "internal_error"}` | стандартные |
`Cache-Control: private, max-age=3600` — позволяет браузеру держать
файл в кэше час (treki иммутабельны до следующего pipeline-прогона).
ETag не выставляем (overkill).
### 7. Связь с ADR-015
ADR-015 фиксирует **политику разрешений** на скачивание по источнику
(per-source флаг `download_allowed`). ADR-014 использует эту политику
как точку проверки 403. Разделение: «как качаем» (ADR-014) vs «что
качать вообще можно» (ADR-015).
## Последствия
### Положительные
- **Нулевые новые runtime-зависимости** — stdlib хватает на сборку GPX
и парсинг Content-Disposition.
- **Переиспользование** проверенного клиентского паттерна
(`Blob+URL.createObjectURL+a.download`) — iOS Safari проблема R-1 в
BRD уже de facto митигирована тем же кодом в `app.js::downloadGPX()`.
- **Унификация error-UX** через `showToast` — пользователь видит
человекочитаемые сообщения для 403/404/413/5xx.
- **Чистая модульность** — `export.py` тестируется unit-ами без БД и
без HTTP-моков; всё, что осталось — integration-тест endpoint'а.
- **Защита от patho-кейсов** — два уровня (cap REQ-NF-02 на 200k +
валидация `format`-whitelist).
- **Соответствие схеме GPX 1.1** — гарантировано тестом UT-03 и IT-07
через `lxml.etree.XMLSchema`.
### Отрицательные / ограничения
- **`lxml` в dev-deps** — небольшая (~3 МБ) транзитивная зависимость,
только для XSD-валидации в тестах. Если избегать любых новых
dev-deps — можно валидировать через subprocess `xmllint --schema`,
но это вводит C-зависимость в CI-image. `lxml` через pip проще.
- **In-memory сборка** — для патологического 200k трека (≈ 20 МБ XML)
один запрос — 20 МБ heap. На текущем железе test-сервера (1 ГБ RAM
свободно у контейнера) — норма; 4 параллельных запроса = 80 МБ, не
блокирует. Если когда-нибудь cap REQ-NF-02 поднимется выше 200k —
переключаемся на C2 (StreamingResponse).
- **Не поддерживаем `<ele>` и `<time>` в точках** — это пожелание BRD
A2; высоты не лежат в БД (одно из ограничений ET-008). При запросе
пользователя «верните высоту» — нужен отдельный work item на
обогащение точек через terrain DEM (out of scope ET-011).
- **Кнопка «Скачать» появляется во всех popup**, в том числе для
треков, для которых backend отдаст 403 (Wikiloc/EnduroRussia/ttrails
при дефолтной политике ADR-015). Альтернатива «прятать кнопку для
запрещённых источников» требует знать `download_allowed` на клиенте —
значит расширять `/health` или MVT-properties. Решение: оставляем
кнопку всегда видимой, ошибку 403 показываем через toast с CTA «открыть
на сайте источника». Это **сознательный** компромисс UX vs объём
изменений: предотвращает запрос на расширение MVT-контракта; не
фрустрирует пользователя из-за «непредсказуемо скрытой» кнопки.
### Нейтральные
- Регистрация route в `create_gps_router` не пересекается с
существующими (`""`, `/tiles/{z}/{x}/{y}.mvt`, `/health`,
`/cache/clear`). Конфликта префиксов нет.
- CORS — без изменений (middleware приложения уже отдаёт
`Access-Control-Allow-Origin: *` для всего /api/).
- gzip — если включён `GZipMiddleware` (проверить в `src/api/main.py`
или `app.py`), GPX-ответы сжимаются автоматически. Если не включён —
не вводим (out of scope; build-output 800 КБ для типичного трека —
ок без gzip).
## Классификация изменения
**Minor change.** Никаких новых сервисов, БД, портов, схем; добавляется
один эндпоинт в существующий router + один frontend-обработчик.
Лейбл `arch:major-change` **не выставляется**.
## Невыполнимость / эскалация
- **Q-2** (KML): отложено (BRD дефолт). Если Owner запросит KML — это
новый ADR-update, расширение `ALLOWED_FORMATS` и нового
`build_kml(...)`. Архитектурный риск ноль (контракт `format`-query
уже whitelist).
- **R-1** (iOS Safari download): де факто митигирован переиспользованием
паттерна `downloadGPX()`. Если в проде обнаружится регресс
возвращаемся в Build через `back-to:build`, добавляем fallback
`window.location.href = url` (старый паттерн), но без revoke. Это не
меняет ADR.
- **Q-1** (license whitelist): закрывается ADR-015. Если Owner закроет
Q-1 как «всё разрешено» — ADR-015 переводится в `superseded`, REQ-F-06
no-op, AC-11/IT-05/E2E-04 — out.
## Связанные документы
- `docs/work-items/ET-011/01-brd.md` §110
- `docs/work-items/ET-011/02-trz.md` REQ-F-01..F-07, REQ-NF-01..NF-07
- `docs/work-items/ET-011/03-acceptance-criteria.md` AC-1..AC-15
- `docs/work-items/ET-011/04-test-plan.yaml` UT-01..05, IT-01..08, E2E-01..04
- `docs/work-items/ET-011/06-adr/ADR-015-source-redistribution-policy.md` (этот пакет)
- `docs/work-items/ET-011/07-infra-requirements.md` (этот work item)
- `docs/work-items/ET-011/08-data-requirements.md` (этот work item)
- `docs/work-items/ET-011/10-tech-risks.md` (этот work item)
- `docs/work-items/ET-008/06-adr/ADR-005-storage-schema.md` — схема
`tracks` (read-only)
- `docs/work-items/ET-008/06-adr/ADR-008-tile-vs-geojson-strategy.md` —
существующий контракт API
- `docs/architecture/README.md` (обновлён в ET-011)
- `docs/architecture/adr/README.md` (обновлён в ET-011)

View File

@@ -0,0 +1,357 @@
---
type: adr
work_item_id: ET-011
adr_id: ADR-015
title: "ADR-015: Политика реэкспорта публичных треков — per-source флаг `download_allowed` в `gps_sources.yaml`, default-deny"
status: accepted
created_at: 2026-06-03
updated_at: 2026-06-03
authors:
- "agent:architect"
supersedes: []
superseded_by: []
labels:
- "ET-011:licensing"
- "minor-change"
---
# ADR-015 — Политика реэкспорта публичных треков на скачивание
## Статус
**Accepted.** Архитектурное решение для ET-011. Закрывает BRD §9 Q-1
по дефолту «только OSM».
## Контекст
ET-008 разрешает **собирать** публичные треки в БД по licensing-ADR
каждого источника (ADR-009..012). Эти ADR описывают **что разрешено
сохранять** в БД и при каких условиях (обезличенно / без `description`
/ rate-limit / атрибуция). Решение «отдавать ли собранный трек на
скачивание» — **отдельное** юридическое решение:
- **OSM ODbL** — явно разрешает реэкспорт при условии атрибуции и
same-license (ODbL); GPX-файл с `<copyright>...openstreetmap.org/copyright</copyright>`
удовлетворяет условиям (ADR-009 §4).
- **EnduroRussia.ru** — публичный API, нет явных условий на реэкспорт;
условие ADR-010 — обезличенно. Реэкспорт чужого контента третьим
лицам без явного разрешения публикатора — серая зона; default-deny
безопаснее.
- **Wikiloc** — proprietary, ToS запрещает массовый ре-экспорт; ADR-012
разрешает только **некоммерческое тестовое** хранение в нашей БД.
Отдача файла downstream — нарушение ToS.
- **ttrails.ru** — `proposed` (заблокирован) в ADR-011; не собирается
и не отдаётся.
BRD §9 Q-1 — открытый вопрос; **дефолт BRD = «только OSM»**, что
формально и есть default-deny с whitelist'ом `["osm"]`.
ET-008 и ET-009 фиксируют licensing-policy **на collection-stage**.
Этот ADR-015 фиксирует **отдельную** licensing-policy на
**redistribution-stage**. Они независимы: трек может быть в БД (collect
разрешено), но не отдаваться по download (redistribute запрещено).
## Альтернативы и решения
### Решение A — Где живёт флаг
| Опция | Плюсы | Минусы |
|---|---|---|
| A1: hardcode `ALLOWED_SOURCES = ["osm"]` в `endpoint.py` | Минимум изменений; защищено от случайной правки конфига | Любое расширение списка требует деплоя; ops не может выключить «на горячо» |
| A2 (**выбрано**): per-source поле `download_allowed: bool` в `config/gps_sources.yaml` | Конфигурируемо без релиза; согласовано с уже существующим паттерном (поля `enabled`, `license_adr`, `attribution`); видно рядом с источником | Чуть больше кода для чтения конфига в API-роутере (раньше API не читал `gps_sources.yaml`) |
| A3: новое поле в license-ADR front-matter (`redistribution: allowed/forbidden`) | Лежит рядом с юридическим основанием решения | API-роутер тогда читает ADR-файлы на каждый запрос (медленно) или нужен кэш; затрудняет тестовую подмену; нарушает разделение «runtime config vs документация» |
**Обоснование A2.** Этот же файл уже читается pipeline-сервисом
`gps-collector` (`config.py::load_gps_sources`). Расширяем его одним
полем `download_allowed: bool` (default `false` если поле отсутствует
— default-deny). API-роутер при старте читает `gps_sources.yaml` один
раз и держит `ALLOWED_SOURCES: set[str]` в памяти; rebuild при
рестарте контейнера (тот же подход, что и для MVT-кэша).
Парсер конфига в `src/api/gps_tracks/config.py` уже есть (ET-008). Его
схема расширяется одним optional-полем.
### Решение B — Семантика разрешения для трека с несколькими источниками
Один трек может иметь `sources_json = ["osm", "wikiloc"]` после dedup-
merge (ADR-006 ET-008). Возможные правила:
| Правило | Плюсы | Минусы |
|---|---|---|
| B1 (**выбрано**): **ANY** — хотя бы один разрешённый source ⇒ download разрешён | Меньше ложных 403 для треков, существующих в нескольких источниках; OSM — авторитетный «первичный» исходник; геометрия одна и та же | Метаданные (`<name>`, `<desc>`) могут быть взяты с merge'нутого priority-источника (например, EnduroRussia) — могут содержать proprietary текст |
| B2: **ALL** — все sources в whitelist | Гарантирует, что ни байт metadata из запрещённого источника не утекает | Резко сужает выборку: если OSM-трек дедупится с Wikiloc-треком, download выключается, хотя OSM-факт сам по себе ODbL |
**Обоснование B1.** Геометрия (точки) — общее достояние двух
publisher'ов; если хотя бы один разрешил реэкспорт — отдаём. Чтобы
избежать «утечки» metadata из proprietary источника, **в момент сборки
GPX** ADR-014 §G предписывает:
- `<copyright>` фиксируется на OSM-license при `"osm" ∈ sources`;
- иначе `<copyright>` опускаем.
- `<link>` оставляем для **всех** `external_urls` — это **атрибуция**,
даже на proprietary платформу (open в браузере по клику).
`<name>` / `<desc>` могут быть от не-OSM источника. Это компромисс:
название трека = «creative work» ниже порога копирайт-защиты в РФ
(краткие фразы), но осторожно — описание (`description`) может быть
длинным текстом. Митигация в ADR-014: для треков, где `"osm" ∉ sources`
**и** есть merge от других источников, в `<desc>` пишется только
`description` от OSM (если есть) или ничего; никогда — от Wikiloc/
EnduroRussia. Это требует дополнительной фильтрации в `build_gpx`:
поле `description` в `tracks` хранит merged-значение (priority-based),
без обратной связи с источником. Пока — упрощение: `description`
отдаём как есть, если хотя бы один source разрешён.
> **Уточнение** (closes potential review concern): если в Build-стадии
> окажется, что merged `description` действительно содержит proprietary
> текст (например, длинный отчёт с EnduroRussia), вернуть в Analysis для
> per-source-field tracking — это бóльшее изменение схемы БД и не
> в scope ET-011.
### Решение C — Дефолт нового поля при отсутствии в YAML
| Опция | Поведение |
|---|---|
| C1 (**выбрано**): отсутствует ⇒ `false` (deny) | Безопасно по умолчанию; защищает от случайного забывания при добавлении нового источника в будущем |
| C2: отсутствует ⇒ `true` | Удобство, но юридически рискованно: новый источник в `gps_sources.yaml` сразу выставляется на реэкспорт без отдельного review |
**Обоснование C1.** Pydantic-модель `GpsSourceConfig` в `config.py`
получает `download_allowed: bool = False`. Любое добавление нового
источника требует **явного** `download_allowed: true` + обновления
ADR-015 (или нового licensing-update ADR) с обоснованием.
### Решение D — Финальный whitelist для ET-011 (закрытие BRD Q-1)
Закрытие BRD §9 Q-1 по дефолту «только OSM»:
| Source | `download_allowed` | Обоснование |
|---|---|---|
| `osm` | **`true`** | ODbL разрешает реэкспорт при атрибуции; `<copyright>` ссылается на openstreetmap.org/copyright |
| `enduro_russia` | **`false`** | ADR-010 разрешает только collection (обезличенно); ToS платформы не содержит явного разрешения на ре-экспорт чужих треков |
| `wikiloc` | **`false`** | ADR-012 — proprietary, ToS запрещает массовый ре-экспорт; collection только для тестового non-commercial |
| `ttrails` | **`false`** | ADR-011 — proposed (blocked); поле для консистентности конфига |
В UI: для треков из 1+ запрещённых источников **без OSM** backend
вернёт 403 с `external_urls`. Frontend (ADR-014) покажет toast
«Источник запрещает скачивание. Откройте трек на сайте источника»
+ опциональную ссылку на первый `external_url`.
### Решение E — Если Owner закроет Q-1 как «всё разрешено»
Изменение **только** в `gps_sources.yaml`: выставить
`download_allowed: true` для трёх остальных источников + обновить
ADR-015 §«Решение D». Никаких изменений в коде, тестах или
архитектуре. Защищающая роль ADR — задокументировать **почему**
разрешено.
### Решение F — Где валидируется policy
В route-handler `download_track`, после 404-check и 413-check, перед
сборкой GPX:
```python
allowed_sources = router_state.allowed_sources # set[str]
sources = json.loads(row["sources_json"] or "[]")
if not any(s in allowed_sources for s in sources):
external_urls = json.loads(row["external_urls_json"] or "[]")
raise HTTPException(
status_code=403,
detail={"detail": "source_forbidden", "external_urls": external_urls},
)
```
`router_state.allowed_sources` инициализируется при создании router'а:
```python
def create_gps_router(db_path: str, sources_config_path: str | None = None) -> APIRouter:
if sources_config_path:
cfg = load_gps_sources(sources_config_path)
allowed = {s.id for s in cfg.sources if s.download_allowed}
else:
allowed = {"osm"} # safe-deny дефолт для unit-тестов
...
```
Подача `sources_config_path` — из `src/api/main.py` (или его аналога),
где уже монтируется `db_path`. Если конфиг недоступен на runtime
(test-fixture) — дефолт `{"osm"}` совпадает с production-выбором.
### Решение G — Контракт ответа 403
```json
{
"detail": "source_forbidden",
"external_urls": ["https://www.openstreetmap.org/way/123", ...]
}
```
Клиент может использовать `external_urls[0]` для CTA «Открыть на сайте
источника» в toast'е. Если массив пуст — просто текстовый toast.
### Решение H — Тестируемость
- **Unit (export.py)** — не зависят от политики; `build_gpx` чистая
функция.
- **Integration** — фикстуры с `sources_config_path` указывают на
тестовый YAML с разным набором whitelist'ов. Тест IT-05 (test-plan)
проверяет 403 для `sources=["wikiloc"]`.
- **Test для CONFIG-парсера** — добавить кейсы в существующий
`tests/api/test_gps_tracks_config.py` (или создать) — проверка дефолта
`download_allowed=False` для записи без поля.
## Решение
### 1. Расширить `config/gps_sources.yaml`
```yaml
sources:
- id: osm
# ... существующие поля
download_allowed: true # NEW (ET-011)
- id: enduro_russia
# ... существующие поля
download_allowed: false # NEW (ET-011, default-deny)
- id: wikiloc
# ... существующие поля
download_allowed: false # NEW (ET-011, default-deny)
- id: ttrails
# ... существующие поля
download_allowed: false # NEW (ET-011, default-deny)
```
Поле опциональное в схеме (default `False`); для документации
явно прописано на всех четырёх источниках.
### 2. Расширить Pydantic-модель `GpsSourceConfig`
В `src/api/gps_tracks/config.py`:
```python
class GpsSourceConfig(BaseModel):
id: str
name: str
enabled: bool
license_adr: str
# ...existing fields
download_allowed: bool = False # NEW (ET-011)
```
### 3. Передать конфиг в router
В `src/api/main.py` (точка сборки FastAPI-приложения, где уже
вызывается `create_gps_router(db_path)`) — добавить второй аргумент
`sources_config_path`:
```python
from src.api.gps_tracks.config import SOURCES_CONFIG_PATH
app.include_router(create_gps_router(GPS_DB_PATH, SOURCES_CONFIG_PATH))
```
Путь `SOURCES_CONFIG_PATH` уже определён в `config.py` ET-008 для
pipeline. Для unit-тестов — параметр опциональный (default {"osm"}).
### 4. Логика 403 в `download_track`
См. ADR-014 §H шаг 4. Реализация — 5 строк.
### 5. Frontend (ADR-014 §3.b)
`_handleDownloadError(403, body)` показывает:
```js
const url = body?.external_urls?.[0];
const msg = 'Источник запрещает скачивание.';
if (url && typeof showToast === 'function') {
showToast(`${msg} Откройте трек на сайте источника: ${url}`);
// builder может расширить showToast'ом, поддерживающим clickable link;
// в минимальном варианте — текст в toast
} else if (typeof showToast === 'function') {
showToast(msg);
}
```
### 6. Документация
- README архитектуры (`docs/architecture/README.md`) — короткая нота в
§«Клиентский слой публичных треков»:
> Скачивание GPX из popup трека (ET-011) разрешено только для
> источников с `download_allowed: true` в `config/gps_sources.yaml`
> (MVP: только `osm`). См. ADR-014 / ADR-015.
- `adr/README.md` — два новых ряда ADR-014 / ADR-015 в таблице индекса.
## Последствия
### Положительные
- **Default-deny** — добавление нового источника в будущем не открывает
его на реэкспорт без явного решения.
- **Конфигурируемо без релиза** — ops может переключить флаг и
перезапустить API (`docker compose up -d --no-deps app`, ≈ 5 сек
простоя).
- **Разделение confidently distinct concerns**: collection-licensing
(ADR-009..012) vs redistribution-licensing (ADR-015) — отдельные
юридические основания фиксируются отдельными ADR.
- **Юридическая прозрачность** — ADR-015 явно перечисляет, **что
разрешено** реэкспортировать и **на основании какого** условия
licensing-ADR.
- **Тестируемость** — IT-05 / E2E-04 покрывают 403-путь.
### Отрицательные / ограничения
- **UX-фрустрация** для треков из EnduroRussia / Wikiloc: пользователь
видит кнопку, нажимает, получает toast. Митигация: чёткий текст
с CTA на сайт источника; в release-notes — короткое объяснение, что
«качаем пока только OSM-треки».
- **Treki от 1 не-OSM source с OSM-merge** проходят 403-чек (правило
ANY), но в GPX попадает name/description от merged-priority-source.
Это компромисс UX (см. Решение B); полное per-source-field tracking
— отдельный work item на расширение схемы БД.
- **Конфиг-out-of-sync risk**: если в `gps_sources.yaml` забыли
`download_allowed`, источник по умолчанию выключен на скачивание.
Это **желаемое** поведение default-deny, но требует осознанности при
добавлении новых источников.
- **API-роутер теперь читает `gps_sources.yaml` при старте** — новая
зависимость на конфиг-файл. Если конфига нет на диске —
fallback `{"osm"}` (см. Решение F). Логируется WARNING.
### Нейтральные
- БД не меняется. Скоринг dedup не меняется. Pipeline-collector не
меняется. Не затрагивает PH-9 PWA (download-кнопка работает только
online, как `app.js::downloadGPX` для маршрута).
## Классификация изменения
**Minor change.** Один новый optional-поле в существующей конфиг-схеме
+ одна функция-проверка в API-роутере. Нет новых компонентов,
зависимостей, БД-схем.
Лейбл `arch:major-change` **не выставляется**.
## Невыполнимость / эскалация
- Если Owner ответит на BRD Q-1 как «разрешить всё» **до** merge'a
ET-011 — править `gps_sources.yaml` (все `download_allowed: true`)
+ обновить ADR-015 §«Решение D»; IT-05 и E2E-04 отключить
(`enabled_if: false`). Это **post-Architecture** правка без возврата
в analysis.
- Если в Build обнаружится, что merged `description` действительно
содержит proprietary текст из non-OSM источников и Owner это
считает нарушением: `back-to:analysis` — расширение схемы БД на
per-source поля.
## Связанные документы
- `docs/work-items/ET-011/01-brd.md` §6 R-4, §9 Q-1
- `docs/work-items/ET-011/02-trz.md` REQ-F-06
- `docs/work-items/ET-011/03-acceptance-criteria.md` AC-11
- `docs/work-items/ET-011/06-adr/ADR-014-gpx-download-endpoint.md` §G, §H
- `docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md` (collection)
- `docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md` (collection)
- `docs/work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md` (collection, proposed)
- `docs/work-items/ET-008/06-adr/ADR-012-wikiloc-licensing.md` (collection)
- `docs/work-items/ET-011/10-tech-risks.md` R-3, R-9 (этот work item)
- `docs/architecture/README.md` (обновлён в ET-011)
- `docs/architecture/adr/README.md` (обновлён в ET-011)

View File

@@ -0,0 +1,326 @@
---
type: infra-requirements
work_item_id: ET-011
title: "Инфраструктурные требования — ET-011: Скачивание трека из popup"
version: 1
status: approved
created_at: 2026-06-03
authors:
- "agent:architect"
---
# Инфраструктурные требования — ET-011
## 1. Резюме
ET-011 — **API-extension only**. Добавляется один эндпоинт в
существующий router `/api/gps-tracks/*` + правки UI-модуля
`gps_tracks.js`. Инфраструктура **не меняется**:
- 0 новых docker-сервисов;
- 0 новых файлов БД;
- 0 новых cron-записей;
- 0 новых env / секретов / API-ключей;
- 0 новых исходящих HTTPS-соединений;
- 0 новых портов и nginx-правил.
Все изменения локализованы в:
- `src/api/gps_tracks/export.py` (новый, ~130 строк)
- `src/api/gps_tracks/endpoint.py` (+1 route, ~50 строк)
- `src/api/gps_tracks/config.py` (+1 optional поле в Pydantic-модели)
- `src/api/main.py` (или эквивалент — +1 аргумент при include_router)
- `src/web/gps_tracks.js` (+обработчик + правка popup)
- `src/web/app.css` (+стиль кнопки)
- `config/gps_sources.yaml` (+per-source флаг `download_allowed`)
- tests (3 новых файла + расширение существующих)
Эскалация: **minor change** (см. ADR-014 §«Классификация», ADR-015
§«Классификация»).
## 2. Контейнеры и сервисы
| Аспект | Требование |
|---|---|
| Новый сервис | **Нет** |
| Изменения `Dockerfile` | **Нет** |
| Изменения `docker-compose.yml` | **Нет** |
| Перезапуск API после деплоя | Нужен — `docker compose up -d --no-deps app` (≈ 5 сек простоя). Подхватывает новый route + обновлённые `src/web/*.js`/`*.css`/`gps_tracks.js` |
| Перезапуск `gps-collector` | Не нужен — pipeline не затронут (collector использует тот же `gps_sources.yaml`, но игнорирует новое optional-поле `download_allowed`) |
### 2.1 Зависимости между сервисами
Без изменений. Новый эндпоинт `GET /api/gps-tracks/{id}/download`
обслуживается тем же контейнером `app`, читает ту же БД
`/app/data/gps_tracks.sqlite`.
## 3. Сеть
| Аспект | Требование |
|---|---|
| Новые входящие порты | **Нет** |
| Изменения nginx | **Нет** (новый route попадает под существующий `location /enduro/api/`) |
| Новые исходящие соединения с mva154 | **Нет** |
| CORS | Без изменений; middleware уже отдаёт `Access-Control-Allow-Origin: *` для всего `/api/` |
### 3.1 Egress trafик
Скачивание GPX — **только** в downstream браузер. Один типичный трек
≈ 800 КБ (5000 точек) или ≤ 8 МБ (50000 точек). Cap REQ-NF-02:
максимум 200000 точек ⇒ ≤ 20 МБ на запрос.
Пиковая оценка: при 20 одновременных скачиваниях типичных треков —
≈ 16 МБ/сек egress; в норме 12 одновременно. Не блокирует канал
test-сервера (uplink ≥ 100 Mbps по DuckDNS).
### 3.2 Rate-limit на эндпоинт
**Не вводим** в этой итерации (BRD §5 «out of scope»). Если в проде
будет аномальный трафик — добавляем `slowapi`-middleware в отдельном
DevOps-task'е (out of ET-011).
## 4. Хранилища данных
| Аспект | Требование |
|---|---|
| Новые БД | **Нет** |
| Изменения схемы `tracks` / `pipeline_runs` | **Нет** |
| Миграции | **Нет** |
| Новые SELECT-запросы | Один: `SELECT … FROM tracks WHERE id = ?` (использует PK-индекс, O(log n)) |
| Новые INSERT/UPDATE | **Нет** (эндпоинт read-only) |
| Backup | Без изменений |
### 4.1 Производительность БД
Запрос по PK — ~ 1 ms на test-сервере. Сборка GPX через
`xml.etree.ElementTree`: 5000 точек ≈ 30 ms, 50000 точек ≈ 150 ms,
200000 точек (cap) ≈ 500 ms. Бюджет REQ-NF-01 = 300 ms p95 для
50k точек — соблюдается с запасом.
`_wkb_to_coords` (переиспользуется из `mvt.py`) — уже бенчмаркнут в
ET-008: ≈ 1 ms на 1000 точек.
## 5. Конфигурация и секреты
| Аспект | Требование |
|---|---|
| Новые env-переменные | **Нет** |
| Новые секреты / API-ключи | **Нет** |
| Новые конфиг-файлы | **Нет**; меняется только содержимое `config/gps_sources.yaml` (+optional поле) |
### 5.1 Изменения `config/gps_sources.yaml`
Добавляется одно поле `download_allowed: bool` per-source. Финальные
значения для ET-011 (см. ADR-015 §«Решение D»):
```yaml
sources:
- id: osm
# ... existing fields unchanged
download_allowed: true
- id: enduro_russia
# ... existing fields unchanged
download_allowed: false
- id: wikiloc
# ... existing fields unchanged
download_allowed: false
- id: ttrails
# ... existing fields unchanged
download_allowed: false
```
Все остальные поля (`enabled`, `license_adr`, `base_url`,
`rate_limit_sec`, `user_agent`, `attribution`, `parser_module`,
`source_priority`, …) — без изменений.
### 5.2 Перечитывание конфига
`gps_sources.yaml` читается **при старте контейнера app** (один раз) —
в момент `create_gps_router(db_path, sources_config_path)`. Для
изменения политики `download_allowed``docker compose up -d --no-deps app`
(≈ 5 сек простоя).
## 6. Зависимости
| Аспект | Требование |
|---|---|
| Новые Python-пакеты (runtime) | **Нет** (`xml.etree.ElementTree`, `urllib.parse` — stdlib Python 3.12) |
| Новые Python-пакеты (dev) | `lxml` (для XSD-валидации в UT-03 / IT-07). Возможно уже присутствует через `defusedxml`; добавить в `requirements-dev.txt` если отсутствует. ~3 МБ |
| Новые JS-зависимости | **Нет** (vanilla JS + MapLibre API уже доступен) |
| Системные библиотеки в Dockerfile | **Нет** |
| Версия Python | 3.12, без изменений |
### 6.1 XSD-фикстура
Файл `tests/fixtures/gpx-1.1/gpx.xsd` (~30 КБ) — скачивается **один
раз** разработчиком из `http://www.topografix.com/GPX/1/1/gpx.xsd`,
коммитится в репо. Не зависит от runtime, не часть production-образа
(на `.dockerignore` уровне `tests/` уже исключён, если нет — проверить).
## 7. Сборка и деплой
### 7.1 Pipeline CI
Существующий Gitea Actions:
- `make lint` (ruff + eslint) — должен пройти без замечаний по новому
коду (`export.py`, правки `endpoint.py`, `gps_tracks.js`).
- `make test` — должен включать новые тесты:
- `tests/api/test_gps_tracks_gpx_builder.py` (UT-01..05)
- `tests/api/test_gps_tracks_filename.py` (UT-04 cases)
- `tests/api/test_gps_tracks_download.py` (IT-01..08)
- `tests/web/test_track_download.spec.ts` (E2E-01..04)
- `make build` — пересобирает образ (никаких изменений в Dockerfile;
но новые тестовые фикстуры и `gpx.xsd` попадают в репо).
### 7.2 Деплой шаг-за-шагом
1. `git pull origin main` на mva154.
2. `docker compose build` (опционально; никаких изменений в
Dockerfile/requirements не было).
3. `docker compose up -d --no-deps app` — рестарт API (≈ 5 сек
простоя) для подхвата:
- нового эндпоинта `/api/gps-tracks/{id}/download`;
- обновлённого `src/web/gps_tracks.js` (popup + handler);
- обновлённого `src/web/app.css` (стили кнопки);
- расширенного `config/gps_sources.yaml`.
4. Smoke в UI:
- Открыть https://openclaw.mva154.duckdns.org/enduro/
- Включить «Публичные треки», тапнуть OSM-трек → видна кнопка
«Скачать» → клик → файл `<name>.gpx` в загрузках.
- Тапнуть EnduroRussia-трек → клик «Скачать» → toast «Источник
запрещает скачивание…» с ссылкой на сайт источника.
5. Smoke API:
```bash
curl -I https://openclaw.mva154.duckdns.org/enduro/api/gps-tracks/<osm-track-id>/download
# ожидаемо: HTTP 200, Content-Type: application/gpx+xml, Content-Disposition: attachment; filename*=UTF-8''…
curl -I https://openclaw.mva154.duckdns.org/enduro/api/gps-tracks/99999999/download
# ожидаемо: HTTP 404
```
6. Зафиксировать результат в `docs/work-items/ET-011/14-deploy-log.md`.
### 7.3 Время простоя
API: ≤ 5 секунд на шаге 3 (стандартный рестарт контейнера).
Pipeline: не задействован.
### 7.4 Rollback
| Сценарий | Действие | Время |
|---|---|---|
| Откат всего ET-011 | `git revert <merge-commit>` + `docker compose up -d --no-deps app` | ≈ 2 мин |
| «Выключить» новый эндпоинт без отката кода | Закомментировать `@router.get("/{track_id}/download")` или поставить `download_allowed: false` для всех источников в `gps_sources.yaml` + рестарт API | ≈ 1 мин |
| Откат БД | Не применимо (схема не менялась) | n/a |
## 8. Cron / scheduled jobs
**Нет** новых cron в ET-011. Существующий cron `gps-collector` (ET-008,
Mon+Thu 03:00 UTC) — без изменений; ET-011 не затрагивает collection.
## 9. Ресурсы (CPU / RAM / диск)
### 9.1 API-контейнер
| Метрика | Изменение | Комментарий |
|---|---|---|
| RAM idle | без изменений | загрузка `gps_sources.yaml` — < 10 КБ |
| RAM на один запрос /download | +5 МБ на 50k точек, +20 МБ на cap 200k | в пиковом сценарии 10 параллельных скачиваний по 200k = +200 МБ; в реальности 12 параллельно |
| CPU per запрос | 100500 мс worker'а | ниже ETC-008 MVT-сборки |
| Disk write | 0 | эндпоинт read-only |
| Disk read | размер записи в `tracks` (geom ≈ 200 КБ для 50k точек) | через PK-индекс |
Никаких изменений `cpus:` / `mem_limit:` в `docker-compose.yml`.
### 9.2 gps-collector контейнер
Не задействован.
### 9.3 Диск
| Аспект | Изменение |
|---|---|
| `data/gps_tracks.sqlite` | без изменений (read-only эндпоинт) |
| `tests/fixtures/gpx-1.1/gpx.xsd` | +30 КБ в репо (не в production-образе) |
| Production-образ docker | без изменений (`tests/` исключены) |
## 10. Наблюдаемость
| Артефакт | Состояние после ET-011 |
|---|---|
| `uvicorn` access-log | Новые строки `200 GET /api/gps-tracks/<id>/download` (через стандартный middleware) |
| Структурный лог (stdout) | Новая строка `track_download id=<id> sources=<csv> size_bytes=<n>` на каждое 200-скачивание (через `logging.getLogger("uvicorn.access").info`) |
| 4xx/5xx | Видны в access-log в обычном формате; 5xx — stderr с traceback |
| `GET /api/gps-tracks/health` | Без изменений (download — read-only, не влияет на counters) |
| Метрики (Prometheus / OpenMetrics) | Не вводим (REQ-NF-06 явно отказывается от метрик в этой итерации) |
### 10.1 Алерты
**Нет** новых алертов. При появлении в логах систематических 500 —
ручной разбор stack-trace.
### 10.2 Logrotate
Без изменений (uvicorn пишет в stdout, Docker logger справляется).
## 11. Безопасность
| Vector | Митигация |
|---|---|
| SQL-injection через `track_id` | `track_id: int = Path(..., ge=1)` — FastAPI/Pydantic валидация, далее parameterized SQL |
| Path-traversal в имени файла на диске пользователя | `safe_filename()` заменяет `/ \ : * ? " < > |` на `_`, триммит управляющие символы; см. ADR-014 §F |
| XSS через `tracks.name` в GPX | `xml.etree.ElementTree` экранирует текст и атрибуты автоматически; integration-тест IT-07 валидирует через XSD |
| XML-bomb / external entity в **сгенерированном** GPX | N/A — мы только генерируем, не парсим. `xml.etree.ElementTree` (для сборки) не подвержен XXE |
| Утечка PII через скачанный GPX | `tracks.user` есть только для OSM (ADR-009 разрешает по ODbL); для остальных — `null` в БД (ADR-010/012); попадает в `<author>` только если присутствует |
| Утечка proprietary metadata через `<desc>` / `<name>` | Для OSM-источника — публичные данные; для не-OSM — `<copyright>` опускается (ADR-014 §G); если merged через ANY-rule (ADR-015 §B) — компромисс зафиксирован в ADR-015 |
| Утечка лицензионно-защищённой геометрии | License-guard (ADR-015) — 403 для не-разрешённых источников |
| DoS через скачивание трека 50000+ точек | Cap REQ-NF-02 ⇒ 413 для > 200000 точек; rate-limit на API — out of scope |
| Чтение чужой БД через mounted volume | Без изменений (контейнер запускается с user `appuser`, volume `/app/data` read-write только для приложения) |
### 11.1 Лицензионные атаки (юридические риски)
Покрыты ADR-015 (default-deny whitelist). Любой источник без явного
`download_allowed: true` — недоступен для скачивания. См. `10-tech-risks.md`
R-9.
## 12. Влияние на C4 / архитектурную документацию
### 12.1 Обновления `docs/architecture/README.md`
В разделе «GPS Tracks Pipeline (ET-008) → Клиентский слой публичных
треков» добавить **одну** строку после описания GeoJSON-эндпоинта:
```
- скачивание одного трека через `GET /api/gps-tracks/{track_id}/download`
(GPX 1.1) — разрешено только для источников с
`download_allowed: true` в `config/gps_sources.yaml` (ET-011 / ADR-014 / ADR-015).
```
### 12.2 Обновления `docs/architecture/adr/README.md`
Добавить две строки в таблице индекса ADR:
| # | Решение | Статус | Дата | Источник |
|---|---------|--------|------|----------|
| 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 | accepted | 2026-06-03 | [ET-011](../../work-items/ET-011/06-adr/ADR-015-source-redistribution-policy.md) |
### 12.3 C4 mmd-диаграммы
В проекте отсутствуют (см. ET-008 §12, ET-009 §12). ET-011 не вводит
новых компонентов или контейнеров — обновление диаграмм не требуется.
## 13. Вывод
ET-011 — **minimal-change** на инфра-уровне:
- 0 новых сервисов / 0 новых БД / 0 миграций / 0 новых cron / 0 новых env / 0 новых портов / 0 новых runtime-зависимостей;
- Все изменения локализованы в src-коде, тестах, одной опциональной
ячейке `gps_sources.yaml`;
- Деплой = git pull + рестарт API;
- Rollback = `git revert` или конфиг-флаг.
Эскалация: **не требуется** (`arch:major-change` не выставлен; см.
ADR-014, ADR-015).

View File

@@ -0,0 +1,341 @@
---
type: data-requirements
work_item_id: ET-011
title: "Требования к данным — ET-011: Скачивание трека из popup"
version: 1
status: approved
created_at: 2026-06-03
authors:
- "agent:architect"
---
# Требования к данным — ET-011
## 1. Резюме
ET-011 — **read-only data event**. Никаких изменений схемы БД,
никаких новых таблиц, индексов, миграций, localStorage-ключей. Эндпоинт
`GET /api/gps-tracks/{id}/download` собирает GPX-файл из существующих
полей таблицы `tracks` (ET-008 / ADR-005), переиспользует существующий
WKB-парсер (`mvt.py::_wkb_to_coords`), не пишет ни в одну таблицу.
**Меняется:**
- Содержимое `config/gps_sources.yaml` (одно optional-поле
`download_allowed: bool` per-source; см. ADR-015).
- Контракт API расширяется одним новым endpoint'ом (`/download`).
**Не меняется:**
- Schema таблиц `tracks`, `pipeline_runs`;
- Контракты существующих API `/api/gps-tracks`, `/tiles/...`, `/health`,
`/cache/clear`;
- localStorage ключи и значения клиента;
- Dedup-алгоритм (`compute_dedup_key`);
- ACTIVITY_TYPES enum;
- Маппинги `SOURCE_ATTRIBUTIONS`, `SOURCE_LABELS`.
## 2. Архитектурные границы данных
| Слой данных | Тип | Расположение | Изменения в ET-011 |
|---|---|---|---|
| OSM-vector (`trails`) | существующий | `/app/data/centralfederal.sqlite` | **нет** |
| Личные GPX треки (ET-006) | существующий | браузер (memory) | **нет** |
| Публичные GPS треки (ET-008) | существующий | `/app/data/gps_tracks.sqlite` | **read-only**: новый запрос на скачивание; никаких INSERT/UPDATE/DELETE |
| OSRM-граф | существующий | `/app/data/enduro.osrm.*` | **нет** |
| User UI state | существующий | `localStorage` | **нет** новых ключей |
| Скачанный GPX-файл | **новое (выход)** | downloads-папка браузера пользователя | формат GPX 1.1, см. §4 |
## 3. Серверные данные — `gps_tracks.sqlite`
### 3.1 Schema
**Без изменений vs ET-008.** См. `docs/work-items/ET-008/08-data-requirements.md`
§3.1, §3.5. Никаких ALTER TABLE / DROP COLUMN / CREATE INDEX.
### 3.2 Используемые поля в SELECT для /download
| Поле | Использование |
|---|---|
| `id` | Path-параметр запроса; PK lookup |
| `name` | `<metadata><name>` и `<trk><name>` в GPX; имя файла |
| `description` | `<metadata><desc>` (если не null) |
| `activity_type` | `<trk><type>` |
| `user` | `<metadata><author><name>` (если не null; для OSM по ADR-009) |
| `created_at` | `<metadata><time>` (если не null; ISO-8601 UTC) |
| `length_m` | информативно, в GPX не входит |
| `points_count` | проверка cap REQ-NF-02 (> 200000 → 413) |
| `geom` (WKB) | парсится через `_wkb_to_coords()` в `[(lon, lat), ...]`; каждая пара → один `<trkpt>` |
| `sources_json` | license-guard ADR-015; `<link>` элементы в `<metadata>` |
| `external_urls_json` | `<link href=…>` элементы; ответ 403 для CTA |
| `dedup_key`, `tags_json`, `inserted_at`, `updated_at`, `min_lon..max_lat` | не используется в /download |
### 3.3 SQL-запрос
```sql
SELECT id, name, description, activity_type, user, created_at,
length_m, points_count, geom, sources_json, external_urls_json
FROM tracks WHERE id = ?
```
Один параметр `?` — integer, валидируется FastAPI. Использует
автоматический PRIMARY KEY-индекс. Стоимость: ~1 ms.
### 3.4 Кэширование на стороне сервера
**Не вводим.** Mvt-кэш ET-008 — другой механизм (по `(z,x,y)`). Для
скачивания одиночного трека:
- Кэш-хит редкий (пользователь обычно качает один раз).
- Размер GPX до 20 МБ × N треков — раздуло бы LRU-кэш и заняло RAM.
- Производительность сборки и так в бюджете (REQ-NF-01 = 300 ms p95).
Клиентский кэш — через заголовок `Cache-Control: private, max-age=3600`
(см. ADR-014 §6). Браузер сам кэширует blob.
### 3.5 Изменения объёма БД
**Нет.** Эндпоинт read-only.
### 3.6 Backup retention
Без изменений (см. ET-008 §9).
## 4. Контракт GPX-файла (выходные данные)
### 4.1 Структура XML
```xml
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1"
creator="Enduro Trails"
xmlns="http://www.topografix.com/GPX/1/1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">
<metadata>
<name>{tracks.name | "Без названия"}</name>
<desc>{tracks.description}</desc> <!-- если не null -->
<author>
<name>{tracks.user}</name> <!-- если не null -->
</author>
<link href="{external_urls[0]}">
<text>Источник: {sources[0]}</text>
</link>
<!-- ... по одному <link> на каждый external_url -->
<time>{tracks.created_at | ISO-8601 UTC}</time> <!-- если не null -->
<copyright author="Enduro Trails"> <!-- если "osm" ∈ sources -->
<license>https://www.openstreetmap.org/copyright</license>
</copyright>
</metadata>
<trk>
<name>{tracks.name | "Без названия"}</name>
<type>{tracks.activity_type | "other"}</type>
<trkseg>
<trkpt lat="55.123456" lon="37.654321" />
<!-- ... по одному <trkpt> на каждую координату из geom -->
</trkseg>
</trk>
</gpx>
```
### 4.2 Соответствие схеме
Валидируется по `http://www.topografix.com/GPX/1/1/gpx.xsd` без
ошибок и warnings (REQ-NF-03, AC-5). Тестовая фикстура
`tests/fixtures/gpx-1.1/gpx.xsd` (snapshot схемы).
### 4.3 Размер и плотность
| Кол-во точек | Типичный размер | Время сборки (4-worker uvicorn) |
|---|---|---|
| 100 | ~ 15 КБ | < 5 мс |
| 1 000 | ~ 130 КБ | < 20 мс |
| 5 000 | ~ 650 КБ | < 50 мс |
| 50 000 | ~ 6.5 МБ | 80150 мс |
| 200 000 (cap) | ~ 26 МБ | 400500 мс |
| > 200 000 | — | **413 Payload Too Large** |
Округление координат `%.6f` — точность ≈ 0.11 м (более чем достаточно
для эндуро-навигации; экономит ~30% bytes vs Python-default float repr).
### 4.4 Кодировка
UTF-8 строго. `Content-Type: application/gpx+xml; charset=utf-8`.
ElementTree сам выдаёт UTF-8 при `tostring(root, encoding="utf-8",
xml_declaration=True)`.
### 4.5 Что НЕ попадает в GPX
| Поле | Причина |
|---|---|
| `<ele>` (высота) | Не хранится в БД (BRD A2 / ET-008 ограничение) |
| `<time>` в каждом `<trkpt>` | Не хранится в БД (BRD A2) |
| `<wpt>` (waypoints) | Не moнодим из треков |
| `<rte>` (роуты) | Не применимо для public GPS-tracks |
| `<extensions>` | Минимализм; кастомные расширения — отдельная фича |
| `tracks.dedup_key`, `tracks.length_m`, `tracks.points_count` | Внутренние метаданные, не часть GPX-стандарта |
| `tracks.tags_json` | В этой итерации не нужны; если потребуется — `<keywords>` в metadata |
## 5. Конфигурация — `gps_sources.yaml`
### 5.1 Новое поле `download_allowed`
| Поле | Тип | Default | Назначение |
|---|---|---|---|
| `download_allowed` | bool | `false` (если отсутствует — deny) | Управляет ответом 403 в `/download` эндпоинте |
Финальные значения для ET-011 (закрытие BRD Q-1):
| `source.id` | `download_allowed` | Юридическое основание |
|---|---|---|
| `osm` | `true` | ODbL разрешает реэкспорт с атрибуцией (ADR-009 + ADR-015 §«Решение D») |
| `enduro_russia` | `false` | Default-deny; ADR-010 ничего не говорит про реэкспорт |
| `wikiloc` | `false` | ToS Wikiloc запрещает массовый ре-экспорт (ADR-012) |
| `ttrails` | `false` | ADR-011 в `proposed`; не собирается и не отдаётся |
### 5.2 Влияние на pipeline
`gps-collector` **игнорирует** новое поле (pipeline-код не обращается к
`download_allowed`). Это redistribution-only флаг.
## 6. Контракт публичного API
### 6.1 `GET /api/gps-tracks/{track_id}/download` — **новый**
#### Параметры
| Параметр | Тип | Где | Обязательный | Default |
|---|---|---|---|---|
| `track_id` | int (ge=1) | path | да | — |
| `format` | str | query | нет | `"gpx"` (whitelist `{"gpx"}`) |
#### Ответы
| Статус | Body | Headers (ключевые) | Триггер |
|---|---|---|---|
| 200 | XML (GPX 1.1) | `Content-Type: application/gpx+xml; charset=utf-8`<br>`Content-Disposition: attachment; filename="…"; filename*=UTF-8''…`<br>`Cache-Control: private, max-age=3600`<br>`Content-Length: <bytes>` | happy path |
| 400 | `{"detail": "unsupported_format"}` | стандартные | `format` не в whitelist |
| 403 | `{"detail": "source_forbidden", "external_urls": [...]}` | стандартные | Ни один source трека не в `download_allowed` whitelist (ADR-015) |
| 404 | `{"detail": "track_not_found"}` | стандартные | Трек с указанным `id` отсутствует в БД |
| 413 | `{"detail": "track_too_large"}` | стандартные | `tracks.points_count > 200000` |
| 500 | `{"detail": "internal_error"}` | стандартные | необработанное исключение (db read fail, XML build fail) |
#### Кодирование имени файла
RFC 5987:
- `filename="<ascii_fallback>.gpx"` — ASCII-printable санитизированное
имя (см. ADR-014 §F).
- `filename*=UTF-8''<percent_encoded>.gpx` — UTF-8 имя через
`urllib.parse.quote(name, safe='', encoding='utf-8')`.
Пример (`name = "По грязи к Чёрному озеру"`):
```
Content-Disposition: attachment; filename="track-42.gpx"; filename*=UTF-8''%D0%9F%D0%BE%20%D0%B3%D1%80%D1%8F%D0%B7%D0%B8%20%D0%BA%20%D0%A7%D1%91%D1%80%D0%BD%D0%BE%D0%BC%D1%83%20%D0%BE%D0%B7%D0%B5%D1%80%D1%83.gpx
```
ASCII-fallback `track-42.gpx` используется только если у пользователя
браузер не понимает `filename*` (последние 10+ лет — не встречается).
### 6.2 Существующие эндпоинты — без изменений
`GET /api/gps-tracks`, `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt`,
`GET /api/gps-tracks/health`, `POST /api/gps-tracks/cache/clear`
без изменений.
## 7. Клиентское хранилище
### 7.1 localStorage
**Без изменений.** Никаких новых ключей. Существующие ключи ET-008
(`gps-tracks-enabled`, `gps-tracks-activities`, `gps-tracks-sources`,
`gps-tracks-color-mode`) — без изменений.
### 7.2 Не-персистентное состояние
`window.gpsTracksLayer` — без изменений.
`SOURCE_ATTRIBUTIONS`, `SOURCE_LABELS` маппинги — без изменений.
## 8. Персональные данные (PII)
| Канал | PII | Обработка в ET-011 |
|---|---|---|
| `<author><name>` в скачанном GPX | возможно (OSM user-name) | попадает только для OSM (ADR-009 collect_user_field: true). Для EnduroRussia/Wikiloc/ttrails — null в БД, элемент опускается |
| `<metadata><desc>` | возможно (свободный текст автора) | только для OSM-источника при ANY-rule ADR-015 трек качается; для не-OSM — `<copyright>` не указывается, но `<desc>` может содержать merged-text. Это **сознательный** компромисс ADR-015 §B (см. R-3 в `10-tech-risks.md`) |
| `<link href=…>` external_urls | URL-ы могут указывать на профиль автора | сохранены как есть в `external_urls_json` (паттерн ET-008) |
| IP клиента в логах скачивания | стандартный uvicorn access-log | без изменений; ротация в Docker |
### 8.1 Право на удаление
Без изменений. Удаление записи из `tracks` (ET-008 §7.1) автоматически
делает её недоступной через `/download` (404).
### 8.2 GDPR / РФ ФЗ-152
Обрабатываются только публично выложенные данные с условием
`download_allowed: true`. ODbL OSM покрывает реэкспорт (ADR-009).
## 9. Атрибуция
В скачанном GPX:
- `<copyright>` с OSM-license URL — если `"osm" ∈ sources`.
- `<link>` для каждого `external_url` — атрибуция в виде ссылок,
кликабельная в любом GPX-просмотрщике (OsmAnd, Garmin BaseCamp, QGIS).
- `creator="Enduro Trails"` в корневом `<gpx>` — атрибуция нашего
сервиса.
В UI: без изменений (MapLibre Attribution control остаётся как в ET-008).
## 10. Backup и retention
**Не применимо** к ET-011. Эндпоинт read-only, не создаёт persistent-
артефактов.
## 11. Тестовые данные (фикстуры)
### 11.1 Новые фикстуры
| Файл | Содержимое | Использование |
|---|---|---|
| `tests/fixtures/gpx-1.1/gpx.xsd` | XSD-схема topografix 1.1 (~30 КБ), скачана один раз | UT-03, IT-07 (валидация выходного GPX) |
| `tests/fixtures/gps-tracks/sample-tracks-fixture.sql` | (опц.) набор INSERT для трёх кейсов: OSM-трек 5 точек, EnduroRussia-трек 50 точек, Wikiloc-трек 100 точек | IT-01..08 |
`gpx.xsd` коммитится один раз; не зависит от внешних сервисов в
runtime (только на момент UT-теста).
### 11.2 Юридический статус фикстур
`gpx.xsd` — открытый XML Schema от `topografix.com`, свободно
распространяемый (см. footer на topografix.com). Хранение в репо для
тестирования — стандартная практика.
Тестовые SQL-фикстуры с координатами — синтетические (рандомные),
не содержат реальных треков от публикаторов.
## 12. Контракты, которые нельзя ломать
1. **Schema `tracks`, `pipeline_runs`** — не меняются (read-only
эндпоинт).
2. **Структура GeoJSON и MVT** на других эндпоинтах — не меняется.
3. **GPX 1.1 формат выходного файла** — соответствует topografix XSD;
изменение структуры (например, добавление `<extensions>`) — breaking
change для пользователей, которые уже импортировали в свои навигаторы;
требует minor-bump в `creator="Enduro Trails"` или отдельной фичи.
4. **`download_allowed` поле в `gps_sources.yaml`** — optional, default
`false`; никогда не делать его required (поломает все существующие
конфиги). Pipeline не должен начать читать это поле в будущем —
разделение confidently distinct concerns.
5. **Ответ 403 schema**`{"detail": "source_forbidden", "external_urls": [...]}`
— клиент использует `external_urls[0]` для CTA. Удаление поля
сломает UX.
## 13. Вывод
ET-011 — **read-only data event**:
- Не меняет схему БД, не добавляет миграции, не вводит новые таблицы;
- Использует существующие данные в `tracks` через один SELECT;
- Возвращает новый артефакт (GPX-файл) пользователю — не сохраняет на
сервер;
- Расширяет один конфиг-файл одним optional-полем;
- Поддерживает default-deny для лицензионной чистоты.
Юридически защищён через ADR-009 (OSM ODbL) + ADR-015 (default-deny
whitelist). Pipeline-collector не затронут.

View File

@@ -0,0 +1,347 @@
---
type: tech-risks
work_item_id: ET-011
title: "Технические риски — ET-011: Скачивание трека из popup"
version: 1
status: approved
created_at: 2026-06-03
authors:
- "agent:architect"
---
# Технические риски — ET-011
Технические риски этапа добавления GPX-download эндпоинта и UI-кнопки
в popup публичного трека. Бизнес-риски — в BRD §8 ET-011. Шкала:
вероятность (Н/С/В) × влияние (Н/С/В).
## R-1 — iOS Safari игнорирует `Content-Disposition: attachment`
- **Описание:** Исторически iOS Safari склонен открывать XML inline
вместо скачивания. Если эндпоинт отдаёт правильный header, но Safari
показывает GPX как текст в новой вкладке — UX сломан.
- **Вероятность / Влияние:** С (был — В, де факто митигирован) / С.
- **Митигация:**
- **Архитектурное решение (ADR-014 §A)**: используем `fetch + Blob +
URL.createObjectURL + <a download>` паттерн — тот же, что
`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-<id>.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)**: `<copyright>` ставим
только для OSM (`license = openstreetmap.org/copyright`); для не-
OSM `<copyright>` опускаем. Это защищает от ложной атрибуции.
- **Архитектурное ограничение (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 в строку
занимает 400500 мс 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 & <special>"`
или подобные кейсы.
- Тест 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="<ascii_no_quote>.gpx"` без escaped quotes (санитизация
в `safe_filename` заменяет `"` на `_`).
- Fallback `track-<id>.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"` на `<button>`.
- Code-review checklist: проверить наличие `aria-label` для всех
icon-only buttons.
## R-16 — Зависание popup при медленном API (типичное скачивание > 1 сек)
- **Описание:** При построении GPX на 50000 точек + плохой downlink
у пользователя — visual stall на кнопке. Если индикатор не показан,
кажется «не работает».
- **Вероятность / Влияние:** С / Н.
- **Митигация:**
- **Архитектурное решение (ADR-014 §3.b)**: CSS-класс `.is-loading`
с visual spinner через `::after` псевдоэлемент. Применяется на
время `fetch()`.
- Снимается в `finally` блоке (даже при ошибке).
- REQ-NF-01 = 300 ms p95 на 50k точек на test-сервере — нормально
без видимого индикатора в большинстве случаев.
## R-17 — `gps_sources.yaml` не существует на runtime → `download` падает
- **Описание:** Если `SOURCES_CONFIG_PATH` указывает на несуществующий
файл (например, после refactor'а директорий), `create_gps_router`
при старте упадёт.
- **Вероятность / Влияние:** Н / В.
- **Митигация:**
- **Архитектурное решение (ADR-015 §«Решение F»)**: если конфиг
недоступен — fallback `allowed_sources = {"osm"}`. Это совпадает
с production-дефолтом, поэтому функциональность сохраняется.
- Логируется WARNING в stdout: `gps_sources.yaml not found, falling
back to safe-deny whitelist`.
- Test-fixtures без конфига работают через тот же fallback.
## R-18 — gzip middleware не сжимает GPX → большой объём egress
- **Описание:** Если starlette `GZipMiddleware` не настроен или
настроен на minimum size > 1 МБ, GPX-ответ для маленького трека (5k
точек ≈ 650 КБ) уходит несжатым.
- **Вероятность / Влияние:** Н / Н.
- **Митигация:**
- Не блокирует функциональность. Egress test-сервера ≥ 100 Mbps,
нагрузка от download'ов минимальна.
- Опционально (out of scope): добавить `GZipMiddleware` в
`src/api/main.py`, если ещё не добавлен. Это affects **все**
эндпоинты, не только download — отдельная задача.
- GPX-XML сжимается gzip'ом обычно ×3..5.
## R-19 — Параллельные клики на «Скачать» создают N запросов
- **Описание:** Если пользователь нервно тапает кнопку 5 раз подряд —
N параллельных fetch к одному треку. Тратятся ресурсы.
- **Вероятность / Влияние:** С / Н.
- **Митигация:**
- **Архитектурное решение (ADR-014 §3.b)**: `btnEl.classList.add('is-loading')`
+ CSS `pointer-events: none` блокирует повторные клики до
`finally`.
- Backend идемпотентен (read-only), повторный запрос не вредит
state.
## Сводная таблица
| ID | Риск | Вер. | Влияние | Класс | Статус |
|---|---|---|---|---|---|
| R-1 | iOS Safari игнорирует Content-Disposition | С | С | Средний | переиспользование рабочего паттерна `downloadGPX()` |
| R-2 | Кириллица в filename | С | Н | Низкий | RFC 5987 `filename*` + ASCII-fallback |
| R-3 | Утечка proprietary metadata через merged GPX | С | С | Средний | `<copyright>` только OSM; per-field tracking — отдельный work item |
| R-4 | Patho-трек срывает timeout | Н | С | Низкий | cap REQ-NF-02 = 200k → 413 |
| R-5 | RAM от параллельных скачиваний | Н | С | Низкий | 200 МБ при 10 параллельных, < free RAM × 5 |
| R-6 | Кнопка всегда видна → 403 после клика | В | Н | Низкий | сознательный UX-compromise + toast c CTA |
| R-7 | XML-escape `tracks.name` | В (без ET) / **Н** (с ET) | В | Средний | `xml.etree.ElementTree` авто-escape |
| R-8 | `lxml` в test-deps | Случилось | Н | Низкий | optional add в `requirements-dev.txt` |
| R-9 | Юридическая ошибка в `download_allowed` whitelist | С | В | **Высокий** | default-deny + ADR-015 §D + IT-05 + review |
| R-10 | Регрессия существующих эндпоинтов | Н | С | Низкий | IT-08 smoke + opt arg `sources_config_path` |
| R-11 | Frontend парсинг Content-Disposition | Н | Н | Низкий | fallback `track-<id>.gpx` |
| R-12 | XSD-фикстура устаревает | Н | Н | Низкий | GPX 1.1 frozen |
| R-13 | Race delete | Н | Н | Низкий | 404 = корректный UX |
| R-14 | Кнопка не тапается на маленьких viewport | Н | С | Низкий | CSS `32px × 32px` + E2E-02 + TC-UI-02 |
| R-15 | Screen-reader не получает label | Н | С | Низкий | `aria-label` + `title` + review |
| R-16 | Visual stall при медленном API | С | Н | Низкий | `.is-loading` spinner |
| R-17 | Конфиг не существует на runtime | Н | В | **Высокий** | fallback `{"osm"}` + WARNING log |
| R-18 | gzip не сжимает | Н | Н | Низкий | optional middleware add |
| R-19 | Параллельные клики | С | Н | Низкий | `pointer-events: none` + idempotent backend |
**Высокие классы:**
- **R-9** — legal/license risk. Митигация многослойная: default-deny в
Pydantic + явный whitelist в ADR-015 + integration-тест + code-review
чеклист.
- **R-17** — runtime safety. Митигация: silent-fallback на consistent
с production default (= `{"osm"}`), не падаем при стартe.
**Средние классы:**
- **R-1** — переиспользуем de facto проверенный паттерн.
- **R-3** — известный compromise, задокументирован в ADR-015 §B; полное
решение — отдельный work item.
**Блокирующих рисков нет.** Высокие классы требуют внимания на этапе
разработки и code review.
## Эскалация
- **arch:major-change** — **не выставляется** (см. ADR-014 §«Классификация»,
ADR-015 §«Классификация»). ET-011 не вводит новых архитектурных
компонентов.
- **back-to:analysis** — не требуется. ТЗ полное, BRD-вопросы Q-1/Q-2/Q-3
закрыты дефолтными значениями (см. BRD §9).
- Эскалация в Architecture требуется **только** если:
1. Owner закрывает Q-1 как разрешающий — обновление ADR-015 (но не
back-to:analysis).
2. Review-агент находит конкретный случай утечки proprietary
metadata (R-3) — `back-to:analysis` для расширения схемы БД.
3. iOS Safari возвращает регресс по R-1 — `back-to:build` (не
`back-to:analysis`) для добавления fallback'а на `window.location.href`.