init
This commit is contained in:
164
lib/garmin/client.ts
Normal file
164
lib/garmin/client.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { GarminConnect } from "garmin-connect";
|
||||
import type { IActivity } from "garmin-connect/dist/garmin/types/activity";
|
||||
import type { IOauth1Token } from "garmin-connect/dist/garmin/types";
|
||||
import type { RoutePoint, RunningActivityInput } from "@/lib/models/running";
|
||||
import { getSavedOauth1Token } from "@/lib/models/garmin-auth";
|
||||
import { completeMfaAndGetTicket, loginAndGetTicket, type GarminPendingMfa } from "@/lib/garmin/sso";
|
||||
|
||||
const FETCH_LIMIT = 50;
|
||||
|
||||
export class GarminLoginRequiredError extends Error {
|
||||
constructor() {
|
||||
super("Wymagane logowanie do Garmin Connect.");
|
||||
}
|
||||
}
|
||||
|
||||
function parseGarminDate(value: string): Date {
|
||||
return new Date(`${value.replace(" ", "T")}Z`);
|
||||
}
|
||||
|
||||
function isRunningActivity(activity: IActivity): boolean {
|
||||
return activity.activityType?.typeKey?.toLowerCase().includes("running") ?? false;
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function toText(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function mapActivity(activity: IActivity): RunningActivityInput {
|
||||
return {
|
||||
garminActivityId: activity.activityId,
|
||||
name: activity.activityName,
|
||||
startTime: parseGarminDate(activity.startTimeGMT),
|
||||
durationSec: activity.duration,
|
||||
distanceM: activity.distance,
|
||||
avgPaceSecPerKm: activity.averageSpeed > 0 ? 1000 / activity.averageSpeed : 0,
|
||||
avgHr: activity.averageHR || undefined,
|
||||
maxHr: activity.maxHR || undefined,
|
||||
calories: activity.calories || undefined,
|
||||
elevationGainM: activity.elevationGain || undefined,
|
||||
avgCadence: activity.averageRunningCadenceInStepsPerMinute || undefined,
|
||||
avgVerticalOscillationCm: toNumber(activity.avgVerticalOscillation),
|
||||
avgGroundContactTimeMs: toNumber(activity.avgGroundContactTime),
|
||||
avgStrideLengthCm: activity.avgStrideLength || undefined,
|
||||
avgGroundContactBalanceLeftPct: toNumber(activity.avgGroundContactBalance),
|
||||
avgVerticalRatioPct: toNumber(activity.avgVerticalRatio),
|
||||
vo2Max: activity.vO2MaxValue || undefined,
|
||||
aerobicTrainingEffect: toNumber(activity.aerobicTrainingEffect),
|
||||
anaerobicTrainingEffect: toNumber(activity.anaerobicTrainingEffect),
|
||||
trainingEffectLabel: toText(activity.trainingEffectLabel),
|
||||
avgPowerW: toNumber(activity.avgPower),
|
||||
maxPowerW: toNumber(activity.maxPower),
|
||||
normPowerW: toNumber(activity.normPower),
|
||||
avgRespirationRate: toNumber(activity.avgRespirationRate),
|
||||
hasRoute: activity.hasPolyline || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const GC_API = "https://connectapi.garmin.com";
|
||||
const MAX_POLYLINE_POINTS = 500;
|
||||
|
||||
type GarminPolylinePoint = { lat: number; lon: number; altitude?: number };
|
||||
type GarminActivityDetailsResponse = {
|
||||
geoPolylineDTO?: { polyline?: GarminPolylinePoint[] };
|
||||
};
|
||||
|
||||
export async function fetchActivityRoutePoints(
|
||||
client: GarminConnect,
|
||||
garminActivityId: number
|
||||
): Promise<RoutePoint[] | null> {
|
||||
const url = `${GC_API}/activity-service/activity/${garminActivityId}/details?maxPolylineSize=${MAX_POLYLINE_POINTS}`;
|
||||
const data = await client.get<GarminActivityDetailsResponse>(url);
|
||||
const polyline = data?.geoPolylineDTO?.polyline;
|
||||
if (!Array.isArray(polyline) || polyline.length === 0) return null;
|
||||
return polyline.map((p) => [p.lat, p.lon] as RoutePoint);
|
||||
}
|
||||
|
||||
function getCredentials(): { username: string; password: string } {
|
||||
const username = process.env.GARMIN_EMAIL;
|
||||
const password = process.env.GARMIN_PASSWORD;
|
||||
if (!username || !password) {
|
||||
throw new Error("Brak danych logowania do Garmin Connect (GARMIN_EMAIL / GARMIN_PASSWORD).");
|
||||
}
|
||||
return { username, password };
|
||||
}
|
||||
|
||||
async function exchangeOauth1Token(client: GarminConnect, oauth1Token: IOauth1Token): Promise<void> {
|
||||
const http = client.client;
|
||||
if (!http.OAUTH_CONSUMER) {
|
||||
await http.fetchOauthConsumer();
|
||||
}
|
||||
const consumer = http.OAUTH_CONSUMER;
|
||||
if (!consumer) {
|
||||
throw new Error("Nie udało się pobrać konfiguracji OAuth Garmin.");
|
||||
}
|
||||
|
||||
const oauth = http.getOauthClient(consumer);
|
||||
http.oauth1Token = oauth1Token;
|
||||
await http.exchange({ oauth, token: oauth1Token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a client authenticated using a previously saved OAuth1 token
|
||||
* (long-lived, survives across syncs) - no MFA needed if it's still valid.
|
||||
*/
|
||||
export async function getAuthorizedClient(): Promise<GarminConnect> {
|
||||
const saved = await getSavedOauth1Token();
|
||||
if (!saved) {
|
||||
throw new GarminLoginRequiredError();
|
||||
}
|
||||
|
||||
const client = new GarminConnect({ username: "", password: "" });
|
||||
try {
|
||||
await exchangeOauth1Token(client, saved);
|
||||
} catch {
|
||||
throw new GarminLoginRequiredError();
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
async function establishClientFromTicket(ticket: string): Promise<{ client: GarminConnect; oauth1Token: IOauth1Token }> {
|
||||
const client = new GarminConnect({ username: "", password: "" });
|
||||
await client.client.fetchOauthConsumer();
|
||||
const oauth1 = await client.client.getOauth1Token(ticket);
|
||||
await client.client.exchange(oauth1);
|
||||
return { client, oauth1Token: oauth1.token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a fresh SSO login using env credentials. If the account requires
|
||||
* MFA, returns the pending state needed to complete it via
|
||||
* `completeGarminMfaLogin` once the user supplies the emailed code.
|
||||
*/
|
||||
export async function beginGarminLogin(): Promise<
|
||||
{ client: GarminConnect; oauth1Token: IOauth1Token } | { mfaRequired: true; pendingState: GarminPendingMfa }
|
||||
> {
|
||||
const { username, password } = getCredentials();
|
||||
const result = await loginAndGetTicket(username, password);
|
||||
if ("mfaRequired" in result) return result;
|
||||
return establishClientFromTicket(result.ticket);
|
||||
}
|
||||
|
||||
export async function completeGarminMfaLogin(
|
||||
pendingState: GarminPendingMfa,
|
||||
code: string
|
||||
): Promise<{ client: GarminConnect; oauth1Token: IOauth1Token }> {
|
||||
const ticket = await completeMfaAndGetTicket(pendingState, code);
|
||||
return establishClientFromTicket(ticket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all recent running activities (mapped), regardless of `since` -
|
||||
* callers should upsert all of them so previously-synced activities get
|
||||
* backfilled with newly added metric fields, but can use `since` to decide
|
||||
* which ones are "new" for reporting purposes.
|
||||
*/
|
||||
export async function fetchRunningActivities(client: GarminConnect): Promise<RunningActivityInput[]> {
|
||||
const activities = await client.getActivities(0, FETCH_LIMIT);
|
||||
|
||||
return activities.filter(isRunningActivity).map(mapActivity);
|
||||
}
|
||||
176
lib/garmin/sso.ts
Normal file
176
lib/garmin/sso.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
const GARMIN_SSO_ORIGIN = "https://sso.garmin.com";
|
||||
const GARMIN_SSO = `${GARMIN_SSO_ORIGIN}/sso`;
|
||||
const GARMIN_SSO_EMBED = `${GARMIN_SSO}/embed`;
|
||||
const GC_MODERN = "https://connect.garmin.com/modern";
|
||||
const SIGNIN_URL = `${GARMIN_SSO}/signin`;
|
||||
const USER_AGENT_BROWSER =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36";
|
||||
|
||||
const TICKET_RE = /ticket=([^"]+)"/;
|
||||
const CSRF_RE = /name="_csrf"\s+value="(.+?)"/;
|
||||
|
||||
const SIGNIN_PARAMS: Record<string, string | boolean> = {
|
||||
id: "gauth-widget",
|
||||
embedWidget: true,
|
||||
clientId: "GarminConnect",
|
||||
locale: "en",
|
||||
gauthHost: GARMIN_SSO_EMBED,
|
||||
service: GARMIN_SSO_EMBED,
|
||||
source: GARMIN_SSO_EMBED,
|
||||
redirectAfterAccountLoginUrl: GARMIN_SSO_EMBED,
|
||||
redirectAfterAccountCreationUrl: GARMIN_SSO_EMBED,
|
||||
};
|
||||
|
||||
export type GarminPendingMfa = {
|
||||
cookies: [string, string][];
|
||||
mfaUrl: string;
|
||||
csrf: string;
|
||||
};
|
||||
|
||||
export type GarminLoginResult = { ticket: string } | { mfaRequired: true; pendingState: GarminPendingMfa };
|
||||
|
||||
function toQueryString(params: Record<string, string | boolean>): string {
|
||||
return Object.entries(params)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
class CookieJar {
|
||||
private cookies = new Map<string, string>();
|
||||
|
||||
constructor(initial?: [string, string][]) {
|
||||
if (initial) {
|
||||
for (const [key, value] of initial) this.cookies.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
apply(response: Response): void {
|
||||
const setCookies = response.headers.getSetCookie?.() ?? [];
|
||||
for (const cookie of setCookies) {
|
||||
const [pair] = cookie.split(";");
|
||||
const idx = pair.indexOf("=");
|
||||
this.cookies.set(pair.slice(0, idx), pair.slice(idx + 1));
|
||||
}
|
||||
}
|
||||
|
||||
header(): string {
|
||||
return Array.from(this.cookies.entries())
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
entries(): [string, string][] {
|
||||
return Array.from(this.cookies.entries());
|
||||
}
|
||||
}
|
||||
|
||||
async function request(jar: CookieJar, url: string, init: RequestInit = {}): Promise<{ response: Response; body: string }> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
redirect: "manual",
|
||||
headers: {
|
||||
"User-Agent": USER_AGENT_BROWSER,
|
||||
Cookie: jar.header(),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
jar.apply(response);
|
||||
const body = await response.text();
|
||||
return { response, body };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays the Garmin SSO web login flow (garmin-connect's HttpClient has no
|
||||
* cookie jar and a no-op handleMFA, so it cannot complete login when the
|
||||
* account has email-based MFA enabled).
|
||||
*/
|
||||
export async function loginAndGetTicket(username: string, password: string): Promise<GarminLoginResult> {
|
||||
const jar = new CookieJar();
|
||||
|
||||
const embedUrl = `${GARMIN_SSO_EMBED}?${toQueryString({ clientId: "GarminConnect", locale: "en", service: GC_MODERN })}`;
|
||||
await request(jar, embedUrl);
|
||||
|
||||
const signinPageUrl = `${SIGNIN_URL}?${toQueryString({
|
||||
id: "gauth-widget",
|
||||
embedWidget: true,
|
||||
locale: "en",
|
||||
gauthHost: GARMIN_SSO_EMBED,
|
||||
})}`;
|
||||
const signinPage = await request(jar, signinPageUrl);
|
||||
const csrfMatch = signinPage.body.match(CSRF_RE);
|
||||
if (!csrfMatch) {
|
||||
throw new Error("Logowanie do Garmin nie powiodło się (brak tokenu CSRF na stronie logowania).");
|
||||
}
|
||||
|
||||
const signinUrl = `${SIGNIN_URL}?${toQueryString(SIGNIN_PARAMS)}`;
|
||||
const credentialsForm = new URLSearchParams({ username, password, embed: "true", _csrf: csrfMatch[1] });
|
||||
const credentialsResult = await request(jar, signinUrl, {
|
||||
method: "POST",
|
||||
body: credentialsForm.toString(),
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Dnt: "1",
|
||||
Origin: GARMIN_SSO_ORIGIN,
|
||||
Referer: SIGNIN_URL,
|
||||
},
|
||||
});
|
||||
|
||||
const redirectLocation = credentialsResult.response.headers.get("location");
|
||||
if (redirectLocation?.includes("verifyMFA")) {
|
||||
const mfaPage = await request(jar, redirectLocation, { headers: { Referer: signinUrl } });
|
||||
const mfaCsrfMatch = mfaPage.body.match(CSRF_RE);
|
||||
if (!mfaCsrfMatch) {
|
||||
throw new Error("Logowanie do Garmin nie powiodło się (nie znaleziono formularza kodu MFA).");
|
||||
}
|
||||
return {
|
||||
mfaRequired: true,
|
||||
pendingState: { cookies: jar.entries(), mfaUrl: redirectLocation, csrf: mfaCsrfMatch[1] },
|
||||
};
|
||||
}
|
||||
|
||||
const ticketMatch = credentialsResult.body.match(TICKET_RE);
|
||||
if (!ticketMatch) {
|
||||
throw new Error("Logowanie do Garmin nie powiodło się (Ticket not found or MFA), sprawdź login i hasło.");
|
||||
}
|
||||
return { ticket: ticketMatch[1] };
|
||||
}
|
||||
|
||||
export async function completeMfaAndGetTicket(pendingState: GarminPendingMfa, code: string): Promise<string> {
|
||||
const jar = new CookieJar(pendingState.cookies);
|
||||
|
||||
const mfaForm = new URLSearchParams({
|
||||
"mfa-code": code.trim(),
|
||||
embed: "true",
|
||||
_csrf: pendingState.csrf,
|
||||
fromPage: "setupEnterMfaCode",
|
||||
});
|
||||
|
||||
const verifyResult = await request(jar, pendingState.mfaUrl, {
|
||||
method: "POST",
|
||||
body: mfaForm.toString(),
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Dnt: "1",
|
||||
Origin: GARMIN_SSO_ORIGIN,
|
||||
Referer: pendingState.mfaUrl,
|
||||
},
|
||||
});
|
||||
|
||||
let body = verifyResult.body;
|
||||
let location = verifyResult.response.headers.get("location");
|
||||
let previousUrl = pendingState.mfaUrl;
|
||||
let hops = 0;
|
||||
while (location && hops < 5) {
|
||||
const next = await request(jar, location, { headers: { Referer: previousUrl } });
|
||||
body = next.body;
|
||||
previousUrl = location;
|
||||
location = next.response.headers.get("location");
|
||||
hops += 1;
|
||||
}
|
||||
|
||||
const ticketMatch = body.match(TICKET_RE);
|
||||
if (!ticketMatch) {
|
||||
throw new Error("Weryfikacja kodu MFA nie powiodła się - sprawdź kod i spróbuj ponownie.");
|
||||
}
|
||||
return ticketMatch[1];
|
||||
}
|
||||
52
lib/garmin/wellness.ts
Normal file
52
lib/garmin/wellness.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { GarminConnect } from "garmin-connect";
|
||||
|
||||
export type DayWellness = {
|
||||
date: string;
|
||||
sleepDurationMin?: number;
|
||||
sleepScore?: number;
|
||||
deepSleepMin?: number;
|
||||
remSleepMin?: number;
|
||||
avgOvernightHrv?: number;
|
||||
hrvStatus?: string;
|
||||
restingHr?: number;
|
||||
bodyBatteryChange?: number;
|
||||
};
|
||||
|
||||
export async function fetchRecentWellness(
|
||||
client: GarminConnect,
|
||||
days: number
|
||||
): Promise<DayWellness[]> {
|
||||
const today = new Date();
|
||||
|
||||
const dates = Array.from({ length: days }, (_, i) => {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
return d;
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
dates.map((date) => client.getSleepData(date))
|
||||
);
|
||||
|
||||
return results
|
||||
.map((result, i) => {
|
||||
const dateStr = dates[i].toISOString().slice(0, 10);
|
||||
if (result.status === "rejected" || !result.value?.dailySleepDTO) {
|
||||
return { date: dateStr };
|
||||
}
|
||||
const { value: data } = result;
|
||||
const dto = data.dailySleepDTO;
|
||||
return {
|
||||
date: dateStr,
|
||||
sleepDurationMin: dto.sleepTimeSeconds ? Math.round(dto.sleepTimeSeconds / 60) : undefined,
|
||||
sleepScore: dto.sleepScores?.overall?.value ?? undefined,
|
||||
deepSleepMin: dto.deepSleepSeconds ? Math.round(dto.deepSleepSeconds / 60) : undefined,
|
||||
remSleepMin: dto.remSleepSeconds ? Math.round(dto.remSleepSeconds / 60) : undefined,
|
||||
avgOvernightHrv: data.avgOvernightHrv || undefined,
|
||||
hrvStatus: data.hrvStatus || undefined,
|
||||
restingHr: data.restingHeartRate || undefined,
|
||||
bodyBatteryChange: typeof data.bodyBatteryChange === "number" ? data.bodyBatteryChange : undefined,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
Reference in New Issue
Block a user