Require login to export files; allow downloading the original too
CI / check (push) Successful in 43s
CI / deploy (push) Successful in 16s

?download=... on GET /api/gpx/file now requires login, same as
upload/move/delete/create-folder -- plain viewing (no ?download)
stays public. Added ?download=original alongside the existing
GeoJSON export, serving the untouched, as-uploaded file directly via
send_file -- the file name link still downloads GeoJSON, a new
button next to delete downloads the original.

Since both are real <a href> links (so right-click/middle-click still
work), a logged-out click can't just be left as a plain navigation --
it would land on a raw JSON error page instead of being sent to log
in. _download (gpx.js) fetches instead, reuses the existing
redirectIfUnauthorized 401 check, and only then turns the response
into a blob and clicks a throwaway <a download> to trigger the save.

Verified in a real browser: an anonymous click on either download
correctly redirects through /login to the real OAuth login page
instead of showing raw JSON.
This commit is contained in:
randy committed 2026-09-07 23:18:59 +02:00
1 parent ceaaa0bb92
commit eca8519152
4 files changed
+123 -30

No files matched your search

+52 -11
View File
@@ -278,17 +278,26 @@ export class GpxPanelControl {
// disk) rather than still reading as a plain, un-converted .gpx/.fit.
// A direct .geojson upload already ends in .geojson, so it's left
// alone. It's an <a>, not a <span>: clicking it downloads that GeoJSON
// (?download=1) — the only way to get it out of the app, since the
// original .gpx/.fit is never deleted but also never re-shown once
// converted. An <a> nested in a <label> doesn't also toggle the
// checkbox on click (unlike a plain element), so this doesn't fight
// the row's own toggle-on-map behavior.
// — the only way to get it out of the app, since the original .gpx/.fit
// is never deleted but also never re-shown once converted. An <a>
// nested in a <label> doesn't also toggle the checkbox on click (unlike
// a plain element), so this doesn't fight the row's own
// toggle-on-map behavior. href kept pointing at the real endpoint (so
// hover/right-click/middle-click still do something sensible) but the
// actual click goes through _download, which — like every other write
// action in this file — needs to redirect to /login on a 401 rather
// than navigate to a raw JSON error page (this endpoint now requires
// login the same as upload/move/delete, see app.rb).
const displayName = file.cached && !file.name.toLowerCase().endsWith(".geojson") ? `${file.name}.geojson` : file.name;
const name = document.createElement("a");
name.className = "gpx-tree__file-name";
name.href = `${gpxFileUrl(file.path)}?download=1`;
name.href = `${gpxFileUrl(file.path)}?download=geojson`;
name.textContent = displayName;
name.title = `Download ${displayName}`;
name.addEventListener("click", (event) => {
event.preventDefault();
this._download(name.href, displayName);
});
label.appendChild(checkbox);
(file.kinds || []).forEach((kind) => label.appendChild(this._kindIcon(kind)));
if (!file.cached) label.appendChild(this._pendingIcon());
@@ -324,18 +333,50 @@ export class GpxPanelControl {
return icon;
}
// Same destination as clicking the file's name (gpxFileUrl + ?download=1)
// — a second, more obviously clickable entry point for the same action.
// The file's name already downloads its GeoJSON (?download=geojson,
// matching what's displayed) — this covers the other half: the
// untouched, as-uploaded file, which otherwise has no way out of the
// app once it stops being shown as itself. For a native .geojson
// upload the two downloads are identical (nothing to convert), so this
// isn't hidden for those — just redundant, not wrong.
_downloadButton(file) {
const link = document.createElement("a");
link.className = "gpx-tree__download";
link.href = `${gpxFileUrl(file.path)}?download=1`;
link.title = "Download GeoJSON";
link.setAttribute("aria-label", "Download GeoJSON");
link.href = `${gpxFileUrl(file.path)}?download=original`;
link.title = "Download original file";
link.setAttribute("aria-label", "Download original file");
link.innerHTML = DOWNLOAD_ICON;
link.addEventListener("click", (event) => {
event.preventDefault();
this._download(link.href, file.name);
});
return link;
}
// fetch-based rather than a plain navigation so a 401 (not logged in —
// both download kinds require login now, see app.rb) redirects to
// /login like every other write action in this file
// (redirectIfUnauthorized), instead of navigating to a raw JSON error
// page. The filename is already known client-side, so there's no need
// to parse it back out of a Content-Disposition header.
async _download(url, filename) {
const response = await fetch(url);
if (redirectIfUnauthorized(response)) return;
if (!response.ok) {
window.alert("Could not download the file.");
return;
}
const blobUrl = URL.createObjectURL(await response.blob());
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(blobUrl);
}
_deleteButton(onClick) {
const button = document.createElement("button");
button.type = "button";