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 227dd1a..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,4 +43,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 '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 8b874fe..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 @@ -240,6 +244,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 +254,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..66f6504 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 @@ -21,9 +22,14 @@ import time CONTACT_EMAIL = "mailto:info@timesafari.app" +PUSH_SERVER_VERSION = os.getenv('PUSH_SERVER_VERSION') + app = Flask(__name__) class WebPushService(): + + latest_subscription_run = None + """ This class provides services for sending web push notifications. """ @@ -44,10 +50,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 @@ -126,7 +134,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. @@ -135,8 +143,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: @@ -199,43 +209,122 @@ 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) + # 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__ + def ping(self) -> str: + """ + Endpoint to show liveness info - # Sleeping for 24 hours before sending the next set of notifications - time.sleep(24 * 60 * 60) + Returns: + - Response: Text with some subscription-run info + """ + 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[str, int]: + def regenerate_vapid(self) -> Tuple[Response, int, dict[str, str]] | Tuple[Response, int]: """ Endpoint to regenerate VAPID keys. @@ -248,7 +337,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. @@ -312,7 +401,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. @@ -344,10 +433,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 @@ -355,7 +455,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 = { @@ -373,12 +474,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. @@ -409,7 +510,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: @@ -418,7 +519,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. @@ -473,7 +574,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 diff --git a/data/webpush.db.empty b/data/webpush.db.empty index ede51c9..0560268 100644 Binary files a/data/webpush.db.empty and b/data/webpush.db.empty differ 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) - -