architect(ET): auto-commit from architect run_id=64
This commit is contained in:
@@ -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` — состояние БД,
|
||||
число треков по источникам, последний прогон.
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
503
docs/work-items/ET-011/06-adr/ADR-014-gpx-download-endpoint.md
Normal file
503
docs/work-items/ET-011/06-adr/ADR-014-gpx-download-endpoint.md
Normal 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: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: `<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-экранирование (`&`, `<` в названии трека) — типичный источник 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-<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 (`32–126`), остальное в `_`; если пустота —
|
||||
`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:135–137` (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` §1–10
|
||||
- `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)
|
||||
@@ -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)
|
||||
326
docs/work-items/ET-011/07-infra-requirements.md
Normal file
326
docs/work-items/ET-011/07-infra-requirements.md
Normal 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; в норме 1–2 одновременно. Не блокирует канал
|
||||
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 МБ; в реальности 1–2 параллельно |
|
||||
| CPU per запрос | 100–500 мс 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).
|
||||
341
docs/work-items/ET-011/08-data-requirements.md
Normal file
341
docs/work-items/ET-011/08-data-requirements.md
Normal 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 МБ | 80–150 мс |
|
||||
| 200 000 (cap) | ~ 26 МБ | 400–500 мс |
|
||||
| > 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 не затронут.
|
||||
347
docs/work-items/ET-011/10-tech-risks.md
Normal file
347
docs/work-items/ET-011/10-tech-risks.md
Normal 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 в строку
|
||||
занимает 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 & <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`.
|
||||
Reference in New Issue
Block a user