From eea6c846c2e20c61057f0fe475d00dd0380a48fe Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 3 Jun 2026 20:59:53 +0000 Subject: [PATCH] feat(gps-tracks): GPX download from public track popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализация ET-011: кнопка «Скачать GPX» в popup публичного GPS-трека и новый эндпоинт GET /api/gps-tracks/{track_id}/download (GPX 1.1 + Content-Disposition с UTF-8 именем по RFC 5987). Реэкспорт защищён per-source флагом `download_allowed` в `config/gps_sources.yaml` (default-deny, MVP whitelist = `osm`). Backend: - `src/api/gps_tracks/export.py` — чистый stdlib-builder GPX 1.1 (`build_gpx`) + санитизация имени файла (`safe_filename`, RFC 5987). - `src/api/gps_tracks/endpoint.py` — новый route с проверками 400 / 403 / 404 / 413; cap 200 000 точек (REQ-NF-02). - `src/api/gps_tracks/config.py` — `load_download_allowed_sources()` читает YAML, default-deny при отсутствии поля; fallback на `{"osm"}` при отсутствии конфига. - `src/api/main.py` — пробрасывает `GPS_SOURCES_CONFIG_PATH` в router. Frontend: - `src/web/gps_tracks.js` — кнопка в `_renderTrackPopupHtml`, обработчик `_downloadPublicTrack` (fetch + Blob + a.download — тот же паттерн, что в `app.js::downloadGPX`, R-1 митигирован), парсер `_parseFilenameFromCD` для RFC 5987, маппинг ошибок `_handleDownloadError` (403/404/413/5xx → showToast). - `src/web/app.css` — стиль кнопки, 32×32 CSS px (REQ-NF-04). Тесты: - 13 unit для GPX-builder (UT-01/02/03/05; XSD-валидация против `tests/fixtures/gpx-1.1/gpx.xsd`). - 10 unit для `safe_filename` (UT-04). - 11 integration для download-эндпоинта (IT-01..08 + ANY-rule license check + default-deny без конфига). ADR-014 (gpx-download-endpoint), ADR-015 (source-redistribution-policy). Refs: ET-011 Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 14 + config/gps_sources.yaml | 8 + src/api/gps_tracks/config.py | 54 ++ src/api/gps_tracks/endpoint.py | 139 +++- src/api/gps_tracks/export.py | 265 ++++++++ src/api/main.py | 9 +- src/web/app.css | 41 ++ src/web/gps_tracks.js | 136 +++- tests/api/test_gps_tracks_download.py | 412 ++++++++++++ tests/api/test_gps_tracks_filename.py | 95 +++ tests/api/test_gps_tracks_gpx_builder.py | 331 ++++++++++ tests/fixtures/gpx-1.1/gpx.xsd | 788 +++++++++++++++++++++++ 12 files changed, 2284 insertions(+), 8 deletions(-) create mode 100644 src/api/gps_tracks/export.py create mode 100644 tests/api/test_gps_tracks_download.py create mode 100644 tests/api/test_gps_tracks_filename.py create mode 100644 tests/api/test_gps_tracks_gpx_builder.py create mode 100644 tests/fixtures/gpx-1.1/gpx.xsd diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c62262..8278a67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to this project will be documented in this file. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +## [Unreleased] + +### Added +- ET-011: Скачивание GPX из popup публичного трека. Новый эндпоинт + `GET /api/gps-tracks/{track_id}/download` собирает GPX 1.1 из геометрии + трека и отдаёт с `Content-Disposition: attachment` (UTF-8 имя файла по + RFC 5987). В popup на карте появилась кнопка «Скачать GPX» (32×32 CSS px, + mobile-friendly). Реализация: новый модуль `src/api/gps_tracks/export.py` + (`build_gpx`, `safe_filename`); расширение `config/gps_sources.yaml` + per-source флагом `download_allowed` (default-deny; MVP whitelist = `osm`, + см. ADR-015); helper `load_download_allowed_sources` в `config.py`. + Тесты: 13 unit GPX-builder + 10 unit filename + 11 integration download. + ADR-014, ADR-015. Refs: ET-011. + ## [v0.0.2] — 2026-06-02 ### Added diff --git a/config/gps_sources.yaml b/config/gps_sources.yaml index a8fd78c..b26e7af 100644 --- a/config/gps_sources.yaml +++ b/config/gps_sources.yaml @@ -10,6 +10,8 @@ sources: parser_module: "src.api.gps_tracks.sources.osm" save_user_field: true external_url_template: "https://www.openstreetmap.org/user/{user}/traces/{external_id_numeric}" + # ET-011 / ADR-015: ODbL разрешает реэкспорт при атрибуции. + download_allowed: true - id: enduro_russia name: "EnduroRussia.ru" @@ -22,6 +24,8 @@ sources: parser_module: "src.api.gps_tracks.sources.enduro_russia" save_user_field: false source_priority: 80 + # ET-011 / ADR-015: ToS не содержит явного разрешения на ре-экспорт. + download_allowed: false - id: wikiloc name: "Wikiloc" @@ -36,6 +40,8 @@ sources: source_priority: 70 activity_filter: [motorcycle, enduro] max_tracks_per_run: 50 + # ET-011 / ADR-015: proprietary, ToS запрещает массовый ре-экспорт. + download_allowed: false - id: ttrails name: "Тропинки.ру" @@ -47,3 +53,5 @@ sources: attribution: "ttrails.ru" parser_module: "src.api.gps_tracks.sources.ttrails" save_user_field: false + # ET-011 / ADR-015: collection-ADR proposed (blocked), реэкспорт запрещён. + download_allowed: false diff --git a/src/api/gps_tracks/config.py b/src/api/gps_tracks/config.py index 6fa7a5a..9c0ce44 100644 --- a/src/api/gps_tracks/config.py +++ b/src/api/gps_tracks/config.py @@ -1,6 +1,16 @@ """Загрузка и валидация конфигурации GPS-источников и регионов (ET-008).""" +import logging +import os + import yaml +logger = logging.getLogger(__name__) + + +# ET-011 / ADR-015: дефолтный whitelist для скачивания, если конфиг недоступен +# (например, в unit-тестах). Совпадает с production-выбором "только OSM". +DEFAULT_DOWNLOAD_ALLOWED_SOURCES = frozenset({"osm"}) + def load_sources_config(path: str) -> list: """Загружает конфигурацию источников GPS-треков. @@ -87,3 +97,47 @@ def load_regions_config(path: str) -> list: ) return regions + + +def load_download_allowed_sources(path: str | None) -> set[str]: + """ET-011 / ADR-015: возвращает whitelist source_id с download_allowed=true. + + Семантика default-deny: если поле `download_allowed` отсутствует, + источник **не** добавляется в whitelist. + + Args: + path: путь к `config/gps_sources.yaml` либо None. + + Returns: + set[str] — id источников, для которых разрешено скачивание. + При path=None / отсутствии файла / ошибке парсинга — возвращает + `DEFAULT_DOWNLOAD_ALLOWED_SOURCES` (`{"osm"}`) и логирует WARNING. + """ + if not path: + return set(DEFAULT_DOWNLOAD_ALLOWED_SOURCES) + if not os.path.exists(path): + logger.warning( + "gps_sources config not found at %s; falling back to default " + "download whitelist=%s", + path, + sorted(DEFAULT_DOWNLOAD_ALLOWED_SOURCES), + ) + return set(DEFAULT_DOWNLOAD_ALLOWED_SOURCES) + try: + sources = load_sources_config(path) + except (ValueError, OSError) as exc: + logger.warning( + "failed to load gps_sources config from %s (%s); falling back to " + "default download whitelist=%s", + path, + exc, + sorted(DEFAULT_DOWNLOAD_ALLOWED_SOURCES), + ) + return set(DEFAULT_DOWNLOAD_ALLOWED_SOURCES) + + allowed: set[str] = set() + for src in sources: + # Дефолт `False` — default-deny (ADR-015 §C). + if src.get("download_allowed") is True: + allowed.add(src["id"]) + return allowed diff --git a/src/api/gps_tracks/endpoint.py b/src/api/gps_tracks/endpoint.py index ac526c6..e86e34d 100644 --- a/src/api/gps_tracks/endpoint.py +++ b/src/api/gps_tracks/endpoint.py @@ -1,13 +1,17 @@ -"""FastAPI router для GPS-треков (ET-008).""" +"""FastAPI router для GPS-треков (ET-008, расширен в ET-011).""" import json +import logging import os from typing import Optional -from fastapi import APIRouter, HTTPException, Query, Response +from fastapi import APIRouter, HTTPException, Path, Query, Response +from src.api.gps_tracks.config import load_download_allowed_sources from src.api.gps_tracks.db import get_tracks_in_bbox, init_db, open_db +from src.api.gps_tracks.export import build_gpx, safe_filename from src.api.gps_tracks.mvt import ( _gps_tile_cache, + _wkb_to_coords, build_gps_mvt, clear_gps_tile_cache, get_gps_cached_tile, @@ -15,6 +19,13 @@ from src.api.gps_tracks.mvt import ( _tile_to_bbox, ) +logger = logging.getLogger("uvicorn.access") + +# ET-011 / ADR-014: +ALLOWED_DOWNLOAD_FORMATS = {"gpx"} +MAX_POINTS_FOR_DOWNLOAD = 200_000 # REQ-NF-02 +GPX_MEDIA_TYPE = "application/gpx+xml; charset=utf-8" + def _parse_bbox(bbox_str: str) -> tuple: """Парсит и валидирует bbox строку 'west,south,east,north'. @@ -52,8 +63,6 @@ def _parse_bbox(bbox_str: str) -> tuple: def _row_to_geojson_feature(row) -> dict: """Конвертирует sqlite3.Row в GeoJSON Feature.""" - from src.api.gps_tracks.mvt import _wkb_to_coords - coords = _wkb_to_coords(row["geom"]) sources = json.loads(row["sources_json"] or "[]") @@ -94,17 +103,29 @@ def _row_to_geojson_feature(row) -> dict: } -def create_gps_router(db_path: str) -> APIRouter: +def create_gps_router( + db_path: str, + sources_config_path: Optional[str] = None, +) -> APIRouter: """Создаёт FastAPI router для GPS-треков. Args: - db_path: путь к SQLite БД для GPS-треков + db_path: путь к SQLite БД для GPS-треков. + sources_config_path: путь к ``config/gps_sources.yaml``. + Если None — для ET-011 download-эндпоинта используется + default-deny whitelist ``{"osm"}`` (см. ADR-015). Returns: APIRouter с prefix="/api/gps-tracks" """ router = APIRouter(prefix="/api/gps-tracks", tags=["gps-tracks"]) + # ET-011 / ADR-015: whitelist source_id, для которых разрешено + # скачивание GPX. Читается один раз при старте router'а. + allowed_download_sources: set[str] = load_download_allowed_sources( + sources_config_path + ) + def _get_conn(): conn = open_db(db_path) init_db(conn) @@ -307,4 +328,110 @@ def create_gps_router(db_path: str) -> APIRouter: clear_gps_tile_cache() return {"status": "ok", "cleared": True} + # ─── ET-011: скачивание GPX из popup ────────────────────────── + @router.get("/{track_id}/download") + async def download_track( + track_id: int = Path(..., ge=1), + format: str = Query("gpx", description="Формат файла (только 'gpx' в MVP)"), + ): + """Отдаёт GPX-файл для трека с правильным Content-Disposition. + + Реализует ADR-014 / ADR-015 для ET-011. + + Порядок проверок (ADR-014 §H): + 1. format ∈ whitelist (иначе 400). + 2. SELECT по id (иначе 404). + 3. points_count <= MAX (иначе 413). + 4. licence policy по sources (иначе 403). + 5. Сборка GPX → 200. + """ + if format not in ALLOWED_DOWNLOAD_FORMATS: + raise HTTPException( + status_code=400, + detail="unsupported_format", + ) + + try: + conn = _get_conn() + cur = conn.cursor() + cur.execute( + """ + SELECT id, name, description, activity_type, user, created_at, + length_m, points_count, geom, sources_json, + external_urls_json + FROM tracks + WHERE id = ? + """, + (track_id,), + ) + row = cur.fetchone() + conn.close() + except Exception as exc: + raise HTTPException(500, f"DB error: {exc}") + + if row is None: + raise HTTPException(status_code=404, detail="track_not_found") + + points_count = row["points_count"] or 0 + if points_count > MAX_POINTS_FOR_DOWNLOAD: + raise HTTPException(status_code=413, detail="track_too_large") + + sources = json.loads(row["sources_json"] or "[]") + external_urls = json.loads(row["external_urls_json"] or "[]") + + # ADR-015 §B1: разрешение по принципу ANY — хотя бы один разрешённый. + if not any(s in allowed_download_sources for s in sources): + raise HTTPException( + status_code=403, + detail={ + "detail": "source_forbidden", + "external_urls": external_urls, + }, + ) + + coords = _wkb_to_coords(row["geom"]) or [] + + try: + xml_str = build_gpx( + track_id=row["id"], + name=row["name"], + description=row["description"], + activity_type=row["activity_type"], + user=row["user"], + created_at=row["created_at"], + sources=sources, + external_urls=external_urls, + coords=coords, + ) + except Exception as exc: + logger.exception("build_gpx failed for track_id=%s", track_id) + raise HTTPException(500, f"GPX build error: {exc}") + + ascii_name, utf8_quoted = safe_filename(row["name"], track_id) + content_disposition = ( + f'attachment; filename="{ascii_name}.gpx"; ' + f"filename*=UTF-8''{utf8_quoted}.gpx" + ) + + xml_bytes = xml_str.encode("utf-8") + + # REQ-F-07: лёгкое журналирование успешной отдачи. + logger.info( + "track_download id=%d sources=%s size_bytes=%d", + track_id, + ",".join(sources) if sources else "", + len(xml_bytes), + ) + + return Response( + content=xml_bytes, + media_type=GPX_MEDIA_TYPE, + headers={ + "Content-Disposition": content_disposition, + "Cache-Control": "private, max-age=3600", + "Access-Control-Allow-Origin": "*", + "Access-Control-Expose-Headers": "Content-Disposition", + }, + ) + return router diff --git a/src/api/gps_tracks/export.py b/src/api/gps_tracks/export.py new file mode 100644 index 0000000..44ce627 --- /dev/null +++ b/src/api/gps_tracks/export.py @@ -0,0 +1,265 @@ +"""GPX-экспорт публичных GPS-треков (ET-011, ADR-014). + +Сборка GPX 1.1 из метаданных трека + санитизация имени файла для +HTTP Content-Disposition с поддержкой RFC 5987 (UTF-8 filename*). + +Чистый stdlib-модуль, без I/O — легко тестируется юнитами. +""" +from __future__ import annotations + +import urllib.parse +import xml.etree.ElementTree as ET +from datetime import datetime, timezone + +# OSM-license URL для блока (ADR-014 §G, ODbL). +_OSM_LICENSE_URL = "https://www.openstreetmap.org/copyright" + +# Запрещённые в FAT/NTFS символы (ADR-014 §F.2). +_FORBIDDEN_NAME_CHARS = set('/\\:*?"<>|') + +# Лимит длины ASCII-fallback по байтам UTF-8 (ADR-014 §F.5; RFC 5987 — 254 +# на параметр, минус префикс "filename*=UTF-8''" и расширение). +_MAX_NAME_BYTES = 80 + + +def _format_utc(iso_str: str | None) -> str | None: + """Нормализует ISO-8601 datetime → 'YYYY-MM-DDTHH:MM:SSZ' (UTC). + + Поддерживает входные строки с/без таймзоны. None / нераспарсимое — None. + """ + if not iso_str: + return None + try: + # Python 3.11+ fromisoformat понимает 'Z'-суффикс; для надёжности + # делаем явную замену. + normalized = iso_str.replace("Z", "+00:00") + dt = datetime.fromisoformat(normalized) + except (ValueError, TypeError): + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + + +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]], +) -> str: + """Собирает GPX 1.1 как XML-строку (с XML-declaration). + + Args: + track_id: id трека (используется только в fallback-имени). + name: tracks.name (если пусто — в `` ставится «Без названия»). + description: tracks.description (если пусто — `` опускается). + activity_type: tracks.activity_type, попадает в ``. + user: tracks.user — попадает в ``. + created_at: ISO-8601 строка → нормализуется в UTC `