474 lines
22 KiB
Markdown
474 lines
22 KiB
Markdown
---
|
||
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).
|