feat(gps-tracks): GPX download from public track popup
Some checks failed
CI / lint (push) Failing after 5s
CI / test (push) Failing after 6s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 5s
CI / build (pull_request) Has been skipped

Реализация 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>
This commit is contained in:
2026-06-03 20:59:53 +00:00
parent 6fe2ecf12b
commit eea6c846c2
12 changed files with 2284 additions and 8 deletions

View File

@@ -1300,3 +1300,44 @@ body.satellite-active #btn-basemap {
.track-popup-sources a:hover {
text-decoration: underline;
}
/* ET-011: кнопка «Скачать GPX» в popup публичного трека (REQ-NF-04) */
.track-popup-actions {
margin-top: 8px;
display: flex;
gap: 8px;
}
.track-popup-download-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
cursor: pointer;
background: var(--accent, #ff8c1a);
color: #fff;
padding: 0;
transition: opacity 0.15s ease;
}
.track-popup-download-btn:hover {
opacity: 0.9;
}
.track-popup-download-btn:focus {
outline: 2px solid var(--accent, #ff8c1a);
outline-offset: 2px;
}
.track-popup-download-btn svg {
width: 18px;
height: 18px;
}
.track-popup-download-btn.is-loading {
opacity: 0.6;
pointer-events: none;
}

View File

@@ -460,6 +460,10 @@ async function fetchAndUpdateGpsGeoJson(bounds) {
// ─── Popup при клике ──────────────────────────────────────────────
// ET-011: SVG-иконка «download», копия из index.html sheet-route::downloadGPX
// (см. ADR-014 §3.a). Inline-SVG, чтобы popup не зависел от внешнего ассета.
const _GPS_DOWNLOAD_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
function _renderTrackPopupHtml(props) {
const name = props.name || 'Без названия';
const activity = props.activity_type || props.activity || 'other';
@@ -488,6 +492,22 @@ function _renderTrackPopupHtml(props) {
}
} catch(e) {}
// ET-011 / REQ-F-01: кнопка «Скачать» в popup публичного трека.
// Безопасно используем числовой id (FastAPI Path int ge=1 на сервере),
// но всё равно делаем явный Number() — на случай, если MVT отдал строку.
const trackId = Number(props.id);
const actionsHtml = Number.isFinite(trackId) && trackId > 0
? `<div class="track-popup-actions">
<button type="button"
class="track-popup-download-btn"
aria-label="Скачать GPX"
title="Скачать GPX"
data-track-id="${trackId}">
${_GPS_DOWNLOAD_ICON_SVG}
</button>
</div>`
: '';
return `
<div class="track-popup">
<div class="track-popup-name">${name}</div>
@@ -495,11 +515,109 @@ function _renderTrackPopupHtml(props) {
<div class="track-popup-row">📏 ${lengthKm} км · ${points} точек</div>
${dateStr ? `<div class="track-popup-row">📅 ${dateStr}</div>` : ''}
${user ? `<div class="track-popup-row">👤 ${user}</div>` : ''}
${actionsHtml}
${sourcesHtml}
</div>
`;
}
// ─── ET-011: Скачивание GPX из popup ─────────────────────────────
/**
* ET-011 (ADR-014 §3): парсит заголовок Content-Disposition и возвращает имя
* файла. Приоритет — `filename*=UTF-8''<percent-encoded>` (RFC 5987);
* fallback — `filename="…"`; при отсутствии обоих — null.
*
* @param {string|null} cd
* @returns {string|null}
*/
function _parseFilenameFromCD(cd) {
if (!cd) return null;
// RFC 5987: filename*=UTF-8''<encoded>
const ext = cd.match(/filename\*=UTF-8''([^;]+)/i);
if (ext && ext[1]) {
try {
return decodeURIComponent(ext[1].trim());
} catch (_) {
// битый percent-encoding — упадём в обычный filename
}
}
const plain = cd.match(/filename="([^"]+)"/i) || cd.match(/filename=([^;]+)/i);
if (plain && plain[1]) return plain[1].trim();
return null;
}
/**
* ET-011 (ADR-014 §3.b): человекочитаемое сообщение по HTTP-статусу.
*
* @param {number} status
* @param {object} body уже распарсенный JSON ответа (может быть пустым)
*/
function _handleDownloadError(status, body) {
if (typeof showToast !== 'function') return;
if (status === 403) {
// ADR-015 §G: backend кладёт external_urls в detail.
const urls = (body && body.detail && body.detail.external_urls)
|| (body && body.external_urls);
const firstUrl = Array.isArray(urls) && urls.length ? urls[0] : null;
if (firstUrl) {
showToast(`Источник запрещает скачивание. Откройте трек на сайте источника: ${firstUrl}`);
} else {
showToast('Источник запрещает скачивание. Откройте трек на сайте источника.');
}
} else if (status === 404) {
showToast('Трек не найден.');
} else if (status === 413) {
showToast('Трек слишком большой для скачивания.');
} else if (status === 400) {
showToast('Неподдерживаемый формат файла.');
} else {
showToast('Не удалось скачать. Попробуйте ещё раз.');
}
}
/**
* ET-011: скачивает GPX для трека с публичного слоя.
* Использует тот же паттерн (fetch → Blob → URL.createObjectURL → a.download),
* что и app.js::downloadGPX(), — он уже отлажен на iOS Safari (BRD R-1).
*
* @param {number|string} trackId
* @param {HTMLElement|null} btnEl кнопка, на которой показываем индикатор
*/
async function _downloadPublicTrack(trackId, btnEl) {
if (btnEl) btnEl.classList.add('is-loading');
const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
const url = `${basePath}/api/gps-tracks/${encodeURIComponent(trackId)}/download`;
try {
const resp = await fetch(url);
if (!resp.ok) {
let body = {};
try { body = await resp.json(); } catch (_) {}
_handleDownloadError(resp.status, body);
return;
}
const blob = await resp.blob();
const filename = _parseFilenameFromCD(resp.headers.get('Content-Disposition'))
|| `track-${trackId}.gpx`;
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Освобождаем blob чуть позже — Safari иногда отменяет скачивание,
// если revoke сработал синхронно с click().
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
} catch (err) {
if (typeof showToast === 'function') {
showToast('Не удалось скачать. Попробуйте ещё раз.');
}
} finally {
if (btnEl) btnEl.classList.remove('is-loading');
}
}
function _setupGpsClickHandler(map) {
const layerIds = [window.gpsTracksLayer.layerId, window.gpsTracksLayer.layerGeoId];
@@ -511,10 +629,26 @@ function _setupGpsClickHandler(map) {
const feature = e.features && e.features[0];
if (!feature) return;
new maplibregl.Popup({ closeOnClick: true, maxWidth: '300px' })
const popup = new maplibregl.Popup({ closeOnClick: true, maxWidth: '300px' })
.setLngLat(e.lngLat)
.setHTML(_renderTrackPopupHtml(feature.properties))
.addTo(map);
// ET-011 / ADR-014 §3.b: делегированный обработчик клика на
// кнопку «Скачать». Popup в проекте перерисовывается при каждом
// открытии, так что листенер живёт ровно столько, сколько popup.
const popupEl = popup.getElement && popup.getElement();
if (popupEl) {
popupEl.addEventListener('click', (ev) => {
const btn = ev.target.closest && ev.target.closest('.track-popup-download-btn');
if (!btn) return;
ev.preventDefault();
ev.stopPropagation();
const tid = btn.getAttribute('data-track-id');
if (!tid) return;
_downloadPublicTrack(tid, btn);
});
}
});
map.on('mouseenter', layerId, () => { map.getCanvas().style.cursor = 'pointer'; });