From 5071a8bee4bfb72944b037b5f0a01871e468eedb Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Fri, 29 Mar 2024 10:45:44 -0600 Subject: [PATCH 1/4] add a 'ping' endpoint, redirect stderr to stdout --- Dockerfile | 3 ++- README.md | 3 +++ app.py | 20 ++++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 227dd1a..dcdc2ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,4 +41,5 @@ RUN chown -R myuser:myuser /app USER myuser # Start gunicorn with the appropriate options -CMD ["gunicorn", "-b", "0.0.0.0:3000", "--log-level=debug", "--workers=1", "app:app"] +# Without "2>&1" the gunicorn internal logging shows in 'docker logs' but doesn't go to stdout like our 'printf' commands. +CMD ["gunicorn", "-b", "0.0.0.0:3000", "--log-level=debug", "--workers=1", "app:app", "2>&1"] diff --git a/README.md b/README.md index 8b874fe..7eb0cc1 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,7 @@ Run the app: ```commandline sh <(curl https://pkgx.sh) +python.org +virtualenv.pypa.io sh +# first time python -m venv . source bin/activate @@ -249,6 +250,8 @@ 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 diff --git a/app.py b/app.py index d0b5235..2896b76 100644 --- a/app.py +++ b/app.py @@ -23,7 +23,11 @@ CONTACT_EMAIL = "mailto:info@timesafari.app" app = Flask(__name__) + class WebPushService(): + + latest_subscription_run = None + """ This class provides services for sending web push notifications. """ @@ -44,10 +48,12 @@ class WebPushService(): # Setting the application instance self.app = app 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 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') self.app.config['SQLALCHEMY_DATABASE_URI'] = db_uri @@ -230,10 +236,24 @@ class WebPushService(): 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) + self.latest_subscription_run = now + # Sleeping for 24 hours before sending the next set of notifications time.sleep(24 * 60 * 60) + # This is an endpoint, routed in __init__ + def ping(self) -> Response: + """ + Endpoint to show liveness info + + Returns: + - Response: Text with some subscription-run info + """ + + return f"pong ... with latest subscription run at {self.latest_subscription_run}" + + # This is an endpoint, routed in __init__ def regenerate_vapid(self) -> Tuple[str, int]: """ -- 2.30.2 From 2a9b1fcf750c919325564f8707a32aa3d740b54b Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Sat, 30 Mar 2024 15:57:34 -0600 Subject: [PATCH 2/4] run the notification check frequently throughout the day, and allow different times for users --- Dockerfile | 2 +- app.py | 126 ++++++++++++++++++++++++++++++++---------- data/webpush.db.empty | Bin 20480 -> 20480 bytes models.py | 10 +++- 4 files changed, 106 insertions(+), 32 deletions(-) diff --git a/Dockerfile b/Dockerfile index dcdc2ce..4449547 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,4 +42,4 @@ USER myuser # Start gunicorn with the appropriate options # Without "2>&1" the gunicorn internal logging shows in 'docker logs' but doesn't go to stdout like our 'printf' commands. -CMD ["gunicorn", "-b", "0.0.0.0:3000", "--log-level=debug", "--workers=1", "app:app", "2>&1"] +CMD ["sh", "-c", "gunicorn -b 0.0.0.0:3000 --log-level=debug --workers=1 app:app 2>&1"] diff --git a/app.py b/app.py index 2896b76..192c362 100644 --- a/app.py +++ b/app.py @@ -4,13 +4,14 @@ Environment variables: - 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 flask import Flask, request, jsonify, Response +from models import db, VAPIDKey, Settings, Subscription from pywebpush import webpush, WebPushException +from sqlalchemy import and_ +from typing import Dict, Tuple import base64 import datetime @@ -141,8 +142,10 @@ class WebPushService(): - 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 + Returns Dict with the following keys + - success: True if the push notification was sent successfully, False otherwise + - message: a string message with the resulting text, usually nothing on success + - result: 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: @@ -205,41 +208,108 @@ class WebPushService(): while True: - now = datetime.datetime.now().isoformat() + now = datetime.datetime.now() 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 + # Retrieve the VAPID key from the database vapid_key = VAPIDKey.query.first() - # Constructing the push notification message + # 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}"} - # 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) - - self.latest_subscription_run = now - - # Sleeping for 24 hours before sending the next set of notifications - time.sleep(24 * 60 * 60) + # 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')}) + + """ + Storing the HH:MM for the desired notification time isn't a bad idea. + + However, the logic that compares directly to it is a bit complicated. + It would be more straightforward to save the next notification time in each record + and then update that every time this process runs. It would require raw SQL with + some calculations and many DB updates each day, but the logic would be more clear. + """ + + # 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 prev_notify_end_time < now.replace(hour=0, minute=0, second=0, microsecond=0): + # make the start time the later of: prevNotifyEndTime or yesterday at this time + start_time = max( + prev_notify_end_time, + # if the current time is later in the day, use that + # because the next run will pick up everything from midnight until now today + now.replace(second=0, microsecond=0) - datetime.timedelta(days=1) + ) + # make the end time right at midnight at the beginning of today + end_time = now.replace(hour=0, minute=0, second=0, microsecond=0) + + start_minute = start_time.strftime('%H:%M') + # (we'd never catch anything if we used a non-zero start_minute and "00:00" as the end_minute) + end_minute = '23:60' # gotta catch "23:59", too + + else: + # the start time is OK + start_time = prev_notify_end_time + # the end time is now + end_time = now.replace(second=0, microsecond=0) + + start_minute = start_time.strftime('%H:%M') + end_minute = end_time.strftime('%H:%M') + + # 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() + 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 + + # get all the subscriptions that have a notify_time between start_minute inclusive and end_minute exclusive + all_subscriptions = Subscription.query.filter( + and_(Subscription.notify_time >= start_minute, Subscription.notify_time < end_minute) + ) + + # Send a push notification to each subscribed client + num_subscriptions = all_subscriptions.count() + 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 + ) + + settings.prev_notify_end_time = end_time.isoformat() + settings.running_notify_end_time = None + db.session.commit() + print(f"{now} - Finished sending {num_subscriptions} subscriptions.", flush=True) + 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) + + + self.latest_subscription_run = now.isoformat() + + # Sleep before repeating + time.sleep(5 * 60) # This is an endpoint, routed in __init__ diff --git a/data/webpush.db.empty b/data/webpush.db.empty index ede51c97c62e939c302b182f212c60d331a84f75..05602686641a61ef793f141211e7664d21b2d92c 100644 GIT binary patch delta 434 zcmZozz}T>Wae}mAH7>()xn5cqA zW{N^)UP)?tYLP-gQD$ypQKdq5YNZa4lUf!J^>S)n3aYoUxKA!G9iQ^ef&CI+xD nvThbs_`=V{3yJ{#4-EVtfc$6t0(`(IQDiVOFf!3KFa#n1<1&N| delta 97 zcmZozz}T>Wae}m<4FdxMD-g2)F%t++)G-DM>cva(f_Qv74E#ZSI-3O*_VBVaDKoK8 rHsEWS{Ff(q@*94k$yL0Tn}hhbDzGvF)v^K={o>!OpzxZ1VgNq?Zp{^B diff --git a/models.py b/models.py index 21e0d4d..ef9a08a 100644 --- a/models.py +++ b/models.py @@ -8,11 +8,15 @@ class VAPIDKey(db.Model): private_key = db.Column(db.String(255), nullable=False) subscriptions = db.relationship('Subscription', backref='vapid_key', lazy=True) +class Settings(db.Model): + id = db.Column(db.Integer, primary_key=True) + prev_notify_end_time = db.Column(db.String(29), nullable=False) + running_notify_end_time = db.Column(db.String(29), nullable=True) + class Subscription(db.Model): id = db.Column(db.Integer, primary_key=True) + auth = db.Column(db.String(255), nullable=False) endpoint = db.Column(db.String(500), nullable=False) + notify_time = db.Column(db.String(5), nullable=True) # HH:MM p256dh = db.Column(db.String(255), nullable=False) - auth = db.Column(db.String(255), nullable=False) vapid_key_id = db.Column(db.Integer, db.ForeignKey('vapid_key.id'), nullable=False) - - -- 2.30.2 From 62c76eb6516595778d40771ba9e2dcf38c44cb58 Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Sat, 30 Mar 2024 17:31:27 -0600 Subject: [PATCH 3/4] accept parameters for the daily notification time --- app.py | 40 +++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/app.py b/app.py index 192c362..85c1a11 100644 --- a/app.py +++ b/app.py @@ -133,7 +133,7 @@ class WebPushService(): @staticmethod - def _send_push_notification(subscription_info: Dict, message: Dict, vapid_key: VAPIDKey) -> bool: + def _send_push_notification(subscription_info: Dict, message: Dict, vapid_key: VAPIDKey) -> Dict[str, any]: """ Sends a push notification using the provided subscription information, message, and VAPID key. @@ -311,9 +311,8 @@ class WebPushService(): # Sleep before repeating time.sleep(5 * 60) - # This is an endpoint, routed in __init__ - def ping(self) -> Response: + def ping(self) -> str: """ Endpoint to show liveness info @@ -323,9 +322,8 @@ class WebPushService(): return f"pong ... with latest subscription run at {self.latest_subscription_run}" - # This is an endpoint, routed in __init__ - def regenerate_vapid(self) -> Tuple[str, int]: + def regenerate_vapid(self) -> Tuple[Response, int, dict[str, str]] | Tuple[Response, int]: """ Endpoint to regenerate VAPID keys. @@ -338,7 +336,7 @@ class WebPushService(): Header: Authentication: Basic ... Returns: - - tuple with "success" as True or False, and "message" message string + - 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. @@ -402,7 +400,7 @@ class WebPushService(): @staticmethod @app.route('/web-push/subscribe', methods=['POST']) - def subscribe() -> Tuple[str, int]: + def subscribe() -> Tuple[Response, int]: """ Endpoint to handle new web push subscription requests. @@ -434,10 +432,21 @@ class WebPushService(): if not vapid_key: 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'] + else: + notify_minute = 0 + notify_time = '{:02d}'.format(notify_hour) + ":" + '{:02d}'.format(notify_minute) + # Creating a new Subscription instance with the provided data - subscription = Subscription(endpoint=content['endpoint'], + subscription = Subscription(auth=content['keys']['auth'], + endpoint=content['endpoint'], + notify_time=notify_time, p256dh=content['keys']['p256dh'], - auth=content['keys']['auth'], vapid_key_id=vapid_key.id) # Saving the subscription data to the database @@ -445,7 +454,8 @@ class WebPushService(): db.session.commit() # Introducing a delay (ensure that gateway endpoint is available) - time.sleep(10) + # ... 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 = { @@ -463,12 +473,12 @@ class WebPushService(): result = WebPushService._send_push_notification(subscription_info, message, vapid_key) # Returning the operation status - return jsonify(success=result["success"], message=result["message"]) + return jsonify(success=result["success"], message=result["message"]), 200 @staticmethod @app.route('/web-push/unsubscribe', methods=['POST']) - def unsubscribe() -> Tuple[str, int]: + def unsubscribe() -> Tuple[Response, int]: """ Endpoint to handle web push unsubscription requests. @@ -499,7 +509,7 @@ class WebPushService(): if subscription: db.session.delete(subscription) db.session.commit() - return jsonify(success=True, message="Subscription deleted successfully") + return jsonify(success=True, message="Subscription deleted successfully"), 200 # If the subscription is not found, return an error message else: @@ -508,7 +518,7 @@ class WebPushService(): @staticmethod @app.route('/web-push/send-test', methods=['POST']) - def send_test() -> Tuple[str, int]: + def send_test() -> Tuple[Response, int]: """ Endpoint to send a test push notification to a specific client. @@ -563,7 +573,7 @@ class WebPushService(): ) print(f"Test sent: {result['success']}") - return jsonify(success=result["success"], message=result["message"]) + return jsonify(success=result["success"], message=result["message"]), 200 else: print(f"Test failed due to missing subscription. Request: {json.dumps(content)}") return jsonify({"success": False, "message": "Subscription not found"}), 404 -- 2.30.2 From 400d9e6eb0e8508efbb4c870a79c0008d15f2200 Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Sun, 31 Mar 2024 15:41:57 -0600 Subject: [PATCH 4/4] update docker build with version, add changelog --- CHANGELOG.md | 18 ++++++++++++++++++ Dockerfile | 10 ++++++---- README.md | 4 ++++ app.py | 5 +++-- 4 files changed, 31 insertions(+), 6 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..38fcb17 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog +All notable changes to this project will be documented in this file. + +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.2.0] - 2024-03-31 +### Added +- Different times for users to receive notifications +- Ping endpoint +### Changed +- Notification loop runs every 5 minutes. + + +## [0.1.0] +- First release with subscriptions and daily notifications. + diff --git a/Dockerfile b/Dockerfile index 4449547..23f38bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,10 @@ # Use an official Python runtime as a parent image -FROM python:3.8-alpine3.18 as builder +FROM python:alpine3.19 as builder RUN apk update && apk upgrade RUN apk add --no-cache --virtual .build-deps build-base git RUN apk add --upgrade --no-cache bash sqlite libffi-dev tzdata -ENV TZ America/New_York ENV PYTHONUNBUFFERED 1 # Set the working directory in the container to /app @@ -25,7 +24,10 @@ RUN pip install --no-cache-dir -r requirements.txt RUN apk del .build-deps # ---- Production Stage ---- -FROM python:3.8-alpine3.18 as production +FROM python:alpine3.19 as production + +ARG PUSH_SERVER_VERSION +ENV PUSH_SERVER_VERSION=${PUSH_SERVER_VERSION} # Create a user to run our application RUN adduser -D myuser -u 1000 @@ -41,5 +43,5 @@ RUN chown -R myuser:myuser /app USER myuser # Start gunicorn with the appropriate options -# Without "2>&1" the gunicorn internal logging shows in 'docker logs' but doesn't go to stdout like our 'printf' commands. +# Without "2>&1" the gunicorn internal logging shows in 'docker logs' but doesn't go to stdout like our 'print' commands. CMD ["sh", "-c", "gunicorn -b 0.0.0.0:3000 --log-level=debug --workers=1 app:app 2>&1"] diff --git a/README.md b/README.md index 7eb0cc1..f03c8f0 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ ## Docker Build & Deploy +- Update CHANGELOG.md + +- Commit & tag with release version + ``` export PUSH_SERVER_VERSION=0.1 diff --git a/app.py b/app.py index 85c1a11..66f6504 100644 --- a/app.py +++ b/app.py @@ -22,8 +22,9 @@ import time CONTACT_EMAIL = "mailto:info@timesafari.app" -app = Flask(__name__) +PUSH_SERVER_VERSION = os.getenv('PUSH_SERVER_VERSION') +app = Flask(__name__) class WebPushService(): @@ -320,7 +321,7 @@ class WebPushService(): - Response: Text with some subscription-run info """ - return f"pong ... with latest subscription run at {self.latest_subscription_run}" + return f"pong ... version {PUSH_SERVER_VERSION} ... with latest subscription run at {self.latest_subscription_run}" # This is an endpoint, routed in __init__ def regenerate_vapid(self) -> Tuple[Response, int, dict[str, str]] | Tuple[Response, int]: -- 2.30.2