""" Environment variables: - SQLALCHEMY_DATABASE_URI: path to sqlite file, starting with "sqlite:////" - ADMIN_PASSWORD: password for admin user for sensitive endpoints, defaults to 'admin' """ from typing import Dict, Tuple, Union, Optional from flask import Flask, request, jsonify, Response from models import db, VAPIDKey, Subscription from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec from pywebpush import webpush, WebPushException import base64 import datetime import json import os import threading import time CONTACT_EMAIL = "mailto:info@timesafari.app" app = Flask(__name__) class WebPushService(): """ This class provides services for sending web push notifications. """ def __init__(self, app, config_name: str) -> None: """ Initializes the WebPushService with the given application and configuration name. Args: - app: The application instance where the service will be attached. - config_name (str): The name of the configuration to be used. Attributes: - app: The application instance. - daily_notification_thread (threading.Thread): A thread to send daily notifications. """ # Setting the application instance self.app = app self.app.add_url_rule('/web-push/regenerate-vapid', view_func=self.regenerate_vapid, methods=['POST']) # Setting the database URI for the application db_uri = os.getenv('SQLALCHEMY_DATABASE_URI', 'sqlite:////app/instance/data/webpush.db') # This relative path works in docker-compose #db_uri = os.getenv('SQLALCHEMY_DATABASE_URI', 'sqlite:///data/webpush.db') self.app.config['SQLALCHEMY_DATABASE_URI'] = db_uri # Initializing the database with the application db.init_app(self.app) # Creating a context for the application and initializing services with self.app.app_context(): self._initialize() # Creating and starting a thread to send daily notifications self.daily_notification_thread = threading.Thread(target=self._send_daily_notifications) self.daily_notification_thread.start() def _generate_and_save_vapid_keys(self) -> None: """ Generates VAPID (Voluntary Application Server Identification) keys and saves them to the database. The method generates a pair of public and private keys using the elliptic curve SECP256R1. Both the public and private keys are then serialized and encoded in base64 format. The keys are then stored in the database using a VAPIDKey model. Notes: - In case of any exception during the key generation or database operations, the error is printed to the console. Raises: - Exceptions raised by the key generation or database operations are caught and printed. """ try: # Generating a private key using the elliptic curve SECP256R1 private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) # Serializing and encoding the public key to base64 format public_key_bytes = private_key.public_key().public_bytes( encoding=serialization.Encoding.X962, format=serialization.PublicFormat.UncompressedPoint ) public_key_base64 = base64.b64encode(public_key_bytes).decode() # Serializing and encoding the private key to base64 format private_key_bytes = private_key.private_bytes( encoding=serialization.Encoding.DER, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption() ) private_key_base64 = base64.b64encode(private_key_bytes).decode() # Saving the keys to the database key = VAPIDKey(public_key=public_key_base64, private_key=private_key_base64) db.session.add(key) db.session.commit() except Exception as e: print(f"Error generating VAPID keys: {str(e)}") raise e def _initialize(self) -> None: """ Initializes the WebPushService by checking for the presence of VAPID keys in the database. If no VAPID keys are found in the database, this method triggers the generation and storage of new VAPID keys by invoking the `_generate_and_save_vapid_keys` method. Notes: - VAPID (Voluntary Application Server Identification) keys are essential for sending push notifications to web clients, hence the check and generation if they don't exist. """ # Checking if there are any VAPID keys in the database if not VAPIDKey.query.first(): # Generating and saving VAPID keys if none are found self._generate_and_save_vapid_keys() @staticmethod def _send_push_notification(subscription_info: Dict, message: Dict, vapid_key: VAPIDKey) -> bool: """ Sends a push notification using the provided subscription information, message, and VAPID key. Args: - subscription_info (Dict): The information required to send the push notification to a specific client. - message (Dict): The actual message content to be sent as the push notification. - vapid_key (VAPIDKey): The VAPID key model instance containing the private key used for sending the notification. Returns: - request.Response https://requests.readthedocs.io/en/latest/api.html#requests.Response if the push notification was sent successfully or False if there was an exception Notes: - The `webpush` function is used to send the notification. - In case of any exception, especially a WebPushException, the error is printed to the console. """ # Sending the push notification using the webpush function try: result = webpush( subscription_info=subscription_info, data=json.dumps(message), vapid_private_key=vapid_key.private_key, vapid_claims={"sub": CONTACT_EMAIL} ) # "because sometimes that's what I had to do to make it work!" - Matthew time.sleep(1) return {"success": result.status_code == 201, "message": result.text, "result": result} except WebPushException as ex: now = datetime.datetime.now().isoformat() endpoint = subscription_info['endpoint'] print(f"{now}: Failed to send push notification for {endpoint} -- {ex}", flush=True) unsubscribed_msg = '410 Gone' unsubscribed = False if unsubscribed_msg in ex.args[0]: subscription = Subscription.query.filter_by(endpoint=endpoint).first() # Delete the subscription if found if subscription: db.session.delete(subscription) db.session.commit() print(f"Committed delete of {subscription_info}", flush=True) unsubscribed = True else: print(f"Could not find subscription at: {endpoint}", flush=True) else: print("Error other than unsubscribed/expired.", ex.args[0], flush=True) return {"success": False, "message": str(ex), "error": ex, "unsubscribed": unsubscribed} def _send_daily_notifications(self) -> None: """ Continuously sends daily push notifications to all subscribed clients. This method: 1. Retrieves all subscription data from the database. 2. Constructs the push notification message. 3. Sends a push notification to each subscribed client. 4. Sleeps for 24 hours before repeating the process. Notes: - The method runs in an infinite loop, meaning it will keep sending notifications until the program is terminated. - The notifications are sent using the `_send_push_notification` method. - A context for the application is created to enable database operations. - The message content for the daily update is hardcoded in this method. """ while True: now = datetime.datetime.now().isoformat() print(f"{now} - Starting to send subscriptions...", flush=True) # Creating a context for the application to enable database operations with self.app.app_context(): # Retrieving all subscription data from the database all_subscriptions = Subscription.query.all() # Retrieving the VAPID key from the database vapid_key = VAPIDKey.query.first() # Constructing the push notification message # The title value is a key, triggering the device to apply logic and customize both title and message. # See https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/src/commit/1e6159869fc28ca6e6b5b3d186617d75705100b4/sw_scripts/additional-scripts.js#L65 UPDATE_TITLE = "DAILY_CHECK" message = {"title": UPDATE_TITLE, "message": f"Update for {now}"} # Sending a push notification to each subscribed client for subscription in all_subscriptions: subscription_info = { "endpoint": subscription.endpoint, "keys": { "p256dh": subscription.p256dh, "auth": subscription.auth } } result = WebPushService._send_push_notification(subscription_info, message, vapid_key) print(f"Result from sub {subscription.id}: success={result['success']} text={result['message']}", flush=True) print(f"{now} - Finished sending {len(all_subscriptions)} subscriptions.", flush=True) # Sleeping for 24 hours before sending the next set of notifications time.sleep(24 * 60 * 60) # This is an endpoint, routed in __init__ def regenerate_vapid(self) -> Tuple[str, int]: """ Endpoint to regenerate VAPID keys. This method: 1. Deletes the current VAPID keys from the database. 2. Generates and stores new VAPID keys using the `_generate_and_save_vapid_keys` method. URL: /web-push/regenerate-vapid Method: POST Header: Authentication: Basic ... Returns: - tuple with "success" as True or False, and "message" message string Notes: - If the operation is successful, a JSON response with a success message is returned with a 200 status code. - If there's an error during the operation, a JSON response with the error message is returned with a 500 status code. """ # This default can be invoked thus: curl -X POST -H "Authorization: Basic YWRtaW46YWRtaW4=" localhost:3000/web-push/regenerate-vapid envPassword = os.getenv('ADMIN_PASSWORD', 'admin') auth = request.authorization if (auth is None or auth.username is None or auth.username != 'admin' or auth.password is None or auth.password != envPassword): return ( jsonify(error='Wrong password'), 401, {'WWW-Authenticate': 'Basic realm="Login Required"'} ) # Creating a context for the application to enable database operations try: with self.app.app_context(): # Deleting the current VAPID keys from the database VAPIDKey.query.delete() db.session.commit() # Generating and saving new VAPID keys self._generate_and_save_vapid_keys() return jsonify(success=True, message="VAPID keys regenerated successfully"), 200 except Exception as e: return jsonify(success=False, message=f'Error regenerating VAPID keys: {str(e)}'), 500 @staticmethod @app.route('/web-push/vapid') def vapid() -> Response: """ Endpoint to retrieve the current VAPID public key. This method fetches the VAPID public key from the database and returns it in a JSON response. URL: /web-push/vapid Method: GET Returns: - Response: A JSON response containing the VAPID public key. Notes: - The response contains a key "vapidKey" with the associated public key as its value. """ # Retrieving the VAPID key from the database key = VAPIDKey.query.first() # Returning the public key in a JSON response return jsonify(vapidKey=key.public_key) @staticmethod @app.route('/web-push/subscribe', methods=['POST']) def subscribe() -> Tuple[str, int]: """ Endpoint to handle new web push subscription requests. This method: 1. Retrieves the VAPID key from the database. 2. Reads the subscription content from the incoming request. 3. Saves the subscription data to the database. 4. Sends a confirmation push notification to the new subscriber. URL: /web-push/subscribe Method: POST Returns: - A JSON response with "success" as True or False, "message" as a response message string, and potentially a "result" as a request.Response object from sending a test notification Notes: - If the operation is successful, a confirmation push notification is sent to the subscriber with a success message. - If there are no VAPID keys available, an error message is returned with a 500 status code. - There's a hardcoded 5-second sleep after saving the subscription, which might be intended for some delay before sending the confirmation. Ensure this is the desired behavior. """ # Retrieving the content from the incoming request content = request.json # Retrieving the VAPID key from the database vapid_key = VAPIDKey.query.first() # Checking if the VAPID key is available if not vapid_key: return jsonify(success=False, message="No VAPID keys available"), 500 # Creating a new Subscription instance with the provided data subscription = Subscription(endpoint=content['endpoint'], p256dh=content['keys']['p256dh'], auth=content['keys']['auth'], vapid_key_id=vapid_key.id) # Saving the subscription data to the database db.session.add(subscription) db.session.commit() # Introducing a delay (ensure that gateway endpoint is available) time.sleep(10) # Constructing the subscription information for the push notification subscription_info = { "endpoint": subscription.endpoint, "keys": { "p256dh": subscription.p256dh, "auth": subscription.auth } } # Creating a confirmation message for the push notification message = {"title": "Subscription Successful", "message": "Thank you for subscribing!"} # Sending the confirmation push notification result = WebPushService._send_push_notification(subscription_info, message, vapid_key) # Returning the operation status return jsonify(success=result["success"], message=result["message"]) @staticmethod @app.route('/web-push/unsubscribe', methods=['POST']) def unsubscribe() -> Tuple[str, int]: """ Endpoint to handle web push unsubscription requests. This method: 1. Reads the endpoint from the incoming request. 2. Searches for the subscription in the database using the endpoint. 3. If found, deletes the subscription from the database. URL: /web-push/unsubscribe Method: POST Returns: - Tuple[str, int]: A JSON response indicating the success or failure of the operation, along with the appropriate HTTP status code. Notes: - If the unsubscription is successful, a JSON response with a success message is returned. - If the subscription is not found in the database, an error message is returned with a 404 status code. """ # Retrieving the endpoint from the incoming request content = request.json endpoint = content['endpoint'] # Searching for the subscription in the database using the endpoint subscription = Subscription.query.filter_by(endpoint=endpoint).first() # If the subscription is found, delete it from the database if subscription: db.session.delete(subscription) db.session.commit() return jsonify(success=True, message="Subscription deleted successfully") # If the subscription is not found, return an error message else: return jsonify(success=False, message="Subscription not found"), 404 @staticmethod @app.route('/web-push/send-test', methods=['POST']) def send_test() -> Tuple[str, int]: """ Endpoint to send a test push notification to a specific client. This method: 1. Retrieves the subscription information from the incoming request. 2. Looks up the subscription in the database. 3. Calls the _send_push_notification method to send a test push notification. - The subscription will include the "title" and "message" if supplied in the body object. 4. Returns the result of the _send_push_notification call. URL: /web-push/send-test Method: POST Returns: - A JSON response with the result of the _send_push_notification call. Notes: - The incoming request should contain the parameters "endpoint", "p256dh", and "auth". """ # Retrieving the subscription information from the incoming request content = request.json endpoint = content['endpoint'] p256dh = content['keys']['p256dh'] auth = content['keys']['auth'] # Looking up the subscription in the database subscription = Subscription.query.filter_by(endpoint=endpoint, p256dh=p256dh, auth=auth).first() # If the subscription is found, call the _send_push_notification method if subscription: subscription_info = { "endpoint": subscription.endpoint, "keys": { "p256dh": subscription.p256dh, "auth": subscription.auth } } title = "Test Notification" if "title" in content: title = content['title'] message = "This is a test notification." if "message" in content: message = content['message'] vapid_key = VAPIDKey.query.filter_by(id=subscription.vapid_key_id).first() result = WebPushService._send_push_notification( subscription_info, {"title": title, "message": message}, vapid_key ) print(f"Test sent: {result['success']}") return jsonify(success=result["success"], message=result["message"]) else: print(f"Test failed due to missing subscription. Request: {json.dumps(content)}") return jsonify({"success": False, "message": "Subscription not found"}), 404 web_push_service = WebPushService(app, "app")