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

22 KiB
Raw Blame History

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
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):
    {
      "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: «🔗 » (например, «🔗 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).