Deploy Stride with encrypted manual and scheduled Garmin sync
This commit is contained in:
+170
@@ -0,0 +1,170 @@
|
||||
"""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.")
|
||||
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("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)
|
||||
Reference in New Issue
Block a user