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" 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) } # 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") GPX_STORE = GpxStore.new(File.join(DATA_DIR, "gpx")) LIVETRACK_STORE = LiveTrackStore.new(File.join(DATA_DIR, "livetrack.json")) # 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", "") # 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 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" end get "/api/offline-tiles" do json available: File.exist?(OFFLINE_PMTILES_PATH) 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 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 # ?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