Overpass categories now carry the same icon+color styling a p4n2gpx waypoint gets, instead of falling back to gpx.js's plain red marker, plus a popup description built from opening_hours/operator/website tags. Supermarket/convenience store reuse NPS's generic store glyph, the only honest match in that icon set. Each plugin run's saved filename now bakes in the area's center and a timestamp, so repeated downloads for different areas land as separate, identifiable files instead of overpass-pois.geojson/-1/-2/...
563 lines
22 KiB
Ruby
563 lines
22 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"
|
|
require_relative "lib/maptool/kiwix"
|
|
require_relative "lib/maptool/plugins"
|
|
|
|
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")
|
|
TERRAIN_PMTILES_PATH = File.join(DATA_DIR, "terrain.pmtiles")
|
|
GPX_STORE = GpxStore.new(File.join(DATA_DIR, "gpx"))
|
|
LIVETRACK_STORE = LiveTrackStore.new(File.join(DATA_DIR, "livetrack.json"))
|
|
|
|
# Not under DATA_DIR: unlike everything else there (uploads, PMTiles,
|
|
# the kiwix ZIM), a plugin is code, not local data — plugins/overpass-pois
|
|
# ships checked into this repo as the reference example of the
|
|
# contract. A private/scraper plugin just lives in its own directory
|
|
# here too, simply never `git add`ed, no special-casing needed.
|
|
# Overridable so tests can point it at a throwaway fixture directory.
|
|
PLUGINS_DIR = ENV.fetch("MAPTOOL_PLUGINS_DIR") { File.join(__dir__, "plugins") }
|
|
|
|
# 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", "")
|
|
|
|
# An external kiwix-serve instance (see docs/development.adoc), not
|
|
# something this app runs itself — blank disables the "Nearby on
|
|
# Wikipedia" popup link the same way the other optional-integration
|
|
# blanks above do. Only ever reached server-side (the Docker-network
|
|
# service name in production, e.g. "http://kiwix:8080") — production
|
|
# sits behind a reverse proxy (map.priesol.net) that only forwards to
|
|
# this app, so a browser can't reach kiwix-serve directly by any
|
|
# hostname/port of its own. GET /kiwix/* below proxies to it instead.
|
|
KIWIX_URL = ENV.fetch("KIWIX_URL", "")
|
|
|
|
# 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", revision: REVISION
|
|
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
|
|
|
|
# Same optional-local-file story as offline.pmtiles above — see
|
|
# docs/development.adoc for how to generate one, until then hillshade
|
|
# just stays unavailable while the offline basemap is active.
|
|
get "/api/terrain-tiles" do
|
|
json available: File.exist?(TERRAIN_PMTILES_PATH)
|
|
end
|
|
|
|
get "/tiles/terrain.pmtiles" do
|
|
halt 404 unless File.exist?(TERRAIN_PMTILES_PATH)
|
|
|
|
send_file TERRAIN_PMTILES_PATH, type: "application/octet-stream"
|
|
end
|
|
|
|
# Pinged once per page load (public/js/map.js) — url is a same-origin
|
|
# path (not kiwix-serve's own address, which the browser can't reach
|
|
# directly behind the reverse proxy in front of this app), proxied by
|
|
# GET /kiwix/* below.
|
|
get "/api/kiwix" do
|
|
reachable = Kiwix.reachable?(KIWIX_URL)
|
|
json available: reachable, url: (reachable ? "/kiwix" : "")
|
|
end
|
|
|
|
# Proxies kiwix-serve's own search UI and content pages through this
|
|
# app instead of linking the browser to it directly (see KIWIX_URL) —
|
|
# kiwix-serve is started with --urlRootLocation=/kiwix
|
|
# (docker-compose.yaml), so every link/asset it emits already carries
|
|
# this same /kiwix prefix and Just Works through this passthrough. GET
|
|
# only: browsing a read-only ZIM archive never needs to write anything.
|
|
get "/kiwix/*" do
|
|
halt 404 if KIWIX_URL.empty?
|
|
response = Kiwix.proxy(KIWIX_URL, "/kiwix/#{params[:splat].first}", request.query_string)
|
|
halt 502 unless response
|
|
|
|
content_type response["content-type"] || "application/octet-stream"
|
|
status response.code.to_i
|
|
body response.body
|
|
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
|
|
|
|
# A hand-drawn point/note (see notes.js) — unlike the upload above, this
|
|
# grows one existing/new .geojson "layer" file in place (GpxStore#
|
|
# append_feature) rather than always landing as its own new file.
|
|
post "/api/gpx/notes" do
|
|
require_login!
|
|
begin
|
|
feature = JSON.parse(params[:feature].to_s)
|
|
rescue JSON::ParserError
|
|
halt 400, json(error: "invalid feature")
|
|
end
|
|
halt 400, json(error: "invalid feature") unless feature.is_a?(Hash) && feature["geometry"]
|
|
|
|
begin
|
|
path = GPX_STORE.append_feature(
|
|
feature: feature,
|
|
path: params[:path],
|
|
folder: params[:folder].to_s,
|
|
name: params[:name].to_s
|
|
)
|
|
rescue GpxStore::InvalidPath
|
|
halt 400, json(error: "invalid path")
|
|
rescue Errno::ENOENT
|
|
halt 404, json(error: "file not found")
|
|
end
|
|
|
|
json path: path, tree: GPX_STORE.tree
|
|
end
|
|
|
|
# Editing/deleting one note (see the popup's Edit/Delete actions,
|
|
# gpx.js) — targets a feature by properties["id"], not array position
|
|
# (see GpxStore#update_feature/#delete_feature for why).
|
|
put "/api/gpx/notes" do
|
|
require_login!
|
|
begin
|
|
feature = JSON.parse(params[:feature].to_s)
|
|
rescue JSON::ParserError
|
|
halt 400, json(error: "invalid feature")
|
|
end
|
|
halt 400, json(error: "invalid feature") unless feature.is_a?(Hash) && feature["geometry"]
|
|
halt 400, json(error: "path and id are required") if params[:path].to_s.empty? || params[:id].to_s.empty?
|
|
|
|
begin
|
|
GPX_STORE.update_feature(path: params[:path], id: params[:id], feature: feature)
|
|
rescue GpxStore::InvalidPath
|
|
halt 400, json(error: "invalid path")
|
|
rescue Errno::ENOENT
|
|
halt 404, json(error: "note not found")
|
|
end
|
|
|
|
json ok: true
|
|
end
|
|
|
|
delete "/api/gpx/notes" do
|
|
require_login!
|
|
halt 400, json(error: "path and id are required") if params[:path].to_s.empty? || params[:id].to_s.empty?
|
|
|
|
begin
|
|
GPX_STORE.delete_feature(path: params[:path], id: params[:id])
|
|
rescue GpxStore::InvalidPath
|
|
halt 400, json(error: "invalid path")
|
|
rescue Errno::ENOENT
|
|
halt 404, json(error: "note not found")
|
|
end
|
|
|
|
json ok: true
|
|
end
|
|
|
|
# Lists whatever import plugins are on disk (see lib/maptool/plugins.rb
|
|
# for the contract) — read-only, doesn't need require_login! the way
|
|
# actually running one does below.
|
|
get "/api/plugins" do
|
|
json Plugins.list(PLUGINS_DIR)
|
|
end
|
|
|
|
# Runs one plugin for the given bbox (the current map viewport, from
|
|
# the frontend's "Run for this area" button) and saves its GeoJSON
|
|
# output as a new file the same way an uploaded .geojson would be
|
|
# saved — reusing GpxStore#save rather than #append_feature since this
|
|
# writes a whole FeatureCollection at once, not one feature into an
|
|
# existing one.
|
|
post "/api/plugins/:name/run" do
|
|
require_login!
|
|
bbox = %w[south west north east].map { |key| params[key].to_s }
|
|
halt 400, json(error: "south, west, north, east are required") if bbox.any?(&:empty?)
|
|
|
|
south, west, north, east = bbox.map(&:to_f)
|
|
begin
|
|
geojson = Plugins.run(PLUGINS_DIR, params[:name], [south, west, north, east])
|
|
rescue Plugins::RunError => e
|
|
halt 502, json(error: e.message)
|
|
end
|
|
|
|
# Filename carries the area's center and the run's timestamp (same
|
|
# idea as a p4n2gpx export's own "p4n_area_<lat>_<lon>_<date>_<time>"
|
|
# naming) — without it, running this again for a different area just
|
|
# overwrote/renamed-with-a-counter the same "overpass-pois.geojson",
|
|
# with nothing in the file tree to tell one run's POIs from another's.
|
|
center_lat = format("%.2f", (south + north) / 2)
|
|
center_lon = format("%.2f", (west + east) / 2)
|
|
timestamp = Time.now.strftime("%y%m%d_%H%M")
|
|
filename = "#{params[:name]}_#{center_lat}_#{center_lon}_#{timestamp}.geojson"
|
|
|
|
path = GPX_STORE.save(folder: "", filename: filename, io: StringIO.new(JSON.generate(geojson)))
|
|
json path: path, 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
|