feat(gps-tracks): GPX download from public track popup
Реализация 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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'; });
|
||||
|
||||
Reference in New Issue
Block a user