def handleSubUpdate(client, updateContent): """ Handles Strava webhook subscription update. This function is called by a valid Strava POST request to the webhook subscription callback URL. Parameters ---------- client. Stravalib model client object. Contains access token to strava API for the user. updateContent. Dict. POST request JSON data formatted by Flask as a dict. Returns ------- Nothing. Data are inserted into Postgres/PostGIS. """ # Parse update information into a model using stravalib update = client.handle_subscription_update(updateContent) # Verify that the athlete(s) and subscription ID contained in the message are in Postgres if DBQueriesStrava.checkAthleteAndSub(update.owner_id, update.subscription_id): application.logger.debug("Sub update from Strava appears valid") # Insert subscription update message details into Postgres DBQueriesStrava.insertSubUpdate(update) # Verify that the update is a activity creation event if update.aspect_type == "create" and update.object_type == "activity": application.logger.debug( "This is a activity create event, creating thread to process activity" ) try: # Create a thread to handle async processing of the activity and its derivatives # Threading allows the activity to long process with a quick 200 code to be sent to the Strava API Thread(target=APIFunctionsStrava.singleActivityProcessing, args=(client, update.object_id)).start() except Exception as e: application.logger.error( f"Creating a thread to process new activity failed with in the error: {e}" ) errorEmail.sendErrorEmail(script="Webhook Activity Threading", exceptiontype=e.__class__.__name__, body=e) elif update.aspect_type == "update" and update.object_type == "activity": application.logger.debug( "This is a activity update event, updating existing record") # Update existing activity title DBQueriesStrava.updateExistingActivity(update) else: # Write logic to handle delete events application.logger.debug( "Sub update message contains an delete event, skipping request" ) pass else: application.logger.debug( "POST request is invalid, user ID or subscription ID don't match those in database!" )
def removewebhooksub(): """ Removes activate webhook subscription from database and Strava API. SubID is disabled and will no longer be accepted by Flask. Called by Strava Activity admin page inputs. """ # Get POST request info # athID = int(request.form['athID']) # subID = int(request.form['subID']) # if DBQueriesStrava.checkAthleteAndSub(athID, subID): # Get Strava API access credentials client = OAuthStrava.getAuth() # Send request to Strava to delete webhook subscription # Get webhook subID subID = DBQueriesStrava.getActiveSubID() application.logger.debug(f"Sub ID is: {subID}") if subID: try: application.logger.debug( f"Received request to remove the webhook subscription {subID}") client.delete_subscription(subID, os.getenv('STRAVA_CLIENT_ID'), os.getenv('STRAVA_CLIENT_SECRET')) # Set active webhook to inactive in database DBQueriesStrava.setWebhookInactive(subID) application.logger.debug( f"webhook subscription {subID} has been set to inactive") return Response(status=200) except Exception as e: application.logger.error(e) return Response(status=400) else: application.logger.debug( "No active webhook subscription to remove in DB, querying Strava API to check if one " "exists.") # Send request to Strava API webhook service to get details of existing subscription request = requests.get(stravaSubUrl, data={ "client_id": os.getenv("STRAVA_CLIENT_ID"), "client_secret": os.getenv("STRAVA_CLIENT_SECRET") }) # Parse to JSON r = request.json()[0] if r["id"]: application.logger.debug("Webhook exists, sending delete request") delR = client.delete_subscription( r["id"], os.getenv('STRAVA_CLIENT_ID'), os.getenv('STRAVA_CLIENT_SECRET')) application.logger.debug("Existing webhook has been removed!") return Response(status=200)
def singleActivityProcessing(client, actID): """ Processes a single Strava Activity by placing the full activity in the database, making a simplified and masked public version, and by creating a privacy masked stream CSV which is added to a S3 Bucket. Finally a TopoJSON of the public activities is generated and uploaded to the S3 Bucket. @param client: stravalib client instance with valid access token @param actID: Int. ID of Strava Activity to be processed @return: Email. Message states if process was successful or failed """ try: # Wait 45 minutes before processing update, this allows time for user to update any ride details before they # are processed, in particular changing details uploaded from Wahoo # Check if in development mode, if not wait 45 minutes if application.config['ENV'] != "development": time.sleep(2700) application.logger.debug("Getting full activity details") # Get all activity details for newly created activity, including stream data activity = getFullDetails(client, actID) application.logger.debug("Inserting activity details") # Insert original, non-masked, coordinates and attribute details into Postgres/PostGIS DBQueriesStrava.insertOriginalAct(activity['act']) # Calculate masked, publicly sharable, activities and insert into Postgres masked table application.logger.debug("Processing and inserting masked geometries") DBQueriesStrava.processActivitiesPublic(activity["act"]["actId"]) # Handle CSV stream processing generateAndUploadCSVStream(client, actID, activity) # Create topojson file topoJSON = DBQueriesStrava.createStravaPublicActTopoJSON() # Upload topoJSON to AWS S3 StravaAWSS3.uploadToS3(topoJSON) # Send success email errorEmail.sendSuccessEmail( "Webhook Activity Update", f'The strava activity: {activity["act"]["actId"]}' f' has been processed, the activity can be' f' viewed on Strava at: ' f'https://www.strava.com/activities/{activity["act"]["actId"]}') application.logger.debug("Strava activity has been processed!") except Exception as e: application.logger.error( f"Handling and inserting new webhook activity inside a thread failed with the error {e}" ) errorEmail.sendErrorEmail( script="Webhook Activity Threaded Task Update", exceptiontype=e.__class__.__name__, body=e) # Raise another exception, this will signal the route function to return an error 500 raise ()
def deleteSingleActivity(actID): """ Deletes a single Strava activity from database and removes S3 file @param actID: Int. Strava Activity ID @return: Nothing """ application.logger.debug(f"Deleting activity {actID}") # Delete activity from database DBQueriesStrava.removeActivityFromDB(actID) # Delete from S3 # Get bucket details from environmental variable bucket = os.getenv("S3_TRIMMED_STREAM_BUCKET") application.logger.debug(f"Removing {actID} from S3 bucket") StravaAWSS3.deleteFromS3(bucket, "trimmedCSV", actID)
def processActivity(): """ Processes a single Strava Activity, will add/remove the activity and all derivatives, or just the Stream data. Called by Strava Activity admin page inputs. """ # Get POST request info actID = int(request.form['actID']) athID = int(request.form['athID']) actionType = str(request.form['actionType']) procScope = str(request.form['scope']) # Verify that athlete ID is in database try: if athID in DBQueriesStrava.getAthleteList(): application.logger.debug( f"Received a valid POST request to process a Strava Activity: {request.form}" ) # Get Strava API access credentials client = OAuthStrava.getAuth() if procScope == "scopeFullActivity": # Process entire activity and all derived products # Delete activity, if it exists application.logger.debug( f"Fully deleting the activity {actID}, if it exists") APIFunctionsStrava.deleteSingleActivity(actID) # Add new activity if actionType == "Add": application.logger.debug( f"Fully processing the activity {actID}") # Issue activity Update APIFunctionsStrava.singleActivityProcessing(client, actID) # elif actionType == "Delete": # application.logger.debug(f"Fully deleting the activity {actID}, if it exists") # APIFunctionsStrava.deleteSingleActivity(actID) else: return Response(status=400) elif procScope == "scopeCSV": # Delete existing Strava Stream from S3 application.logger.debug( f"Removing the activity stream {actID}") StravaAWSS3.deleteFromS3(os.getenv("S3_TRIMMED_STREAM_BUCKET"), "trimmedCSV", actID) if actionType == "Add": # Add new Strava stream application.logger.debug( f"Adding the activity stream {actID}") APIFunctionsStrava.generateAndUploadCSVStream( client, actID) # elif actionType == "Remove": # # Delete existing Strava Stream from S3 # application.logger.debug(f"Removing the activity stream {actID}") # StravaAWSS3.deleteFromS3(os.getenv("S3_TRIMMED_STREAM_BUCKET"), "trimmedCSV", actID) else: return Response(status=200) else: return Response(status=400) return Response(status=200) else: return Response(status=400) except: return Response(status=500)
def genTopoJSON(): """ Generates a new TopoJSON file using all stored Strava activities and uploads to S3 Bucket, replaces existing file. Called by Strava Activity admin page inputs. """ # Create topojson file application.logger.debug(f"Received request to generate a new TopoJSON") topoJSON = DBQueriesStrava.createStravaPublicActTopoJSON() # Upload topoJSON to AWS S3 StravaAWSS3.uploadToS3(topoJSON) application.logger.debug(f"New TopoJSON has been generated") return Response(status=200)
def createStravaWebhook(client): """ Creates new Strava webhook subscription. Client information and client generated token are pulled from environmental variables and the callback URL is set to a dedicated callback address on the application. If subscription is successful, a subscription ID will be provided by Strava and this ID will be inserted into Postgres. Returns ------- Integer. Strava subscription id """ try: # Kick off process to create new webhook #### The following may not be true, HTTPS may work, server was experiencing other problems that may have #### interferred in the process, need to test. # callback URL needs to be a HTTP url, not HTTPS, so the elastic beanstalk base environment URL is provided # as all calls to leavittmapping.com are redirected to HTTPS, consider making HTTP only mapping to domain. application.logger.debug( f"Attempting to create a new Strava webhook subscription with the values: client id: " f"{os.getenv('STRAVA_CLIENT_ID')} client_secret: {os.getenv('STRAVA_CLIENT_SECRET')}" f"callback url: {(os.getenv('httpSiteIndex') + os.getenv('strava_callback_url'))} " f"and the verify token: {os.getenv('STRAVA_VERIFY_TOKEN')}") response = client.create_subscription( client_id=os.getenv("STRAVA_CLIENT_ID"), client_secret=os.getenv("STRAVA_CLIENT_SECRET"), callback_url=(os.getenv('httpSiteIndex') + os.getenv('strava_callback_url')), verify_token=os.getenv("STRAVA_VERIFY_TOKEN")) application.logger.debug(f"Response id is {response.id}") # Update database with sub id DBQueriesStrava.updateSubId(response.id) return response.id except Exception as e: # Something broke, log error application.logger.error( f"Create subscription function failed with the error {e}")
def addwebhooksub(): """ Adds a new Strava webhook subscription to the database and Strava API. Kicks off callback verification process. Called by Strava Activity admin page inputs. """ # Get POST request info # athID = int(request.form['athID']) # callbackurl = str(request.form['callbackURL']) # Generate 14 character verify token string verifytoken = secrets.token_hex(7) # Insert token into database, will be updated if subID if successful, otherwise row will be deleted DBQueriesStrava.insertVerifyToken(verifytoken) application.logger.debug( f"New verification token {verifytoken} has been added to database") # Get Strava API access credentials client = OAuthStrava.getAuth() try: # Send request to create webhook subscription, will be given the new subscription ID in response application.logger.debug( f"Callback url is {os.getenv('STRAVA_CALLBACK_URL')}") # postDat = {"client_id": os.getenv("STRAVA_CLIENT_ID"), # "client_secret": os.getenv("STRAVA_CLIENT_SECRET"), # "callback_url": os.getenv('FULL_STRAVA_CALLBACK_URL'), # "verify_token": verifytoken} # # r = requests.post("https://www.strava.com/api/v3/push_subscriptions", data=postDat) # resp = r.json() resp = client.create_subscription( client_id=os.getenv("STRAVA_CLIENT_ID"), client_secret=os.getenv("STRAVA_CLIENT_SECRET"), # callback_url=os.getenv('FULL_STRAVA_CALLBACK_URL'), callback_url=os.getenv('STRAVA_CALLBACK_URL'), verify_token=verifytoken) application.logger.debug(resp) # application.logger.debug(f"New sub id is {resp['id']}, updating database") application.logger.debug(f"New sub id is {resp.id}, updating database") # Update database with new sub id # DBQueriesStrava.updateSubId(resp["id"], verifytoken) DBQueriesStrava.updateSubId(resp.id, verifytoken) # application.logger.debug(f"New sub id {resp['id']} has been added to the database") application.logger.debug( f"New sub id {resp.id} has been added to the database") return Response(status=200) except Exception as e: application.logger.debug( f"Webhook creation process failed with the error {e}") # logging.error(e, exc_info=True) DBQueriesStrava.deleteVerifyTokenRecord(verifytoken) return Response(status=400, response=str(e))
def generateAndUploadCSVStream(client, actID, activity=None): """ Generates and uploads a privacy zone masked Strava Stream CSV. @param client: stravalib client instance with valid access token @param actID: Int. Activity ID of Strava activity to process @param activity: Dictionary. Optional. Dictionary of full Strava Activity details, generated if not provided @return: Nothing. Uploads file to S3 Bucket """ if not activity: # Get all activity details for newly created activity, including stream data activity = getFullDetails(client, actID) # Create in-memory buffer csv of stream data csvBuff = StravaAWSS3.writeMemoryCSV(activity["stream"]) # Get WKT formatted latlng stream data wktStr = formatStreamData(activity["stream"]) # application.logger.debug(f"wktSTR is: \n {wktStr}") # Get list of coordinates which cross privacy areas, these will be removed from the latlng stream CSV data removeCoordList = DBQueriesStrava.getIntersectingPoints(wktStr) # application.logger.debug(f"Remove cord list is: \n {removeCoordList}") # Trim/remove rows from latlng CSV stream which have coordinates that intersect the privacy areas trimmedMemCSV = trimStreamCSV(removeCoordList, csvBuff) # Upload trimmed buffer csv to AWS S3 bucket StravaAWSS3.uploadToS3(trimmedMemCSV, activity["act"]["actId"])
def handleSubCallback(request): """ Handles requests to Strava subscription callback URL. GET: Webhoook Subscription Creation Process: CallbackURL is sent a GET request containing a challenge code. This code is sent back to requester to verify the callback. The initial request to create a new webhook subscription is then provided with verification and the new subscription ID. POST: Webhook subscription update message. Sent when a activity on a subscribed account is created, updated, or deleted, or when a privacy related profile setting is changed. All update messages are inputted into Postgres. Currently, only activity creation events are handled, additional development is needed to handle other events. Returns ------- GET request: JSON, echoed Strava challenge text. POST request: Success code if data are successfully added to Postgres/PostGIS. Strava must receive a 200 code in response to POST. """ application.logger.debug( f"Request to Strava callback url. Request is: {request}") # Check if request is a GET callback request, part of webhook subscription process if request.method == 'GET': application.logger.debug( "Got a GET callback request from Strava to verify webhook. Request args are: " f"{request.args}") # Extract challenge and verification tokens callBackContent = request.args.get("hub.challenge") callBackVerifyToken = request.args.get("hub.verify_token") # Form callback response as dict callBackResponse = {"hub.challenge": callBackContent} # Check if verification tokens match, i.e. if GET request is from Strava if callBackVerifyToken and DBQueriesStrava.checkVerificationToken( callBackVerifyToken): application.logger.debug( f"Strava callback verification succeeded, " f" responding with the challenge code" f" message: {callBackResponse}") # Verification succeeded, return challenge code as dict # Using Flask Response API automatically converts it to JSON with HTTP 200 success code return callBackResponse else: # Verification failed, raise error application.logger.error( f"Strava verification token doesn't match!") raise ValueError( 'Strava token verification failed, no match found.') # POST request containing webhook subscription update message, new activity or other change to Strava account elif request.method == 'POST': application.logger.debug( "New activity incoming! Got a POST callback request from Strava") try: # Convert JSON body to dict callbackContent = json.loads(request.data, strict=False) application.logger.debug("JSON content has been extracted") application.logger.debug(f"Update content is {callbackContent}") # application.logger.debug(f"Update content dir is {dir(callbackContent)}") # Call function to handle update message and process new activity, if applicable # Get application access credentials client = OAuthStrava.getAuth() handleSubUpdate(client, callbackContent) application.logger.debug( "Inserted webhook update and activity details into postgres tables!" ) return Response(status=200) except Exception as e: application.logger.error( f"Strava subscription update failed with the error {e}")