Реализация 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) <noreply@anthropic.com>
266 lines
11 KiB
Python
266 lines
11 KiB
Python
"""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 для блока <copyright> (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 (если пусто — в `<name>` ставится «Без названия»).
|
||
description: tracks.description (если пусто — `<desc>` опускается).
|
||
activity_type: tracks.activity_type, попадает в `<trk><type>`.
|
||
user: tracks.user — попадает в `<metadata><author><name>`.
|
||
created_at: ISO-8601 строка → нормализуется в UTC `<metadata><time>`.
|
||
sources: список source_id (для `<copyright>` и `<link><text>`).
|
||
external_urls: список внешних URL → `<metadata><link>` по одному.
|
||
coords: список (lon, lat) — точки трека.
|
||
|
||
Returns:
|
||
XML-строка (включает `<?xml …?>`-декларацию).
|
||
"""
|
||
# GPX namespace должен быть default — иначе ET создаёт префикс ns0:gpx.
|
||
gpx_ns = "http://www.topografix.com/GPX/1/1"
|
||
xsi_ns = "http://www.w3.org/2001/XMLSchema-instance"
|
||
ET.register_namespace("", gpx_ns)
|
||
ET.register_namespace("xsi", xsi_ns)
|
||
|
||
root = ET.Element(
|
||
f"{{{gpx_ns}}}gpx",
|
||
{
|
||
"version": "1.1",
|
||
"creator": "Enduro Trails",
|
||
f"{{{xsi_ns}}}schemaLocation": (
|
||
"http://www.topografix.com/GPX/1/1 "
|
||
"http://www.topografix.com/GPX/1/1/gpx.xsd"
|
||
),
|
||
},
|
||
)
|
||
|
||
# ─── <metadata> ───────────────────────────────────────────────
|
||
# Порядок дочерних элементов фиксирован XSD-схемой GPX 1.1:
|
||
# name, desc, author, copyright, link*, time, keywords, bounds, extensions.
|
||
# Любое отклонение → DocumentInvalid (см. UT-03).
|
||
metadata = ET.SubElement(root, f"{{{gpx_ns}}}metadata")
|
||
|
||
meta_name = ET.SubElement(metadata, f"{{{gpx_ns}}}name")
|
||
meta_name.text = (name or "").strip() or "Без названия"
|
||
|
||
desc_clean = (description or "").strip()
|
||
if desc_clean:
|
||
desc_el = ET.SubElement(metadata, f"{{{gpx_ns}}}desc")
|
||
desc_el.text = desc_clean
|
||
|
||
user_clean = (user or "").strip()
|
||
if user_clean:
|
||
author_el = ET.SubElement(metadata, f"{{{gpx_ns}}}author")
|
||
author_name = ET.SubElement(author_el, f"{{{gpx_ns}}}name")
|
||
author_name.text = user_clean
|
||
|
||
# <copyright>: OSM → официальная ODbL-ссылка (ADR-014 §G).
|
||
# Для не-OSM источников: license = первый external_url (если есть),
|
||
# иначе блок опускаем.
|
||
sources_list = list(sources or [])
|
||
if "osm" in sources_list:
|
||
cr_el = ET.SubElement(
|
||
metadata, f"{{{gpx_ns}}}copyright", {"author": "Enduro Trails"}
|
||
)
|
||
lic_el = ET.SubElement(cr_el, f"{{{gpx_ns}}}license")
|
||
lic_el.text = _OSM_LICENSE_URL
|
||
elif external_urls:
|
||
first_url = next((u for u in external_urls if u), None)
|
||
if first_url:
|
||
cr_el = ET.SubElement(
|
||
metadata, f"{{{gpx_ns}}}copyright", {"author": "Enduro Trails"}
|
||
)
|
||
lic_el = ET.SubElement(cr_el, f"{{{gpx_ns}}}license")
|
||
lic_el.text = first_url
|
||
|
||
# <link> на каждый external_url; <text> = "Источник: <source_id>".
|
||
# ADR-014 §G: по одному `<link>` на каждый элемент external_urls.
|
||
src_for_link = list(sources or [])
|
||
for idx, url in enumerate(external_urls or []):
|
||
if not url:
|
||
continue
|
||
link_el = ET.SubElement(metadata, f"{{{gpx_ns}}}link", {"href": url})
|
||
text_el = ET.SubElement(link_el, f"{{{gpx_ns}}}text")
|
||
src_label = src_for_link[idx] if idx < len(src_for_link) else (
|
||
src_for_link[0] if src_for_link else ""
|
||
)
|
||
text_el.text = (
|
||
f"Источник: {src_label}" if src_label else "Источник"
|
||
)
|
||
|
||
time_str = _format_utc(created_at)
|
||
if time_str:
|
||
time_el = ET.SubElement(metadata, f"{{{gpx_ns}}}time")
|
||
time_el.text = time_str
|
||
|
||
# ─── <trk> ────────────────────────────────────────────────────
|
||
trk = ET.SubElement(root, f"{{{gpx_ns}}}trk")
|
||
trk_name = ET.SubElement(trk, f"{{{gpx_ns}}}name")
|
||
trk_name.text = (name or "").strip() or "Без названия"
|
||
|
||
act_clean = (activity_type or "").strip()
|
||
if act_clean:
|
||
trk_type = ET.SubElement(trk, f"{{{gpx_ns}}}type")
|
||
trk_type.text = act_clean
|
||
|
||
trkseg = ET.SubElement(trk, f"{{{gpx_ns}}}trkseg")
|
||
# Координаты приходят как (lon, lat) из _wkb_to_coords (см. mvt.py).
|
||
# GPX: lat/lon атрибуты с фиксированной точностью 6 знаков
|
||
# (~0.11 м, ADR-014 §G).
|
||
for lon, lat in coords or []:
|
||
ET.SubElement(
|
||
trkseg,
|
||
f"{{{gpx_ns}}}trkpt",
|
||
{"lat": f"{lat:.6f}", "lon": f"{lon:.6f}"},
|
||
)
|
||
|
||
# ET.tostring с xml_declaration=True даёт нужный prolog.
|
||
xml_bytes = ET.tostring(
|
||
root,
|
||
encoding="utf-8",
|
||
xml_declaration=True,
|
||
)
|
||
return xml_bytes.decode("utf-8")
|
||
|
||
|
||
def _sanitize_for_filesystem(name: str) -> str:
|
||
"""Заменяет запрещённые / управляющие символы на '_'.
|
||
|
||
Затем триммит пробелы и точки по краям (Windows-нюанс).
|
||
"""
|
||
out_chars: list[str] = []
|
||
for ch in name:
|
||
code = ord(ch)
|
||
if ch in _FORBIDDEN_NAME_CHARS:
|
||
out_chars.append("_")
|
||
elif code < 0x20 or code == 0x7F:
|
||
out_chars.append("_")
|
||
else:
|
||
out_chars.append(ch)
|
||
return "".join(out_chars).strip(" .")
|
||
|
||
|
||
def _truncate_utf8(name: str, max_bytes: int) -> str:
|
||
"""Триммит строку так, чтобы её UTF-8-длина не превышала max_bytes."""
|
||
encoded = name.encode("utf-8")
|
||
if len(encoded) <= max_bytes:
|
||
return name
|
||
# Декодируем с ignore, чтобы не обрезать середину code-point'а.
|
||
return encoded[:max_bytes].decode("utf-8", errors="ignore")
|
||
|
||
|
||
def _ascii_fallback(name: str) -> str:
|
||
"""ASCII-fallback для параметра `filename=` (без `*`).
|
||
|
||
ADR-014 §F.7: транслитерации **не делаем**; non-ASCII / non-printable
|
||
символы заменяем на '_'. Если результат пуст — caller подставит
|
||
'track-<id>'.
|
||
"""
|
||
out: list[str] = []
|
||
for ch in name:
|
||
code = ord(ch)
|
||
# 0x20..0x7E — printable ASCII, исключая запрещённые ФС-символы
|
||
# (они уже подменены в _sanitize_for_filesystem, но на всякий случай).
|
||
if 0x20 <= code <= 0x7E and ch not in _FORBIDDEN_NAME_CHARS:
|
||
out.append(ch)
|
||
else:
|
||
out.append("_")
|
||
return "".join(out).strip(" .")
|
||
|
||
|
||
def safe_filename(name: str | None, track_id: int) -> tuple[str, str]:
|
||
"""Возвращает (ascii_fallback, utf8_percent_quoted) без расширения.
|
||
|
||
Алгоритм (ADR-014 §F):
|
||
1. Пустой/None → 'track-<id>'.
|
||
2. Запрещённые / управляющие символы → '_'.
|
||
3. Триммим пробелы и точки.
|
||
4. Триммим до 80 байт UTF-8.
|
||
5. Пустой результат → 'track-<id>'.
|
||
6. ASCII-fallback: только printable ASCII; non-ASCII → '_'.
|
||
7. UTF-8 quoted: urllib.parse.quote(name, safe='', encoding='utf-8').
|
||
|
||
Args:
|
||
name: исходное имя (tracks.name) — может быть None / пустым.
|
||
track_id: id трека для fallback-имени.
|
||
|
||
Returns:
|
||
Кортеж (ascii_fallback, utf8_percent_quoted). Оба без расширения.
|
||
"""
|
||
fallback = f"track-{track_id}"
|
||
|
||
raw = (name or "").strip()
|
||
if not raw:
|
||
return fallback, urllib.parse.quote(fallback, safe="", encoding="utf-8")
|
||
|
||
sanitized = _sanitize_for_filesystem(raw)
|
||
if not sanitized:
|
||
return fallback, urllib.parse.quote(fallback, safe="", encoding="utf-8")
|
||
|
||
truncated = _truncate_utf8(sanitized, _MAX_NAME_BYTES).strip(" .")
|
||
if not truncated:
|
||
return fallback, urllib.parse.quote(fallback, safe="", encoding="utf-8")
|
||
|
||
utf8_quoted = urllib.parse.quote(truncated, safe="", encoding="utf-8")
|
||
|
||
ascii_ok = _ascii_fallback(truncated)
|
||
if not ascii_ok:
|
||
ascii_ok = fallback
|
||
|
||
return ascii_ok, utf8_quoted
|