Add GPX upload with hierarchical folders and map display

Upload GPX files into a folder hierarchy (traversal-safe path
resolution against the store root), browse them in a tree panel, and
toggle each one's waypoints/tracks on the map. Waypoints render as
clickable points showing name, description and time; bare URLs in the
description are turned into safe links. GPX layers survive switching
between the Online/Offline base map, same as the radar overlay.

Adds rexml as an explicit dependency — it moved to a bundled gem in
this Ruby version and isn't picked up by Bundler otherwise.
This commit is contained in:
randy committed 2026-08-25 14:05:39 +02:00
1 parent 16e06b2ffe
commit 4e0d2abbd0
13 files changed
+629 -2

No files matched your search

+4
View File
@@ -9,6 +9,10 @@
# see docs/development.adoc)
/data/*.pmtiles
# User-uploaded GPX files
/data/gpx/*
!/data/gpx/.gitkeep
# Editor / OS
.DS_Store
*.swp
+1
View File
@@ -3,6 +3,7 @@ source "https://rubygems.org"
gem "sinatra", "~> 4.2"
gem "sinatra-contrib", "~> 4.2"
gem "puma", "~> 8.0"
gem "rexml", "~> 3.4"
group :development do
gem "rerun"
+3
View File
@@ -35,6 +35,7 @@ GEM
ffi (~> 1.0)
rerun (0.14.0)
listen (~> 3.0)
rexml (3.4.4)
sinatra (4.2.1)
logger (>= 1.6.0)
mustermann (~> 3.0)
@@ -65,6 +66,7 @@ PLATFORMS
DEPENDENCIES
puma (~> 8.0)
rerun
rexml (~> 3.4)
sinatra (~> 4.2)
sinatra-contrib (~> 4.2)
@@ -93,6 +95,7 @@ CHECKSUMS
rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe
rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e
rerun (0.14.0) sha256=996a5af2b2b34dcb30146c6c728addab9113e07c119003370daa9bf27feb88fd
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080
sinatra-contrib (4.2.1) sha256=10d091c944d268aa910c618ea40a3c3ebe0533e6e32990d84af92235a3d26b4a
tilt (2.9.0) sha256=da5735d0280bba96e9a91041bb14aee435ccad5c17b0fa519249ae543d9aa3a5
+2 -1
View File
@@ -11,7 +11,8 @@ The project's shape is still open — this is the initial scaffold.
Early scaffold. The web application serves a vector map (MapLibre GL
JS), with an online style and an optional offline style backed by a
local PMTiles file, plus an animated RainViewer radar overlay.
local PMTiles file, an animated RainViewer radar overlay, and GPX
upload/display (points and tracks, organized in folders).
== Requirements
+35
View File
@@ -1,5 +1,7 @@
require "sinatra/base"
require "sinatra/json"
require_relative "lib/maptool/gpx"
require_relative "lib/maptool/gpx_store"
module Maptool
class App < Sinatra::Base
@@ -7,6 +9,7 @@ module Maptool
set :views, File.join(__dir__, "views")
OFFLINE_PMTILES_PATH = File.join(__dir__, "data", "offline.pmtiles")
GPX_STORE = GpxStore.new(File.join(__dir__, "data", "gpx"))
get "/" do
erb :index
@@ -28,5 +31,37 @@ module Maptool
send_file OFFLINE_PMTILES_PATH, type: "application/octet-stream"
end
get "/api/gpx/tree" do
json GPX_STORE.tree
end
post "/api/gpx" do
file = params[:file]
halt 400, json(error: "file is required") unless file && file[:tempfile]
begin
path = GPX_STORE.save(folder: params[:folder].to_s, filename: file[:filename], io: file[:tempfile])
rescue GpxStore::InvalidPath
halt 400, json(error: "invalid folder")
end
json path: path, tree: GPX_STORE.tree
end
get "/api/gpx/file/*" do
begin
path = GPX_STORE.resolve_file(params[:splat].first)
rescue GpxStore::InvalidPath
halt 400
end
halt 404 unless File.file?(path)
begin
json Gpx.to_geojson(path)
rescue Gpx::ParseError
halt 422, json(error: "could not parse GPX file")
end
end
end
end
View File
Whitespace-only changes.
+51 -1
View File
@@ -30,7 +30,11 @@ opened in a browser.
`docs/third-party-notices.adoc`.
| `data/`
| Local, gitignored data files, e.g. `offline.pmtiles`.
| Local, gitignored data files, e.g. `offline.pmtiles` and uploaded
GPX files under `data/gpx/`.
| `lib/maptool/`
| Application code beyond routing: the GPX parser and file store.
| `docs/`
| Project documentation, in AsciiDoc.
@@ -110,6 +114,52 @@ This is a live, online-only overlay — it always needs internet access
and is unrelated to the offline PMTiles basemap above. Per RainViewer's
terms, the API is free for personal/non-commercial use.
== GPX tracks and waypoints
The "GPX" map control (`public/js/gpx.js`) opens a side panel to upload
GPX files and toggle them on the map, individually.
Storage:: Uploaded files are stored as-is under `data/gpx/`, in
whatever folder hierarchy the user types into the upload form (e.g.
`Hiking/2026`) — `Maptool::GpxStore`
(`lib/maptool/gpx_store.rb`) creates folders as needed. Every path
coming from the client (upload folder, file path in a URL) is
resolved with `File.expand_path` and checked against the store root
before touching the filesystem, so `../..`-style path traversal can't
escape `data/gpx/`.
`GET /api/gpx/tree` returns the resulting folder/file tree as JSON,
which the panel renders recursively (folders as `<details>`, files as
a checkbox).
Rendering:: Checking a file fetches `GET
/api/gpx/file/<path>` — `Maptool::Gpx` (`lib/maptool/gpx.rb`) parses
the GPX with REXML and converts it to a GeoJSON `FeatureCollection`:
`<wpt>` waypoints become `Point` features (with `name`, `desc` and
`time` carried through as properties), each `<trk>` becomes one
`MultiLineString` feature (one line per `<trkseg>`). MapLibre's
`geometry-type` expression treats `MultiLineString` the same as
`LineString`, so a single GeoJSON source per file with a `line` layer
and a `circle` layer (each filtered by geometry type) is enough to
render both waypoints and tracks. `<rte>` routes are not handled.
Like the radar overlay, GPX layers are removed by `map.setStyle()`
(switching Online/Offline) and get re-added on `style.load` for
whichever files were checked.
Waypoint popups:: Clicking a waypoint circle opens a MapLibre popup
with its name, description and time. Click/hover handling is
registered once per file (via `map.on(type, layerId, handler)`, a
delegated listener that survives `setStyle()`) rather than on every
`style.load` re-add, which would otherwise stack duplicate handlers
and pop up several copies per click. Waypoint text comes straight from
an uploaded file — untrusted input — so `public/js/gpx.js`'s
`linkify()` turns bare URLs in the description into `<a>` links by
splitting the text on a URL pattern and escaping every piece
(including the URL itself, as both `href` and link text) before
assembling any HTML; the URL pattern excludes quote characters, so a
matched URL can never break out of the `href="..."` attribute. Every
other value is HTML-escaped before being handed to `Popup#setHTML`.
== Networking
The Sinatra server only binds to `127.0.0.1`. It is not meant to be
+67
View File
@@ -0,0 +1,67 @@
require "rexml/document"
module Maptool
# Converts a GPX file to a GeoJSON FeatureCollection: waypoints (<wpt>)
# become Point features, tracks (<trk>) become MultiLineString features
# (one line per <trkseg>). Routes (<rte>) are not handled — GPX in
# practice is either points or recorded tracks for this app.
module Gpx
class ParseError < StandardError; end
def self.to_geojson(path)
root = REXML::Document.new(File.read(path)).root
raise ParseError, "not a GPX file" unless root && root.name == "gpx"
features = []
root.each_element("wpt") { |wpt| features << waypoint_feature(wpt) }
root.each_element("trk") { |trk| features << track_feature(trk) }
{ "type" => "FeatureCollection", "features" => features }
rescue REXML::ParseException => e
raise ParseError, e.message
end
def self.waypoint_feature(wpt)
{
"type" => "Feature",
"geometry" => { "type" => "Point", "coordinates" => coordinate(wpt) },
"properties" => {
"kind" => "waypoint",
"name" => child_text(wpt, "name"),
"description" => child_text(wpt, "desc"),
"time" => child_text(wpt, "time"),
},
}
end
private_class_method :waypoint_feature
def self.track_feature(trk)
lines = []
trk.each_element("trkseg") do |seg|
points = []
seg.each_element("trkpt") { |pt| points << coordinate(pt) }
lines << points unless points.empty?
end
{
"type" => "Feature",
"geometry" => { "type" => "MultiLineString", "coordinates" => lines },
"properties" => { "kind" => "track", "name" => child_text(trk, "name") },
}
end
private_class_method :track_feature
def self.coordinate(point_el)
lat = point_el.attributes["lat"].to_f
lon = point_el.attributes["lon"].to_f
ele = child_text(point_el, "ele")
ele ? [lon, lat, ele.to_f] : [lon, lat]
end
private_class_method :coordinate
def self.child_text(el, name)
el.elements[name]&.text&.strip
end
private_class_method :child_text
end
end
+87
View File
@@ -0,0 +1,87 @@
require "fileutils"
module Maptool
# Filesystem-backed store for uploaded GPX files, organized in
# user-chosen folders (arbitrary nesting). All paths coming from the
# outside (folder names, file paths in URLs) are resolved and checked
# against the store root before touching the filesystem, so path
# traversal (e.g. "../../etc") can't escape it.
class GpxStore
class InvalidPath < StandardError; end
def initialize(root)
@root = File.expand_path(root)
FileUtils.mkdir_p(@root)
end
# Nested { type:, name:, path:, children: } hash describing every
# folder and .gpx file under the store root.
def tree
{ "type" => "folder", "name" => "root", "path" => "", "children" => list_children(@root, "") }
end
# Saves `io`'s contents as a .gpx file under `folder` (created if
# needed), deriving a safe filename from `filename`. Returns the
# stored file's path relative to the store root.
def save(folder:, filename:, io:)
dir = resolve_dir(folder.to_s)
FileUtils.mkdir_p(dir)
name = unique_filename(dir, safe_filename(filename))
File.binwrite(File.join(dir, name), io.read)
relative = folder.to_s.empty? ? name : "#{folder}/#{name}"
relative
end
# Absolute filesystem path for a store-relative file path, after
# confirming it stays within the store root.
def resolve_file(relative_path)
path = File.expand_path(File.join(@root, relative_path.to_s))
raise InvalidPath unless within_root?(path)
path
end
private
def resolve_dir(folder)
path = File.expand_path(File.join(@root, folder))
raise InvalidPath unless within_root?(path)
path
end
def within_root?(path)
path == @root || path.start_with?(@root + File::SEPARATOR)
end
def safe_filename(filename)
base = File.basename(filename.to_s)
base = "upload.gpx" if base.empty? || base == "." || base == ".."
base = "#{base}.gpx" unless base.downcase.end_with?(".gpx")
base
end
def unique_filename(dir, name)
return name unless File.exist?(File.join(dir, name))
ext = File.extname(name)
stem = name[0...-ext.length]
n = 1
n += 1 while File.exist?(File.join(dir, "#{stem}-#{n}#{ext}"))
"#{stem}-#{n}#{ext}"
end
def list_children(dir, relative)
Dir.children(dir).sort.filter_map do |entry|
full = File.join(dir, entry)
rel = relative.empty? ? entry : "#{relative}/#{entry}"
if File.directory?(full)
{ "type" => "folder", "name" => entry, "path" => rel, "children" => list_children(full, rel) }
elsif entry.downcase.end_with?(".gpx")
{ "type" => "file", "name" => entry, "path" => rel }
end
end
end
end
end
+117
View File
@@ -76,3 +76,120 @@ html, body {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.gpx-toggle button {
/* Override maplibregl-ctrl-group's 29x29px square icon-button sizing —
this is a text button. */
width: auto;
height: auto;
border: none;
background: #fff;
padding: 6px 10px;
font: inherit;
font-size: 13px;
white-space: nowrap;
cursor: pointer;
}
.gpx-panel {
position: fixed;
top: 0;
right: 0;
width: 300px;
max-width: 90vw;
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
font-size: 13px;
z-index: 10;
}
.gpx-panel[hidden] {
display: none;
}
.gpx-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid #eee;
}
.gpx-panel__close {
border: none;
background: none;
font-size: 18px;
line-height: 1;
padding: 0 4px;
cursor: pointer;
}
.gpx-upload {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border-bottom: 1px solid #eee;
}
.gpx-upload input {
font: inherit;
font-size: 13px;
}
.gpx-upload button {
align-self: flex-start;
font: inherit;
font-size: 13px;
cursor: pointer;
}
.gpx-upload__status {
margin: 0;
color: #666;
}
.gpx-tree {
overflow-y: auto;
padding: 8px 12px;
}
.gpx-tree ul {
list-style: none;
margin: 0;
padding-left: 16px;
}
.gpx-tree > ul {
padding-left: 0;
}
.gpx-tree summary {
cursor: pointer;
font-weight: 600;
}
.gpx-tree__file {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 0;
cursor: pointer;
}
.gpx-tree__empty {
color: #888;
font-style: italic;
}
.maplibregl-popup-content p {
margin: 4px 0 0;
}
.gpx-popup__time {
color: #888;
font-size: 12px;
}
+244
View File
@@ -0,0 +1,244 @@
import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs";
const TREE_URL = "/api/gpx/tree";
const UPLOAD_URL = "/api/gpx";
const TRACK_COLOR = "#e63946";
// A MapLibre IControl: a "GPX" button that opens a side panel for
// uploading GPX files (stored server-side in a folder hierarchy) and
// toggling their tracks/waypoints on and off the map.
export class GpxPanelControl {
constructor(map) {
this._map = map;
this._visible = new Set(); // file paths currently shown on the map
this._interactiveLayers = new Set(); // point layer ids with click/hover wired up
// setStyle() (switching the Online/Offline base map) tears down all
// custom sources and layers — put visible GPX layers back afterwards.
map.on("style.load", () => {
this._visible.forEach((path) => this._addLayers(path));
});
}
onAdd() {
this._button = document.createElement("div");
this._button.className = "maplibregl-ctrl maplibregl-ctrl-group gpx-toggle";
this._button.innerHTML = `<button type="button">GPX</button>`;
this._button
.querySelector("button")
.addEventListener("click", () => this._togglePanel());
this._panel = document.getElementById("gpx-panel");
this._treeContainer = this._panel.querySelector(".gpx-tree");
this._uploadForm = this._panel.querySelector(".gpx-upload");
this._uploadStatus = this._panel.querySelector(".gpx-upload__status");
this._panel
.querySelector(".gpx-panel__close")
.addEventListener("click", () => this._togglePanel(false));
this._uploadForm.addEventListener("submit", (event) => this._handleUpload(event));
this._loadTree();
return this._button;
}
onRemove() {
this._button.remove();
}
_togglePanel(force) {
this._panel.hidden = force === undefined ? !this._panel.hidden : !force;
}
async _loadTree() {
const response = await fetch(TREE_URL);
const tree = await response.json();
this._renderTree(tree);
}
_renderTree(node) {
this._treeContainer.innerHTML = "";
this._treeContainer.appendChild(this._renderChildren(node.children));
}
_renderChildren(children) {
const ul = document.createElement("ul");
if (children.length === 0) {
const li = document.createElement("li");
li.className = "gpx-tree__empty";
li.textContent = "No files yet";
ul.appendChild(li);
return ul;
}
children.forEach((child) => {
const li = document.createElement("li");
if (child.type === "folder") {
const details = document.createElement("details");
details.open = true;
const summary = document.createElement("summary");
summary.textContent = child.name;
details.appendChild(summary);
details.appendChild(this._renderChildren(child.children));
li.appendChild(details);
} else {
const label = document.createElement("label");
label.className = "gpx-tree__file";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = this._visible.has(child.path);
checkbox.addEventListener("change", () => this._toggleFile(child.path, checkbox.checked));
label.appendChild(checkbox);
label.append(` ${child.name}`);
li.appendChild(label);
}
ul.appendChild(li);
});
return ul;
}
_toggleFile(path, visible) {
if (visible) {
this._visible.add(path);
this._addLayers(path);
} else {
this._visible.delete(path);
this._removeLayers(path);
}
}
_addLayers(path) {
const id = sourceId(path);
if (this._map.getSource(id)) return;
this._map.addSource(id, { type: "geojson", data: gpxFileUrl(path) });
this._map.addLayer({
id: `${id}-line`,
type: "line",
source: id,
filter: ["==", ["geometry-type"], "LineString"],
layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": TRACK_COLOR, "line-width": 3 },
});
this._map.addLayer({
id: `${id}-points`,
type: "circle",
source: id,
filter: ["==", ["geometry-type"], "Point"],
paint: {
"circle-radius": 5,
"circle-color": TRACK_COLOR,
"circle-stroke-color": "#fff",
"circle-stroke-width": 1.5,
},
});
this._registerPointInteractivity(`${id}-points`);
}
// Registered once per layer id, ever — map.on(type, layerId, fn) is a
// delegated listener that lives on the map itself and keeps matching
// that layer id after setStyle() removes and (via _addLayers) re-adds
// it, so re-registering on every re-add would stack duplicate popups.
_registerPointInteractivity(layerId) {
if (this._interactiveLayers.has(layerId)) return;
this._interactiveLayers.add(layerId);
this._map.on("click", layerId, (event) => {
const feature = event.features[0];
if (!feature) return;
new Popup({ closeButton: true, maxWidth: "260px" })
.setLngLat(event.lngLat)
.setHTML(popupHtml(feature.properties))
.addTo(this._map);
});
this._map.on("mouseenter", layerId, () => {
this._map.getCanvas().style.cursor = "pointer";
});
this._map.on("mouseleave", layerId, () => {
this._map.getCanvas().style.cursor = "";
});
}
_removeLayers(path) {
const id = sourceId(path);
[`${id}-line`, `${id}-points`].forEach((layerId) => {
if (this._map.getLayer(layerId)) this._map.removeLayer(layerId);
});
if (this._map.getSource(id)) this._map.removeSource(id);
}
async _handleUpload(event) {
event.preventDefault();
const formData = new FormData(this._uploadForm);
this._setStatus("Uploading…");
try {
const response = await fetch(UPLOAD_URL, { method: "POST", body: formData });
if (!response.ok) throw new Error("upload failed");
const { tree } = await response.json();
this._renderTree(tree);
this._uploadForm.reset();
this._setStatus("Uploaded.");
} catch (err) {
this._setStatus("Upload failed.");
}
}
_setStatus(text) {
this._uploadStatus.hidden = false;
this._uploadStatus.textContent = text;
}
}
function sourceId(path) {
return `gpx-${path}`;
}
function gpxFileUrl(path) {
return `/api/gpx/file/${path.split("/").map(encodeURIComponent).join("/")}`;
}
// properties come straight from an uploaded GPX file — untrusted input —
// so every value is escaped before landing in the popup's HTML.
function popupHtml({ name, description, time }) {
const parts = [];
if (name) parts.push(`<strong>${escapeHtml(name)}</strong>`);
if (description) parts.push(`<p>${linkify(description).replace(/\n/g, "<br>")}</p>`);
if (time) {
const parsed = new Date(time);
if (!Number.isNaN(parsed.getTime())) {
parts.push(`<p class="gpx-popup__time">${escapeHtml(parsed.toLocaleString())}</p>`);
}
}
return parts.length > 0 ? parts.join("") : "<em>No details</em>";
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
// Turns bare URLs in untrusted text into safe <a> links: the string is
// split on the URL pattern and every piece is escaped — including the URL
// itself, both as href and as link text — before any HTML is assembled, so
// nothing bypasses escaping. The URL pattern excludes quote characters, so
// a matched URL can never break out of the href="..." attribute.
const URL_PATTERN = /(https?:\/\/[^\s<>"']+)/g;
function linkify(text) {
return text
.split(URL_PATTERN)
.map((part, i) => (i % 2 === 1 ? urlToLink(part) : escapeHtml(part)))
.join("");
}
function urlToLink(url) {
// Keep trailing sentence punctuation (e.g. "...place/1234.") out of the
// link itself.
const [, core, trailing] = url.match(/^(.*?)([.,;:!?]*)$/);
const href = escapeHtml(core);
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${href}</a>${escapeHtml(trailing)}`;
}
+2
View File
@@ -4,6 +4,7 @@ import {
addProtocol,
} from "/vendor/maplibre-gl/maplibre-gl.mjs";
import { RadarControl } from "/js/radar.js";
import { GpxPanelControl } from "/js/gpx.js";
const CENTER = [17.1077, 48.1486];
const ZOOM = 13;
@@ -74,6 +75,7 @@ map.addControl(new RadarControl(map), "top-right");
const layerToggle = new LayerToggleControl(applyStyle);
map.addControl(layerToggle, "top-left");
layerToggle.setActive("online");
map.addControl(new GpxPanelControl(map), "top-left");
function applyStyle(name) {
layerToggle.setActive(name);
+16
View File
@@ -1 +1,17 @@
<div id="map"></div>
<div id="gpx-panel" class="gpx-panel" hidden>
<div class="gpx-panel__header">
<strong>GPX</strong>
<button type="button" class="gpx-panel__close" aria-label="Close">&times;</button>
</div>
<form class="gpx-upload">
<input type="file" name="file" accept=".gpx" required>
<input type="text" name="folder" placeholder="folder/subfolder (optional)">
<button type="submit">Upload</button>
<p class="gpx-upload__status" hidden></p>
</form>
<div class="gpx-tree"></div>
</div>