Render a session's track on the map
CI / check (push) Successful in 47s
CI / deploy (push) Successful in 19s

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:
randy committed 2026-09-06 22:07:13 +02:00
1 parent 7083cec36c
commit f4b5237f01
7 files changed
+326 -29

No files matched your search

+23 -11
View File
@@ -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