trails/apps/journal/app/lib/connected-services/providers/garmin/import.server.ts
Ullrich Schäfer 6eea6af673
feat(journal): harden FIT→GPX conversion (pause/session segmentation, validation, sport)
Implements the fit-parsing-hardening change. The shared converter now:
- splits output into <trkseg>s on timer stop/start events, falling back to
  record gaps > 5 min, so downstream moving-time never bridges a pause;
- slices records into per-session windows (multisport → one activity, one
  segment per session), single-session behavior unchanged;
- validates records: finite/in-range coordinates required, timestamp
  required, non-finite altitude dropped (point kept), prefers
  enhanced_altitude;
- returns { gpx, sport }, mapping FIT session sport/sub-sport to a Journal
  SportType (first session wins), consumed by the Wahoo importer + webhook
  and Garmin importer as a fallback when the provider sends no type.

Tests drive the converter via mocked fit-file-parser output (segmentation,
validation, sport mapping, no-GPS) per the chosen fixtures approach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:03:25 +02:00

87 lines
3.3 KiB
TypeScript

// Garmin activity import — the slow half of the webhook pipeline.
// Runs inside the `garmin-import-activity` pg-boss job: download the
// activity file from the notification's callback URL (Authorized via
// the user's token), convert to GPX, create the activity, record the
// dedupe row. Stats-only when there is no file.
import { fitToGpx, type FitConversion } from "../../fit.ts";
import { fetchWithTimeout } from "../../../http.server.ts";
import { isAlreadyImported, importActivity } from "../../../sync/imports.server.ts";
import { getServiceById, withFreshCredentials } from "../../manager.ts";
import type { OAuthCredentials } from "../../types.ts";
import { logger } from "../../../logger.server.ts";
import { GARMIN_API } from "./constants.ts";
// SSRF guard: notification callback URLs are attacker-controllable
// input until proven otherwise — only Garmin's API host is fetchable.
const ALLOWED_CALLBACK_HOSTS = new Set([new URL(GARMIN_API).host]);
export function isAllowedGarminCallback(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "https:" && ALLOWED_CALLBACK_HOSTS.has(parsed.host);
} catch {
return false;
}
}
export interface GarminImportData {
serviceId: string;
userId: string;
externalId: string;
callbackUrl: string | null;
fileType: string | null;
name: string | null;
startedAt: string | null;
duration: number | null;
distance: number | null;
}
export async function runGarminActivityImport(data: GarminImportData): Promise<void> {
// Connection may have been revoked/relinked between enqueue and run.
const service = await getServiceById(data.serviceId);
if (!service || service.status !== "active") {
logger.info({ serviceId: data.serviceId }, "garmin import: connection not active — skipped");
return;
}
if (await isAlreadyImported(data.userId, "garmin", data.externalId)) return;
let gpx: string | undefined;
let sport: FitConversion["sport"] = null;
if (data.callbackUrl && isAllowedGarminCallback(data.callbackUrl)) {
const buffer = await withFreshCredentials(service.id, async (credentials) => {
const creds = credentials as OAuthCredentials;
const resp = await fetchWithTimeout(data.callbackUrl!, {
headers: { Authorization: `Bearer ${creds.access_token}` },
});
if (!resp.ok) {
throw new Error(`Garmin file download failed: ${resp.status}`);
}
return Buffer.from(await resp.arrayBuffer());
});
if (data.fileType === "GPX") {
// Garmin can serve GPX directly; createActivity validates it.
gpx = buffer.toString("utf8");
} else {
// FIT (default) — shared provider-agnostic converter.
const conversion = await fitToGpx(buffer, data.name ?? "Garmin activity");
gpx = conversion.gpx ?? undefined;
sport = conversion.sport;
}
}
await importActivity(data.userId, "garmin", data.externalId, {
name: data.name ?? "Garmin activity",
gpx,
distance: data.distance,
duration: data.duration,
startedAt: data.startedAt ? new Date(data.startedAt) : null,
// FIT session sport (GPX imports carry none); provider-explicit would win.
sportType: sport ?? undefined,
});
logger.info(
{ externalId: data.externalId, hadFile: !!gpx },
"garmin import: activity imported",
);
}