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") 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) return { available: false } unless File.exist?(path) { available: true, size: File.size(path), mtime: File.mtime(path).utc.iso8601 } 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) 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 # 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 extracting them from Protomaps' # own daily full-planet basemap build — 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 # 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) end get "/tiles/terrain.pmtiles" do halt 404 unless File.exist?(TERRAIN_PMTILES_PATH) send_file TERRAIN_PMTILES_PATH, type: "application/octet-stream" 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 # 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____