diff --git a/CHANGELOG.md b/CHANGELOG.md index a8be79b..4f2334f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.4.0] - 2024-03-31 +## [0.5.0] - 2024-11-24 +### Added +- `notifyType` when subscribing, including `DIRECT_NOTIFICATION` with a hard-coded message +- `SECONDS_BETWEEN_NOTIFICATIONS` environment variable for length of notification loop +### Changed +- DB changes: + - `alter table subscription add column notify_type varchar(32);` + - `update subscription set notify_type = 'DAILY_CHECK';` + - `alter table subscription add column created_date datetime;` + - `alter table subscription add column message varchar(100);` + +## [0.4.0] - 2024-03-31 - 400d9e6eb0e8508efbb4c870a79c0008d15f2200 ### Added - Different times for users to receive notifications - Ping endpoint diff --git a/README.md b/README.md index f03c8f0..1931545 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,59 @@ # py-push-server + +## Run the server outside Docker + +Run the app: + +```commandline +sh <(curl https://pkgx.sh) +python.org +virtualenv.pypa.io sh + +# first time +python -m venv . + +source bin/activate + +# first time +pip install -r requirements.txt + +cp data/webpush.db.empty data/webpush.db +# or: python init_db.py + +# For local DB access, you'll have to uncomment the local path for `db_uri`. + +# 3 workers would trigger 3 daily subscription runs +gunicorn -b 0.0.0.0:3000 --log-level=debug --workers=1 app:app + +``` + +... and see the results in a browser: http://localhost:3000/web-push/vapid + +See Troubleshooting below if that doesn't work out of the box. + + + + + +Run a test: + +```commandline +python test-webpush.py +``` + + + +Run haproxy (on a Mac): + +* Create "haproxy-config" directory for those files above, eg. /usr/local/etc/haproxy + +* Comment out the `log rsyslog` and `bind *:443` lines in /usr/local/etc/haproxy/haproxy.cfg and then run: + +`haproxy -f /usr/local/etc/haproxy/haproxy.cfg` + + + + + ## Docker Build & Deploy - Update CHANGELOG.md @@ -40,6 +94,7 @@ Finally, after it's started, generate a new VAPID by hitting the `regenerate-vap + ## Docker Compose & HAProxy Setup On a production server for security (eg /web-push/generate_vapid): set an environment variable `ADMIN_PASSWORD` for permissions; one way is to create a .env file with the value inside before running `docker compose` commands: @@ -237,56 +292,6 @@ timesafari-pwa.anomalistlabs.com/web-push/ web_push_backend -## Run the server outside Docker - -Run the app: - -```commandline -sh <(curl https://pkgx.sh) +python.org +virtualenv.pypa.io sh - -# first time -python -m venv . - -source bin/activate - -# first time -pip install -r requirements.txt - -cp data/webpush.db.empty data/webpush.db - -# For DB access, you'll have to uncomment the local path for `db_uri`. - -# 3 workers would trigger 3 daily subscription runs -gunicorn -b 0.0.0.0:3000 --log-level=debug --workers=1 app:app - -``` - -... and see the results in a browser: http://localhost:3000/web-push/vapid - -See Troubleshooting below if that doesn't work out of the box. - - - - - -Run a test: - -```commandline -python webpush.py -``` - - - -Run haproxy (on a Mac): - -* Create "haproxy-config" directory for those files above, eg. /usr/local/etc/haproxy - -* Comment out the `log rsyslog` and `bind *:443` lines in /usr/local/etc/haproxy/haproxy.cfg and then run: - -`haproxy -f /usr/local/etc/haproxy/haproxy.cfg` - - - Troubleshooting * If you get "no such table: vapid_key" then your file pointers are probably wrong. diff --git a/app.py b/app.py index eab0cca..d9e737a 100644 --- a/app.py +++ b/app.py @@ -2,7 +2,8 @@ Environment variables: - ADMIN_PASSWORD: password for admin user for sensitive endpoints, defaults to 'admin' - PUSH_SERVER_VERSION: optional version of server -- SQLALCHEMY_DATABASE_URI: path to sqlite file, starting with "sqlite:////" +- SECONDS_BETWEEN_NOTIFICATIONS: optional number of seconds between notifications, defaults to 5 minutes +- SQLALCHEMY_DATABASE_URI: absolute path to sqlite file, starting with "sqlite:////" """ from cryptography.hazmat.backends import default_backend @@ -23,7 +24,14 @@ import time CONTACT_EMAIL = "mailto:info@timesafari.app" +# On Time Safari, this title bypasses the filters and shows the message directly. +TITLE_DIRECT_NOTIFICATION = 'DIRECT_NOTIFICATION' +# On Time Safari, this title triggers the API check for the user's latest data. +TITLE_DAILY_INDIVIDUAL_CHECK = 'DAILY_CHECK' + PUSH_SERVER_VERSION = os.getenv('PUSH_SERVER_VERSION') +SECONDS_BETWEEN_NOTIFICATIONS_STR = os.getenv('SECONDS_BETWEEN_NOTIFICATIONS', '60') +SECONDS_BETWEEN_NOTIFICATIONS = int(SECONDS_BETWEEN_NOTIFICATIONS_STR) app = Flask(__name__) @@ -53,11 +61,11 @@ class WebPushService(): self.app.add_url_rule('/web-push/regenerate-vapid', view_func=self.regenerate_vapid, methods=['POST']) self.app.add_url_rule('/web-push/ping', view_func=self.ping, methods=['GET']) - # Setting the database URI for the application + # Setting the database URI for the application, with an absolute path db_uri = os.getenv('SQLALCHEMY_DATABASE_URI', 'sqlite:////app/instance/data/webpush.db') # This relative path works in docker-compose - # This relative path works on a local run if you link to this dir from "var/app-instance" - #db_uri = os.getenv('SQLALCHEMY_DATABASE_URI', 'sqlite:///data/webpush.db') + # This relative path works on a local run if you link to the dir with app.py from "var/app-instance" + 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 @@ -135,14 +143,14 @@ class WebPushService(): @staticmethod - def _send_push_notification(subscription_info: Dict, message: Dict, vapid_key: VAPIDKey) -> Dict[str, any]: + def _send_push_notification(subscription_info: Dict, message: Dict, vapid_private_key: str) -> Dict[str, any]: """ 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. + - vapid_private_key (str): The private key used for sending the notification. Returns Dict with the following keys - success: True if the push notification was sent successfully, False otherwise @@ -160,7 +168,7 @@ class WebPushService(): result = webpush( subscription_info=subscription_info, data=json.dumps(message), - vapid_private_key=vapid_key.private_key, + vapid_private_key=vapid_private_key, vapid_claims={"sub": CONTACT_EMAIL} ) # "because sometimes that's what I had to do to make it work!" - Matthew @@ -170,7 +178,7 @@ class WebPushService(): 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) + print(f"{now}: Failed to send push notification for {json.dumps(endpoint)} -- {ex}", flush=True) unsubscribed_msg = '410 Gone' unsubscribed = False @@ -199,7 +207,7 @@ class WebPushService(): 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. + 4. Sleeps for 5 minutes and repeats the process. Notes: - The method runs in an infinite loop, meaning it will keep sending notifications until the program is terminated. @@ -211,7 +219,7 @@ class WebPushService(): while True: now = datetime.datetime.now() - print(f"{now} - Starting to send subscriptions...", flush=True) + # print(f"{now} - Starting to send subscriptions...", flush=True) # Creating a context for the application to enable database operations with self.app.app_context(): @@ -219,19 +227,10 @@ class WebPushService(): # Retrieve the VAPID key from the database vapid_key = VAPIDKey.query.first() - # Construct 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}"} - # Determine the beginning and end time to check for subscriptions settings = Settings.query.first() if settings.running_notify_end_time is None: - - # set all subscriptions without a time to the current time - # (these won't be picked-up in the current run, since thie current minute is the end minute) - Subscription.query.filter_by(notify_time=None).update({Subscription.notify_time: now.strftime('%H:%M')}) + # only do this if we're not already inside one of these loops """ Storing the HH:MM for the desired notification time isn't a bad idea. @@ -244,8 +243,9 @@ class WebPushService(): # get the previous notify end time from the DB as a datetime prev_notify_end_time = datetime.datetime.fromisoformat(settings.prev_notify_end_time) - # if it's before midnight this morning, catch us up to the beginning of today: + # if it's before midnight this morning if prev_notify_end_time < now.replace(hour=0, minute=0, second=0, microsecond=0): + # catch us up to the beginning of today # make the start time the later of: prevNotifyEndTime or yesterday at this time start_time = max( prev_notify_end_time, @@ -272,7 +272,13 @@ class WebPushService(): # This really should update & continue only if the running_notify_end_time is still None, # just in case another thread started. settings.running_notify_end_time = end_time.isoformat() + + # set all subscriptions without a time to the current time + # (these won't be picked-up in the current run, since the current minute is the end minute) + Subscription.query.filter_by(notify_time=None).update({Subscription.notify_time: now.strftime('%H:%M')}) + db.session.commit() + # this check was generated by Copilot; it's probably unnecessary if settings.running_notify_end_time == end_time.isoformat(): # Now get the 'HH:MM' value for start & end so we can compare to the notify_time field @@ -292,9 +298,18 @@ class WebPushService(): "auth": subscription.auth } } - result = WebPushService._send_push_notification(subscription_info, message, vapid_key) + # Construct the push notification message + # The title value is a key, defaulting to trigger 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 + payload = {"title": subscription.notify_type} + if subscription.message: + payload['message'] = subscription.message + elif subscription.notify_type == TITLE_DIRECT_NOTIFICATION: + # They should get some message. + payload['message'] = "Just a friendly reminder: click and share some gratitude with the world." + result = WebPushService._send_push_notification(subscription_info, payload, vapid_key.private_key) print( - f"Result from sub {subscription.id}: success={result['success']} text={result['message']}", + f"Result from sub {subscription.id}: success={result['success']} message={result['message']}", flush=True ) @@ -305,13 +320,12 @@ class WebPushService(): else: print(f"{now} - Failed to update running_notify_end_time", flush=True) else: - print(f"{now} - Stopped because we're already running a notification check.", flush=True) - + print(f"{now} - Subscription check stopped because we're already running a notification check.", flush=True) self.latest_subscription_run = now.isoformat() # Sleep before repeating - time.sleep(5 * 60) + time.sleep(SECONDS_BETWEEN_NOTIFICATIONS) # This is an endpoint, routed in __init__ def ping(self) -> str: @@ -415,6 +429,20 @@ class WebPushService(): URL: /web-push/subscribe Method: POST + Body: + - JSON object with the following keys: + - endpoint: the endpoint URL for the push notification + - keys: a JSON object with the following keys: + - p256dh: the P-256 elliptic curve Diffie-Hellman key pair + - auth: the authentication secret for the push subscription + - message: an optional string message to send to the subscriber, max 100 characters + The message is only used for certain notifyType values like TITLE_DIRECT_NOTIFICATION + - notifyTime: a JSON object with the following keys: + - utcHour: the hour in UTC + - minute: the minute in UTC + - notifyType: optional type of notification to send + If not type is sent, the internal type is set to TITLE_DAILY_INDIVIDUAL_CHECK + 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 @@ -426,6 +454,14 @@ class WebPushService(): # Retrieving the content from the incoming request content = request.json + if (content is None) or ('endpoint' not in content) or ('keys' not in content): + return jsonify(success=False, message="Missing subscription information"), 400 + if ('p256dh' not in content['keys']) or ('auth' not in content['keys']): + return jsonify(success=False, message="Missing subscription keys information"), 400 + if ('notifyTime' not in content) or ('utcHour' not in content['notifyTime']): + return jsonify(success=False, message="Missing notifyTime information"), 400 + if ('notifyType' in content) and (content['notifyType'] not in [TITLE_DIRECT_NOTIFICATION, TITLE_DAILY_INDIVIDUAL_CHECK]): + return jsonify(success=False, message="Invalid notifyType"), 400 # Retrieving the VAPID key from the database vapid_key = VAPIDKey.query.first() @@ -435,19 +471,32 @@ class WebPushService(): return jsonify(success=False, message="No VAPID keys available"), 500 # Constructing the notify_time string - notify_time = "13:13" # random time that is in most people's waking hours (server time, typically UTC) - if ('notifyTime' in content) and ('utcHour' in content['notifyTime']): - notify_hour = content['notifyTime']['utcHour'] - if 'minute' in content['notifyTime']: - notify_minute = content['notifyTime']['minute'] + notify_hour = content['notifyTime']['utcHour'] + if 'minute' in content['notifyTime']: + notify_minute = content['notifyTime']['minute'] + else: + notify_minute = 0 + notify_time = '{:02d}'.format(notify_hour) + ":" + '{:02d}'.format(notify_minute) + + notify_type = TITLE_DAILY_INDIVIDUAL_CHECK + if 'notifyType' in content: + notify_type = content['notifyType'] + + # check that the message is 100 characters or less + message = None + if 'message' in content: + if len(content['message']) > 100: + return jsonify(success=False, message="Message is too long. Max 100 characters."), 400 else: - notify_minute = 0 - notify_time = '{:02d}'.format(notify_hour) + ":" + '{:02d}'.format(notify_minute) + message = content['message'] # Creating a new Subscription instance with the provided data subscription = Subscription(auth=content['keys']['auth'], + created_date=datetime.datetime.now().isoformat(), endpoint=content['endpoint'], + message=message, notify_time=notify_time, + notify_type=notify_type, p256dh=content['keys']['p256dh'], vapid_key_id=vapid_key.id) @@ -455,10 +504,6 @@ class WebPushService(): db.session.add(subscription) db.session.commit() - # Introducing a delay (ensure that gateway endpoint is available) - # ... which I'm now commenting out because there's no pending request so it doesn't make sense... we'll see if things still work - #time.sleep(10) - # Constructing the subscription information for the push notification subscription_info = { "endpoint": subscription.endpoint, @@ -472,7 +517,7 @@ class WebPushService(): message = {"title": "Subscription Successful", "message": "Thank you for subscribing!"} # Sending the confirmation push notification - result = WebPushService._send_push_notification(subscription_info, message, vapid_key) + result = WebPushService._send_push_notification(subscription_info, message, vapid_key.private_key) # Returning the operation status return jsonify(success=result["success"], message=result["message"]), 200 @@ -492,6 +537,14 @@ class WebPushService(): URL: /web-push/unsubscribe Method: POST + Body: + - JSON object with the following keys: + - endpoint: the endpoint URL for the push notification + - keys: a JSON object with the following keys: + - p256dh: the P-256 elliptic curve Diffie-Hellman key pair + - auth: the authentication secret for the push subscription + - notifyType: "DAILY_CHECK" or "DIRECT_NOTIFICATION" -- if empty, all notifications deleted + Returns: - Tuple[str, int]: A JSON response indicating the success or failure of the operation, along with the appropriate HTTP status code. @@ -502,20 +555,22 @@ class WebPushService(): # Retrieving the endpoint from the incoming request content = request.json - endpoint = content['endpoint'] + endpoint = content.get('endpoint') + if (endpoint is None): + return jsonify(success=False, message="Missing endpoint information"), 400 # 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) + if 'notifyType' in content: + notify_type = content['notifyType'] + print(f"Deleting subscription for {endpoint} with notifyType {notify_type}", flush=True) + db.session.query(Subscription).filter(and_(Subscription.endpoint == endpoint, Subscription.notify_type == notify_type)).delete(synchronize_session=False) db.session.commit() - return jsonify(success=True, message="Subscription deleted successfully"), 200 - - # If the subscription is not found, return an error message else: - return jsonify(success=False, message="Subscription not found"), 404 + print(f"Deleting all subscriptions for {endpoint}", flush=True) + db.session.query(Subscription).filter(Subscription.endpoint == endpoint).delete(synchronize_session=False) + db.session.commit() + + return jsonify(success=True, message="Subscription deleted successfully"), 200 @staticmethod @@ -543,9 +598,11 @@ class WebPushService(): # Retrieving the subscription information from the incoming request content = request.json - endpoint = content['endpoint'] - p256dh = content['keys']['p256dh'] - auth = content['keys']['auth'] + endpoint = content.get('endpoint') + p256dh = content.get('keys', {}).get('p256dh') + auth = content.get('keys', {}).get('auth') + if (endpoint is None) or (p256dh is None) or (auth is None): + return jsonify({"success": False, "message": "Missing subscription information"}), 400 # Looking up the subscription in the database subscription = Subscription.query.filter_by(endpoint=endpoint, p256dh=p256dh, auth=auth).first() @@ -553,10 +610,10 @@ class WebPushService(): # If the subscription is found, call the _send_push_notification method if subscription: subscription_info = { - "endpoint": subscription.endpoint, + "endpoint": endpoint, "keys": { - "p256dh": subscription.p256dh, - "auth": subscription.auth + "p256dh": p256dh, + "auth": auth } } @@ -571,13 +628,13 @@ class WebPushService(): result = WebPushService._send_push_notification( subscription_info, {"title": title, "message": message}, - vapid_key + vapid_key.private_key ) - print(f"Test sent: {result['success']}") + print(f"Test sent: success={result['success']} message={result['message']}", flush=True) return jsonify(success=result["success"], message=result["message"]), 200 else: - print(f"Test failed due to missing subscription. Request: {json.dumps(content)}") + print(f"Test failed due to missing subscription. Request: {json.dumps(content)}", flush=True) return jsonify({"success": False, "message": "Subscription not found"}), 404 diff --git a/data/webpush.db.empty b/data/webpush.db.empty index 0560268..efe988e 100644 Binary files a/data/webpush.db.empty and b/data/webpush.db.empty differ diff --git a/models.py b/models.py index ef9a08a..f98bc3f 100644 --- a/models.py +++ b/models.py @@ -16,7 +16,10 @@ class Settings(db.Model): class Subscription(db.Model): id = db.Column(db.Integer, primary_key=True) auth = db.Column(db.String(255), nullable=False) + created_date = db.Column(db.String(29), nullable=True) endpoint = db.Column(db.String(500), nullable=False) - notify_time = db.Column(db.String(5), nullable=True) # HH:MM + message = db.Column(db.String(100), nullable=True) + notify_time = db.Column(db.String(5), nullable=False) # HH:MM + notify_type = db.Column(db.String(32), nullable=True) # 'DAILY_CHECK', 'DIRECT_NOTIFICATION' p256dh = db.Column(db.String(255), nullable=False) vapid_key_id = db.Column(db.Integer, db.ForeignKey('vapid_key.id'), nullable=False) diff --git a/webpush.py b/test-webpush.py similarity index 89% rename from webpush.py rename to test-webpush.py index 63a2925..68b47cf 100644 --- a/webpush.py +++ b/test-webpush.py @@ -25,10 +25,10 @@ import sys # sVR_s8J4JHv3h4ZmvemL5w subscription_info = { - "endpoint": "https://fcm.googleapis.com/fcm/send/eqNQV7MVPic:APA91bGrIMxqz3sQ4wboUkmZithJHMAdrNgjm6BYcIGmgJozgEGeg23JsXLlNpnKwzBCmUXh1ciHmE_3wZakHX-Rho5f9Xovc28nun4nH7w4BMoYzX27pOw_pC4FtfAkBQaQ-8jm36jf", + "endpoint": "https://fcm.googleapis.com/fcm/send/drt0Dj1eLFE:APA91bEwLMJ69OKH23-tX183oaH__BwEYPa7woAcI9hMZ0eOc4qmzYD4qsq_dr7L_H4xQhpa79ixFi6ZlfdQQXux_Mx2jKA9kgN9cdGXqcs8ynnzPcUYMRCkTIANw6JBH159DCqu66Lm", "keys": { - "p256dh": "BDo2fIIN7qoA5bOVXdrHATZUSPHY7030V8PKW1mIHAZHDAxS-p6RggVeI7IZoi3bGxpR713RYY8H8vu-lX5LY1w", - "auth": "sVR_s8J4JHv3h4ZmvemL5w" + "p256dh": "BKAc27ZWWY6-pwbz8BCSxH7DorffZ9lbPdTsbD8vOjNYn37OyBYtPmmNHSddChsVj0py6aPTON4J60e0jLgNIzI", + "auth": "8o5vgTKJsPqb1Ve5GazVuQ" } }