Render a session's track on the map
Clicking a LiveTrack session now fetches and draws its track (line + latest position). Garmin's own API 403s any plain server-side request (Cloudflare bot protection), so LiveTrackClient fetches the session page instead and pulls the track out of the server-rendered data embedded in it. The webhook now captures the session's token too, since the id alone isn't enough to fetch anything.
This commit is contained in:
1 parent
7083cec36c
commit
f4b5237f01
7 files changed
+326
-29
No files matched your search
@@ -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]
|
||||
|
||||
+23
-11
@@ -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/<uuid>/token/<token>` — the regex
|
||||
above only captures the `<uuid>` part, stopping at the next `/`. Garmin
|
||||
also sends other LiveTrack notifications (e.g. activity ended) that
|
||||
`https://livetrack.garmin.com/session/<uuid>/token/<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
|
||||
|
||||
|
||||
@@ -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><body>
|
||||
<script>self.__next_f.push([1,"6:[\\"$\\",\\"div\\",null,{}]"])</script>
|
||||
<script>self.__next_f.push([1,"10:{\\"unrelated\\":true,\\"nested\\":{\\"data\\":{\\"pages\\":[{\\"trackPoints\\":[{\\"position\\":{\\"lat\\":48.1,\\"lon\\":17.2},\\"dateTime\\":\\"2026-01-01T00:00:00Z\\"},{\\"position\\":{\\"lat\\":48.2,\\"lon\\":17.3},\\"dateTime\\":\\"2026-01-01T00:01:00Z\\"}]}]}}}"])</script>
|
||||
</body></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, "<html></html>").nil?
|
||||
|
||||
puts "ok"
|
||||
end
|
||||
@@ -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/<id>/token/<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
|
||||
|
||||
+25
-2
@@ -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 {
|
||||
|
||||
+105
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user