Upload/delete/move/create-folder now require logging in via Gitea OAuth2 (only MAPTOOL_ALLOWED_GITEA_USER is granted write access); reading stays public. Deploy secrets (session secret, OAuth2 client credentials, allowed user) are written to .env from Gitea Actions repo secrets on every deploy instead of living on the server by hand. Also switch the container's bind address to "[::]" (dual-stack) so the app is reachable over IPv6, not just IPv4.
13 KiB
Architecture
Overview
MapTool is a Sinatra web application, served locally by Puma and opened in a browser.
Directory layout
| Path | Purpose |
|---|---|
|
Sinatra application and Rack entry point. |
|
Puma server configuration (binds to |
|
Frontend assets and Sinatra templates. The map view uses MapLibre GL JS, rendering vector tiles. |
|
Third-party frontend libraries, vendored (not loaded from a CDN) so
the app also works with no internet access. See
|
|
Local, gitignored data files, e.g. |
|
Application code beyond routing: the GPX parser and file store. |
|
Project documentation, in AsciiDoc. |
Offline maps
Map data is vector, not raster: the app ships
MapLibre GL JS (vendored, see below) and the map
view (public/js/map.js) offers two styles:
-
Online — a full hosted vector style, OpenFreeMap "liberty", requires internet access.
-
Offline — built at runtime from a local PMTiles archive at
data/offline.pmtiles, served as-is (see below) at/tiles/offline.pmtiles, decoded client-side by pmtiles.js registered as apmtiles://protocol handler, and styled with base layers from protomaps-themes-base.
GET /api/offline-tiles reports whether data/offline.pmtiles is
present ({"available": true|false}); the frontend uses it once, on
load, to default to the offline style when local tile data exists —
otherwise it defaults to the online style. Either way, the layer
toggle in the top-left of the map lets the user switch manually.
PMTiles is a single, self-indexed file, which is why this needs no
tile-decoding logic on the server: app.rb’s `/tiles/offline.pmtiles
route (send_file, which Sinatra serves with HTTP range-request
support) just returns byte ranges of the file; pmtiles.js in the
browser reads the archive’s own directory and requests only the bytes
it needs for the tiles on screen.
Offline text labels and color palette
Label rendering in MapLibre needs a "glyphs" server serving font PBF
ranges (256 Unicode code points each); data/offline.pmtiles’s fields
(`name, name:en, script, …) match what
protomaps-themes-base’s `layers(source, theme, { lang: "en" })
expects for its label layers (verified against the actual PMTiles
vector layer schema), so passing that option turns labels on. The
glyphs themselves are vendored under public/vendor/fonts/ — Noto
Sans Regular/Italic/Medium, ranges 0-255 and 256-511
(Basic Latin + Latin-1 Supplement + Latin Extended-A, SIL OFL
licensed, from
protomaps/basemaps-assets),
covering accented Latin place names (Slovak, Czech, Polish, German,
…). Labels in other scripts (Cyrillic, Greek, CJK, …) won’t render
offline, since those glyph ranges aren’t vendored.
public/js/map.js’s `OFFLINE_THEME also nudges a handful of color
roles (background, water, wood, buildings, the two road-importance
tiers) from `protomaps-themes-base’s stock "light" theme towards the
online "liberty" style’s actual palette (sampled from its style JSON),
so Online/Offline don’t look like two different apps. This is
deliberately partial — most roles (labels, casings, minor terrain
fills, …) are left at their theme defaults — not the kind of
full per-layer recolor a previous iteration of this app did (and
removed, along with any dependency on OsmAnd’s own tile server, which
this app cannot use per OsmAnd’s terms restricting tile use to "personal,
non-commercial use within OsmAnd applications and platforms").
No PMTiles file ships with the repository — it is expected to be
supplied locally (see docs/development.adoc) and stays gitignored
because such extracts are typically large binary files.
Weather radar overlay
public/js/radar.js adds a "Radar" map control that overlays recent
precipitation radar from RainViewer's free
public API (no API key required). Enabling it:
-
Fetches
https://api.rainviewer.com/public/weather-maps.json, which lists RainViewer’s past radar frames (fixed 10-minute cadence, at most 13 covering the last 120 minutes). -
Takes the most recent 7 (last ~60 minutes — the same default as the Home Assistant weather-radar-card, for a consistent feel with that setup) and adds one MapLibre raster source/layer per frame, all pre-loaded but only the current frame’s
raster-opacitynon-zero. -
Animates by toggling that opacity across frames on an interval (play/pause + a scrub slider), rather than swapping tiles in and out — avoids reloading tiles on every step.
Tile URL: {host}{frame.path}/256/{z}/{x}/{y}/2/1_0.png — 2 is
RainViewer’s "Universal Blue" color scheme, 1 enables their
smoothing, 0 disables the snow overlay. host and each frame’s
path come from the API response.
Since map.setStyle() (switching Online/Offline) tears down all
custom sources and layers, the control listens for style.load and
re-adds the radar layers if the overlay was on.
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 by dragging them in, organize them into folders (also
by dragging), and toggle each one on the map individually.
- Storage
-
Files are stored as-is under
data/gpx/, in a folder hierarchy the user manages directly —Maptool::GpxStore(lib/maptool/gpx_store.rb) providessave,create_folder,delete_file,delete_folderandmove. Every path coming from the client (a folder to create/upload/move into, a file path in a URL) is resolved withFile.expand_pathand checked against the store root before touching the filesystem, so../..-style path traversal can’t escapedata/gpx/;delete_folderadditionally refuses an empty path or one that resolves to the store root itself, so it can neverrm -rfthe whole store.GET /api/gpx/treereturns the resulting folder/file tree as JSON, which the panel renders recursively (folders as<details>, files as a checkbox, both with a delete button). - Drag & drop
-
The dropzone at the top of the panel accepts one or more
.gpxfiles (POST /api/gpx, multipart) into the top level, or click it to browse instead. An already-uploaded file (a.gpx-tree__filerow,draggable) can be dragged onto a folder to move it there (POST /api/gpx/move), or dropped anywhere else in the tree to move it back to the top level; an OS file can also be dropped directly onto a folder to upload straight into it. Both drag sources land on the same drop targets and are told apart by a customapplication/x-gpx-pathdrag payload type versus the browser’s own"Files"type. If a file that’s currently checked (visible on the map) gets moved, its map layers are re-added under the new path so it stays visible; if it (or its containing folder) gets deleted, its layers are removed instead. - 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 GeoJSONFeatureCollection:<wpt>waypoints becomePointfeatures (withname,descandtimecarried through as properties), each<trk>becomes oneMultiLineStringfeature (one line per<trkseg>). MapLibre’sgeometry-typeexpression treatsMultiLineStringthe same asLineString, so a single GeoJSON source per file with alinelayer and acirclelayer (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 survivessetStyle()) rather than on everystyle.loadre-add, which would otherwise stack duplicate handlers and pop up several copies per click. Waypoint text comes straight from an uploaded file — untrusted input — sopublic/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 bothhrefand link text) before assembling any HTML; the URL pattern excludes quote characters, so a matched URL can never break out of thehref="…"attribute. Every other value is HTML-escaped before being handed toPopup#setHTML.
Live GNSS position
A "GNSS" map control (public/js/gnss.js) shows the fix from a local
GNSS receiver (a real one, plugged into the machine running the
server — distinct from NavigationControl’s browser
`navigator.geolocation, which the OS may or may not derive from such
a receiver at all).
lib/maptool/gpsd_client.rb is a small client for
gpsd's JSON protocol over TCP (port 2947 by default):
it watches for TPV — "time-position-velocity", gpsd’s term for a fix —
reports and yields each as a plain hash, reconnecting on any I/O error
so a receiver that isn’t plugged in yet (or gpsd not running) is not
fatal. app.rb runs one of these in a background thread for the
whole process and keeps the latest fix in GNSS_LATEST, guarded by a
mutex since the HTTP threads read it concurrently.
GET /api/gnss/position, polled by the frontend every 1.5s, is a
deliberate choice over a push channel (SSE/WebSocket): gpsd itself
only reports at roughly 1Hz, so a push channel buys nothing here, and
it sidesteps a real problem — Sinatra’s stream(:keep_open) helper,
absent an EventMachine reactor (i.e. under plain threaded Puma, as
here), calls its block in a tight loop instead of waiting for external
writes. That is fine for a stream that itself blocks between writes,
but for a "register once, get pushed to later" connection — which is
what a live position feed is — the block returns immediately and gets
re-invoked immediately, forever: every iteration re-registers the
connection, so held connections and memory grow unbounded. This was
caught in testing (the dev server hit ~98% CPU and ~28GB RSS within
minutes) before it shipped.
The endpoint reports available: false once the last fix is more than
GNSS_STALE_AFTER (10s) old, rather than serving a stale position
forever after the receiver loses its fix or gets unplugged.
MapLibre Marker`s (used for the fix) are plain positioned DOM
elements, not tied to the style’s sources/layers, so — unlike the
radar/GPX layers — the marker survives `map.setStyle() (switching
Online/Offline) with no extra re-add handling needed.
Networking
By default (local dev) the Sinatra server only binds to 127.0.0.1
and isn’t reachable from other machines. The deployed instance is an
exception — see docs/deployment.adoc for BIND_HOST and how it’s
exposed via Docker/nginx as map.priesol.net.
Authentication
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.
Login delegates to the Gitea instance at git.priesol.net via OAuth2
(GET /login redirects to Gitea’s authorize endpoint, GET
/auth/callback exchanges the code and fetches
/api/v1/user) rather than maintaining a separate local
username/password — there’s already an account to reuse, and it keeps
credential storage (password hash, resets, …) out of this app
entirely. Successfully authenticating with Gitea is not by itself
enough to write, though — the callback also checks the returned
login against MAPTOOL_ALLOWED_GITEA_USER, so registering (or
already having) any other account on that Gitea instance doesn’t grant
access here.
Session state is a signed cookie (Rack::Session::Cookie, secure
in production, httponly, same_site: lax) keyed off
SESSION_SECRET — see docs/deployment.adoc for where that and the
OAuth2 client id/secret live (not committed to the repo).