Add Komoot import with public (bio verification) and authenticated modes
Two-mode import: public mode verifies Komoot account ownership by checking that the user's trails.cool profile URL appears in their Komoot bio — no credentials stored. Authenticated mode uses email + password (AES-256-GCM encrypted) to import private tours as well. Includes unit tests for crypto/komoot client and E2E tests for the full connect + import flow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b63fd1a303
commit
03304c354b
22 changed files with 1612 additions and 4 deletions
188
apps/journal/app/lib/komoot.server.ts
Normal file
188
apps/journal/app/lib/komoot.server.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
// Komoot API client. Covers unauthenticated public profile access and
|
||||
// authenticated tour listing / GPX export.
|
||||
//
|
||||
// All network calls use plain fetch; no auth state is cached here.
|
||||
|
||||
const API_BASE = "https://api.komoot.de/v007";
|
||||
const AUTH_BASE = "https://api.komoot.de/v006";
|
||||
|
||||
export interface KomootTour {
|
||||
id: string;
|
||||
name: string;
|
||||
sport: string;
|
||||
date: string;
|
||||
distance: number;
|
||||
duration: number;
|
||||
elevationUp: number;
|
||||
elevationDown: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Accepts a plain numeric ID or a Komoot profile URL in any locale variant.
|
||||
// Returns the numeric user ID string, or null if it cannot be parsed.
|
||||
export function parseKomootUserId(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
// Plain numeric ID
|
||||
if (/^\d+$/.test(trimmed)) return trimmed;
|
||||
// URL forms: https://www.komoot.com/user/12345 or .../de-de/user/12345
|
||||
const match = trimmed.match(/komoot\.(?:com|de)\/(?:[a-z]{2}-[a-z]{2}\/)?user\/(\d+)/i);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public profile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface KomootPublicProfileResponse {
|
||||
username: string;
|
||||
display_name?: string;
|
||||
content?: {
|
||||
text?: string | null;
|
||||
link?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchPublicProfile(komootUserId: string): Promise<{
|
||||
displayName: string;
|
||||
contentText: string | null;
|
||||
contentLink: string | null;
|
||||
}> {
|
||||
const resp = await fetch(`${API_BASE}/users/${komootUserId}/`);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Komoot profile fetch failed: ${resp.status}`);
|
||||
}
|
||||
const data = (await resp.json()) as KomootPublicProfileResponse;
|
||||
return {
|
||||
displayName: data.display_name ?? data.username,
|
||||
contentText: data.content?.text ?? null,
|
||||
contentLink: data.content?.link ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// Returns true if the profile's content text contains the trails profile URL
|
||||
// (case-insensitive, trimmed).
|
||||
export async function verifyKomootOwnership(
|
||||
komootUserId: string,
|
||||
trailsProfileUrl: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const profile = await fetchPublicProfile(komootUserId);
|
||||
if (!profile.contentText) return false;
|
||||
return profile.contentText.toLowerCase().includes(trailsProfileUrl.toLowerCase().trim());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentication
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface KomootAuthResponse {
|
||||
username: string;
|
||||
// The API returns user data nested under the email key in v006
|
||||
email?: string;
|
||||
}
|
||||
|
||||
// Login with email+password via Basic auth. Returns the Komoot username
|
||||
// (numeric user ID) and the base64-encoded Basic auth token.
|
||||
export async function loginKomoot(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ username: string; basicAuthToken: string }> {
|
||||
const basicAuthToken = Buffer.from(`${email}:${password}`).toString("base64");
|
||||
const resp = await fetch(`${AUTH_BASE}/account/email/${encodeURIComponent(email)}/`, {
|
||||
headers: { Authorization: `Basic ${basicAuthToken}` },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Komoot login failed: ${resp.status}`);
|
||||
}
|
||||
const data = (await resp.json()) as KomootAuthResponse;
|
||||
return { username: data.username, basicAuthToken };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tours
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface KomootTourRaw {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
date: string;
|
||||
distance?: number;
|
||||
duration?: number;
|
||||
elevation_up?: number;
|
||||
elevation_down?: number;
|
||||
}
|
||||
|
||||
interface KomootToursResponse {
|
||||
_embedded?: {
|
||||
tours?: KomootTourRaw[];
|
||||
};
|
||||
page?: {
|
||||
totalPages?: number;
|
||||
number?: number;
|
||||
};
|
||||
}
|
||||
|
||||
function toTour(raw: KomootTourRaw): KomootTour {
|
||||
return {
|
||||
id: String(raw.id),
|
||||
name: raw.name || `Tour ${raw.id}`,
|
||||
sport: raw.type,
|
||||
date: raw.date,
|
||||
distance: raw.distance ?? 0,
|
||||
duration: raw.duration ?? 0,
|
||||
elevationUp: raw.elevation_up ?? 0,
|
||||
elevationDown: raw.elevation_down ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchKomootTours(
|
||||
komootUserId: string,
|
||||
page: number,
|
||||
basicAuthToken?: string,
|
||||
): Promise<{ tours: KomootTour[]; totalPages: number }> {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page - 1), // Komoot uses 0-based pages
|
||||
limit: "50",
|
||||
});
|
||||
if (!basicAuthToken) {
|
||||
params.set("status", "public");
|
||||
}
|
||||
const headers: Record<string, string> = {};
|
||||
if (basicAuthToken) {
|
||||
headers["Authorization"] = `Basic ${basicAuthToken}`;
|
||||
}
|
||||
const resp = await fetch(`${API_BASE}/users/${komootUserId}/tours/?${params}`, { headers });
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Komoot tours fetch failed: ${resp.status}`);
|
||||
}
|
||||
const data = (await resp.json()) as KomootToursResponse;
|
||||
const tours = (data._embedded?.tours ?? []).map(toTour);
|
||||
const totalPages = data.page?.totalPages ?? 1;
|
||||
return { tours, totalPages };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GPX export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function fetchKomootTourGpx(
|
||||
tourId: string,
|
||||
basicAuthToken?: string,
|
||||
): Promise<string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (basicAuthToken) {
|
||||
headers["Authorization"] = `Basic ${basicAuthToken}`;
|
||||
}
|
||||
const resp = await fetch(`${API_BASE}/tours/${tourId}.gpx`, { headers });
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Komoot GPX fetch failed: ${resp.status}`);
|
||||
}
|
||||
return resp.text();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue