debug daily notification subscriptions #5

Merged
trentlarson merged 4 commits from debug-subsc into master 8 months ago
  1. 18
      CHANGELOG.md
  2. 11
      Dockerfile
  3. 7
      README.md
  4. 175
      app.py
  5. BIN
      data/webpush.db.empty
  6. 10
      models.py

18
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.

11
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"]

7
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

175
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

BIN
data/webpush.db.empty

Binary file not shown.

10
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)

Loading…
Cancel
Save