"""Durable sync requests; shared filesystem locks serialize each user's session.""" import json import logging import time from datetime import UTC, datetime, timedelta from pathlib import Path import click from cryptography.fernet import Fernet from filelock import FileLock, Timeout from flask import current_app from garminconnect import Garmin, GarminConnectAuthenticationError from garmin_sync.service import SyncError, account_key, private_directory, sync_activities from .extensions import db from .models import GarminConnection, User from .services import import_cache def cipher(): key = current_app.config.get("TOKEN_ENCRYPTION_KEY") if not key: raise SyncError("Garmin connections are not configured on this server.") return Fernet(key) def session_lock(user_id): directory = Path(current_app.instance_path) / "sync-locks" private_directory(directory) return FileLock(str(directory / f"{user_id}.lock"), timeout=0) def connect(user, payload): if len(payload) > 65536: raise SyncError("Token file must be smaller than 64 KB.") try: data = json.loads(payload) keys = ("di_token", "di_refresh_token", "di_client_id") if not isinstance(data, dict) or not all( isinstance(data.get(k), str) and data[k] for k in keys ): raise ValueError() except (ValueError, TypeError): raise SyncError("Export a session with garminconnect 0.3.17.") from None encryption = cipher() with session_lock(user.id): api = Garmin() api.login(json.dumps({k: data[k] for k in keys})) key = account_key(api, "garmin.com") other = db.session.scalar(db.select(User).filter_by(local_account_key=key)) if other and other.id != user.id: raise SyncError("This Garmin account is already connected.") connection = db.session.scalar(db.select(GarminConnection).filter_by(user_id=user.id)) if connection and connection.account_key != key: raise SyncError("Disconnect the existing Garmin account before replacing it.") if not connection: connection = GarminConnection(user_id=user.id, account_key=key) db.session.add(connection) connection.tokens = encryption.encrypt(api.client.dumps().encode()).decode() connection.pending = True connection.next_sync = datetime.now(UTC) connection.failures = 0 connection.message = "Session connected. Sync queued." user.local_account_key = key db.session.commit() def run_connection(connection_id): connection = db.session.get(GarminConnection, connection_id) if not connection: return False try: with session_lock(connection.user_id): db.session.refresh(connection) now = datetime.now(UTC) due = connection.next_sync.replace(tzinfo=UTC) <= now if not connection.pending and not (connection.scheduled and due): return False api = None try: api = Garmin() api.login(cipher().decrypt(connection.tokens.encode()).decode()) if account_key(api, "garmin.com") != connection.account_key: raise GarminConnectAuthenticationError("Account mismatch") # Persist rotated sessions even if the subsequent download fails. connection.tokens = cipher().encrypt(api.client.dumps().encode()).decode() db.session.commit() try: result = sync_activities( api, Path(current_app.config["DATA_ROOT"]) / connection.account_key, 100, None, ) finally: connection.tokens = cipher().encrypt(api.client.dumps().encode()).decode() db.session.commit() user = db.session.get(User, connection.user_id) count = import_cache(user, current_app.config["DATA_ROOT"]) if result["failed"]: raise SyncError("Some activities failed; sync will retry.") db.session.commit() if connection.health_enabled: from .recovery import sync_health try: health = sync_health(api, user) connection.health_message = health["message"] except Exception: # noqa: BLE001 -- optional provider data cannot block activities db.session.rollback() connection.health_message = "Daily recovery unavailable; will retry. Activity sync completed." finally: connection.tokens = cipher().encrypt(api.client.dumps().encode()).decode() db.session.commit() connection.last_success = now connection.failures = 0 connection.message = f"Sync complete. {count} new activities imported." connection.next_sync = now + timedelta(hours=1) except Exception as error: # noqa: BLE001 -- do not expose provider credentials db.session.rollback() connection = db.session.get(GarminConnection, connection_id) connection.failures += 1 connection.pending = False if isinstance(error, GarminConnectAuthenticationError): connection.scheduled = False connection.message = ( "Session expired. Upload a fresh Garmin session to reconnect." ) else: connection.message = ( "Sync failed. Retry later or reconnect your Garmin session." ) connection.next_sync = now + timedelta( minutes=min(360, 5 * 2 ** min(connection.failures, 6)) ) connection.pending = False db.session.commit() return True except Timeout: db.session.rollback() return False def run_due(): ids = list( db.session.scalars( db.select(GarminConnection.id).where( db.or_( GarminConnection.pending.is_(True), db.and_( GarminConnection.scheduled.is_(True), GarminConnection.next_sync <= datetime.now(UTC), ), ) ) ) ) for connection_id in ids: run_connection(connection_id) return len(ids) def register_cli(app): @app.cli.command("backfill-health") @click.option("--email", required=True) @click.option("--days", default=90, type=click.IntRange(1, 365)) def health_backfill(email, days): from .recovery import sync_health logging.getLogger("garminconnect").disabled = True user = db.session.scalar(db.select(User).filter_by(email=email.strip().lower())) connection = db.session.scalar(db.select(GarminConnection).filter_by( user_id=user.id)) if user else None if not connection: raise click.ClickException("No connected account found.") try: with session_lock(user.id): api = Garmin() authenticated = False try: api.login(cipher().decrypt(connection.tokens.encode()).decode()) authenticated = True result = sync_health(api, user, days, days) connection.health_enabled = True connection.health_message = result["message"] click.echo(result["message"]) finally: if authenticated: connection.tokens = cipher().encrypt(api.client.dumps().encode()).decode() db.session.commit() except Timeout: raise click.ClickException("A sync is running; retry after it finishes.") from None except Exception: # noqa: BLE001 -- suppress provider credentials at the operator boundary raise click.ClickException("Daily recovery import failed; retry or reconnect.") from None @app.cli.command("sync-worker") @click.option("--once", is_flag=True, help="Process currently due syncs and exit.") def worker(once): logging.getLogger("garminconnect").disabled = True while True: try: count = run_due() click.echo(f"Sync worker heartbeat: {count} due connections.") except Exception: # noqa: BLE001 -- suppress provider credentials at boundary db.session.rollback() click.echo("Sync worker iteration failed; retrying without logging credentials.") if once: raise click.ClickException("Sync worker failed.") from None finally: db.session.remove() if once: break time.sleep(30)