From 5fd60ba07d245d1c4e5a1e7f8623d0fcea1d7004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Mon, 13 Apr 2026 20:10:00 +0200 Subject: [PATCH 001/495] WIP: Komoot import integration Add Komoot API client, import logic, crypto helpers, integration routes, DB schema changes, and i18n strings for the Komoot import feature. Import processing runs as a durable pg-boss background job with retries instead of blocking the HTTP request. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/jobs/komoot-import.ts | 55 +++++ apps/journal/app/lib/boss.server.ts | 14 ++ apps/journal/app/lib/crypto.server.test.ts | 40 ++++ apps/journal/app/lib/crypto.server.ts | 31 +++ apps/journal/app/lib/komoot-import.server.ts | 143 +++++++++++++ apps/journal/app/lib/komoot.server.test.ts | 54 +++++ apps/journal/app/lib/komoot.server.ts | 141 +++++++++++++ apps/journal/app/root.tsx | 3 + apps/journal/app/routes.ts | 5 + .../routes/api.integrations.komoot.connect.ts | 44 ++++ .../api.integrations.komoot.disconnect.ts | 23 +++ .../api.integrations.komoot.import-status.ts | 35 ++++ .../routes/api.integrations.komoot.import.ts | 45 ++++ apps/journal/app/routes/auth.register.tsx | 57 +++-- apps/journal/app/routes/integrations.tsx | 195 ++++++++++++++++++ apps/journal/app/routes/privacy.tsx | 1 + apps/journal/server.ts | 7 +- infrastructure/docker-compose.yml | 1 + infrastructure/secrets.app.env | 8 +- openspec/changes/komoot-import/tasks.md | 56 ++--- packages/db/src/schema/journal.ts | 19 ++ packages/i18n/src/locales/de.ts | 27 +++ packages/i18n/src/locales/en.ts | 27 +++ packages/jobs/src/worker.ts | 2 +- 24 files changed, 982 insertions(+), 51 deletions(-) create mode 100644 apps/journal/app/jobs/komoot-import.ts create mode 100644 apps/journal/app/lib/boss.server.ts create mode 100644 apps/journal/app/lib/crypto.server.test.ts create mode 100644 apps/journal/app/lib/crypto.server.ts create mode 100644 apps/journal/app/lib/komoot-import.server.ts create mode 100644 apps/journal/app/lib/komoot.server.test.ts create mode 100644 apps/journal/app/lib/komoot.server.ts create mode 100644 apps/journal/app/routes/api.integrations.komoot.connect.ts create mode 100644 apps/journal/app/routes/api.integrations.komoot.disconnect.ts create mode 100644 apps/journal/app/routes/api.integrations.komoot.import-status.ts create mode 100644 apps/journal/app/routes/api.integrations.komoot.import.ts create mode 100644 apps/journal/app/routes/integrations.tsx diff --git a/apps/journal/app/jobs/komoot-import.ts b/apps/journal/app/jobs/komoot-import.ts new file mode 100644 index 0000000..cd262e8 --- /dev/null +++ b/apps/journal/app/jobs/komoot-import.ts @@ -0,0 +1,55 @@ +import type { JobDefinition } from "@trails-cool/jobs"; +import { eq, and } from "drizzle-orm"; +import { getDb } from "../lib/db.ts"; +import { syncConnections } from "@trails-cool/db/schema/journal"; +import { decrypt } from "../lib/crypto.server.ts"; +import { importKomootTours } from "../lib/komoot-import.server.ts"; +import type { KomootCredentials } from "../lib/komoot.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +interface KomootImportData { + userId: string; + connectionId: string; + batchId: string; +} + +export const komootImportJob: JobDefinition = { + name: "komoot-import", + retryLimit: 1, + expireInSeconds: 300, + async handler(jobs) { + for (const job of jobs) { + const { userId, connectionId, batchId } = job.data; + logger.info({ userId, connectionId, batchId }, "starting Komoot import job"); + + const db = getDb(); + const [connection] = await db + .select() + .from(syncConnections) + .where( + and( + eq(syncConnections.id, connectionId), + eq(syncConnections.userId, userId), + ), + ); + + if (!connection?.encryptedCredentials) { + throw new Error(`Komoot connection ${connectionId} not found or missing credentials`); + } + + const { email, password } = JSON.parse(decrypt(connection.encryptedCredentials)) as { + email: string; + password: string; + }; + const creds: KomootCredentials = { + email, + password, + username: connection.providerUserId!, + token: connection.accessToken, + }; + + await importKomootTours(userId, connectionId, creds, batchId); + logger.info({ batchId }, "Komoot import job completed"); + } + }, +}; diff --git a/apps/journal/app/lib/boss.server.ts b/apps/journal/app/lib/boss.server.ts new file mode 100644 index 0000000..d588ecc --- /dev/null +++ b/apps/journal/app/lib/boss.server.ts @@ -0,0 +1,14 @@ +import type { createBoss } from "@trails-cool/jobs"; + +type Boss = ReturnType; + +let boss: Boss | null = null; + +export function setBoss(instance: Boss): void { + boss = instance; +} + +export function getBoss(): Boss { + if (!boss) throw new Error("pg-boss not initialized — server still starting?"); + return boss; +} diff --git a/apps/journal/app/lib/crypto.server.test.ts b/apps/journal/app/lib/crypto.server.test.ts new file mode 100644 index 0000000..4e04039 --- /dev/null +++ b/apps/journal/app/lib/crypto.server.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { encrypt, decrypt } from "./crypto.server"; + +beforeAll(() => { + process.env.INTEGRATION_SECRET = "test-secret-for-unit-tests"; +}); + +describe("crypto", () => { + it("encrypts and decrypts a string", () => { + const plaintext = "my-secret-password-123"; + const encrypted = encrypt(plaintext); + expect(encrypted).not.toBe(plaintext); + expect(decrypt(encrypted)).toBe(plaintext); + }); + + it("produces different ciphertext for the same input", () => { + const plaintext = "same-input"; + const a = encrypt(plaintext); + const b = encrypt(plaintext); + expect(a).not.toBe(b); + expect(decrypt(a)).toBe(plaintext); + expect(decrypt(b)).toBe(plaintext); + }); + + it("handles empty string", () => { + const encrypted = encrypt(""); + expect(decrypt(encrypted)).toBe(""); + }); + + it("handles unicode", () => { + const plaintext = "Passwort mit Ümlauten: äöü 🏔️"; + expect(decrypt(encrypt(plaintext))).toBe(plaintext); + }); + + it("throws on tampered ciphertext", () => { + const encrypted = encrypt("secret"); + const tampered = encrypted.slice(0, -2) + "XX"; + expect(() => decrypt(tampered)).toThrow(); + }); +}); diff --git a/apps/journal/app/lib/crypto.server.ts b/apps/journal/app/lib/crypto.server.ts new file mode 100644 index 0000000..5223ec0 --- /dev/null +++ b/apps/journal/app/lib/crypto.server.ts @@ -0,0 +1,31 @@ +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; +const TAG_LENGTH = 16; + +function getKey(): Buffer { + const secret = process.env.INTEGRATION_SECRET; + if (!secret) throw new Error("INTEGRATION_SECRET environment variable is required"); + return scryptSync(secret, "trails-cool-integrations", 32); +} + +export function encrypt(plaintext: string): string { + const key = getKey(); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH }); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, encrypted]).toString("base64"); +} + +export function decrypt(encoded: string): string { + const key = getKey(); + const buf = Buffer.from(encoded, "base64"); + const iv = buf.subarray(0, IV_LENGTH); + const tag = buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); + const encrypted = buf.subarray(IV_LENGTH + TAG_LENGTH); + const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH }); + decipher.setAuthTag(tag); + return decipher.update(encrypted) + decipher.final("utf8"); +} diff --git a/apps/journal/app/lib/komoot-import.server.ts b/apps/journal/app/lib/komoot-import.server.ts new file mode 100644 index 0000000..5ef1573 --- /dev/null +++ b/apps/journal/app/lib/komoot-import.server.ts @@ -0,0 +1,143 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { getDb } from "./db.ts"; +import { importBatches } from "@trails-cool/db/schema/journal"; +import { createActivity } from "./activities.server.ts"; +import { createRoute, type RouteInput } from "./routes.server.ts"; +import { isAlreadyImported, recordImport } from "./sync/imports.server.ts"; +import { fetchTours, fetchTourGpx, type KomootCredentials, type KomootTour } from "./komoot.server.ts"; + +export async function importKomootTours( + userId: string, + connectionId: string, + creds: KomootCredentials, + existingBatchId?: string, +): Promise { + const db = getDb(); + const batchId = existingBatchId ?? randomUUID(); + + if (!existingBatchId) { + await db.insert(importBatches).values({ + id: batchId, + userId, + connectionId, + status: "running", + }); + } else { + await db + .update(importBatches) + .set({ status: "running" }) + .where(eq(importBatches.id, batchId)); + } + + try { + let page = 0; + let totalFound = 0; + let importedCount = 0; + let duplicateCount = 0; + let hasMore = true; + + while (hasMore) { + const result = await fetchTours(creds, page); + totalFound += result.tours.length; + + await db + .update(importBatches) + .set({ totalFound }) + .where(eq(importBatches.id, batchId)); + + for (const tour of result.tours) { + const alreadyImported = await isAlreadyImported(userId, "komoot", tour.id); + if (alreadyImported) { + duplicateCount++; + continue; + } + + try { + await importSingleTour(userId, creds, tour); + importedCount++; + } catch (err) { + console.error(`Failed to import Komoot tour ${tour.id}:`, err); + } + + await db + .update(importBatches) + .set({ importedCount, duplicateCount }) + .where(eq(importBatches.id, batchId)); + } + + hasMore = page + 1 < result.totalPages; + page++; + } + + await db + .update(importBatches) + .set({ + status: "completed", + totalFound, + importedCount, + duplicateCount, + completedAt: new Date(), + }) + .where(eq(importBatches.id, batchId)); + + return batchId; + } catch (err) { + await db + .update(importBatches) + .set({ + status: "failed", + errorMessage: err instanceof Error ? err.message : String(err), + completedAt: new Date(), + }) + .where(eq(importBatches.id, batchId)); + throw err; + } +} + +async function importSingleTour( + userId: string, + creds: KomootCredentials, + tour: KomootTour, +): Promise { + let gpx: string | undefined; + try { + gpx = await fetchTourGpx(creds, tour.id); + } catch { + // Tour without downloadable GPX — import metadata only + } + + const routeInput: RouteInput = { + name: tour.name, + gpx, + }; + + const routeId = await createRoute(userId, routeInput); + + // Mark the route source as komoot + const db = getDb(); + const { routes } = await import("@trails-cool/db/schema/journal"); + await db.update(routes).set({ source: "komoot" }).where(eq(routes.id, routeId)); + + const activityId = await createActivity(userId, { + name: tour.name, + gpx, + routeId, + distance: tour.distance, + duration: tour.duration, + startedAt: new Date(tour.date), + }); + + await recordImport(userId, "komoot", tour.id, activityId); +} + +export async function getLatestBatch(userId: string, connectionId: string) { + const db = getDb(); + const [batch] = await db + .select() + .from(importBatches) + .where(eq(importBatches.connectionId, connectionId)) + .orderBy(importBatches.startedAt) + .limit(1); + return batch ?? null; +} diff --git a/apps/journal/app/lib/komoot.server.test.ts b/apps/journal/app/lib/komoot.server.test.ts new file mode 100644 index 0000000..4f78851 --- /dev/null +++ b/apps/journal/app/lib/komoot.server.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { parseTourResponse } from "./komoot.server"; + +describe("komoot", () => { + describe("parseTourResponse", () => { + it("parses a page of tours", () => { + const data = { + _embedded: { + tours: [ + { + id: 12345, + name: "Morning hike in the Alps", + sport: "hike", + date: "2026-03-15T08:30:00.000Z", + distance: 15234.5, + duration: 18900, + elevation_up: 850, + elevation_down: 830, + }, + { + id: 67890, + name: "Cycling to work", + sport: "touringbicycle", + date: "2026-03-14T07:00:00.000Z", + distance: 8500, + duration: 1800, + elevation_up: 45, + elevation_down: 50, + }, + ], + }, + }; + + const tours = parseTourResponse(data); + expect(tours).toHaveLength(2); + expect(tours[0]).toEqual({ + id: "12345", + name: "Morning hike in the Alps", + sport: "hike", + date: "2026-03-15T08:30:00.000Z", + distance: 15234.5, + duration: 18900, + elevationUp: 850, + elevationDown: 830, + }); + }); + + it("handles empty response", () => { + expect(parseTourResponse({})).toEqual([]); + expect(parseTourResponse({ _embedded: {} })).toEqual([]); + expect(parseTourResponse({ _embedded: { tours: [] } })).toEqual([]); + }); + }); +}); diff --git a/apps/journal/app/lib/komoot.server.ts b/apps/journal/app/lib/komoot.server.ts new file mode 100644 index 0000000..1b08369 --- /dev/null +++ b/apps/journal/app/lib/komoot.server.ts @@ -0,0 +1,141 @@ +const API_BASE = "https://api.komoot.de/v007"; +const AUTH_BASE = "https://api.komoot.de/v006"; + +export interface KomootCredentials { + email: string; + password: string; + username: string; + token: string; +} + +export interface KomootTour { + id: string; + name: string; + sport: string; + date: string; + distance: number; + duration: number; + elevationUp: number; + elevationDown: number; +} + +export interface KomootTourPage { + tours: KomootTour[]; + totalPages: number; + page: number; +} + +export async function login( + email: string, + password: string, +): Promise<{ username: string; token: string }> { + const res = await fetch(`${AUTH_BASE}/account/email/${email}/`, { + headers: { + Authorization: `Basic ${Buffer.from(`${email}:${password}`).toString("base64")}`, + }, + }); + + if (!res.ok) { + if (res.status === 401 || res.status === 403) { + throw new Error("Invalid Komoot credentials"); + } + throw new Error(`Komoot login failed: ${res.status}`); + } + + const data = (await res.json()) as { username: string }; + return { username: data.username, token: Buffer.from(`${email}:${password}`).toString("base64") }; +} + +export async function fetchTours( + creds: KomootCredentials, + page = 0, +): Promise { + const res = await fetch( + `${API_BASE}/users/${creds.username}/tours/?page=${page}&limit=50&type=tour_recorded&sort_field=date&sort_direction=desc`, + { + headers: { Authorization: `Basic ${creds.token}` }, + }, + ); + + if (!res.ok) { + if (res.status === 401) throw new Error("Komoot auth expired"); + throw new Error(`Komoot tours fetch failed: ${res.status}`); + } + + const data = (await res.json()) as { + _embedded?: { + tours?: Array<{ + id: number; + name: string; + sport: string; + date: string; + distance: number; + duration: number; + elevation_up: number; + elevation_down: number; + }>; + }; + page?: { totalPages: number; number: number }; + }; + + console.log("[komoot] page response keys:", Object.keys(data), "page field:", JSON.stringify(data.page)); + + const tours = (data._embedded?.tours ?? []).map((t) => ({ + id: String(t.id), + name: t.name, + sport: t.sport, + date: t.date, + distance: t.distance, + duration: t.duration, + elevationUp: t.elevation_up, + elevationDown: t.elevation_down, + })); + + return { + tours, + totalPages: data.page?.totalPages ?? 1, + page: data.page?.number ?? 0, + }; +} + +export async function fetchTourGpx( + creds: KomootCredentials, + tourId: string, +): Promise { + const res = await fetch(`${API_BASE}/tours/${tourId}.gpx`, { + headers: { Authorization: `Basic ${creds.token}` }, + }); + + if (!res.ok) { + throw new Error(`Komoot GPX fetch failed for tour ${tourId}: ${res.status}`); + } + + return res.text(); +} + +export function parseTourResponse(data: unknown): KomootTour[] { + const obj = data as { + _embedded?: { + tours?: Array<{ + id: number; + name: string; + sport: string; + date: string; + distance: number; + duration: number; + elevation_up: number; + elevation_down: number; + }>; + }; + }; + return (obj._embedded?.tours ?? []).map((t) => ({ + id: String(t.id), + name: t.name, + sport: t.sport, + date: t.date, + distance: t.distance, + duration: t.duration, + elevationUp: t.elevation_up, + elevationDown: t.elevation_down, + })); +} diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx index be4a5f4..b4094b0 100644 --- a/apps/journal/app/root.tsx +++ b/apps/journal/app/root.tsx @@ -68,6 +68,9 @@ function NavBar({ user }: { user: { id: string; username: string } | null }) { {t("nav.activities")} + + {t("nav.integrations")} + > )} diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index 537369b..19dd6c9 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -32,6 +32,11 @@ export default [ route("api/sync/callback/:provider", "routes/api.sync.callback.$provider.ts"), route("api/sync/disconnect/:provider", "routes/api.sync.disconnect.$provider.ts"), route("api/sync/webhook/:provider", "routes/api.sync.webhook.$provider.ts"), + route("integrations", "routes/integrations.tsx"), + route("api/integrations/komoot/connect", "routes/api.integrations.komoot.connect.ts"), + route("api/integrations/komoot/disconnect", "routes/api.integrations.komoot.disconnect.ts"), + route("api/integrations/komoot/import", "routes/api.integrations.komoot.import.ts"), + route("api/integrations/komoot/import-status", "routes/api.integrations.komoot.import-status.ts"), route("privacy", "routes/privacy.tsx"), // REST API v1 route("api/v1/routes", "routes/api.v1.routes._index.ts"), diff --git a/apps/journal/app/routes/api.integrations.komoot.connect.ts b/apps/journal/app/routes/api.integrations.komoot.connect.ts new file mode 100644 index 0000000..b77ff29 --- /dev/null +++ b/apps/journal/app/routes/api.integrations.komoot.connect.ts @@ -0,0 +1,44 @@ +import { data } from "react-router"; +import { randomUUID } from "node:crypto"; +import type { Route } from "./+types/api.integrations.komoot.connect"; +import { getSessionUser } from "~/lib/auth.server"; +import { getDb } from "~/lib/db"; +import { syncConnections } from "@trails-cool/db/schema/journal"; +import { login } from "~/lib/komoot.server"; +import { encrypt } from "~/lib/crypto.server"; + +export async function action({ request }: Route.ActionArgs) { + const user = await getSessionUser(request); + if (!user) throw data(null, { status: 401 }); + + const form = await request.formData(); + const email = String(form.get("email") ?? "").trim(); + const password = String(form.get("password") ?? ""); + + if (!email || !password) { + return data({ error: "Email and password are required" }, { status: 400 }); + } + + let credentials: { username: string; token: string }; + try { + credentials = await login(email, password); + } catch { + return data({ error: "Invalid Komoot credentials" }, { status: 400 }); + } + + const encrypted = encrypt(JSON.stringify({ email, password })); + + const db = getDb(); + await db.insert(syncConnections).values({ + id: randomUUID(), + userId: user.id, + provider: "komoot", + accessToken: credentials.token, + refreshToken: "", + expiresAt: new Date("2099-12-31"), + providerUserId: credentials.username, + encryptedCredentials: encrypted, + }); + + return data({ ok: true }); +} diff --git a/apps/journal/app/routes/api.integrations.komoot.disconnect.ts b/apps/journal/app/routes/api.integrations.komoot.disconnect.ts new file mode 100644 index 0000000..971fee2 --- /dev/null +++ b/apps/journal/app/routes/api.integrations.komoot.disconnect.ts @@ -0,0 +1,23 @@ +import { data } from "react-router"; +import { eq, and } from "drizzle-orm"; +import type { Route } from "./+types/api.integrations.komoot.disconnect"; +import { getSessionUser } from "~/lib/auth.server"; +import { getDb } from "~/lib/db"; +import { syncConnections } from "@trails-cool/db/schema/journal"; + +export async function action({ request }: Route.ActionArgs) { + const user = await getSessionUser(request); + if (!user) throw data(null, { status: 401 }); + + const db = getDb(); + await db + .delete(syncConnections) + .where( + and( + eq(syncConnections.userId, user.id), + eq(syncConnections.provider, "komoot"), + ), + ); + + return data({ ok: true }); +} diff --git a/apps/journal/app/routes/api.integrations.komoot.import-status.ts b/apps/journal/app/routes/api.integrations.komoot.import-status.ts new file mode 100644 index 0000000..aad5d30 --- /dev/null +++ b/apps/journal/app/routes/api.integrations.komoot.import-status.ts @@ -0,0 +1,35 @@ +import { data } from "react-router"; +import { eq, and, desc } from "drizzle-orm"; +import type { Route } from "./+types/api.integrations.komoot.import-status"; +import { getSessionUser } from "~/lib/auth.server"; +import { getDb } from "~/lib/db"; +import { syncConnections, importBatches } from "@trails-cool/db/schema/journal"; + +export async function loader({ request }: Route.LoaderArgs) { + const user = await getSessionUser(request); + if (!user) throw data(null, { status: 401 }); + + const db = getDb(); + const [connection] = await db + .select() + .from(syncConnections) + .where( + and( + eq(syncConnections.userId, user.id), + eq(syncConnections.provider, "komoot"), + ), + ); + + if (!connection) { + return data({ batch: null }); + } + + const [batch] = await db + .select() + .from(importBatches) + .where(eq(importBatches.connectionId, connection.id)) + .orderBy(desc(importBatches.startedAt)) + .limit(1); + + return data({ batch: batch ?? null }); +} diff --git a/apps/journal/app/routes/api.integrations.komoot.import.ts b/apps/journal/app/routes/api.integrations.komoot.import.ts new file mode 100644 index 0000000..3417f4d --- /dev/null +++ b/apps/journal/app/routes/api.integrations.komoot.import.ts @@ -0,0 +1,45 @@ +import { randomUUID } from "node:crypto"; +import { data } from "react-router"; +import { eq, and } from "drizzle-orm"; +import type { Route } from "./+types/api.integrations.komoot.import"; +import { getSessionUser } from "~/lib/auth.server"; +import { getDb } from "~/lib/db"; +import { syncConnections, importBatches } from "@trails-cool/db/schema/journal"; +import { getBoss } from "~/lib/boss.server"; + +export async function action({ request }: Route.ActionArgs) { + const user = await getSessionUser(request); + if (!user) throw data(null, { status: 401 }); + + const db = getDb(); + const [connection] = await db + .select() + .from(syncConnections) + .where( + and( + eq(syncConnections.userId, user.id), + eq(syncConnections.provider, "komoot"), + ), + ); + + if (!connection?.encryptedCredentials) { + return data({ error: "Komoot not connected" }, { status: 400 }); + } + + const batchId = randomUUID(); + await db.insert(importBatches).values({ + id: batchId, + userId: user.id, + connectionId: connection.id, + status: "queued", + }); + + const boss = getBoss(); + await boss.send("komoot-import", { + userId: user.id, + connectionId: connection.id, + batchId, + }); + + return data({ batchId }); +} diff --git a/apps/journal/app/routes/auth.register.tsx b/apps/journal/app/routes/auth.register.tsx index 90ea6b1..dc8f9f0 100644 --- a/apps/journal/app/routes/auth.register.tsx +++ b/apps/journal/app/routes/auth.register.tsx @@ -8,11 +8,14 @@ export default function RegisterPage() { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [supportsPasskey, setSupportsPasskey] = useState(null); + const [usePasskey, setUsePasskey] = useState(true); const [magicLinkSent, setMagicLinkSent] = useState(false); useEffect(() => { import("@simplewebauthn/browser").then(({ browserSupportsWebAuthn }) => { - setSupportsPasskey(browserSupportsWebAuthn()); + const supported = browserSupportsWebAuthn(); + setSupportsPasskey(supported); + setUsePasskey(supported); }); }, []); @@ -114,7 +117,7 @@ export default function RegisterPage() { {t("auth.registerDescription")}
{error}
{t("integrations.komoot.whyPasswordTitle")}
{t("integrations.komoot.whyPasswordBody")}
{t("integrations.komoot.howStored")}
+ {batch.status === "running" + ? t("integrations.importRunning") + : batch.status === "completed" + ? t("integrations.importComplete") + : t("integrations.importFailed")} +
{t("integrations.subtitle")}
{t("integrations.komoot.description")}
Or enter this code on the login page:
Click the button below to sign in to your account. This link expires in 15 minutes.
If the button doesn't work, copy and paste this link:${link}
If you didn't request this link, you can safely ignore this email.
- {t("auth.checkEmail")} {email}. -
- {t("auth.linkExpires")} -
+ {t("auth.checkEmail")} {email}. +
+ {t("auth.linkExpires")} +
{t("auth.codeHelp")}
{t("alpha.message")}
- Your handle will be @{username || "..."}@{typeof window !== "undefined" ? window.location.hostname : "trails.cool"} + {t("auth.handleWillBe", { + handle: `@${username || "…"}@${hostname}`, + })}
- Already have an account?{" "} + {t("auth.alreadyHaveAccount")}{" "} {t("auth.login")} diff --git a/apps/journal/app/routes/legal.imprint.tsx b/apps/journal/app/routes/legal.imprint.tsx new file mode 100644 index 0000000..d3bc4e0 --- /dev/null +++ b/apps/journal/app/routes/legal.imprint.tsx @@ -0,0 +1,94 @@ +import { operator } from "~/lib/operator"; + +export function meta() { + return [ + { title: "Impressum — trails.cool" }, + { name: "robots", content: "noindex" }, + ]; +} + +export default function ImprintPage() { + return ( +
+ E-Mail:{" "} + + {operator.email} + +
+ Die Inhalte dieser Seiten wurden mit größtmöglicher Sorgfalt erstellt. + Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte + können wir jedoch keine Gewähr übernehmen. trails.cool befindet sich + in aktiver Entwicklung (Alpha) — Inhalte, Funktionen und Daten können + sich jederzeit ändern oder entfallen. +
+ Als Diensteanbieter sind wir gemäß § 7 Abs. 1 TMG für eigene Inhalte + auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach + §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht + verpflichtet, übermittelte oder gespeicherte fremde Informationen zu + überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige + Tätigkeit hinweisen. +
+ trails.cool is operated from Germany by {operator.name}, reachable at + the address above and at{" "} + + {operator.email} + + . The service is currently in alpha — see the{" "} + + Terms of Service + {" "} + for details. +
Last updated: 2026-04-17
+ Verantwortlich für die Datenverarbeitung auf dieser Website im Sinne + der DSGVO ist: +
+ Wir verarbeiten personenbezogene Daten auf Grundlage folgender + Rechtsgrundlagen: +
+ Als betroffene Person stehen Ihnen folgende Rechte nach DSGVO zu: +
+ Zur Ausübung dieser Rechte wenden Sie sich bitte an{" "} + + {operator.email} + + . Ein vollständiger Export Ihrer Daten ist jederzeit direkt über die + Einstellungen Ihres Kontos möglich (Routen als GPX, Aktivitäten als + GPX/JSON). Sie können Ihr Konto jederzeit selbst löschen. +
+ Sie haben das Recht, sich bei einer Datenschutzaufsichtsbehörde zu + beschweren. Zuständig für uns ist: +
+ trails.cool is committed to privacy by design. This manifest documents + everything we collect, why, and how you can control it. +
+ The Planner collects no personal data. Sessions are anonymous — + there are no user accounts, no tracking, and no analytics on your routes. +
+ The Journal stores the data you explicitly provide: +
+ All your data is exportable at any time in open formats (GPX, JSON). + You can self-host your own instance and migrate your data completely. +
+ Both apps use Sentry for + error monitoring. This helps us find and fix bugs quickly. +
+ Sentry data is retained for 90 days and then automatically deleted. + Sentry's servers are hosted in the EU (Frankfurt). +
+ The Journal sends transactional emails for magic link login and welcome messages + via SMTP. On the official instance, emails are sent through our own mail server. +
+ Self-hosted instances can configure their own SMTP server. No email content + is stored beyond what your mail server retains. +
+ Last updated: 2026-04-17 • Alpha version — subject to change +
+ trails.cool ist ein experimenteller Dienst zur Planung und Dokumentation + von Outdoor-Aktivitäten. Der Dienst befindet sich in aktiver Entwicklung + (Alpha). Funktionen, Schnittstellen und gespeicherte Daten können + jederzeit ohne Vorankündigung geändert, gelöscht oder eingestellt + werden. Eine Verfügbarkeitsgarantie besteht nicht. +
+ Während der Alpha-Phase behält sich der Betreiber ausdrücklich das Recht + vor, die gesamte Datenbank zurückzusetzen oder einzelne Datensätze ohne + vorherige Benachrichtigung zu löschen. Nutzer:innen sollten wichtige + Routen und Aktivitäten selbstständig über die Export-Funktion (GPX) + sichern. +
+ Der Dienst wird ohne jegliche Gewährleistung bereitgestellt. Eine Haftung + für Schäden, die durch die Nutzung oder Nichtverfügbarkeit des Dienstes + entstehen, ist ausgeschlossen, soweit dies gesetzlich zulässig ist. Bei + Vorsatz und grober Fahrlässigkeit sowie bei Verletzung von Leben, Körper + und Gesundheit gelten die gesetzlichen Vorschriften. +
+ Nutzer:innen sind dafür verantwortlich, regelmäßig Sicherungskopien ihrer + Routen und Aktivitäten anzufertigen. GPX-Exporte sind im Journal für + jede Route und Aktivität verfügbar. +
+ Nutzer:innen können ihr Konto jederzeit über die Einstellungen löschen. + Der Betreiber kann Konten bei Verstößen gegen diese Bedingungen sperren + oder löschen. +
+ Diese Nutzungsbedingungen können sich während der Alpha-Phase ändern. Bei + wesentlichen Änderungen werden registrierte Nutzer:innen per E-Mail + informiert und müssen die neuen Bedingungen erneut bestätigen. +
+ Es gilt deutsches Recht unter Ausschluss des UN-Kaufrechts. Verbraucher:innen + mit gewöhnlichem Aufenthalt in der EU genießen den zwingenden Schutz + ihres nationalen Verbraucherrechts. +
+ The German version is authoritative. This English text is a translation + for convenience. +
+ trails.cool is an experimental service for planning and documenting + outdoor activities. It is under active development (alpha). Features, + APIs, and stored data may change, be deleted, or be discontinued at any + time without notice. No availability is guaranteed. +
+ During alpha, the operator expressly reserves the right to reset the + entire database or delete individual records without prior notice. Users + should back up important routes and activities via the GPX export. +
+ The service is provided without any warranty. Liability for damages + arising from use or unavailability is excluded to the maximum extent + permitted by law. Statutory provisions apply for intent, gross + negligence, and injury to life, body, or health. +
+ Users are responsible for regularly backing up their routes and + activities. GPX exports are available in the Journal for every route + and activity. +
+ Users can delete their account at any time via settings. The operator + may suspend or delete accounts that violate these terms. +
+ These terms may change during alpha. Registered users will be notified + by email of material changes and must re-accept the updated terms. +
+ German law applies, excluding the UN Convention on Contracts for the + International Sale of Goods. Consumers habitually residing in the EU + retain the mandatory protection of their national consumer law. +
- trails.cool is committed to privacy by design. This manifest documents - everything we collect, why, and how you can control it. -
- The Planner collects no personal data. Sessions are anonymous — - there are no user accounts, no tracking, and no analytics on your routes. -
- The Journal stores the data you explicitly provide: -
- All your data is exportable at any time in open formats (GPX, JSON). - You can self-host your own instance and migrate your data completely. -
- Both apps use Sentry for - error monitoring. This helps us find and fix bugs quickly. -
- Sentry data is retained for 90 days and then automatically deleted. - Sentry's servers are hosted in the EU (Frankfurt). -
- The Journal sends transactional emails for magic link login and welcome messages - via SMTP. On the official instance, emails are sent through our own mail server. -
- Self-hosted instances can configure their own SMTP server. No email content - is stored beyond what your mail server retains. -
- Last updated: March 2026. If this manifest changes, we'll note it here. -
Email:{" "} - + {operator.email}
@@ -182,10 +183,19 @@ export default function PrivacyPage() {
+ Hinweis Alpha: Während der Alpha-Phase behält sich der Betreiber + ausdrücklich vor, die Datenbank zurückzusetzen oder einzelne + Datensätze zu löschen. Dies kann zu Datenverlust führen, bevor Sie + eine Löschung veranlassen. Details dazu in den Nutzungsbedingungen. +
English. Account and content kept until you delete them. Ephemeral data (sessions, magic-link tokens, logs, Sentry events) - deleted automatically on the schedules above. + deleted automatically on the schedules above. Alpha caveat: the + operator may reset the database or delete individual records + during alpha, which can cause data loss before you request + deletion. See the Terms of Service.
sendDefaultPii
English. Third parties and what they receive: Sentry (error - details, no IPs/cookies, EU-hosted); OpenStreetMap tile servers + details, no IPs/cookies); OpenStreetMap tile servers (your IP and user-agent, directly from your browser, to load map tiles); Overpass (via our server-side proxy, so upstream only sees our server); BRouter (self-hosted, no third party involved); SMTP @@ -366,7 +383,7 @@ export default function PrivacyPage() {
sendDefaultPii: false
- trails.cool ist ein experimenteller, kostenloser Dienst zur Planung + trails.cool ist ein experimenteller, derzeit kostenloser Dienst zur Planung und Dokumentation von Outdoor-Aktivitäten. Der Dienst befindet sich in aktiver Entwicklung (Alpha). Funktionen, Schnittstellen und gespeicherte Daten können jederzeit ohne Vorankündigung geändert, @@ -31,7 +31,8 @@ export default function TermsPage() { Verfügbarkeitsgarantie besteht nicht.
- English. trails.cool is an experimental, free service in + English. trails.cool is an experimental service, + currently free of charge, in active alpha development. Features, data, and availability can change at any time without notice. No availability is guaranteed.
English. No illegal content, no abuse (spam, flooding, - DoS), no circumvention of technical protections, no bulk scraping - to build competing services. + DoS), no circumvention of technical protections, no bulk + automated data extraction.
- Stand / Last updated: 2026-04-18. Die deutsche Fassung ist maßgeblich. + Stand / Last updated: {PRIVACY_LAST_UPDATED}. Die deutsche Fassung ist maßgeblich. The German version is authoritative; English summaries follow each section.
- Stand / Last updated: 2026-04-18 • Alpha — subject to change. Die + Stand / Last updated: {TERMS_VERSION} • Alpha — subject to change. Die deutsche Fassung ist maßgeblich. The German version is authoritative; English summaries follow each section.
+ {previousVersion + ? t("auth.reaccept.bodyUpdated", { from: previousVersion, to: TERMS_VERSION }) + : t("auth.reaccept.bodyNew", { version: TERMS_VERSION })} +
{route.description}
+ {isOwner ? t("routes.empty.bodyOwner") : t("routes.empty.bodyViewer")} +
private
unlisted
public
/users/<you>
+ {route.visibility === "private" && t("routes.visibility.privateHelp")} + {route.visibility === "unlisted" && t("routes.visibility.unlistedHelp")} + {route.visibility === "public" && t("routes.visibility.publicHelp")} +
No routes yet.
{t("profile.noPublicRoutes")}
{r.description}
+ +
{t("profile.noPublicActivities")}
{a.description}
privat
nicht gelistet
öffentlich
/users/<benutzername>
English. The Journal stores only what you provide (account details and your own routes/activities) plus short-lived auth - artefacts. The Planner is anonymous and holds only ephemeral - session state. Server logs and Sentry error data are covered - separately below. + artefacts. Each route and activity has a visibility setting + (private / unlisted / public) + that defaults to private; content you mark{" "} + public is world-visible (on your public profile and + indexable by search engines). The Planner is anonymous and holds + only ephemeral session state. Server logs and Sentry error data + are covered separately below.
@{user.username}@{user.domain}
{t("subtitle")}
{t("home.heroSubtitle")}
- {t("welcome")} {user.displayName ?? user.username} -
- {t("addPasskeyPrompt")} -
+ {t("home.tryPlannerPrefix")} + + {t("home.tryPlannerLink")} + + {t("home.tryPlannerSuffix")} +
- {t("auth.passkeyNotSupported")} + {showAddPasskey && !passkeyDone && supportsPasskey === true && ( +
{t("addPasskeyPrompt")}
{t("auth.passkeyNotSupported")}
{t("passkeyAdded")}
+ {t(`home.marketing.${key}.body`)}
- {t("passkeyAdded")} -
{t("home.feed.empty")}
+ + {t("home.poweredBy")} + +
- {t("home.tryPlannerPrefix")} - - {t("home.tryPlannerLink")} - - {t("home.tryPlannerSuffix")} -
+ {t("home.dashboardEmpty")} +
{t(`social.${kind}.count`, { count: total })}
+ {t(`social.${kind}.empty`)} +
{t("social.feed.empty")}
/users/<you>/followers
/users/<you>/following
{bio.length}/160
{user.bio}
+ @{row.followerUsername}@{row.followerDomain} ·{" "} + +
{t("social.requests.empty")}
🔒
+ {t("profile.privateStub.heading")} +
+ {isLoggedIn + ? t("profile.privateStub.bodyAuth") + : t("profile.privateStub.bodyAnon")} +