Browsers (and a caching proxy in front of production) were holding onto /js and /css for hours, so a deploy could go unnoticed by an already-open tab. A boot-time version query string on the entry script/stylesheet, plus an import map remapping map.js's internal module imports to the same versioned URLs, fixes that without editing every JS file's import statements.
255 lines
8.5 KiB
Ruby
255 lines
8.5 KiB
Ruby
require "sinatra/base"
|
|
require "sinatra/json"
|
|
require "oauth2"
|
|
require "securerandom"
|
|
require_relative "lib/maptool/gpx"
|
|
require_relative "lib/maptool/gpx_store"
|
|
require_relative "lib/maptool/gpsd_client"
|
|
require_relative "lib/maptool/livetrack_store"
|
|
|
|
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
|
|
|
|
OFFLINE_PMTILES_PATH = File.join(__dir__, "data", "offline.pmtiles")
|
|
GPX_STORE = GpxStore.new(File.join(__dir__, "data", "gpx"))
|
|
LIVETRACK_STORE = LiveTrackStore.new(File.join(__dir__, "data", "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
|
|
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
|
|
|
|
post "/api/gpx" do
|
|
require_login!
|
|
file = params[:file]
|
|
halt 400, json(error: "file is required") unless file && file[:tempfile]
|
|
|
|
begin
|
|
path = GPX_STORE.save(folder: params[:folder].to_s, filename: file[:filename], io: file[:tempfile])
|
|
rescue GpxStore::InvalidPath
|
|
halt 400, json(error: "invalid folder")
|
|
end
|
|
|
|
json path: path, tree: GPX_STORE.tree
|
|
end
|
|
|
|
get "/api/gpx/file/*" do
|
|
begin
|
|
path = GPX_STORE.resolve_file(params[:splat].first)
|
|
rescue GpxStore::InvalidPath
|
|
halt 400
|
|
end
|
|
halt 404 unless File.file?(path)
|
|
|
|
begin
|
|
json Gpx.to_geojson(path)
|
|
rescue Gpx::ParseError
|
|
halt 422, json(error: "could not parse GPX file")
|
|
end
|
|
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, 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
|
|
halt 400, json(error: "session_id is required") if session_id.empty?
|
|
|
|
LIVETRACK_STORE.add(session_id)
|
|
json ok: true
|
|
end
|
|
|
|
get "/api/livetrack/sessions" do
|
|
json sessions: LIVETRACK_STORE.list
|
|
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
|