Files
maptool/app.rb
T
randy 55607aece9
CI / check (push) Successful in 55s
CI / deploy (push) Successful in 23s
Show session summary on hover over its LiveTrack marker
The track endpoint now merges the session summary into the marker's
GeoJSON properties (already computed for storage, just wasn't passed
through), and the marker gets the same hover-popup treatment GPX
points already have.
2026-09-06 22:32:44 +02:00

298 lines
10 KiB
Ruby

require "sinatra/base"
require "sinatra/json"
require "oauth2"
require "securerandom"
require_relative "lib/maptool/gpx"
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")
# 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
OFFLINE_PMTILES_PATH = File.join(__dir__, "data", "offline.pmtiles")
GPX_STORE = GpxStore.new(File.join(__dir__, "data", "gpx"))
LIVETRACK_STORE = LiveTrackStore.new(File.join(__dir__, "data", "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
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] }
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
post "/api/gpx" do
require_login!
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
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'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
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