22 KiB
type, work_item_id, title, version, status, created_at, updated_at, authors
| type | work_item_id | title | version | status | created_at | updated_at | authors | |
|---|---|---|---|---|---|---|---|---|
| trz | ET-008 | ТЗ: GPS-треки с публичных платформ на карте | 1 | draft | 2026-06-01 | 2026-06-01 |
|
ТЗ — 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рядом — «Загрузить». - При нажатии:
- Клиентская валидация URL (
new URL(), схемаhttps?:). - Запрос
GET /api/gpx/fetch?url=<encoded>. - Полученный текст GPX парсится тем же
parseGpx()изgpx.js. - Результат добавляется в
window.gpxTracksкак обычно. Полеsource={kind: 'url', url: '<original>'}. filenameдля отображения: последний segment URL без.gpxили<gpx><metadata><name>если есть.
- Клиентская валидация URL (
- Поддерживается также 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 редиректов с повторной валидацией хоста).
- Схема URL ∈ {
- Загрузка:
- Таймаут 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в секции «Источники». - Текст: «Найти треки в этой области».
- При нажатии:
- Получить bbox видимой области карты:
map.getBounds(). - Валидация: площадь bbox ≤ 0.25 deg² (OSM API limit — иначе ошибка). Если больше — toast «Слишком большая область, увеличьте zoom».
- Запрос
GET /api/gpx/osm/traces?bbox=west,south,east,north. - Открыть подсекцию «Найденные треки» (REQ-F-05).
- Получить bbox видимой области карты:
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) объединяются в один общий «Анонимные треки этой области».
- OSM возвращает GPX 1.0 с
- Кэш:
- Ключ =
(bbox_rounded_to_4_digits, page). - In-memory LRU, max 256 записей, TTL 24 ч.
- Ключ =
- Ответ (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-трека
При клике на «Показать»:
- Запрос
GET /api/gpx/fetch?url=<gpx_url>— тот же эндпоинт, что для произвольного URL (переиспользование кэша и валидации). - После загрузки — те же шаги, что REQ-F-02 (парсинг → модель → рендеринг).
- Поле
source={kind: 'osm', osm_id: <id>, url: <osm_page_url>}. - Карточка в списке найденных треков получает индикатор «✓ Загружен».
- Повторный клик «Показать» — no-op (toast «Уже загружен»).
REQ-F-08: Отображение источника в карточке трека
В существующей карточке трека в списке #gpx-list (ET-006):
- Под именем файла мелким шрифтом добавить строку «источник»:
- Локальный файл: «📁 локальный файл» (без изменения для ET-006).
- URL: «🔗 » (например, «🔗 github.com»).
- OSM: «🌍 OSM #» — кликабельная ссылка на страницу osm.org.
REQ-F-09: Расширение модели window.gpxTracks
Каждый элемент window.gpxTracks дополнительно содержит:
{
// ... существующие поля 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="...">. - Структура:
<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
Добавить под именем файла строку:
<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 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 Внутренняя модель — расширение
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
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
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 метров на экваторе):
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).