diff --git a/.gitignore b/.gitignore index d98680e..b80d778 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ build/ htmlcov/ .coverage .idea* +feedback* # uv uv.lock diff --git a/bincio/extract/garmin_api.py b/bincio/extract/garmin_api.py new file mode 100644 index 0000000..0087389 --- /dev/null +++ b/bincio/extract/garmin_api.py @@ -0,0 +1,225 @@ +"""Garmin Connect credential storage and client factory. + +Credential storage layout +───────────────────────── + {data_dir.parent}/.garmin_key ← Fernet key (outside nginx webroot, chmod 600) + {user_dir}/garmin_creds.json ← encrypted email + password + {user_dir}/garmin_session/ ← garth OAuth token directory (plain JSON, short-lived) + +Security model +────────────── +- The Fernet key lives one directory above the data root, which nginx does NOT serve. + For a standard VPS install: data_dir = /var/bincio/data/ → key at /var/bincio/.garmin_key. +- Credentials are encrypted with that key before being written to disk. +- The garth session directory holds OAuth tokens (not the user's password). + These expire independently and are refreshed automatically by the library. +- If the session expires and re-login is needed, the stored credentials are decrypted + and used automatically — the user does not need to re-enter them. + +DISCLAIMER +────────── +This module uses the unofficial `garminconnect` library. +See docs/garmin_connect_disclaimer.md before shipping this feature to users. +""" + +from __future__ import annotations + +import json +import os +import stat +from pathlib import Path +from typing import Optional + +# ── Constants ───────────────────────────────────────────────────────────────── + +_CREDS_FILE = "garmin_creds.json" +_SESSION_DIR = "garmin_session" +_KEY_FILENAME = ".garmin_key" + + +class GarminError(Exception): + pass + + +# ── Encryption key management ───────────────────────────────────────────────── + +def _key_path(data_dir: Path) -> Path: + """Return the path to the Fernet key file (one level above data_dir).""" + return data_dir.parent / _KEY_FILENAME + + +def _get_or_create_key(data_dir: Path) -> bytes: + """Load the Fernet key, creating and locking it down on first use.""" + from cryptography.fernet import Fernet + + kp = _key_path(data_dir) + if kp.exists(): + return kp.read_bytes().strip() + + key = Fernet.generate_key() + kp.parent.mkdir(parents=True, exist_ok=True) + kp.write_bytes(key) + kp.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600 — owner read/write only + return key + + +def _fernet(data_dir: Path): + from cryptography.fernet import Fernet + return Fernet(_get_or_create_key(data_dir)) + + +# ── Credential encryption helpers ───────────────────────────────────────────── + +def _encrypt(data_dir: Path, value: str) -> str: + return _fernet(data_dir).encrypt(value.encode()).decode() + + +def _decrypt(data_dir: Path, token: str) -> str: + try: + return _fernet(data_dir).decrypt(token.encode()).decode() + except Exception as exc: + raise GarminError("Failed to decrypt Garmin credentials — key may have changed") from exc + + +# ── Credential CRUD ─────────────────────────────────────────────────────────── + +def has_credentials(user_dir: Path) -> bool: + return (user_dir / _CREDS_FILE).exists() + + +def save_credentials(data_dir: Path, user_dir: Path, email: str, password: str) -> None: + """Encrypt and persist the user's Garmin email + password.""" + payload = { + "email": _encrypt(data_dir, email), + "password": _encrypt(data_dir, password), + } + creds_path = user_dir / _CREDS_FILE + creds_path.write_text(json.dumps(payload, indent=2)) + creds_path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600 + + +def load_credentials(data_dir: Path, user_dir: Path) -> tuple[str, str]: + """Return (email, password) decrypted from disk.""" + creds_path = user_dir / _CREDS_FILE + if not creds_path.exists(): + raise GarminError("No Garmin credentials stored for this user") + try: + raw = json.loads(creds_path.read_text()) + except Exception as exc: + raise GarminError("Garmin credentials file is corrupt") from exc + return _decrypt(data_dir, raw["email"]), _decrypt(data_dir, raw["password"]) + + +def delete_credentials(user_dir: Path) -> None: + """Remove stored credentials and session (disconnect).""" + creds_path = user_dir / _CREDS_FILE + if creds_path.exists(): + creds_path.unlink() + + session_dir = user_dir / _SESSION_DIR + if session_dir.exists(): + import shutil + shutil.rmtree(session_dir) + + +# ── Session management (garth OAuth tokens) ─────────────────────────────────── + +def _session_dir(user_dir: Path) -> Path: + d = user_dir / _SESSION_DIR + d.mkdir(exist_ok=True) + return d + + +def _save_session(user_dir: Path, client) -> None: + """Persist garth OAuth tokens so the next sync skips re-login.""" + try: + client.garth.dump(str(_session_dir(user_dir))) + except Exception: + pass # session save is best-effort + + +def _load_session(user_dir: Path, client) -> bool: + """Try to restore a saved garth session. Returns True on success.""" + sd = user_dir / _SESSION_DIR + if not sd.exists(): + return False + try: + client.garth.load(str(sd)) + return True + except Exception: + return False + + +# ── Client factory ──────────────────────────────────────────────────────────── + +def get_client(data_dir: Path, user_dir: Path): + """Return a logged-in Garmin client. + + Strategy: + 1. Try to resume a saved garth session (fast, no network round-trip). + 2. If that fails or the session has expired, re-login using the stored + (encrypted) credentials. + 3. Persist the refreshed session for next time. + + Raises GarminError if credentials are missing or login fails. + """ + try: + import garminconnect + except ImportError as exc: + raise GarminError( + "garminconnect is not installed. " + "Run: uv sync --extra garmin" + ) from exc + + client = garminconnect.Garmin() + + # Try cached session first + if _load_session(user_dir, client): + try: + client.garth.refresh_oauth2() # renew access token if needed + _save_session(user_dir, client) # persist refreshed token + return client + except Exception: + pass # session is dead — fall through to full re-login + + # Full login with stored credentials + email, password = load_credentials(data_dir, user_dir) + try: + client = garminconnect.Garmin(email=email, password=password) + client.login() + except Exception as exc: + raise GarminError(f"Garmin login failed: {exc}") from exc + + _save_session(user_dir, client) + return client + + +def test_login(data_dir: Path, user_dir: Path, email: str, password: str) -> dict: + """Attempt a login with the supplied credentials (does not save them). + + Returns a dict with display_name and full_name on success. + Raises GarminError on failure. + """ + try: + import garminconnect + except ImportError as exc: + raise GarminError("garminconnect is not installed") from exc + + try: + client = garminconnect.Garmin(email=email, password=password) + client.login() + except Exception as exc: + raise GarminError(f"Login failed: {exc}") from exc + + try: + profile = client.get_profile_user_summary() + display = profile.get("displayName", email) + full = f"{profile.get('firstName', '')} {profile.get('lastName', '')}".strip() + except Exception: + display, full = email, "" + + # Credentials are valid — save them and the session + save_credentials(data_dir, user_dir, email, password) + _save_session(user_dir, client) + + return {"display_name": display, "full_name": full} diff --git a/docs/garmin_connect_disclaimer.md b/docs/garmin_connect_disclaimer.md new file mode 100644 index 0000000..8d88475 --- /dev/null +++ b/docs/garmin_connect_disclaimer.md @@ -0,0 +1,75 @@ +# Garmin Connect Sync — Disclaimer + +**This feature uses an unofficial, community-maintained library to access Garmin Connect. +It is not affiliated with, endorsed by, or supported by Garmin Ltd. or its subsidiaries.** + +--- + +## What this feature does + +When you enable Garmin Connect sync, BincioActivity will: + +1. Ask for your Garmin Connect **email address and password** +2. Store those credentials on the server, encrypted at rest +3. Use them to log in to Garmin Connect on your behalf and download your activity files (FIT format) +4. Import those activities into your BincioActivity account + +--- + +## What you need to know before enabling this + +### Your credentials are stored on the server + +Unlike Strava (which uses OAuth — you authorize without sharing your password), +Garmin Connect has no official third-party API. This feature works by logging in +as you, using your actual email and password. + +This means: + +- The server operator has technical access to your stored credentials +- You are trusting both the software and the person running the server +- Only enable this on a server you control or run by someone you fully trust + +### This uses an unofficial API + +Garmin does not provide a public developer API for activity data. +This feature relies on a reverse-engineered interface that: + +- May break without notice when Garmin changes their systems +- Is not covered by any Garmin service agreement or SLA +- May violate Garmin Connect's Terms of Service + +BincioActivity takes no responsibility for account restrictions or bans +that may result from using this feature. + +### Two-factor authentication (2FA) + +If your Garmin account has 2FA enabled, this feature may not work or may +require additional steps. Garmin has changed their authentication flow +several times; compatibility depends on the current state of the underlying library. + +### Rate limits + +Garmin does not publish API rate limits. Syncing too frequently or importing +large volumes of activities may result in temporary or permanent IP blocks. +BincioActivity applies conservative limits, but cannot guarantee uninterrupted access. + +--- + +## How to revoke access + +BincioActivity does not hold an OAuth token that can be revoked from Garmin's settings. +To stop BincioActivity from accessing your Garmin account: + +1. Delete your stored credentials from BincioActivity (Settings → Garmin Connect → Disconnect) +2. **Change your Garmin Connect password** — this is the only way to guarantee that + no previously stored credentials can be used + +--- + +## Recommendation + +If you have concerns about credential storage, consider the alternative: +export your activities from Garmin Connect or Garmin Express as FIT files +and upload them directly to BincioActivity. This requires no credentials +and is always available. diff --git a/pyproject.toml b/pyproject.toml index 34004a5..2e1bb5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,10 @@ serve = [ strava = [ "requests>=2.32", ] +garmin = [ + "garminconnect>=0.2", + "cryptography>=42.0", +] dev = [ "pytest>=9.0", "pytest-cov>=5.0",