Compare commits

..
2 Commits
Author SHA1 Message Date
randy eca8519152 Require login to export files; allow downloading the original too
CI / deploy (push) Successful in 16s
CI / check (push) Successful in 43s
?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.
2026-09-07 23:18:59 +02:00
randy ceaaa0bb92 Apply the NPS-inspired palette to the online map too
ONLINE_STYLE_PATCH mirrors OFFLINE_THEME's color choices onto
OpenFreeMap Liberty's OpenMapTiles layer ids (a separate hand-built
table -- the two schemas share no layer names). loadOnlineStyle
fetches Liberty's style.json once, patches it in place, and the
result replaces the plain URL everywhere ONLINE_STYLE is used --
MapLibre accepts a style object anywhere it accepts a style URL, so
applyStyle needed no change.

Runs before the map (and the rest of the page) is constructed, so
there's no flash of Liberty's own colors first -- but with a 3s
timeout and a catch-all fallback to the plain URL, since this app is
meant to keep working offline in the field and blocking startup on a
remote style server would undermine that.

Verified in a real browser: online map now visually matches the
offline theme (cream ground, brown/khaki land cover, green parks,
muted roads), no console errors, Online/Offline toggle still works.
2026-09-07 23:12:20 +02:00
6 changed files with 257 additions and 33 deletions

No files matched your search

+22 -4
View File
@@ -191,11 +191,29 @@ module Maptool
json paths: paths, tree: GPX_STORE.tree json paths: paths, tree: GPX_STORE.tree
end end
# ?download=1 exports the file's GeoJSON as an attachment instead of # ?download=geojson exports the file's GeoJSON as an attachment instead
# rendering it on the map — the only way to get a .gpx/.fit's converted # of rendering it on the map — the only way to get a .gpx/.fit's
# form out of the app, since GpxStore#geojson's cache is otherwise an # converted form out of the app, since GpxStore#geojson's cache is
# internal implementation detail with no filename of its own to expose. # 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 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 begin
body = GPX_STORE.geojson(params[:splat].first) body = GPX_STORE.geojson(params[:splat].first)
rescue GpxStore::InvalidPath rescue GpxStore::InvalidPath
+52 -13
View File
@@ -121,6 +121,31 @@ No PMTiles file ships with the repository — it is expected to be
supplied locally (see `docs/development.adoc`) and stays gitignored supplied locally (see `docs/development.adoc`) and stays gitignored
because such extracts are typically large binary files. because such extracts are typically large binary files.
The Online map (OpenFreeMap Liberty) gets the same treatment via
`ONLINE_STYLE_PATCH`, a separate hand-built table — Liberty is an
OpenMapTiles-schema style, with its own layer ids (`landcover_wood`,
`road_secondary_tertiary`, `boundary_2`, ...) that don't correspond to
protomaps' theme roles, so the two palettes can't share one object,
only the same underlying color choices. `loadOnlineStyle`
(`public/js/map.js`) fetches Liberty's `style.json` once, patches it
in place, and uses the resulting object everywhere `ONLINE_STYLE` used
to be a plain URL string — MapLibre accepts a style object anywhere it
accepts a style URL, so `applyStyle`'s `map.setStyle(... : ONLINE_STYLE)`
needed no change. That fetch runs before the map (and the rest of the
page) is constructed — a top-level `await` — so there's no flash of
Liberty's own colors before the patch lands; a 3s timeout and a
catch-all fall back to the plain URL (letting MapLibre fetch and
render it unpatched) rather than block the whole page's startup on a
remote style server, since this app is meant to keep working offline
in the field, independent of whether the online map itself loads.
Only the layers that dominate the map's overall color read are
patched (land cover, water, parks, roads, boundaries, buildings) —
same deliberately-partial scope as `OFFLINE_THEME`, not a full
per-layer recolor. Road casings are the one addition with no
protomaps-role equivalent to reuse, since offline roads don't get a
separate outline color there; `#c9a66b` is reasoned to fit the family
rather than sourced from anywhere real.
== Map permalink == Map permalink
The map's `hash: "map"` option (`public/js/map.js`) turns on MapLibre The map's `hash: "map"` option (`public/js/map.js`) turns on MapLibre
@@ -318,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, 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 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 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 not re-shown as itself, so both forms need a way out of the app as an
app as an actual file (`GET /api/gpx/file/<path>?download=1`) — the actual file: the file name itself (`GET /api/gpx/file/<path>?download=geojson`)
cache is otherwise an implementation detail with no name of its own to is a real link, not a `<span>` — clicking it downloads the GeoJSON
expose: the file name itself is a real link (an `<a>`, not a `<span>`, rather than also toggling the row's checkbox, since a native
specifically so clicking it downloads instead of also toggling the `<a>`/`<button>` nested inside a `<label>` claims its own click
row's checkbox — a native `<a>`/`<button>` nested inside a `<label>` instead of the label forwarding it to the associated control — and a
claims its own click instead of the label forwarding it to the second button next to delete (`?download=original`) downloads the
associated control, so the two behaviors don't fight over the same untouched, as-uploaded file, since the name alone only covers half of
click), and a dedicated download button sits next to delete for anyone what's now downloadable and isn't an obvious click target regardless.
who wouldn't guess the name itself is clickable. A directly-uploaded A directly-uploaded `.geojson` keeps its own name and both buttons
`.geojson` keeps its own name (nothing to convert) and downloads download the same (identical) content — redundant there, not wrong.
unchanged either way. `Gpx` parses the GPX with REXML: `<wpt>` 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` waypoints become `Point` features (with `name`, `desc` and `time`
carried through as properties), each `<trk>` becomes one carried through as properties), each `<trk>` becomes one
`MultiLineString` feature (one line per `<trkseg>`), and each `<rte>` `MultiLineString` feature (one line per `<trkseg>`), and each `<rte>`
@@ -651,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 individual files) are public — anyone with the URL can look. Its write
endpoints (`POST /api/gpx`, `POST /api/gpx/folder`, `DELETE endpoints (`POST /api/gpx`, `POST /api/gpx/folder`, `DELETE
/api/gpx/file/*`, `DELETE /api/gpx/folder/*`, `POST /api/gpx/move`) /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 Login delegates to the Gitea instance at `git.priesol.net` via OAuth2
(`GET /login` redirects to Gitea's authorize endpoint, `GET (`GET /login` redirects to Gitea's authorize endpoint, `GET
+2 -2
View File
@@ -24,8 +24,8 @@ checking here before searching from zero next time.
| MIT (per `package.json`); the whole `npmap5-plugins` repo is separately | MIT (per `package.json`); the whole `npmap5-plugins` repo is separately
public domain as a US government work public domain as a US government work
| Park Tiles color palette (offline basemap theme) | Park Tiles color palette (offline + online basemap themes)
| `public/js/map.js`'s `OFFLINE_THEME` | `public/js/map.js`'s `OFFLINE_THEME` and `ONLINE_STYLE_PATCH`
| Colors only — see <<park-tiles>> for why the actual tile service isn't used | Colors only — see <<park-tiles>> for why the actual tile service isn't used
| `overview` (top-right minimap/inset control) | `overview` (top-right minimap/inset control)
+52 -11
View File
@@ -278,17 +278,26 @@ export class GpxPanelControl {
// disk) rather than still reading as a plain, un-converted .gpx/.fit. // disk) rather than still reading as a plain, un-converted .gpx/.fit.
// A direct .geojson upload already ends in .geojson, so it's left // 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 // 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 // — the only way to get it out of the app, since the original .gpx/.fit
// original .gpx/.fit is never deleted but also never re-shown once // is never deleted but also never re-shown once converted. An <a>
// converted. An <a> nested in a <label> doesn't also toggle the // nested in a <label> doesn't also toggle the checkbox on click (unlike
// checkbox on click (unlike a plain element), so this doesn't fight // a plain element), so this doesn't fight the row's own
// the row's own toggle-on-map behavior. // 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 displayName = file.cached && !file.name.toLowerCase().endsWith(".geojson") ? `${file.name}.geojson` : file.name;
const name = document.createElement("a"); const name = document.createElement("a");
name.className = "gpx-tree__file-name"; name.className = "gpx-tree__file-name";
name.href = `${gpxFileUrl(file.path)}?download=1`; name.href = `${gpxFileUrl(file.path)}?download=geojson`;
name.textContent = displayName; name.textContent = displayName;
name.title = `Download ${displayName}`; name.title = `Download ${displayName}`;
name.addEventListener("click", (event) => {
event.preventDefault();
this._download(name.href, displayName);
});
label.appendChild(checkbox); label.appendChild(checkbox);
(file.kinds || []).forEach((kind) => label.appendChild(this._kindIcon(kind))); (file.kinds || []).forEach((kind) => label.appendChild(this._kindIcon(kind)));
if (!file.cached) label.appendChild(this._pendingIcon()); if (!file.cached) label.appendChild(this._pendingIcon());
@@ -324,18 +333,50 @@ export class GpxPanelControl {
return icon; return icon;
} }
// Same destination as clicking the file's name (gpxFileUrl + ?download=1) // The file's name already downloads its GeoJSON (?download=geojson,
// — a second, more obviously clickable entry point for the same action. // 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) { _downloadButton(file) {
const link = document.createElement("a"); const link = document.createElement("a");
link.className = "gpx-tree__download"; link.className = "gpx-tree__download";
link.href = `${gpxFileUrl(file.path)}?download=1`; link.href = `${gpxFileUrl(file.path)}?download=original`;
link.title = "Download GeoJSON"; link.title = "Download original file";
link.setAttribute("aria-label", "Download GeoJSON"); link.setAttribute("aria-label", "Download original file");
link.innerHTML = DOWNLOAD_ICON; link.innerHTML = DOWNLOAD_ICON;
link.addEventListener("click", (event) => {
event.preventDefault();
this._download(link.href, file.name);
});
return link; 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) { _deleteButton(onClick) {
const button = document.createElement("button"); const button = document.createElement("button");
button.type = "button"; button.type = "button";
+107 -1
View File
@@ -19,7 +19,113 @@ import * as maplibregl from "/vendor/maplibre-gl/maplibre-gl.mjs";
const CENTER = [17.1077, 48.1486]; const CENTER = [17.1077, 48.1486];
const ZOOM = 13; const ZOOM = 13;
const ONLINE_STYLE = "https://tiles.openfreemap.org/styles/liberty"; const ONLINE_STYLE_URL = "https://tiles.openfreemap.org/styles/liberty";
// Recolors OpenFreeMap Liberty to the same NPS-inspired palette as
// OFFLINE_THEME below — warm cream ground, brown/khaki land cover, green
// reserved for parks, muted roads/boundaries — mapped onto Liberty's
// OpenMapTiles layer ids instead of protomaps' theme roles (the two
// schemas don't share layer names, so this is a separate hand-built
// table, not a shared object). Only the layers that dominate the map's
// overall color read are touched; labels, halos, rail, and minor
// overlays (cemetery/hospital/school/pitch/track shading, building
// outlines) are left at Liberty's own defaults — same "not a full
// recolor" scope as OFFLINE_THEME. Road casings have no protomaps-role
// equivalent to reuse (offline roads don't get a separate outline), so
// #c9a66b there is reasoned to fit the family rather than sourced from
// anywhere real.
const ONLINE_STYLE_PATCH = {
background: { "background-color": "#f9f6ef" },
landcover_wood: { "fill-color": "#c9bd9c" },
landcover_grass: { "fill-color": "#d9cfb0" },
landcover_sand: { "fill-color": "#f0e4c4" },
water: { "fill-color": "#a3cedf" },
park: { "fill-color": "#b3e378" },
park_outline: { "line-color": "#546619" },
building: { "fill-color": "#ded7cc" },
boundary_2: { "line-color": "#546619" },
boundary_3: { "line-color": "#546619" },
// Highway (motorway/trunk/primary), major (secondary/tertiary/link),
// minor (residential/service/track), pedestrian — each repeated for its
// tunnel_/bridge_ counterpart so a road doesn't change color when it
// dips into a tunnel or crosses a bridge. Rail is left alone.
road_motorway: { "line-color": "#fdefbe" },
road_trunk_primary: { "line-color": "#fdefbe" },
road_secondary_tertiary: { "line-color": "#fef7dd" },
road_link: { "line-color": "#fef7dd" },
road_motorway_link: { "line-color": "#fef7dd" },
road_minor: { "line-color": "#fffdf8" },
road_service_track: { "line-color": "#fffdf8" },
road_path_pedestrian: { "line-color": "#e9e2d8" },
road_motorway_casing: { "line-color": "#c9a66b" },
road_trunk_primary_casing: { "line-color": "#c9a66b" },
road_secondary_tertiary_casing: { "line-color": "#c9a66b" },
road_link_casing: { "line-color": "#c9a66b" },
road_motorway_link_casing: { "line-color": "#c9a66b" },
road_minor_casing: { "line-color": "#c9a66b" },
road_service_track_casing: { "line-color": "#c9a66b" },
tunnel_motorway: { "line-color": "#fdefbe" },
tunnel_trunk_primary: { "line-color": "#fdefbe" },
tunnel_secondary_tertiary: { "line-color": "#fef7dd" },
tunnel_link: { "line-color": "#fef7dd" },
tunnel_motorway_link: { "line-color": "#fef7dd" },
tunnel_minor: { "line-color": "#fffdf8" },
tunnel_service_track: { "line-color": "#fffdf8" },
tunnel_path_pedestrian: { "line-color": "#e9e2d8" },
tunnel_motorway_casing: { "line-color": "#c9a66b" },
tunnel_trunk_primary_casing: { "line-color": "#c9a66b" },
tunnel_secondary_tertiary_casing: { "line-color": "#c9a66b" },
tunnel_link_casing: { "line-color": "#c9a66b" },
tunnel_motorway_link_casing: { "line-color": "#c9a66b" },
tunnel_street_casing: { "line-color": "#c9a66b" },
bridge_motorway: { "line-color": "#fdefbe" },
bridge_trunk_primary: { "line-color": "#fdefbe" },
bridge_secondary_tertiary: { "line-color": "#fef7dd" },
bridge_link: { "line-color": "#fef7dd" },
bridge_motorway_link: { "line-color": "#fef7dd" },
bridge_street: { "line-color": "#fffdf8" },
bridge_service_track: { "line-color": "#fffdf8" },
bridge_path_pedestrian: { "line-color": "#e9e2d8" },
bridge_motorway_casing: { "line-color": "#c9a66b" },
bridge_trunk_primary_casing: { "line-color": "#c9a66b" },
bridge_secondary_tertiary_casing: { "line-color": "#c9a66b" },
bridge_link_casing: { "line-color": "#c9a66b" },
bridge_motorway_link_casing: { "line-color": "#c9a66b" },
bridge_street_casing: { "line-color": "#c9a66b" },
bridge_path_pedestrian_casing: { "line-color": "#c9a66b" },
};
function patchOnlineStyle(style) {
for (const layer of style.layers) {
const patch = ONLINE_STYLE_PATCH[layer.id];
if (patch) Object.assign((layer.paint ??= {}), patch);
}
return style;
}
// Fetched (not just referenced by URL) so its colors can be patched before
// the map ever sees it — MapLibre accepts a style object anywhere it'd
// accept a style URL, so every other use of ONLINE_STYLE (applyStyle's
// map.setStyle) needs no change. This runs before the map — and the rest
// of the page's setup — is constructed (see the top-level await below), so
// it falls back to the plain URL, letting MapLibre fetch and render it
// unpatched, both when the fetch fails outright and when it's just slow
// (a 3s timeout): this app is meant to work offline in the field, and
// blocking the entire page on a remote style server would undermine that
// even though this particular fetch is only for the online map.
async function loadOnlineStyle() {
try {
const response = await fetch(ONLINE_STYLE_URL, { signal: AbortSignal.timeout(3000) });
return patchOnlineStyle(await response.json());
} catch (err) {
return ONLINE_STYLE_URL;
}
}
const ONLINE_STYLE = await loadOnlineStyle();
const protocol = new pmtiles.Protocol(); const protocol = new pmtiles.Protocol();
addProtocol("pmtiles", protocol.tile); addProtocol("pmtiles", protocol.tile);
+22 -2
View File
@@ -76,7 +76,7 @@ class AppTest < Minitest::Test
content = File.read(fixture_path("sample.gpx")) content = File.read(fixture_path("sample.gpx"))
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env) 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_equal 200, response.status
assert_includes response.headers["Content-Disposition"], 'filename="ride.gpx.geojson"' assert_includes response.headers["Content-Disposition"], 'filename="ride.gpx.geojson"'
end end
@@ -85,11 +85,31 @@ class AppTest < Minitest::Test
content = '{"type":"FeatureCollection","features":[]}' content = '{"type":"FeatureCollection","features":[]}'
post_file("/api/gpx", filename: "spots.geojson", content: content, env: logged_in_env) 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_equal 200, response.status
assert_includes response.headers["Content-Disposition"], 'filename="spots.geojson"' assert_includes response.headers["Content-Disposition"], 'filename="spots.geojson"'
end 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 def test_unparseable_gpx_returns_422
post_file("/api/gpx", filename: "bad.gpx", content: "<gpx><trk unclosed>", env: logged_in_env) post_file("/api/gpx", filename: "bad.gpx", content: "<gpx><trk unclosed>", env: logged_in_env)