data/hiking_trails.geojson now generates automatically as the last step of every offline.pmtiles build (PmtilesBuilder.extract_hiking_trails, best-effort — a failure here doesn't fail the whole build), merged across all selected countries with cross-border dedup by OSM relation id (HikingTrails.extract_merged). Served at GET /tiles/hiking_trails.geojson and GET /api/hiking-trails, fetched client-side only once the overlay is actually toggled on. hiking_trails.js now prefers this local file whenever it exists, regardless of which base map (online/offline) is active — same "prefer local when available" story as HillshadeControl already uses for terrain. Offline click hit-tests the real vector layer directly (queryRenderedFeatures) instead of querying Overpass — instant, no network. Coordinates are rounded to 5dp to keep the combined file smaller; colour tag values are normalized against a known-safe set before ever reaching a style paint property. Verified extract_merged (new: combines multiple countries' extracts, dedupes overlapping relations) against two real cached country PBFs on brick directly, not just fixtures.
738 lines
29 KiB
Ruby
738 lines
29 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"
|
|
require_relative "lib/maptool/pmtiles_builder"
|
|
|
|
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")
|
|
HIKING_TRAILS_PATH = PmtilesBuilder.hiking_trails_path(DATA_DIR)
|
|
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. Overridable so tests can point it at a throwaway fixture
|
|
# directory. In production this whole directory is baked into the
|
|
# image (see Dockerfile's `COPY . .`) and wiped/replaced on every
|
|
# deploy along with the rest of the checkout (docs/deployment.adoc) —
|
|
# fine for a plugin checked into this repo, not for a private one.
|
|
PLUGINS_DIR = ENV.fetch("MAPTOOL_PLUGINS_DIR") { File.join(__dir__, "plugins") }
|
|
|
|
# A second, optional plugin directory under DATA_DIR — the same
|
|
# persistent bind mount as gpx/kiwix (docs/deployment.adoc), so a
|
|
# private/scraper plugin (one whose ToS forbids living in this public
|
|
# repo) dropped in data/plugins/ survives a redeploy the way PLUGINS_DIR
|
|
# above can't. Checked after PLUGINS_DIR (see Plugins.list/run), so a
|
|
# same-named private plugin never shadows the built-in one.
|
|
PLUGINS_DIRS = [PLUGINS_DIR, File.join(DATA_DIR, "plugins")].freeze
|
|
|
|
# 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 pmtiles_file_status(path, meta)
|
|
return { available: false } unless File.exist?(path)
|
|
|
|
status = { available: true, size: File.size(path), mtime: File.mtime(path).utc.iso8601 }
|
|
meta ? status.merge(meta: meta) : status
|
|
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 pmtiles_file_status(OFFLINE_PMTILES_PATH, PmtilesBuilder.offline_meta(DATA_DIR))
|
|
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
|
|
|
|
# Compares the recorded build (offline.pmtiles.meta.json) against the
|
|
# current latest Protomaps build without rebuilding anything —
|
|
# update_available is null (not true/false) if there's no local build
|
|
# yet, or the live index couldn't be reached, so the frontend can tell
|
|
# "don't know" apart from "no, up to date". Not folded into GET
|
|
# /api/offline-tiles above: that route is on the hot path (map.js's own
|
|
# topbar-dot check on every page load), this one only gets called when
|
|
# the Offline data panel is actually opened.
|
|
get "/api/offline-tiles/check-update" do
|
|
json update_available: PmtilesBuilder.offline_update_available?(DATA_DIR)
|
|
end
|
|
|
|
# Lists the selectable countries and which of them are currently
|
|
# selected (data/offline_regions.json) — read-only, no login needed,
|
|
# same tier as the plain availability checks above.
|
|
get "/api/offline-regions" do
|
|
json available: PmtilesBuilder.available_regions, selected: PmtilesBuilder.selected_regions(DATA_DIR)
|
|
end
|
|
|
|
# Just updates the selection — does not itself start a build, so
|
|
# ticking several countries doesn't fire off several rebuilds. The
|
|
# frontend follows this with the two build routes below once the user
|
|
# is done choosing.
|
|
post "/api/offline-regions" do
|
|
require_login!
|
|
codes = Array(params[:codes])
|
|
|
|
begin
|
|
PmtilesBuilder.set_selected_regions(DATA_DIR, codes)
|
|
rescue PmtilesBuilder::BuildError => e
|
|
halt 400, json(error: e.message)
|
|
end
|
|
json ok: true
|
|
end
|
|
|
|
# Regenerates offline.pmtiles for the currently selected regions (see
|
|
# POST /api/offline-regions above) by downloading each from Geofabrik
|
|
# and tiling them with Planetiler — see lib/maptool/pmtiles_builder.rb.
|
|
# Runs in the background; poll build-status below for progress.
|
|
post "/api/offline-tiles/build" do
|
|
require_login!
|
|
begin
|
|
PmtilesBuilder.build_offline(data_dir: DATA_DIR)
|
|
rescue PmtilesBuilder::BuildError => e
|
|
halt 409, json(error: e.message)
|
|
end
|
|
json status: "started"
|
|
end
|
|
|
|
get "/api/offline-tiles/build-status" do
|
|
json PmtilesBuilder.offline_status
|
|
end
|
|
|
|
# Deletes offline.pmtiles plus its whole per-country download cache —
|
|
# a manual "reclaim the space"/"start over" action, distinct from
|
|
# unticking countries in the region picker (which only changes what a
|
|
# future build covers, doesn't free anything already on disk).
|
|
delete "/api/offline-tiles" do
|
|
require_login!
|
|
begin
|
|
PmtilesBuilder.delete_offline_data(DATA_DIR)
|
|
rescue PmtilesBuilder::BuildError => e
|
|
halt 409, json(error: e.message)
|
|
end
|
|
json ok: true
|
|
end
|
|
|
|
# What's actually in the per-country download cache right now (not the
|
|
# same as offline.pmtiles' own meta.sources — see cached_extracts) —
|
|
# read-only, same tier as GET /api/offline-regions above, so the region
|
|
# picker can show a size + delete affordance next to each cached
|
|
# country regardless of whether it's still selected or already built.
|
|
get "/api/offline-tiles/cache" do
|
|
json extracts: PmtilesBuilder.cached_extracts(DATA_DIR)
|
|
end
|
|
|
|
# Deletes one country's cached Geofabrik extract only — e.g. to force a
|
|
# clean re-download of just that one, or reclaim space for a country
|
|
# that's no longer selected (the cache otherwise keeps it indefinitely).
|
|
delete "/api/offline-tiles/cache/:code" do
|
|
require_login!
|
|
begin
|
|
PmtilesBuilder.delete_cached_extract(DATA_DIR, params[:code])
|
|
rescue PmtilesBuilder::BuildError => e
|
|
halt 409, json(error: e.message)
|
|
end
|
|
json ok: true
|
|
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 pmtiles_file_status(TERRAIN_PMTILES_PATH, PmtilesBuilder.terrain_meta(DATA_DIR))
|
|
end
|
|
|
|
get "/tiles/terrain.pmtiles" do
|
|
halt 404 unless File.exist?(TERRAIN_PMTILES_PATH)
|
|
|
|
send_file TERRAIN_PMTILES_PATH, type: "application/octet-stream"
|
|
end
|
|
|
|
# Same reasoning as GET /api/offline-tiles/check-update above.
|
|
get "/api/terrain-tiles/check-update" do
|
|
json update_available: PmtilesBuilder.terrain_update_available?(DATA_DIR)
|
|
end
|
|
|
|
post "/api/terrain-tiles/build" do
|
|
require_login!
|
|
begin
|
|
PmtilesBuilder.build_terrain(data_dir: DATA_DIR)
|
|
rescue PmtilesBuilder::BuildError => e
|
|
halt 409, json(error: e.message)
|
|
end
|
|
json status: "started"
|
|
end
|
|
|
|
get "/api/terrain-tiles/build-status" do
|
|
json PmtilesBuilder.terrain_status
|
|
end
|
|
|
|
# Same reasoning as DELETE /api/offline-tiles above.
|
|
delete "/api/terrain-tiles" do
|
|
require_login!
|
|
begin
|
|
PmtilesBuilder.delete_terrain_data(DATA_DIR)
|
|
rescue PmtilesBuilder::BuildError => e
|
|
halt 409, json(error: e.message)
|
|
end
|
|
json ok: true
|
|
end
|
|
|
|
# Generated as a step inside POST /api/offline-tiles/build (see
|
|
# PmtilesBuilder.extract_hiking_trails) rather than its own separate
|
|
# build — it's derived from the exact same per-country .osm.pbf cache
|
|
# offline.pmtiles itself already downloads, so there's nothing of its
|
|
# own to trigger or delete; DELETE /api/offline-tiles removes this
|
|
# file too. No `meta` argument here (unlike offline/terrain above) —
|
|
# parsing the whole GeoJSON just to report a feature count would cost
|
|
# real time on every page load's status check for what's a multi-MB
|
|
# file across several countries; size/mtime alone is enough to know
|
|
# whether it's there.
|
|
get "/api/hiking-trails" do
|
|
json pmtiles_file_status(HIKING_TRAILS_PATH, nil)
|
|
end
|
|
|
|
# Fetched client-side only when the Hiking trails overlay is actually
|
|
# toggled on (public/js/hiking_trails.js), not on page load — same
|
|
# "on demand" story as the online raster tiles it replaces once this
|
|
# is available.
|
|
get "/tiles/hiking_trails.geojson" do
|
|
halt 404 unless File.exist?(HIKING_TRAILS_PATH)
|
|
|
|
send_file HIKING_TRAILS_PATH, type: "application/geo+json"
|
|
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
|
|
|
|
# Backs the "Wikipedia" map layer (public/js/gpx.js) — replaces the old
|
|
# per-popup "Nearby on Wikipedia" link, which only ever pointed at one
|
|
# POI at a time. Read-only and login-free, same as the tree/plugin
|
|
# listing routes below: viewing the map needs no account, only writing
|
|
# to it does.
|
|
get "/api/kiwix/nearby" do
|
|
halt 404 if KIWIX_URL.empty?
|
|
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?)
|
|
|
|
json Kiwix.nearby(KIWIX_URL, bbox.map(&:to_f))
|
|
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_DIRS)
|
|
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_DIRS, 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
|