GET /api/gnss/position now requires login (app.rb) -- it reveals where the receiver physically is, not something to leave open to anyone with the URL. GnssControl's sidebar icon is hidden entirely when logged out (same pattern as the tree UI's write controls) rather than shown and failing to ever get a fix. LocateControl needs no changes: it only ever reaches GNSS through GnssControl#isEnabled(), which can't turn true for a logged-out visitor if the toggle is never on the page to click. Its browser-geolocation fallback stays open to everyone -- a visitor's own device asking for their own location isn't something to gate. Extracted isLoggedIn() out of gpx.js into a shared public/js/auth.js (gnss.js needed the same check) rather than duplicating it. Verified: /api/gnss/position returns 401 logged out; the sidebar icon is actually hidden (computed display: none, not just present-but- unstyled -- checked properly this time after almost repeating the same present-vs-visible test mistake the .gpx-panel__action bug already taught).
379 lines
14 KiB
Ruby
379 lines
14 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")
|
|
|
|
# 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'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
|