Files
enduro-trails/docs/work-items/ET-008/02-trz.md

474 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: trz
work_item_id: ET-008
title: "ТЗ: GPS-треки с публичных платформ на карте"
version: 1
status: draft
created_at: 2026-06-01
updated_at: 2026-06-01
authors:
- "agent:analyst"
---
# ТЗ — ET-008: GPS-треки с публичных платформ на карте
## 1. Функциональные требования
### REQ-F-01: Расширение sheet `#sheet-gpx`
В верхней части `#sheet-gpx` (под header, над списком треков) добавить
секцию «Источники» с двумя вкладками-кнопками (segmented control):
- **Из файла** — текущее поведение ET-006 (`#btn-gpx-upload`).
- **По ссылке** — поле ввода URL + кнопка «Загрузить».
- **Найти рядом** — кнопка «Найти публичные треки в этой области карты».
При первом открытии активна вкладка **Из файла** (обратная совместимость).
### REQ-F-02: Импорт по URL
- Поле `<input id="gpx-url-input" type="url">` с placeholder
«https://example.com/track.gpx».
- Кнопка `#btn-gpx-fetch-url` рядом — «Загрузить».
- При нажатии:
1. Клиентская валидация URL (`new URL()`, схема `https?:`).
2. Запрос `GET /api/gpx/fetch?url=<encoded>`.
3. Полученный текст GPX парсится тем же `parseGpx()` из `gpx.js`.
4. Результат добавляется в `window.gpxTracks` как обычно. Поле
`source` = `{kind: 'url', url: '<original>'}`.
5. `filename` для отображения: последний segment URL без `.gpx` или
`<gpx><metadata><name>` если есть.
- Поддерживается также Enter в поле ввода.
### REQ-F-03: Прокси-эндпоинт `/api/gpx/fetch`
```
GET /api/gpx/fetch?url=<percent-encoded-url>
```
- Валидация:
- Схема URL ∈ {`http`, `https`}.
- Хост резолвится в публичный IP (не RFC1918, не loopback, не link-local).
Проверка через `socket.getaddrinfo()` + `ipaddress.ip_address().is_global`.
- Запрет редиректов на приватные IP (`httpx.AsyncClient(follow_redirects=False)`,
ручная обработка max 3 редиректов с повторной валидацией хоста).
- Загрузка:
- Таймаут 15 секунд.
- Лимит размера ответа: 50 МБ (стримом, прервать при превышении).
- Заголовок `User-Agent: enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)`.
- Кэш:
- Ключ = SHA-256(url).
- In-memory LRU, max 64 записи, TTL 24 ч.
- При cache hit — отдаётся из кэша.
- Ответ:
- `200 OK`, `Content-Type: application/gpx+xml`, тело GPX.
- Заголовок `X-Cache: HIT|MISS`.
- Ошибки → JSON `{error: "..."}`:
- `400` — невалидный URL / приватный IP / запрещённая схема.
- `404` — внешний сервер вернул 404.
- `413` — превышен лимит размера.
- `502` — внешний сервер недоступен / таймаут.
- `504` — таймаут на нашей стороне.
### REQ-F-04: Кнопка «Найти публичные треки»
- Кнопка `#btn-gpx-find-nearby` в секции «Источники».
- Текст: «Найти треки в этой области».
- При нажатии:
1. Получить bbox видимой области карты: `map.getBounds()`.
2. Валидация: площадь bbox ≤ 0.25 deg² (OSM API limit — иначе ошибка).
Если больше — toast «Слишком большая область, увеличьте zoom».
3. Запрос `GET /api/gpx/osm/traces?bbox=west,south,east,north`.
4. Открыть подсекцию «Найденные треки» (REQ-F-05).
### REQ-F-05: Прокси-эндпоинт `/api/gpx/osm/traces`
```
GET /api/gpx/osm/traces?bbox=<west>,<south>,<east>,<north>&page=<n>
```
- Параметры:
- `bbox` — обязательный, 4 числа через запятую.
- `page` — опциональный, целое ≥ 0, default 0.
- Валидация:
- Каждая координата — валидный float, в допустимом диапазоне.
- Площадь bbox ≤ 0.25 deg² — иначе `400`.
- Запрос к OSM:
```
GET https://api.openstreetmap.org/api/0.6/trackpoints
?bbox=<bbox>&page=<page>
```
- Таймаут 10 секунд.
- User-Agent как в REQ-F-03.
- Парсинг ответа:
- OSM возвращает GPX 1.0 с `<trkseg>` и атрибутом `gpx_id` у некоторых
точек (см. формат OSM API). Группируем точки по `gpx_id` →
массив треков-метаданных.
- Анонимные треки (без `gpx_id`) объединяются в один общий «Анонимные треки этой области».
- Кэш:
- Ключ = `(bbox_rounded_to_4_digits, page)`.
- In-memory LRU, max 256 записей, TTL 24 ч.
- Ответ (JSON):
```json
{
"bbox": [w, s, e, n],
"page": 0,
"has_more": false,
"tracks": [
{
"osm_id": 12345,
"name": "Trail in the woods",
"description": "...",
"user": "username",
"points_count": 320,
"distance_km": 12.4,
"url": "https://www.openstreetmap.org/user/.../traces/12345",
"gpx_url": "https://api.openstreetmap.org/api/0.6/gpx/12345/data"
}
]
}
```
- Поле `distance_km` — посчитано на сервере (Haversine).
- Ошибки → JSON `{error: "..."}`:
- `400` — невалидный bbox / слишком большая область.
- `502` — OSM API недоступен.
- `504` — таймаут.
### REQ-F-06: UI списка найденных треков
В подсекции `#gpx-nearby-results` под кнопкой «Найти треки»:
- Заголовок: «Найдено N треков в этой области».
- Список карточек, каждая:
- Иконка-индикатор источника (OSM-логотип маленький).
- Имя трека (или «Без названия»).
- Метаданные: длина (км, через `units.js`), автор (если есть).
- Кнопка «Показать» — импортирует трек на карту.
- Кнопка «↗» — открывает страницу трека на osm.org в новой вкладке.
- Если `has_more` — кнопка «Показать ещё» внизу списка (увеличивает page).
- Если треков нет — текст «В этой области нет публичных GPS-треков».
### REQ-F-07: Импорт выбранного OSM-трека
При клике на «Показать»:
1. Запрос `GET /api/gpx/fetch?url=<gpx_url>` — тот же эндпоинт, что для
произвольного URL (переиспользование кэша и валидации).
2. После загрузки — те же шаги, что REQ-F-02 (парсинг → модель → рендеринг).
3. Поле `source` = `{kind: 'osm', osm_id: <id>, url: <osm_page_url>}`.
4. Карточка в списке найденных треков получает индикатор «✓ Загружен».
5. Повторный клик «Показать» — no-op (toast «Уже загружен»).
### REQ-F-08: Отображение источника в карточке трека
В существующей карточке трека в списке `#gpx-list` (ET-006):
- Под именем файла мелким шрифтом добавить строку «источник»:
- Локальный файл: «📁 локальный файл» (без изменения для ET-006).
- URL: «🔗 <hostname>» (например, «🔗 github.com»).
- OSM: «🌍 OSM #<id>» — кликабельная ссылка на страницу osm.org.
### REQ-F-09: Расширение модели `window.gpxTracks`
Каждый элемент `window.gpxTracks` дополнительно содержит:
```javascript
{
// ... существующие поля ET-006 (id, filename, color, tracks, waypoints, ...)
source: {
kind: 'file' | 'url' | 'osm',
url: string | null, // для kind='url' и 'osm'
osm_id: number | null, // для kind='osm'
}
}
```
Для треков ET-006 (загруженных из файла) `source.kind = 'file'`
(обратная совместимость через миграцию на лету: если `source` отсутствует,
читать как `{kind: 'file'}`).
### REQ-F-10: Обработка ошибок и toast-уведомления
| Ситуация | Toast |
|----------|-------|
| Невалидный URL | «Невалидная ссылка» |
| URL → приватный IP | «Эта ссылка недоступна» |
| Внешний 404 | «Файл не найден по этой ссылке» |
| Внешний таймаут / 502 | «Сервер не отвечает, попробуйте позже» |
| Файл > 50 МБ | «Файл слишком большой (макс. 50 МБ)» |
| Не GPX (DOMParser fail) | «По этой ссылке не GPX-файл» |
| OSM: bbox > 0.25 deg² | «Слишком большая область, увеличьте zoom» |
| OSM: 0 треков | «В этой области нет публичных GPS-треков» (не toast, а inline-сообщение) |
| OSM: rate limit (429) | «Слишком много запросов к OSM, попробуйте через минуту» |
### REQ-F-11: Сохранение при смене стиля карты
Импортированные треки переживают `map.setStyle()` через тот же механизм
`rebuildGpxOverlays()`, что и локальные ET-006. Никаких изменений в
этой функции не требуется — модель данных совместима.
## 2. Нефункциональные требования
### REQ-NF-01: Безопасность
- Прокси `/api/gpx/fetch` защищён от SSRF (REQ-F-03):
- Whitelist схем.
- Резолв и проверка хоста на публичность.
- Ручная обработка редиректов с повторной валидацией.
- Лимит размера ответа стримом.
- Парсинг XML на бэкенде (если потребуется — для OSM-ответа) через
`defusedxml.ElementTree` — защита от XXE / billion laughs.
- Парсинг GPX на клиенте — нативный `DOMParser`, XXE отключён по умолчанию.
- CORS на новых эндпоинтах — наследуется от существующей конфигурации
(`allow_origins=["*"]`), отдельных правил не требуется.
### REQ-NF-02: Производительность
- Запрос OSM с кэш-хитом: ≤ 50 мс.
- Запрос OSM без кэша: ≤ 3 сек (зависит от OSM API).
- URL-импорт GPX 1 МБ: ≤ 2 сек.
- URL-импорт GPX 50 МБ: ≤ 10 сек (с учётом сети).
- Bbox-валидация и серилизация на бэкенде: ≤ 5 мс.
### REQ-NF-03: Кэширование
- LRU-кэш `/api/gpx/fetch`: 64 записи × до 50 МБ = до 3.2 ГБ памяти —
**слишком много**. Решение: хранить только treki ≤ 5 МБ, остальные не
кэшировать. Корректировка: кэш до 64 записей размером ≤ 5 МБ каждая.
- LRU-кэш `/api/gpx/osm/traces`: 256 записей × ≤ 200 КБ JSON ≈ 50 МБ.
- Оба кэша — in-memory, не персистентные, теряются при рестарте контейнера.
- TTL: 24 часа.
- Метрики кэша (`/api/health`): `gpx_fetch_cache_size`, `gpx_osm_cache_size`.
### REQ-NF-04: Совместимость
- Браузеры: те же, что ET-006 (Chrome 90+, Firefox 90+, Safari 15+).
- Мобильные: input type=url с режимом клавиатуры url.
- Backend: Python 3.12, FastAPI, httpx (уже есть), `defusedxml` (новая).
### REQ-NF-05: UX
- Во время сетевого запроса показывать индикатор (повторно используем
`#gpx-loading` из ET-006).
- Кнопка «Найти треки» дизейблится во время запроса.
- Все toast-уведомления — через существующий механизм `showToast()` из `gpx.js`.
## 3. UI-спецификация
### 3.1 Расширение `#sheet-gpx` — секция «Источники»
```
┌─────────────────────────────────────┐
│ ═══ (handle) │
│ 📄 GPX-треки [свернуть]│
├─────────────────────────────────────┤
│ ИСТОЧНИКИ │
│ [📁 Из файла] [🔗 По ссылке] [🌍 Найти рядом] │
│ │
│ ─ если активна «По ссылке»: ─ │
│ ┌──────────────────────────┐ ┌────┐ │
│ │https://example.com/...gpx│ │Загр│ │
│ └──────────────────────────┘ └────┘ │
│ │
│ ─ если активна «Найти рядом»: ─ │
│ [ Найти треки в этой области карты ]│
│ Найдено 5 треков: │
│ ┌─────────────────────────────────┐ │
│ │🌍 Trail in the woods [Показ.] │ │
│ │ 12.4 км · автор: user42 [↗] │ │
│ └─────────────────────────────────┘ │
│ ┌─────────────────────────────────┐ │
│ │🌍 Без названия [✓ Загр.]│ │
│ │ 3.1 км · аноним [↗]│ │
│ └─────────────────────────────────┘ │
│ [ Показать ещё ] │
├─────────────────────────────────────┤
│ ЗАГРУЖЕННЫЕ ТРЕКИ (как в ET-006) │
│ 🔴 morning.gpx [✕] │
│ 📁 локальный файл │
│ 🔵 trail_woods [✕] │
│ 🌍 OSM #12345 │
│ 🟢 strava-export [✕] │
│ 🔗 github.com │
└─────────────────────────────────────┘
```
### 3.2 Segmented control «Источники»
- Контейнер: `<div class="seg-control source-seg" id="source-seg">`.
- Кнопки: `<button class="seg-btn" id="source-btn-file|url|nearby">`.
- Стилизация — переиспользовать существующий `.seg-control` (как в
выборе единиц `unit-seg`).
- Поведение: одна активна, остальные неактивны; контент под секцией
переключается по data-атрибуту.
### 3.3 Карточка найденного OSM-трека
- Контейнер: `<div class="gpx-nearby-card" data-osm-id="...">`.
- Структура:
```html
<div class="gpx-nearby-card">
<div class="gnc-icon">🌍</div>
<div class="gnc-body">
<div class="gnc-title">Trail in the woods</div>
<div class="gnc-meta">12.4 км · автор: user42</div>
</div>
<button class="gnc-import">Показать</button>
<a class="gnc-external" href="..." target="_blank" rel="noopener">↗</a>
</div>
```
### 3.4 Расширение карточки трека в `#gpx-list`
Добавить под именем файла строку:
```html
<div class="gpx-source-row">
<!-- для kind='file' -->
<span>📁 локальный файл</span>
<!-- для kind='url' -->
<span>🔗 <span class="gpx-host">github.com</span></span>
<!-- для kind='osm' -->
<a href="https://www.openstreetmap.org/.../traces/12345"
target="_blank" rel="noopener">🌍 OSM #12345</a>
</div>
```
## 4. Данные
### 4.1 Формат OSM Public GPS Traces API
OSM возвращает GPX 1.0:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.0" creator="OpenStreetMap.org" xmlns="http://www.topografix.com/GPX/1/0">
<trk>
<name>Anonymous tracks</name>
<trkseg>
<trkpt lat="55.7558" lon="37.6173">
<time>2024-05-01T08:00:00Z</time>
</trkpt>
...
</trkseg>
</trk>
</gpx>
```
`gpx_id` атрибут точек официально устарел; вместо группировки треков по
gpx_id отдаём весь bbox-ответ как «Публичные треки этой области (N точек)»
— **единая карточка**, импорт всей выборки как одного трека.
Метаданные индивидуальных треков (user, name) недоступны через
`trackpoints` endpoint без дополнительного запроса.
**Уточнение требования REQ-F-05/F-06** (исходя из реального API):
- Список найденных «треков» — это страницы trackpoints (page 0, 1, 2…).
- Карточка отображает: page N, количество точек, длину, bbox-центр.
- Импорт = загрузить эту страницу как один GPX-трек.
- Кнопка «Показать ещё» → следующая страница.
Это упрощает реализацию и соответствует ограничениям OSM API.
### 4.2 Внутренняя модель — расширение
```javascript
window.gpxTracks = [
{
// существующие поля ET-006
id: 'gpx-1716336000000',
filename: 'trail_woods',
color: '#3cb44b',
tracks: [...],
waypoints: [...],
sourceId: 'gpx-source-...',
layerId: 'gpx-layer-...',
waypointLayerId: 'gpx-wpt-...',
// новое поле ET-008
source: {
kind: 'file' | 'url' | 'osm',
url: 'https://...', // null для kind='file'
osm_page: 0, // только для kind='osm'
osm_bbox: [w, s, e, n] // только для kind='osm'
}
}
];
```
## 5. Файловая структура изменений
```
src/api/
├── main.py # + 2 эндпоинта, импорт нового модуля
├── gpx_proxy.py # НОВЫЙ: SSRF-валидация, fetch, кэш
├── osm_traces.py # НОВЫЙ: OSM trackpoints клиент, парсинг
├── requirements.txt # + defusedxml
src/web/
├── index.html # + секция «Источники» в #sheet-gpx
├── gpx.js # + URL-импорт, OSM-поиск, расширение модели
├── app.css # + стили .source-seg, .gpx-nearby-card, .gpx-source-row
tests/
├── api/test_gpx_proxy.py # НОВЫЙ
├── api/test_osm_traces.py # НОВЫЙ
├── web/gpx.test.js # + тесты на URL/OSM источники
docs/work-items/ET-008/
├── 06-adr/
│ ├── ADR-001-ssrf-protection.md
│ └── ADR-002-osm-trackpoints-aggregation.md
```
## 6. Алгоритмы
### 6.1 SSRF-защита `/api/gpx/fetch`
```python
def is_safe_url(url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https'):
return False
try:
infos = socket.getaddrinfo(parsed.hostname, None)
except socket.gaierror:
return False
for info in infos:
ip = ipaddress.ip_address(info[4][0])
if not ip.is_global or ip.is_loopback or ip.is_private:
return False
return True
```
При следовании редиректам — повторная валидация хоста на каждом шаге.
### 6.2 Bbox area check
```python
def bbox_area_deg2(w, s, e, n):
return abs(e - w) * abs(n - s)
if bbox_area_deg2(*bbox) > 0.25:
raise HTTPException(400, "bbox too large")
```
### 6.3 Кэш-ключ для bbox
Округление до 4 знаков (≈ 11 метров на экваторе):
```python
key = (round(w, 4), round(s, 4), round(e, 4), round(n, 4), page)
```
Это обеспечивает попадание в кэш при незначительном движении карты.
## 7. Взаимодействие с существующими модулями
- **ET-006 `gpx.js`** — расширяем, не переписываем. Существующие функции
(`parseGpx`, `addGpxTrack`, `rebuildGpxOverlays`) остаются. Добавляются:
`importGpxFromUrl(url)`, `findOsmTracesInView()`,
`importOsmTrace(osm_url)`.
- **`units.js`** — используется для форматирования длины треков в списке.
- **`#sheet-gpx`** — единственный sheet для всех источников. Никаких
новых sheet не создаётся.
- **`#toolbar`** — кнопка `#tb-gpx` уже открывает `#sheet-gpx`. Не меняется.
- **`/api/health`** — расширить выдачей размеров кэшей (REQ-NF-03).