diff --git a/app.rb b/app.rb index 9d87cd9..9d24cb4 100644 --- a/app.rb +++ b/app.rb @@ -6,6 +6,7 @@ require_relative "lib/maptool/gpx" 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 @@ -228,14 +229,15 @@ module Maptool end # Called by the Apps Script watching Gmail for Garmin LiveTrack emails — - # it only extracts and forwards the session ID, nothing else. + # 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) + LIVETRACK_STORE.add(session_id, session_token) json ok: true end @@ -243,6 +245,41 @@ module Maptool 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 + points = LiveTrackClient.track_points(session["id"], session["token"]) + rescue LiveTrackClient::FetchError => e + halt 502, json(error: "could not fetch track: #{e.message}") + end + + 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 }, + properties: { + time: last["dateTime"], + speedMetersPerSec: last["speedMetersPerSec"], + heartRateBeatsPerMin: last["heartRateBeatsPerMin"], + }, + }, + ] + ) + end + get "/api/gnss/position" do GNSS_LATEST_MUTEX.synchronize do received_at = GNSS_LATEST[:received_at] diff --git a/docs/deployment.adoc b/docs/deployment.adoc index d94c653..0c3ab33 100644 --- a/docs/deployment.adoc +++ b/docs/deployment.adoc @@ -109,13 +109,25 @@ usernames, e.g. `randy,friend1,friend2` — not their emails, the == Garmin LiveTrack session webhook -`POST /api/livetrack/session` (`session_id`, `secret` form params) records a -Garmin LiveTrack session ID to `data/livetrack.json`; `GET +`POST /api/livetrack/session` (`session_id`, `session_token`, `secret` form +params) records a Garmin LiveTrack session to `data/livetrack.json`; `GET /api/livetrack/sessions` lists them, newest first. Intended caller: a Google Apps Script bound to the Gmail account that receives LiveTrack emails, -triggered on a timer, extracting the session ID from the email and posting -it here — the endpoint itself doesn't know anything about email or Garmin's -API, just the ID string. +triggered on a timer, extracting the session id/token from the email and +posting them here — the endpoint itself doesn't know anything about email or +Garmin's API, just those two strings. + +`GET /api/livetrack/sessions/:id/track` fetches and returns that session's +track as GeoJSON (a `LineString` plus a `Point` at the latest position) — +see `LiveTrackClient` (`lib/maptool/livetrack_client.rb`) for how, since +there's no usable REST call for this: Garmin's own API +(`livetrack.garmin.com/api/v2/...`) 403s a plain server-side HTTP request no +matter what headers it's given (Cloudflare bot protection, fingerprinting +at the TLS/HTTP level rather than anything header-based) — but the session +page itself isn't blocked, and Garmin's Next.js frontend renders the full +track server-side into it (as React Server Component data, not a follow-up +API call), so the client fetches that page and pulls the track out of the +embedded payload instead. `secret` must match the `LIVETRACK_WEBHOOK_SECRET` repo secret (added to `.env` the same way as the others above). Unlike the other secrets, this one @@ -142,12 +154,12 @@ function checkLiveTrackEmail() { // part, only text/html, and the link only exists as an href attribute // — the auto-generated plain-text conversion isn't reliable for it. const body = thread.getMessages()[0].getBody(); - const match = body.match(/https:\/\/livetrack\.garmin\.com\/session\/([\w-]+)/); + const match = body.match(/https:\/\/livetrack\.garmin\.com\/session\/([\w-]+)\/token\/([\w-]+)/); if (match) { UrlFetchApp.fetch(WEBHOOK_URL, { method: "post", - payload: { session_id: match[1], secret: WEBHOOK_SECRET } + payload: { session_id: match[1], session_token: match[2], secret: WEBHOOK_SECRET } }); } @@ -168,11 +180,11 @@ every 5 minutes. Verified against a real "started an activity" LiveTrack email (2026-09-06): sender `noreply@garmin.com`, link -`https://livetrack.garmin.com/session//token/` — the regex -above only captures the `` part, stopping at the next `/`. Garmin -also sends other LiveTrack notifications (e.g. activity ended) that +`https://livetrack.garmin.com/session//token/`, and against +the resulting session page/track fetch (`LiveTrackClient`, same day). +Garmin also sends other LiveTrack notifications (e.g. activity ended) that weren't checked — if one turns out to use a different link shape, the -webhook simply won't receive that session ID; nothing breaks. +webhook simply won't receive that session; nothing breaks. == CI deploy job diff --git a/lib/maptool/livetrack_client.rb b/lib/maptool/livetrack_client.rb new file mode 100644 index 0000000..797b565 --- /dev/null +++ b/lib/maptool/livetrack_client.rb @@ -0,0 +1,115 @@ +require "net/http" +require "json" + +module Maptool + # Fetches a Garmin LiveTrack session's track points. + # + # There's no usable REST call for this: livetrack.garmin.com/api/v2/... is + # behind Cloudflare bot protection that 403s a plain HTTP client regardless + # of headers (checked — not just a missing User-Agent). The session page + # itself isn't blocked, though, and Garmin's Next.js frontend renders it + # server-side, embedding the full track (as React Server Component data, + # via `self.__next_f.push(...)` calls in the HTML) rather than fetching it + # client-side. So: fetch the page, and pull the track out of that instead + # of the API it would otherwise call. + class LiveTrackClient + class FetchError < StandardError; end + + USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36" + PUSH_PATTERN = /self\.__next_f\.push\((\[.*?\])\)<\/script>/m + + def self.track_points(session_id, token) + new.track_points(session_id, token) + end + + def track_points(session_id, token) + points = find_track_points(fetch_html(session_id, token)) + raise FetchError, "no track points found (session invalid or expired?)" unless points + + points.map { |point| clean(point) } + end + + private + + # The RSC payload serializes an absent field as the literal string + # "$undefined" (Next.js's flight protocol, not JSON's own undefined) — + # normalize that to nil so nothing downstream has to know about it. + def clean(node) + case node + when "$undefined" then nil + when Hash then node.transform_values { |value| clean(value) } + when Array then node.map { |value| clean(value) } + else node + end + end + + def fetch_html(session_id, token) + uri = URI("https://livetrack.garmin.com/session/#{session_id}/token/#{token}") + response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 15) do |http| + http.get(uri.request_uri, "User-Agent" => USER_AGENT) + end + raise FetchError, "unexpected response #{response.code}" unless response.is_a?(Net::HTTPSuccess) + + response.body + rescue SocketError, Timeout::Error, OpenSSL::SSL::SSLError => e + raise FetchError, e.message + end + + # Next.js streams several of these RSC payloads per page; the track + # points live inside one of them, under some data-fetching library's own + # nesting (React Query, at the time this was written) that isn't worth + # depending on the exact shape of — so this just walks every payload + # looking for a "trackPoints" array, wherever it ends up. + def find_track_points(html) + html.scan(PUSH_PATTERN).each do |(raw)| + push = safe_parse(raw) + next unless push.is_a?(Array) && push[1].is_a?(String) + + _line_id, json = push[1].split(":", 2) + next unless json + + found = search_track_points(safe_parse(json)) + return found if found + end + nil + end + + def search_track_points(node) + case node + when Hash + return node["trackPoints"] if node["trackPoints"].is_a?(Array) + + node.each_value { |value| (found = search_track_points(value)) and return found } + nil + when Array + node.each { |value| (found = search_track_points(value)) and return found } + nil + end + end + + def safe_parse(json) + JSON.parse(json) + rescue JSON::ParserError + nil + end + end +end + +if __FILE__ == $0 + client = Maptool::LiveTrackClient.new + + fake_page = <<~HTML + + + + + HTML + + points = client.send(:find_track_points, fake_page) + raise "expected 2 points" unless points&.size == 2 + raise "expected first point lat 48.1" unless points[0]["position"]["lat"] == 48.1 + + raise "expected nil when no trackPoints present" unless client.send(:find_track_points, "").nil? + + puts "ok" +end diff --git a/lib/maptool/livetrack_store.rb b/lib/maptool/livetrack_store.rb index fc6dcfa..21be205 100644 --- a/lib/maptool/livetrack_store.rb +++ b/lib/maptool/livetrack_store.rb @@ -2,10 +2,12 @@ require "json" require "fileutils" module Maptool - # JSON-file-backed store of Garmin LiveTrack session IDs, appended to by a + # JSON-file-backed store of Garmin LiveTrack sessions, appended to by a # webhook whenever a new LiveTrack email arrives (see docs/deployment.adoc). - # Validity/expiry of a session isn't tracked here — checking whether a - # given ID still has live data is up to whoever polls Garmin with it. + # The token is what actually lets us fetch the session's track later (see + # LiveTrackClient) — Garmin's session URL is /session//token/, + # and the id alone isn't enough. Validity/expiry isn't tracked here — + # whoever fetches the track finds out then. class LiveTrackStore def initialize(path) @path = path @@ -14,12 +16,12 @@ module Maptool File.write(@path, "[]") unless File.exist?(@path) end - # Records `session_id`, moving it to the front if already present. - def add(session_id) + # Records `session_id`/`token`, moving it to the front if already present. + def add(session_id, token) @mutex.synchronize do sessions = read sessions.reject! { |s| s["id"] == session_id } - sessions.unshift({ "id" => session_id, "received_at" => Time.now.utc.iso8601 }) + sessions.unshift({ "id" => session_id, "token" => token, "received_at" => Time.now.utc.iso8601 }) File.write(@path, JSON.pretty_generate(sessions)) end end @@ -29,6 +31,10 @@ module Maptool read end + def find(session_id) + read.find { |s| s["id"] == session_id } + end + private def read @@ -46,14 +52,17 @@ if __FILE__ == $0 store = Maptool::LiveTrackStore.new(File.join(dir, "livetrack.json")) raise "expected empty list" unless store.list == [] - store.add("abc123") - store.add("def456") + store.add("abc123", "tok-abc") + store.add("def456", "tok-def") raise "expected 2 sessions" unless store.list.size == 2 raise "expected newest first" unless store.list.first["id"] == "def456" + raise "expected token stored" unless store.find("abc123")["token"] == "tok-abc" + raise "expected unknown id to be nil" unless store.find("nope").nil? - store.add("abc123") + store.add("abc123", "tok-abc-2") raise "expected re-added session moved to front, not duplicated" \ unless store.list.map { |s| s["id"] } == %w[abc123 def456] + raise "expected token updated on re-add" unless store.find("abc123")["token"] == "tok-abc-2" reloaded = Maptool::LiveTrackStore.new(File.join(dir, "livetrack.json")) raise "expected data to survive reload" unless reloaded.list.size == 2 diff --git a/public/css/style.css b/public/css/style.css index b84182d..774be63 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -440,12 +440,35 @@ html, body { } .livetrack-panel__item { + border-bottom: 1px solid #f0f0f0; +} + +.livetrack-panel__row { display: flex; align-items: center; justify-content: space-between; gap: 8px; - padding: 6px 0; - border-bottom: 1px solid #f0f0f0; + width: 100%; + padding: 6px 4px; + border: none; + border-radius: 4px; + background: none; + font: inherit; + text-align: left; + cursor: pointer; +} + +.livetrack-panel__row:hover { + background: #f0f0f0; +} + +.livetrack-panel__row.active { + background: #f1e6fb; +} + +.livetrack-panel__row.loading { + opacity: 0.6; + cursor: wait; } .livetrack-panel__id { diff --git a/public/js/livetrack.js b/public/js/livetrack.js index 8d583d6..c54e900 100644 --- a/public/js/livetrack.js +++ b/public/js/livetrack.js @@ -2,11 +2,27 @@ import { LIVETRACK_ICON } from "/js/icons.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; const SESSIONS_URL = "/api/livetrack/sessions"; +const TRACK_SOURCE = "livetrack-track"; +const TRACK_COLOR = "#7209b7"; -// A sidebar icon that opens a side panel listing Garmin LiveTrack session -// IDs received via the webhook (see docs/deployment.adoc) — read-only, just -// so they're visible somewhere instead of only in data/livetrack.json. +// A sidebar icon that opens a side panel listing Garmin LiveTrack sessions +// received via the webhook (see docs/deployment.adoc). Clicking one fetches +// and draws its track on the map (only one shown at a time — clicking the +// active one again hides it). export class LiveTrackPanelControl { + constructor(map) { + this._map = map; + this._activeSessionId = null; + this._geojson = null; + + // setStyle() (switching the Online/Offline base map) tears down all + // custom sources and layers — put the track back afterwards, same as + // GpxPanelControl does for visible GPX files. + map.on("style.load", () => { + if (this._geojson) this._showTrack(this._geojson, { fit: false }); + }); + } + mount(sidebar) { this._container = document.createElement("div"); this._container.className = "sidebar-item"; @@ -56,6 +72,11 @@ export class LiveTrackPanelControl { const li = document.createElement("li"); li.className = "livetrack-panel__item"; + const button = document.createElement("button"); + button.type = "button"; + button.className = "livetrack-panel__row"; + button.classList.toggle("active", id === this._activeSessionId); + const idSpan = document.createElement("span"); idSpan.className = "livetrack-panel__id"; idSpan.textContent = id; @@ -64,8 +85,88 @@ export class LiveTrackPanelControl { timeSpan.className = "livetrack-panel__time"; timeSpan.textContent = new Date(received_at).toLocaleString(); - li.append(idSpan, timeSpan); + button.append(idSpan, timeSpan); + button.addEventListener("click", () => this._toggleTrack(id, button)); + li.appendChild(button); this._list.appendChild(li); }); } + + async _toggleTrack(id, button) { + const wasActive = id === this._activeSessionId; + this._removeTrack(); + this._list.querySelectorAll(".livetrack-panel__row.active").forEach((el) => el.classList.remove("active")); + if (wasActive) return; + + button.classList.add("loading"); + try { + const response = await fetch(`/api/livetrack/sessions/${encodeURIComponent(id)}/track`); + if (!response.ok) { + const { error } = await response.json().catch(() => ({})); + window.alert(error || "Could not load track."); + return; + } + this._geojson = await response.json(); + this._showTrack(this._geojson); + this._activeSessionId = id; + button.classList.add("active"); + } finally { + button.classList.remove("loading"); + } + } + + _showTrack(geojson, { fit = true } = {}) { + if (this._map.getSource(TRACK_SOURCE)) return; + + this._map.addSource(TRACK_SOURCE, { type: "geojson", data: geojson }); + this._map.addLayer({ + id: `${TRACK_SOURCE}-line`, + type: "line", + source: TRACK_SOURCE, + filter: ["==", ["geometry-type"], "LineString"], + layout: { "line-cap": "round", "line-join": "round" }, + paint: { "line-color": TRACK_COLOR, "line-width": 3 }, + }); + this._map.addLayer({ + id: `${TRACK_SOURCE}-point`, + type: "circle", + source: TRACK_SOURCE, + filter: ["==", ["geometry-type"], "Point"], + paint: { + "circle-radius": 6, + "circle-color": TRACK_COLOR, + "circle-stroke-color": "#fff", + "circle-stroke-width": 1.5, + }, + }); + + if (!fit) return; + + const coordinates = geojson.features[0].geometry.coordinates; + const bounds = coordinates.reduce( + ([minLon, minLat, maxLon, maxLat], [lon, lat]) => [ + Math.min(minLon, lon), + Math.min(minLat, lat), + Math.max(maxLon, lon), + Math.max(maxLat, lat), + ], + [Infinity, Infinity, -Infinity, -Infinity], + ); + this._map.fitBounds( + [ + [bounds[0], bounds[1]], + [bounds[2], bounds[3]], + ], + { padding: 40 }, + ); + } + + _removeTrack() { + this._activeSessionId = null; + this._geojson = null; + [`${TRACK_SOURCE}-line`, `${TRACK_SOURCE}-point`].forEach((layerId) => { + if (this._map.getLayer(layerId)) this._map.removeLayer(layerId); + }); + if (this._map.getSource(TRACK_SOURCE)) this._map.removeSource(TRACK_SOURCE); + } } diff --git a/public/js/map.js b/public/js/map.js index 85d6998..816fb3c 100644 --- a/public/js/map.js +++ b/public/js/map.js @@ -128,7 +128,7 @@ layerToggle.setActive("online"); new GpxPanelControl(map).mount(sidebar); new RadarControl(map).mount(sidebar); new GnssControl(map).mount(sidebar); -new LiveTrackPanelControl().mount(sidebar); +new LiveTrackPanelControl(map).mount(sidebar); function applyStyle(name) { layerToggle.setActive(name);