Require login to export files; allow downloading the original too
?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:
1 parent
ceaaa0bb92
commit
eca8519152
4 files changed
+123
-30
No files matched your search
@@ -191,11 +191,29 @@ module Maptool
|
||||
json paths: paths, tree: GPX_STORE.tree
|
||||
end
|
||||
|
||||
# ?download=1 exports the file's GeoJSON as an attachment instead of
|
||||
# rendering it on the map — the only way to get a .gpx/.fit's converted
|
||||
# form out of the app, since GpxStore#geojson's cache is otherwise an
|
||||
# internal implementation detail with no filename of its own to expose.
|
||||
# ?download=geojson exports the file's GeoJSON as an attachment instead
|
||||
# of rendering it on the map — the only way to get a .gpx/.fit's
|
||||
# converted form out of the app, since GpxStore#geojson's cache is
|
||||
# otherwise an internal implementation detail with no filename of its
|
||||
# own to expose. ?download=original exports the untouched, as-uploaded
|
||||
# file instead (a .geojson source's "original" is that same file, so
|
||||
# the two downloads are identical there — harmless, not special-cased).
|
||||
# Both gated the same as the other write/manage actions below (upload,
|
||||
# move, create folder) — viewing a file on the map stays open to
|
||||
# anyone, exporting it as a file does not.
|
||||
get "/api/gpx/file/*" do
|
||||
require_login! if params[:download]
|
||||
|
||||
if params[:download] == "original"
|
||||
begin
|
||||
path = GPX_STORE.resolve_file(params[:splat].first)
|
||||
rescue GpxStore::InvalidPath
|
||||
halt 400
|
||||
end
|
||||
halt 404 unless File.file?(path)
|
||||
return send_file(path, filename: File.basename(path), disposition: "attachment")
|
||||
end
|
||||
|
||||
begin
|
||||
body = GPX_STORE.geojson(params[:splat].first)
|
||||
rescue GpxStore::InvalidPath
|
||||
|
||||
+27
-13
@@ -343,18 +343,29 @@ name instead — `"ride.gpx"` reads as `"ride.gpx.geojson"`, matching
|
||||
the real cache file sitting next to it on disk — since the GeoJSON,
|
||||
not the original, is what's actually rendered on the map from that
|
||||
point on. The original is never deleted or re-parsed once cached, just
|
||||
not re-shown as itself. Two ways to get that converted form out of the
|
||||
app as an actual file (`GET /api/gpx/file/<path>?download=1`) — the
|
||||
cache is otherwise an implementation detail with no name of its own to
|
||||
expose: the file name itself is a real link (an `<a>`, not a `<span>`,
|
||||
specifically so clicking it downloads instead of also toggling the
|
||||
row's checkbox — a native `<a>`/`<button>` nested inside a `<label>`
|
||||
claims its own click instead of the label forwarding it to the
|
||||
associated control, so the two behaviors don't fight over the same
|
||||
click), and a dedicated download button sits next to delete for anyone
|
||||
who wouldn't guess the name itself is clickable. A directly-uploaded
|
||||
`.geojson` keeps its own name (nothing to convert) and downloads
|
||||
unchanged either way. `Gpx` parses the GPX with REXML: `<wpt>`
|
||||
not re-shown as itself, so both forms need a way out of the app as an
|
||||
actual file: the file name itself (`GET /api/gpx/file/<path>?download=geojson`)
|
||||
is a real link, not a `<span>` — clicking it downloads the GeoJSON
|
||||
rather than also toggling the row's checkbox, since a native
|
||||
`<a>`/`<button>` nested inside a `<label>` claims its own click
|
||||
instead of the label forwarding it to the associated control — and a
|
||||
second button next to delete (`?download=original`) downloads the
|
||||
untouched, as-uploaded file, since the name alone only covers half of
|
||||
what's now downloadable and isn't an obvious click target regardless.
|
||||
A directly-uploaded `.geojson` keeps its own name and both buttons
|
||||
download the same (identical) content — redundant there, not wrong.
|
||||
Both kinds of download require login, same as upload/move/delete/
|
||||
create-folder — `GET /api/gpx/file` itself stays open to anyone
|
||||
(viewing a file on the map isn't a write action), just not
|
||||
`?download=...` on it. Since both links are real `<a href>`s (so
|
||||
right-click/middle-click still do something sensible), a logged-out
|
||||
click can't just be a plain navigation the way delete/upload already
|
||||
aren't — `_download` (`gpx.js`) fetches instead, checks for a 401 the
|
||||
same way `redirectIfUnauthorized` already does for every other write
|
||||
action, and only then turns the response into a blob and clicks a
|
||||
throwaway `<a download>` to trigger the actual save — a plain
|
||||
navigation would otherwise land on a raw JSON error page instead of
|
||||
being sent to log in. `Gpx` parses the GPX with REXML: `<wpt>`
|
||||
waypoints become `Point` features (with `name`, `desc` and `time`
|
||||
carried through as properties), each `<trk>` becomes one
|
||||
`MultiLineString` feature (one line per `<trkseg>`), and each `<rte>`
|
||||
@@ -676,7 +687,10 @@ The GPX store's read endpoints (viewing the map, the folder tree,
|
||||
individual files) are public — anyone with the URL can look. Its write
|
||||
endpoints (`POST /api/gpx`, `POST /api/gpx/folder`, `DELETE
|
||||
/api/gpx/file/*`, `DELETE /api/gpx/folder/*`, `POST /api/gpx/move`)
|
||||
require login, guarded by `require_login!` in `app.rb`.
|
||||
require login, guarded by `require_login!` in `app.rb` — and so does
|
||||
exporting a file (`GET /api/gpx/file/*?download=...`), even though
|
||||
plain viewing of that same URL (no `?download`) doesn't; not a write
|
||||
exactly, but treated the same as the others rather than left open.
|
||||
|
||||
Login delegates to the Gitea instance at `git.priesol.net` via OAuth2
|
||||
(`GET /login` redirects to Gitea's authorize endpoint, `GET
|
||||
|
||||
+52
-11
@@ -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";
|
||||
|
||||
+22
-2
@@ -76,7 +76,7 @@ class AppTest < Minitest::Test
|
||||
content = File.read(fixture_path("sample.gpx"))
|
||||
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
||||
|
||||
response = request.get("/api/gpx/file/ride.gpx?download=1")
|
||||
response = request.get("/api/gpx/file/ride.gpx?download=geojson", **logged_in_env)
|
||||
assert_equal 200, response.status
|
||||
assert_includes response.headers["Content-Disposition"], 'filename="ride.gpx.geojson"'
|
||||
end
|
||||
@@ -85,11 +85,31 @@ class AppTest < Minitest::Test
|
||||
content = '{"type":"FeatureCollection","features":[]}'
|
||||
post_file("/api/gpx", filename: "spots.geojson", content: content, env: logged_in_env)
|
||||
|
||||
response = request.get("/api/gpx/file/spots.geojson?download=1")
|
||||
response = request.get("/api/gpx/file/spots.geojson?download=geojson", **logged_in_env)
|
||||
assert_equal 200, response.status
|
||||
assert_includes response.headers["Content-Disposition"], 'filename="spots.geojson"'
|
||||
end
|
||||
|
||||
def test_download_of_the_original_file_serves_it_unconverted
|
||||
content = File.read(fixture_path("sample.gpx"))
|
||||
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
||||
|
||||
response = request.get("/api/gpx/file/ride.gpx?download=original", **logged_in_env)
|
||||
assert_equal 200, response.status
|
||||
assert_includes response.headers["Content-Disposition"], 'filename="ride.gpx"'
|
||||
assert_equal content, response.body
|
||||
end
|
||||
|
||||
def test_download_requires_login
|
||||
content = File.read(fixture_path("sample.gpx"))
|
||||
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
||||
|
||||
assert_equal 401, request.get("/api/gpx/file/ride.gpx?download=geojson").status
|
||||
assert_equal 401, request.get("/api/gpx/file/ride.gpx?download=original").status
|
||||
# Plain viewing (no ?download) stays open to anyone.
|
||||
assert_equal 200, request.get("/api/gpx/file/ride.gpx").status
|
||||
end
|
||||
|
||||
def test_unparseable_gpx_returns_422
|
||||
post_file("/api/gpx", filename: "bad.gpx", content: "<gpx><trk unclosed>", env: logged_in_env)
|
||||
|
||||
|
||||
Reference in new issue
Block a user