From 80a645238907bff5cfbf649c7325d528dd7aaf24 Mon Sep 17 00:00:00 2001 From: voynow Date: Wed, 13 Nov 2024 18:00:39 -0500 Subject: [PATCH] auth from ecs api + some code migration --- api/dockerfile | 3 +- api/poetry.lock | 13 +++++- api/pyproject.toml | 1 + api/requirements.txt | 1 + api/src/auth_manager.py | 66 ++++++++++++++++++++++++++- api/src/email_manager.py | 47 +++++++++++++++++++ api/src/main.py | 48 ++++++++++--------- api/src/supabase_client.py | 26 +++++++++++ api/src/types/webhook.py | 2 + api/src/webhook.py | 45 ++++++++++++++++++ infra/app/ecs/main.tf | 8 ++++ infra/app/ecs/variable.tf | 12 ++++- infra/app/main.tf | 2 + infra/app/variable.tf | 12 ++++- mobile/mobile/StravaAuthManager.swift | 6 +-- web/src/app/strava_webhook/route.tsx | 1 - 16 files changed, 257 insertions(+), 36 deletions(-) create mode 100644 api/src/email_manager.py create mode 100644 api/src/webhook.py diff --git a/api/dockerfile b/api/dockerfile index 5a3abc6..92cb9d9 100644 --- a/api/dockerfile +++ b/api/dockerfile @@ -8,5 +8,4 @@ RUN pip install --no-cache-dir -r requirements.txt EXPOSE 80 -# Start the app with Python -CMD ["python", "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "80"] +CMD ["python", "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "80", "--log-level", "info"] \ No newline at end of file diff --git a/api/poetry.lock b/api/poetry.lock index fff8ee3..240fd98 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1848,6 +1848,17 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "python-multipart" +version = "0.0.17" +description = "A streaming multipart parser for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python_multipart-0.0.17-py3-none-any.whl", hash = "sha256:15dc4f487e0a9476cc1201261188ee0940165cffc94429b6fc565c4d3045cb5d"}, + {file = "python_multipart-0.0.17.tar.gz", hash = "sha256:41330d831cae6e2f22902704ead2826ea038d0419530eadff3ea80175aec5538"}, +] + [[package]] name = "pytz" version = "2024.2" @@ -2539,4 +2550,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "c6443637d908551d0e46d0832ca3f3bcc57b07d7a54f20fd5b9e37e68cb44f4a" +content-hash = "5aa24e368afd48ebe786b46aa6554e5eeac7ec7f6bf929537dda7f7b61155190" diff --git a/api/pyproject.toml b/api/pyproject.toml index 99c45a8..627e9c6 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -27,6 +27,7 @@ httpx = "^0.27.2" cryptography = "^43.0.3" fastapi = "^0.115.4" uvicorn = "^0.32.0" +python-multipart = "^0.0.17" [tool.poetry.group.dev.dependencies] ipykernel = "^6.29.4" diff --git a/api/requirements.txt b/api/requirements.txt index 741e59e..35779ff 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -42,6 +42,7 @@ pyjwt==2.9.0 ; python_version >= "3.10" and python_version < "4.0" pyperclip==1.9.0 ; python_version >= "3.10" and python_version < "4.0" python-dateutil==2.9.0.post0 ; python_version >= "3.10" and python_version < "4.0" python-dotenv==1.0.1 ; python_version >= "3.10" and python_version < "4.0" +python-multipart==0.0.17 ; python_version >= "3.10" and python_version < "4.0" pytz==2024.2 ; python_version >= "3.10" and python_version < "4.0" realtime==2.0.6 ; python_version >= "3.10" and python_version < "4.0" requests==2.32.3 ; python_version >= "3.10" and python_version < "4.0" diff --git a/api/src/auth_manager.py b/api/src/auth_manager.py index 62b35de..fb76f2f 100644 --- a/api/src/auth_manager.py +++ b/api/src/auth_manager.py @@ -1,11 +1,12 @@ import logging import os from datetime import datetime, timezone +from typing import Optional import jwt from fastapi import HTTPException, Security from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from src import supabase_client +from src import email_manager, supabase_client from src.types.user import UserAuthRow, UserRow from stravalib.client import Client @@ -159,3 +160,66 @@ def get_strava_client(athlete_id: int) -> Client: """Interface for retrieving a Strava client with valid authentication""" user_auth = authenticate_athlete(athlete_id) return get_configured_strava_client(user_auth) + + +def authenticate_with_code(code: str) -> UserAuthRow: + """ + Authenticate athlete with code, exchange with strava client for token, + generate new JWT, and update database + + :param code: temporary authorization code + :return: UserAuthRow + """ + token = strava_client.exchange_code_for_token( + client_id=os.environ["STRAVA_CLIENT_ID"], + client_secret=os.environ["STRAVA_CLIENT_SECRET"], + code=code, + ) + strava_client.access_token = token["access_token"] + strava_client.refresh_token = token["refresh_token"] + strava_client.token_expires_at = token["expires_at"] + + athlete = strava_client.get_athlete() + + jwt_token = generate_jwt(athlete_id=athlete.id, expires_at=token["expires_at"]) + + user_auth_row = UserAuthRow( + athlete_id=athlete.id, + access_token=strava_client.access_token, + refresh_token=strava_client.refresh_token, + expires_at=strava_client.token_expires_at, + jwt_token=jwt_token, + device_token=supabase_client.get_device_token(athlete.id), + ) + supabase_client.upsert_user_auth(user_auth_row) + return user_auth_row + + +def signup(user_auth: UserAuthRow, email: Optional[str] = None) -> dict: + """ """ + email_manager.send_alert_email( + subject="TrackFlow Alert: New Signup Attempt", + text_content=f"You have a new client {email=} attempting to signup", + ) + supabase_client.upsert_user(UserRow(athlete_id=user_auth.athlete_id)) + return {"success": True, "jwt_token": user_auth.jwt_token, "is_new_user": True} + + +def authenticate_on_signin(code: str, email: Optional[str] = None) -> dict: + """ + Authenticate with Strava code, and sign up the user if they don't exist. + + :param code: Strava authorization code + :param email: User's email (optional) + :return: Dictionary with success status and JWT token + """ + user_auth = authenticate_with_code(code) + + if not supabase_client.does_user_exist(user_auth.athlete_id): + return signup(user_auth, email) + + return { + "success": True, + "jwt_token": user_auth.jwt_token, + "is_new_user": False, + } diff --git a/api/src/email_manager.py b/api/src/email_manager.py new file mode 100644 index 0000000..f2a784e --- /dev/null +++ b/api/src/email_manager.py @@ -0,0 +1,47 @@ +import os +from typing import Dict + +import sib_api_v3_sdk +from dotenv import load_dotenv +from urllib3.exceptions import ProtocolError + +load_dotenv() + +configuration = sib_api_v3_sdk.Configuration() +configuration.api_key["api-key"] = os.environ["EMAIL_API_KEY"] + +api_instance = sib_api_v3_sdk.TransactionalEmailsApi( + sib_api_v3_sdk.ApiClient(configuration) +) + + +def send_alert_email( + subject: str, + text_content: str, + to: Dict[str, str] = { + "name": "Jamie Voynow", + "email": "voynow99@gmail.com", + }, + sender: Dict[str, str] = { + "name": "Jamie Voynow", + "email": "voynowtestaddress@gmail.com", + }, +) -> sib_api_v3_sdk.CreateSmtpEmail: + """ + Generic template to send alerts/notifications to myself based on miscellaneous events + """ + html_content = f""" + + +

{subject}

+

{text_content}

+ + + """ + send_smtp_email = sib_api_v3_sdk.SendSmtpEmail( + to=[to], html_content=html_content, sender=sender, subject=subject + ) + try: + return api_instance.send_transac_email(send_smtp_email) + except ProtocolError: + return api_instance.send_transac_email(send_smtp_email) diff --git a/api/src/main.py b/api/src/main.py index 161e5f8..9010aa9 100644 --- a/api/src/main.py +++ b/api/src/main.py @@ -1,16 +1,22 @@ import logging from typing import Optional -from fastapi import BackgroundTasks, Body, Depends, FastAPI, HTTPException, Request -from src import activities, auth_manager, supabase_client +from fastapi import BackgroundTasks, Body, Depends, FastAPI, HTTPException, Request, Form +from src import activities, auth_manager, supabase_client, webhook from src.types.training_week import TrainingWeek from src.types.user import UserRow from src.types.webhook import StravaEvent app = FastAPI() -logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) +logger = logging.getLogger("uvicorn.error") + + +# health check +@app.get("/health") +async def health(): + logger.info("Healthy ✅") + return {"status": "healthy"} @app.get("/training_week/", response_model=TrainingWeek) @@ -121,39 +127,31 @@ async def get_weekly_summaries( @app.post("/authenticate/") -async def authenticate(code: str, email: Optional[str] = None) -> dict: +async def authenticate(code: str = Form(...)) -> dict: """ Authenticate with Strava code and sign up new users - :param code: Strava authorization code - :param email: User's email (optional) + :param code: Strava authorization code from form data :return: Dictionary with success status, JWT token and new user flag """ try: - return auth_manager.authenticate_on_signin(code=code, email=email) + logger.info(f"Authenticating with Strava code: {code}") + return auth_manager.authenticate_on_signin(code=code) except Exception as e: logger.error(f"Authentication failed: {e}", exc_info=True) raise HTTPException(status_code=400, detail=str(e)) -def process_strava_event(event: StravaEvent): - """ - Process the Strava webhook event. Perform any updates based on the event data. +@app.post("/strava-webhook/") +async def strava_webhook(request: Request, background_tasks: BackgroundTasks) -> dict: """ - # Replace this with your Strava-specific logic (e.g., updating training week) - logger.info(f"Processing event: {event}") - # Simulate some processing or call any required functions - # For example, handle_activity_create(user, event) - + Handle Strava webhook events -@app.post("/strava-webhook/") -async def strava_webhook(request: Request, background_tasks: BackgroundTasks): + :param request: Webhook request from Strava + :param background_tasks: FastAPI background tasks + :return: Success status + """ event = await request.json() - logger.info(f"Received Strava webhook event: {event}") - - # Validate event and start processing in background strava_event = StravaEvent(**event) - background_tasks.add_task(process_strava_event, strava_event) - - # Immediate response to Strava - return {"status": "received"} + background_tasks.add_task(webhook.maybe_process_strava_event, strava_event) + return {"success": True} diff --git a/api/src/supabase_client.py b/api/src/supabase_client.py index 257125c..e57ffc7 100644 --- a/api/src/supabase_client.py +++ b/api/src/supabase_client.py @@ -134,3 +134,29 @@ def update_preferences(athlete_id: int, preferences: dict): table = client.table("user") table.update({"preferences": preferences}).eq("athlete_id", athlete_id).execute() + + +def upsert_user(user_row: UserRow): + """ + Upsert a row into the user table + + :param user_row: An instance of UserRow + """ + row_data = user_row.dict() + if isinstance(row_data["created_at"], datetime.datetime): + row_data["created_at"] = row_data["created_at"].isoformat() + + table = client.table("user") + table.upsert(row_data, on_conflict="athlete_id").execute() + + +def does_user_exist(athlete_id: int) -> bool: + """ + Check if a user exists in the user table + + :param athlete_id: The ID of the athlete + :return: True if the user exists, False otherwise + """ + table = client.table("user") + response = table.select("*").eq("athlete_id", athlete_id).execute() + return bool(response.data) diff --git a/api/src/types/webhook.py b/api/src/types/webhook.py index 4a3b758..c2667ba 100644 --- a/api/src/types/webhook.py +++ b/api/src/types/webhook.py @@ -7,3 +7,5 @@ class StravaEvent(BaseModel): object_type: str object_id: int owner_id: int + event_time: int + updates: dict diff --git a/api/src/webhook.py b/api/src/webhook.py new file mode 100644 index 0000000..6efa18c --- /dev/null +++ b/api/src/webhook.py @@ -0,0 +1,45 @@ +from src import auth_manager, supabase_client +from src.types.webhook import StravaEvent + +# from src.training_week import update_training_week +# from src.types.exe_type import ExeType + + +def handle_activity_create(event: StravaEvent) -> dict: + """ + Handle the creation of a Strava activity + + :param event: Strava webhook event + """ + pass + # user = supabase_client.get_user(event.owner_id) + # strava_client = auth_manager.get_strava_client(user.athlete_id) + # activity = strava_client.get_activity(event.object_id) + + # if activity.sport_type == "Run": + # return update_training_week( + # user=user, + # exe_type=ExeType.MID_WEEK, + # ) + # return { + # "success": False, + # "error": f"Unsupported activity type: {activity.sport_type}", + # } + + +def maybe_process_strava_event(event: StravaEvent) -> dict: + """ + Process the Strava webhook event. Perform any updates based on the event data. + Strava Event: subscription_id=2****3 aspect_type='create' object_type='activity' object_id=1*********4 owner_id=9******6 event_time=1731515741 updates={} + Strava Event: subscription_id=2****3 aspect_type='update' object_type='activity' object_id=1*********9 owner_id=9******6 event_time=1731515699 updates={'title': 'Best running weather ❄️'} + + :param event: Strava webhook event + :return: Success status and error message if any + """ + if event.aspect_type == "create": + return handle_activity_create(event) + else: + return { + "success": False, + "error": f"Unsupported event type: {event.aspect_type}", + } diff --git a/infra/app/ecs/main.tf b/infra/app/ecs/main.tf index 19953e3..8f59978 100644 --- a/infra/app/ecs/main.tf +++ b/infra/app/ecs/main.tf @@ -149,6 +149,14 @@ resource "aws_ecs_task_definition" "api" { { name = "SUPABASE_KEY", value = var.supabase_key + }, + { + name = "EMAIL_API_KEY", + value = var.email_api_key + }, + { + name = "OPENAI_API_KEY", + value = var.openai_api_key } ] } diff --git a/infra/app/ecs/variable.tf b/infra/app/ecs/variable.tf index ccf07e3..e4f606e 100644 --- a/infra/app/ecs/variable.tf +++ b/infra/app/ecs/variable.tf @@ -27,4 +27,14 @@ variable "supabase_key" { type = string description = "Supabase API key" sensitive = true -} \ No newline at end of file +} +variable "email_api_key" { + type = string + description = "API key for the email service" + sensitive = true +} +variable "openai_api_key" { + type = string + description = "API key for the OpenAI service" + sensitive = true +} diff --git a/infra/app/main.tf b/infra/app/main.tf index 398c23a..c8b6a2c 100644 --- a/infra/app/main.tf +++ b/infra/app/main.tf @@ -20,6 +20,8 @@ module "ecs" { image = var.image supabase_url = var.supabase_url supabase_key = var.supabase_key + email_api_key = var.email_api_key + openai_api_key = var.openai_api_key vpc_id = module.network.vpc.id public_subnet_ids = [for s in module.network.public_subnets : s.id] depends_on = [module.network] diff --git a/infra/app/variable.tf b/infra/app/variable.tf index 4a0f067..08ac9df 100644 --- a/infra/app/variable.tf +++ b/infra/app/variable.tf @@ -19,4 +19,14 @@ variable "supabase_key" { type = string description = "Supabase API key" sensitive = true -} \ No newline at end of file +} +variable "email_api_key" { + type = string + description = "API key for the email service" + sensitive = true +} +variable "openai_api_key" { + type = string + description = "API key for the OpenAI service" + sensitive = true +} diff --git a/mobile/mobile/StravaAuthManager.swift b/mobile/mobile/StravaAuthManager.swift index 5e5dea1..c769c03 100644 --- a/mobile/mobile/StravaAuthManager.swift +++ b/mobile/mobile/StravaAuthManager.swift @@ -48,10 +48,8 @@ class StravaAuthManager: ObservableObject { var request = URLRequest(url: url) request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let payload = ["code": code] - request.httpBody = try JSONSerialization.data(withJSONObject: payload) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = "code=\(code)".data(using: .utf8) let (data, response) = try await APIManager.shared.session.data(for: request) diff --git a/web/src/app/strava_webhook/route.tsx b/web/src/app/strava_webhook/route.tsx index a7a7b29..b10762a 100644 --- a/web/src/app/strava_webhook/route.tsx +++ b/web/src/app/strava_webhook/route.tsx @@ -1,4 +1,3 @@ -// web/src/app/strava_webhook/route.tsx import { NextRequest, NextResponse } from 'next/server'; export async function GET(request: NextRequest) {