Files
maptool/app.rb
T
randy 6126b5055b
CI / check (push) Successful in 37s
CI / deploy (push) Successful in 19s
Disable Rack::Protection::SessionHijacking, it silently logs everyone out
Sinatra turns this on by default once sessions are enabled: it pins a
session to the User-Agent header seen on whichever request first wrote
to it, and empties the session — no exception, nothing logged — on any
later request where that header doesn't match. That's exactly what
production hit behind its reverse proxy: OAuth login set session[:user]
fine, then the very next page load (even a plain refresh) came back
logged out with nothing in the logs to explain why.

Its own docs concede every header it checks is spoofable anyway ("will
not prevent determined hijacking attempts"), so losing it is a minor
tradeoff for a login that actually stays logged in. Every other
Rack::Protection check stays on.
2026-09-16 16:11:01 +02:00

756 lines
31 KiB
Ruby

require "sinatra/base"
require "sinatra/json"
require "oauth2"
require "securerandom"
require "zip"
require "stringio"
require_relative "lib/maptool/gpx"
require_relative "lib/maptool/fit"
require_relative "lib/maptool/gpx_store"
require_relative "lib/maptool/gpsd_client"
require_relative "lib/maptool/livetrack_store"
require_relative "lib/maptool/livetrack_client"
require_relative "lib/maptool/kiwix"
require_relative "lib/maptool/plugins"
require_relative "lib/maptool/pmtiles_builder"
module Maptool
class App < Sinatra::Base
set :public_folder, File.join(__dir__, "public")
set :views, File.join(__dir__, "views")
# sinatra/json defaults to MultiJson, whose `encode` (what
# sinatra-contrib 4.2.1 actually calls) is deprecated in newer
# multi_json versions in favor of a constant this app has no other use
# for — stdlib JSON already does the same job and every use of the
# `json` helper below is a plain hash/array, nothing MultiJson-specific.
set :json_encoder, JSON
# A persistent secret across restarts/redeploys — otherwise every deploy
# would invalidate every logged-in session's cookie (see
# docs/deployment.adoc). Falls back to a random one so the app still
# loads locally/in CI without it set; that fallback just means sessions
# don't survive a restart there, which is fine outside production.
set :sessions, httponly: true, secure: production?, same_site: :lax,
secret: ENV.fetch("SESSION_SECRET") { SecureRandom.hex(32) }
# Sinatra enables Rack::Protection::SessionHijacking by default once
# sessions are on: it fingerprints a session to the User-Agent header
# seen on the request that first wrote to it, and *silently* empties
# the session (no exception, nothing logged) on any later request
# where that header doesn't match — which is exactly what a login
# behind production's reverse proxy hit: the OAuth callback set
# session[:user] fine, but the very next ordinary page load (or even a
# plain refresh) came back logged out with nothing in the logs to
# explain it. Its own docs already admit every header it can check is
# spoofable anyway ("will not prevent determined hijacking attempts")
# — losing it is a minor tradeoff for a login that actually stays
# logged in. Every other Rack::Protection check (XSS header, path
# traversal, JSON CSRF, frame options, ...) stays on.
set :protection, except: [:session_hijacking]
# Cache-busts /js and /css requests: browsers (and a caching proxy in
# front of production, see docs/deployment.adoc) hang onto static
# assets for hours, so without this a deploy that changes app.js/css
# can go unnoticed by an already-open tab until that cache expires.
# A fresh value every process start (i.e. every deploy, which restarts
# the container) is enough — it doesn't need to survive restarts.
ASSET_VERSION = Time.now.to_i
# Written by the CI deploy job (.gitea/workflows/ci.yml) right before it
# drops .git from the deployed copy, so this is the only way to tell
# which commit is actually live short of asking the deploy job itself —
# handy for exactly the kind of "did my push actually deploy, or am I
# looking at a stale browser cache" confusion ASSET_VERSION above can't
# answer by itself (that only proves *a* deploy happened recently, not
# *which* one). Absent outside a real deploy (local dev, CI's own
# `check` job), hence the fallback.
REVISION = begin
File.read(File.join(__dir__, "REVISION")).strip
rescue Errno::ENOENT
"dev"
end
# Overridable so the test suite can point this at a throwaway directory
# instead of the real data/ — nothing else needs to set this.
DATA_DIR = ENV.fetch("MAPTOOL_DATA_DIR") { File.join(__dir__, "data") }
OFFLINE_PMTILES_PATH = File.join(DATA_DIR, "offline.pmtiles")
TERRAIN_PMTILES_PATH = File.join(DATA_DIR, "terrain.pmtiles")
GPX_STORE = GpxStore.new(File.join(DATA_DIR, "gpx"))
LIVETRACK_STORE = LiveTrackStore.new(File.join(DATA_DIR, "livetrack.json"))
# Not under DATA_DIR: unlike everything else there (uploads, PMTiles,
# the kiwix ZIM), a plugin is code, not local data — plugins/overpass-pois
# ships checked into this repo as the reference example of the
# contract. Overridable so tests can point it at a throwaway fixture
# directory. In production this whole directory is baked into the
# image (see Dockerfile's `COPY . .`) and wiped/replaced on every
# deploy along with the rest of the checkout (docs/deployment.adoc) —
# fine for a plugin checked into this repo, not for a private one.
PLUGINS_DIR = ENV.fetch("MAPTOOL_PLUGINS_DIR") { File.join(__dir__, "plugins") }
# A second, optional plugin directory under DATA_DIR — the same
# persistent bind mount as gpx/kiwix (docs/deployment.adoc), so a
# private/scraper plugin (one whose ToS forbids living in this public
# repo) dropped in data/plugins/ survives a redeploy the way PLUGINS_DIR
# above can't. Checked after PLUGINS_DIR (see Plugins.list/run), so a
# same-named private plugin never shadows the built-in one.
PLUGINS_DIRS = [PLUGINS_DIR, File.join(DATA_DIR, "plugins")].freeze
# Shared secret for the Garmin LiveTrack webhook (a Google Apps Script
# watching Gmail for LiveTrack emails, see docs/deployment.adoc) — not a
# Gitea-authenticated user, so it can't use the OAuth2 login flow above.
# Blank means the endpoint is disabled, same fallback story as the
# OAuth2 secrets: fine outside production, nothing works without it set.
LIVETRACK_WEBHOOK_SECRET = ENV.fetch("LIVETRACK_WEBHOOK_SECRET", "")
# An external kiwix-serve instance (see docs/development.adoc), not
# something this app runs itself — blank disables the "Nearby on
# Wikipedia" popup link the same way the other optional-integration
# blanks above do. Only ever reached server-side (the Docker-network
# service name in production, e.g. "http://kiwix:8080") — production
# sits behind a reverse proxy (map.priesol.net) that only forwards to
# this app, so a browser can't reach kiwix-serve directly by any
# hostname/port of its own. GET /kiwix/* below proxies to it instead.
KIWIX_URL = ENV.fetch("KIWIX_URL", "")
# Login delegates to the Gitea instance the user already has an account
# on, rather than maintaining a local username/password — see
# docs/architecture.adoc. Only users listed in ALLOWED_GITEA_USERS may
# write; anyone else can authenticate against Gitea but is still
# refused. No defaults for the OAuth2 client id/secret/user list: blank
# values just mean login (and thus writing) doesn't work, which is fine
# outside production — nothing here is eagerly validated at load time.
GITEA_URL = ENV.fetch("GITEA_URL", "https://git.priesol.net")
ALLOWED_GITEA_USERS = ENV.fetch("MAPTOOL_ALLOWED_GITEA_USER", "").split(",").map(&:strip)
OAUTH_CLIENT = OAuth2::Client.new(
ENV.fetch("GITEA_OAUTH_CLIENT_ID", ""),
ENV.fetch("GITEA_OAUTH_CLIENT_SECRET", ""),
site: GITEA_URL,
authorize_url: "/login/oauth/authorize",
token_url: "/login/oauth/access_token"
)
helpers do
def logged_in?
!!session[:user]
end
def require_login!
halt 401, json(error: "login required", login_url: "/login") unless logged_in?
end
def pmtiles_file_status(path, meta)
return { available: false } unless File.exist?(path)
status = { available: true, size: File.size(path), mtime: File.mtime(path).utc.iso8601 }
meta ? status.merge(meta: meta) : status
end
def callback_url
"#{request.base_url}/auth/callback"
end
def save_zip_entries(folder:, io:)
paths = []
Zip::File.open_buffer(io) do |zip|
zip.each do |entry|
next unless entry.file? && GpxStore::TRACK_EXTENSIONS.any? { |ext| entry.name.downcase.end_with?(ext) }
paths << GPX_STORE.save(folder: folder, filename: File.basename(entry.name), io: StringIO.new(entry.get_input_stream.read))
end
end
paths
end
end
get "/login" do
session[:oauth_state] = SecureRandom.hex(16)
redirect OAUTH_CLIENT.auth_code.authorize_url(
redirect_uri: callback_url,
state: session[:oauth_state],
scope: "read:user"
)
end
get "/auth/callback" do
halt 400, "invalid state" unless params[:state] && params[:state] == session.delete(:oauth_state)
token = OAUTH_CLIENT.auth_code.get_token(params[:code], redirect_uri: callback_url)
user = token.get("/api/v1/user").parsed
halt 403, "not authorized" unless ALLOWED_GITEA_USERS.include?(user["login"])
session[:user] = user["login"]
redirect "/"
end
get "/logout" do
session.clear
redirect "/"
end
# Live GNSS position, polled by the frontend from /api/gnss/position. A
# single background thread reads gpsd (if it's running — harmless retry
# loop if not, see GpsdClient) and keeps this updated; polling instead
# of push (SSE/WebSocket) is deliberate — gpsd itself only reports at
# ~1Hz, so there is nothing to gain from a push channel, and it avoids
# Sinatra's `stream(:keep_open)` helper, which — absent an EventMachine
# reactor (i.e. under plain threaded Puma, as here) — busy-loops calling
# its block repeatedly instead of waiting for external writes; fine for
# a single blocking per-message write, not for "register once, get
# pushed to later" like this.
# A fix is only reported back as "available" for this long after it
# last arrived — otherwise a receiver that's lost its fix, or been
# unplugged, would keep showing its last position forever as if it
# were still live.
GNSS_STALE_AFTER = 10 # seconds
GNSS_LATEST = {}
GNSS_LATEST_MUTEX = Mutex.new
Thread.new do
GpsdClient.new.each_position do |position|
GNSS_LATEST_MUTEX.synchronize do
GNSS_LATEST.replace(position)
GNSS_LATEST[:received_at] = Time.now.to_f
end
end
end
get "/" do
erb :index, locals: { logged_in: logged_in?, user: session[:user], revision: REVISION }
end
get "/api/health" do
json status: "ok", revision: REVISION
end
get "/api/offline-tiles" do
json pmtiles_file_status(OFFLINE_PMTILES_PATH, PmtilesBuilder.offline_meta(DATA_DIR))
end
# Served as a plain range-request-capable file, not decoded server-side:
# pmtiles.js reads the archive's directory and fetches only the byte
# ranges it needs for the tiles currently on screen.
get "/tiles/offline.pmtiles" do
halt 404 unless File.exist?(OFFLINE_PMTILES_PATH)
send_file OFFLINE_PMTILES_PATH, type: "application/octet-stream"
end
# Compares the recorded build (offline.pmtiles.meta.json) against the
# current latest Protomaps build without rebuilding anything —
# update_available is null (not true/false) if there's no local build
# yet, or the live index couldn't be reached, so the frontend can tell
# "don't know" apart from "no, up to date". Not folded into GET
# /api/offline-tiles above: that route is on the hot path (map.js's own
# topbar-dot check on every page load), this one only gets called when
# the Offline data panel is actually opened.
get "/api/offline-tiles/check-update" do
json update_available: PmtilesBuilder.offline_update_available?(DATA_DIR)
end
# Lists the selectable countries and which of them are currently
# selected (data/offline_regions.json) — read-only, no login needed,
# same tier as the plain availability checks above.
get "/api/offline-regions" do
json available: PmtilesBuilder.available_regions, selected: PmtilesBuilder.selected_regions(DATA_DIR)
end
# Just updates the selection — does not itself start a build, so
# ticking several countries doesn't fire off several rebuilds. The
# frontend follows this with the two build routes below once the user
# is done choosing.
post "/api/offline-regions" do
require_login!
codes = Array(params[:codes])
begin
PmtilesBuilder.set_selected_regions(DATA_DIR, codes)
rescue PmtilesBuilder::BuildError => e
halt 400, json(error: e.message)
end
json ok: true
end
# Regenerates offline.pmtiles for the currently selected regions (see
# POST /api/offline-regions above) by downloading each from Geofabrik
# and tiling them with Planetiler — see lib/maptool/pmtiles_builder.rb.
# Runs in the background; poll build-status below for progress.
post "/api/offline-tiles/build" do
require_login!
begin
PmtilesBuilder.build_offline(data_dir: DATA_DIR)
rescue PmtilesBuilder::BuildError => e
halt 409, json(error: e.message)
end
json status: "started"
end
get "/api/offline-tiles/build-status" do
json PmtilesBuilder.offline_status
end
# Deletes offline.pmtiles plus its whole per-country download cache —
# a manual "reclaim the space"/"start over" action, distinct from
# unticking countries in the region picker (which only changes what a
# future build covers, doesn't free anything already on disk).
delete "/api/offline-tiles" do
require_login!
begin
PmtilesBuilder.delete_offline_data(DATA_DIR)
rescue PmtilesBuilder::BuildError => e
halt 409, json(error: e.message)
end
json ok: true
end
# What's actually in the per-country download cache right now (not the
# same as offline.pmtiles' own meta.sources — see cached_extracts) —
# read-only, same tier as GET /api/offline-regions above, so the region
# picker can show a size + delete affordance next to each cached
# country regardless of whether it's still selected or already built.
get "/api/offline-tiles/cache" do
json extracts: PmtilesBuilder.cached_extracts(DATA_DIR)
end
# Deletes one country's cached Geofabrik extract only — e.g. to force a
# clean re-download of just that one, or reclaim space for a country
# that's no longer selected (the cache otherwise keeps it indefinitely).
delete "/api/offline-tiles/cache/:code" do
require_login!
begin
PmtilesBuilder.delete_cached_extract(DATA_DIR, params[:code])
rescue PmtilesBuilder::BuildError => e
halt 409, json(error: e.message)
end
json ok: true
end
# Same optional-local-file story as offline.pmtiles above — see
# docs/development.adoc for how to generate one, until then hillshade
# just stays unavailable while the offline basemap is active.
get "/api/terrain-tiles" do
json pmtiles_file_status(TERRAIN_PMTILES_PATH, PmtilesBuilder.terrain_meta(DATA_DIR))
end
get "/tiles/terrain.pmtiles" do
halt 404 unless File.exist?(TERRAIN_PMTILES_PATH)
send_file TERRAIN_PMTILES_PATH, type: "application/octet-stream"
end
# Same reasoning as GET /api/offline-tiles/check-update above.
get "/api/terrain-tiles/check-update" do
json update_available: PmtilesBuilder.terrain_update_available?(DATA_DIR)
end
post "/api/terrain-tiles/build" do
require_login!
begin
PmtilesBuilder.build_terrain(data_dir: DATA_DIR)
rescue PmtilesBuilder::BuildError => e
halt 409, json(error: e.message)
end
json status: "started"
end
get "/api/terrain-tiles/build-status" do
json PmtilesBuilder.terrain_status
end
# Same reasoning as DELETE /api/offline-tiles above.
delete "/api/terrain-tiles" do
require_login!
begin
PmtilesBuilder.delete_terrain_data(DATA_DIR)
rescue PmtilesBuilder::BuildError => e
halt 409, json(error: e.message)
end
json ok: true
end
# Generated as a step inside POST /api/offline-tiles/build (see
# PmtilesBuilder.extract_hiking_trails) rather than its own separate
# build — it's derived from the exact same per-country .osm.pbf cache
# offline.pmtiles itself already downloads, so there's nothing of its
# own to trigger; DELETE /api/offline-tiles removes it too. One file
# per country (not one combined file) so a viewer only ever fetches
# the country they actually pick in the Hiking trails overlay's own
# checklist (public/js/hiking_trails.js), not every selected country's
# trails at once — `countries` lists which ones exist to build that
# checklist from.
get "/api/hiking-trails" do
json countries: PmtilesBuilder.hiking_trails_countries(DATA_DIR)
end
# Fetched client-side only for a country the viewer actually ticks in
# the Hiking trails checklist, not on page load. :code is constrained
# to exactly what hiking_trails_countries can ever return (two
# uppercase letters) — otherwise this would accept an arbitrary
# ".."-laden path segment.
get "/tiles/hiking_trails/:code.geojson" do
halt 404 unless params[:code].match?(/\A[A-Z]{2}\z/)
path = PmtilesBuilder.hiking_trails_country_path(DATA_DIR, params[:code])
halt 404 unless File.exist?(path)
send_file path, type: "application/geo+json"
end
# Pinged once per page load (public/js/map.js) — url is a same-origin
# path (not kiwix-serve's own address, which the browser can't reach
# directly behind the reverse proxy in front of this app), proxied by
# GET /kiwix/* below.
get "/api/kiwix" do
reachable = Kiwix.reachable?(KIWIX_URL)
json available: reachable, url: (reachable ? "/kiwix" : "")
end
# Backs the "Wikipedia" map layer (public/js/gpx.js) — replaces the old
# per-popup "Nearby on Wikipedia" link, which only ever pointed at one
# POI at a time. Read-only and login-free, same as the tree/plugin
# listing routes below: viewing the map needs no account, only writing
# to it does.
get "/api/kiwix/nearby" do
halt 404 if KIWIX_URL.empty?
bbox = %w[south west north east].map { |key| params[key].to_s }
halt 400, json(error: "south, west, north, east are required") if bbox.any?(&:empty?)
json Kiwix.nearby(KIWIX_URL, bbox.map(&:to_f))
end
# Proxies kiwix-serve's own search UI and content pages through this
# app instead of linking the browser to it directly (see KIWIX_URL) —
# kiwix-serve is started with --urlRootLocation=/kiwix
# (docker-compose.yaml), so every link/asset it emits already carries
# this same /kiwix prefix and Just Works through this passthrough. GET
# only: browsing a read-only ZIM archive never needs to write anything.
get "/kiwix/*" do
halt 404 if KIWIX_URL.empty?
response = Kiwix.proxy(KIWIX_URL, "/kiwix/#{params[:splat].first}", request.query_string)
halt 502 unless response
content_type response["content-type"] || "application/octet-stream"
status response.code.to_i
body response.body
end
get "/api/gpx/tree" do
json GPX_STORE.tree
end
# A .zip (Garmin's bulk/FIT export format) is expanded into its
# .gpx/.fit/.geojson entries instead of being stored itself — nothing
# downstream (tree listing, map rendering) knows what to do with a zip.
post "/api/gpx" do
require_login!
file = params[:file]
halt 400, json(error: "file is required") unless file && file[:tempfile]
begin
paths = if file[:filename].to_s.downcase.end_with?(".zip")
save_zip_entries(folder: params[:folder].to_s, io: file[:tempfile])
else
[GPX_STORE.save(folder: params[:folder].to_s, filename: file[:filename], io: file[:tempfile])]
end
rescue GpxStore::InvalidPath
halt 400, json(error: "invalid folder")
rescue Zip::Error
halt 400, json(error: "invalid zip file")
end
json paths: paths, tree: GPX_STORE.tree
end
# A hand-drawn point/note (see notes.js) — unlike the upload above, this
# grows one existing/new .geojson "layer" file in place (GpxStore#
# append_feature) rather than always landing as its own new file.
post "/api/gpx/notes" do
require_login!
begin
feature = JSON.parse(params[:feature].to_s)
rescue JSON::ParserError
halt 400, json(error: "invalid feature")
end
halt 400, json(error: "invalid feature") unless feature.is_a?(Hash) && feature["geometry"]
begin
path = GPX_STORE.append_feature(
feature: feature,
path: params[:path],
folder: params[:folder].to_s,
name: params[:name].to_s
)
rescue GpxStore::InvalidPath
halt 400, json(error: "invalid path")
rescue Errno::ENOENT
halt 404, json(error: "file not found")
end
json path: path, tree: GPX_STORE.tree
end
# Editing/deleting one note (see the popup's Edit/Delete actions,
# gpx.js) — targets a feature by properties["id"], not array position
# (see GpxStore#update_feature/#delete_feature for why).
put "/api/gpx/notes" do
require_login!
begin
feature = JSON.parse(params[:feature].to_s)
rescue JSON::ParserError
halt 400, json(error: "invalid feature")
end
halt 400, json(error: "invalid feature") unless feature.is_a?(Hash) && feature["geometry"]
halt 400, json(error: "path and id are required") if params[:path].to_s.empty? || params[:id].to_s.empty?
begin
GPX_STORE.update_feature(path: params[:path], id: params[:id], feature: feature)
rescue GpxStore::InvalidPath
halt 400, json(error: "invalid path")
rescue Errno::ENOENT
halt 404, json(error: "note not found")
end
json ok: true
end
delete "/api/gpx/notes" do
require_login!
halt 400, json(error: "path and id are required") if params[:path].to_s.empty? || params[:id].to_s.empty?
begin
GPX_STORE.delete_feature(path: params[:path], id: params[:id])
rescue GpxStore::InvalidPath
halt 400, json(error: "invalid path")
rescue Errno::ENOENT
halt 404, json(error: "note not found")
end
json ok: true
end
# Lists whatever import plugins are on disk (see lib/maptool/plugins.rb
# for the contract) — read-only, doesn't need require_login! the way
# actually running one does below.
get "/api/plugins" do
json Plugins.list(PLUGINS_DIRS)
end
# Runs one plugin for the given bbox (the current map viewport, from
# the frontend's "Run for this area" button) and saves its GeoJSON
# output as a new file the same way an uploaded .geojson would be
# saved — reusing GpxStore#save rather than #append_feature since this
# writes a whole FeatureCollection at once, not one feature into an
# existing one.
post "/api/plugins/:name/run" do
require_login!
bbox = %w[south west north east].map { |key| params[key].to_s }
halt 400, json(error: "south, west, north, east are required") if bbox.any?(&:empty?)
south, west, north, east = bbox.map(&:to_f)
begin
geojson = Plugins.run(PLUGINS_DIRS, params[:name], [south, west, north, east])
rescue Plugins::RunError => e
halt 502, json(error: e.message)
end
# Filename carries the area's center and the run's timestamp (same
# idea as a p4n2gpx export's own "p4n_area_<lat>_<lon>_<date>_<time>"
# naming) — without it, running this again for a different area just
# overwrote/renamed-with-a-counter the same "overpass-pois.geojson",
# with nothing in the file tree to tell one run's POIs from another's.
center_lat = format("%.2f", (south + north) / 2)
center_lon = format("%.2f", (west + east) / 2)
timestamp = Time.now.strftime("%y%m%d_%H%M")
filename = "#{params[:name]}_#{center_lat}_#{center_lon}_#{timestamp}.geojson"
path = GPX_STORE.save(folder: "", filename: filename, io: StringIO.new(JSON.generate(geojson)))
json path: path, tree: GPX_STORE.tree
end
# ?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
halt 400
rescue Errno::ENOENT
halt 404
rescue Gpx::ParseError, Fit::ParseError
halt 422, json(error: "could not parse file")
end
content_type :json
if params[:download]
# Matches the tree's own display name for a cached .gpx/.fit
# (public/js/gpx.js's _renderFile) — "ride.gpx" downloads as
# "ride.gpx.geojson", not "ride.geojson", so the two never disagree.
# A .geojson source is already named right and is left alone.
original_name = File.basename(params[:splat].first)
download_name = original_name.downcase.end_with?(".geojson") ? original_name : "#{original_name}.geojson"
attachment download_name
end
body
end
post "/api/gpx/folder" do
require_login!
begin
GPX_STORE.create_folder(params[:path].to_s)
rescue GpxStore::InvalidPath
halt 400, json(error: "invalid path")
end
json tree: GPX_STORE.tree
end
delete "/api/gpx/file/*" do
require_login!
begin
GPX_STORE.delete_file(params[:splat].first)
rescue GpxStore::InvalidPath
halt 400
rescue Errno::ENOENT
halt 404
end
json tree: GPX_STORE.tree
end
delete "/api/gpx/folder/*" do
require_login!
begin
GPX_STORE.delete_folder(params[:splat].first)
rescue GpxStore::InvalidPath
halt 400
rescue Errno::ENOENT
halt 404
end
json tree: GPX_STORE.tree
end
post "/api/gpx/move" do
require_login!
begin
path = GPX_STORE.move(from: params[:from].to_s, to_folder: params[:to_folder].to_s)
rescue GpxStore::InvalidPath
halt 400, json(error: "invalid path")
rescue Errno::ENOENT
halt 404
end
json path: path, tree: GPX_STORE.tree
end
# Called by the Apps Script watching Gmail for Garmin LiveTrack emails —
# it only extracts and forwards the session id/token, nothing else.
post "/api/livetrack/session" do
halt 403 if LIVETRACK_WEBHOOK_SECRET.empty? || params[:secret] != LIVETRACK_WEBHOOK_SECRET
session_id = params[:session_id].to_s.strip
session_token = params[:session_token].to_s.strip
halt 400, json(error: "session_id is required") if session_id.empty?
LIVETRACK_STORE.add(session_id, session_token)
json ok: true
end
get "/api/livetrack/sessions" do
json sessions: LIVETRACK_STORE.list
end
# Garmin stops serving a session's live-track page some while after the
# activity ends (its own session/token URL, not the API — see
# LiveTrackClient) — the exact cutoff isn't documented, ~24h is what's
# been observed. Ended sessions old enough to likely be past that are
# verified once at startup (LiveTrackStore#stale_ended already skips
# ones already flagged, so this doesn't re-hit Garmin for the same
# dead session on every restart), so the panel can grey them out up
# front instead of only failing after a user clicks one and waits on
# a fetch.
LIVETRACK_MAP_STALE_AFTER = 24 * 60 * 60 # seconds
Thread.new do
LIVETRACK_STORE.stale_ended(LIVETRACK_MAP_STALE_AFTER).each do |session|
LiveTrackClient.fetch(session["id"], session["token"])
rescue LiveTrackClient::FetchError
LIVETRACK_STORE.mark_unavailable(session["id"])
end
end
# Garmin's own API (livetrack.garmin.com/api/v2/...) 403s a plain server
# request no matter what headers it gets — see LiveTrackClient — so this
# fetches the session page itself and pulls the track out of that.
get "/api/livetrack/sessions/:id/track" do
session = LIVETRACK_STORE.find(params[:id])
halt 404, json(error: "unknown session") unless session
halt 404, json(error: "session has no token") if session["token"].to_s.empty?
begin
result = LiveTrackClient.fetch(session["id"], session["token"])
rescue LiveTrackClient::FetchError => e
halt 502, json(error: "could not fetch track: #{e.message}")
end
summary = LiveTrackClient.summarize(result)
LIVETRACK_STORE.set_summary(session["id"], summary)
points = result.points
coordinates = points.filter_map { |p| p.dig("position", "lon") && p.dig("position", "lat") && [p["position"]["lon"], p["position"]["lat"]] }
halt 502, json(error: "track has no positions") if coordinates.empty?
last = points.last
json(
type: "FeatureCollection",
features: [
{ type: "Feature", geometry: { type: "LineString", coordinates: coordinates }, properties: {} },
{
type: "Feature",
geometry: { type: "Point", coordinates: coordinates.last },
# Merges the session summary in too — the point layer's hover
# popup (public/js/livetrack.js) reads it straight off here
# rather than needing a second fetch against /sessions.
properties: summary.merge(
"time" => last["dateTime"],
"speed_meters_per_sec" => last["speedMetersPerSec"],
"heart_rate_beats_per_min" => last["heartRateBeatsPerMin"]
),
},
]
)
end
get "/api/gnss/position" do
require_login!
GNSS_LATEST_MUTEX.synchronize do
received_at = GNSS_LATEST[:received_at]
fresh = received_at && (Time.now.to_f - received_at) < GNSS_STALE_AFTER
json(fresh ? GNSS_LATEST.merge(available: true) : { available: false })
end
end
end
end