Files
maptool/app.rb
T
randy cd4eef342b
CI / check (push) Successful in 1m56s
CI / deploy (push) Successful in 2m56s
Add a persisted test suite
`bundle exec rake test` (minitest, a default gem but pinned in
Gemfile for reproducibility) now covers what ad-hoc scripts kept
re-verifying by hand during development: Gpx/Fit/TrackStats parsing,
GpxStore/LiveTrackStore file/TTL logic, LiveTrackClient's payload
parsing, and app.rb's routes end to end via Rack::MockRequest.

MAPTOOL_DATA_DIR (new, app.rb) lets the suite point GPX_STORE/
LIVETRACK_STORE at a throwaway directory instead of real data/ —
without it every run would leave uploaded test files behind. Each
lib file's old `if __FILE__ == $0` self-check is now redundant with
real tests covering the same ground, so removed rather than kept as
a second copy of the same assertions. Wired into ci.yml so it isn't
just a local convenience.
2026-09-07 12:46:35 +02:00

327 lines
12 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"
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
# 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] }
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 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
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
geojson = path.downcase.end_with?(".fit") ? Fit.to_geojson(path) : Gpx.to_geojson(path)
json geojson
rescue Gpx::ParseError, Fit::ParseError
halt 422, json(error: "could not parse 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