Once a .gpx/.fit is cached, the tree shows it under its converted name
("ride.gpx" -> "ride.gpx.geojson", matching the real cache file next
to it on disk) instead of still reading as un-converted -- the
GeoJSON is what's actually on the map from that point on, and the
original is never deleted, just not re-shown as itself. The name is
now a real link (GET /api/gpx/file/<path>?download=1) instead of
plain text, the only way to get that converted form out of the app.
Verified in a real browser that an <a> nested in a <label> claims its
own click rather than the label forwarding it to the checkbox, so
downloading and toggling visibility don't fight over the same click.
339 lines
12 KiB
Ruby
339 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/.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=1 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.
|
|
get "/api/gpx/file/*" do
|
|
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'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
|