@ -4,13 +4,14 @@ Environment variables:
- ADMIN_PASSWORD : password for admin user for sensitive endpoints , defaults to ' admin '
- 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 . backends import default_backend
from cryptography . hazmat . primitives import serialization
from cryptography . hazmat . primitives import serialization
from cryptography . hazmat . primitives . asymmetric import ec
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 pywebpush import webpush , WebPushException
from sqlalchemy import and_
from typing import Dict , Tuple
import base64
import base64
import datetime
import datetime
@ -21,9 +22,14 @@ import time
CONTACT_EMAIL = " mailto:info@timesafari.app "
CONTACT_EMAIL = " mailto:info@timesafari.app "
PUSH_SERVER_VERSION = os . getenv ( ' PUSH_SERVER_VERSION ' )
app = Flask ( __name__ )
app = Flask ( __name__ )
class WebPushService ( ) :
class WebPushService ( ) :
latest_subscription_run = None
"""
"""
This class provides services for sending web push notifications .
This class provides services for sending web push notifications .
"""
"""
@ -44,10 +50,12 @@ class WebPushService():
# Setting the application instance
# Setting the application instance
self . app = app
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/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
db_uri = os . getenv ( ' SQLALCHEMY_DATABASE_URI ' , ' sqlite:////app/instance/data/webpush.db ' )
db_uri = os . getenv ( ' SQLALCHEMY_DATABASE_URI ' , ' sqlite:////app/instance/data/webpush.db ' )
# This relative path works in docker-compose
# 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')
#db_uri = os.getenv('SQLALCHEMY_DATABASE_URI', 'sqlite:///data/webpush.db')
self . app . config [ ' SQLALCHEMY_DATABASE_URI ' ] = db_uri
self . app . config [ ' SQLALCHEMY_DATABASE_URI ' ] = db_uri
@ -126,7 +134,7 @@ class WebPushService():
@staticmethod
@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 .
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 .
- 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_key ( VAPIDKey ) : The VAPID key model instance containing the private key used for sending the notification .
Returns :
Returns Dict with the following keys
- request . Response https : / / requests . readthedocs . io / en / latest / api . html #requests.Response if the push notification was sent successfully
- 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
or False if there was an exception
Notes :
Notes :
@ -199,43 +209,122 @@ class WebPushService():
while True :
while True :
now = datetime . datetime . now ( ) . isoformat ( )
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
# Creating a context for the application to enable database operations
with self . app . app_context ( ) :
with self . app . app_context ( ) :
# Retrieving all subscription data from the database
# Retrieve the VAPID key from the database
all_subscriptions = Subscription . query . all ( )
# Retrieving the VAPID key from the database
vapid_key = VAPIDKey . query . first ( )
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.
# 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
# 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 "
UPDATE_TITLE = " DAILY_CHECK "
message = { " title " : UPDATE_TITLE , " message " : f " Update for { now } " }
message = { " title " : UPDATE_TITLE , " message " : f " Update for { now } " }
# Sending a push notification to each subscribed client
# Determine the beginning and end time to check for subscriptions
for subscription in all_subscriptions :
settings = Settings . query . first ( )
subscription_info = {
if settings . running_notify_end_time is None :
" endpoint " : subscription . endpoint ,
" keys " : {
# set all subscriptions without a time to the current time
" p256dh " : subscription . p256dh ,
# (these won't be picked-up in the current run, since thie current minute is the end minute)
" auth " : subscription . auth
Subscription . query . filter_by ( notify_time = None ) . update ( { Subscription . notify_time : now . strftime ( ' % H: % M ' ) } )
}
}
"""
result = WebPushService . _send_push_notification ( subscription_info , message , vapid_key )
Storing the HH : MM for the desired notification time isn ' t a bad idea.
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 )
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
Returns :
time . sleep ( 24 * 60 * 60 )
- 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__
# 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 .
Endpoint to regenerate VAPID keys .
@ -248,7 +337,7 @@ class WebPushService():
Header : Authentication : Basic . . .
Header : Authentication : Basic . . .
Returns :
Returns :
- t uple with " success " as True or False , and " message " message string
- T uple with " success " as True or False , and " message " message string
Notes :
Notes :
- If the operation is successful , a JSON response with a success message is returned with a 200 status code .
- 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
@staticmethod
@app . route ( ' /web-push/subscribe ' , methods = [ ' POST ' ] )
@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 .
Endpoint to handle new web push subscription requests .
@ -344,10 +433,21 @@ class WebPushService():
if not vapid_key :
if not vapid_key :
return jsonify ( success = False , message = " No VAPID keys available " ) , 500
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
# 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 ' ] ,
p256dh = content [ ' keys ' ] [ ' p256dh ' ] ,
auth = content [ ' keys ' ] [ ' auth ' ] ,
vapid_key_id = vapid_key . id )
vapid_key_id = vapid_key . id )
# Saving the subscription data to the database
# Saving the subscription data to the database
@ -355,7 +455,8 @@ class WebPushService():
db . session . commit ( )
db . session . commit ( )
# Introducing a delay (ensure that gateway endpoint is available)
# 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
# Constructing the subscription information for the push notification
subscription_info = {
subscription_info = {
@ -373,12 +474,12 @@ class WebPushService():
result = WebPushService . _send_push_notification ( subscription_info , message , vapid_key )
result = WebPushService . _send_push_notification ( subscription_info , message , vapid_key )
# Returning the operation status
# Returning the operation status
return jsonify ( success = result [ " success " ] , message = result [ " message " ] )
return jsonify ( success = result [ " success " ] , message = result [ " message " ] ) , 200
@staticmethod
@staticmethod
@app . route ( ' /web-push/unsubscribe ' , methods = [ ' POST ' ] )
@app . route ( ' /web-push/unsubscribe ' , methods = [ ' POST ' ] )
def unsubscribe ( ) - > Tuple [ str , int ] :
def unsubscribe ( ) - > Tuple [ Response , int ] :
"""
"""
Endpoint to handle web push unsubscription requests .
Endpoint to handle web push unsubscription requests .
@ -409,7 +510,7 @@ class WebPushService():
if subscription :
if subscription :
db . session . delete ( subscription )
db . session . delete ( subscription )
db . session . commit ( )
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
# If the subscription is not found, return an error message
else :
else :
@ -418,7 +519,7 @@ class WebPushService():
@staticmethod
@staticmethod
@app . route ( ' /web-push/send-test ' , methods = [ ' POST ' ] )
@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 .
Endpoint to send a test push notification to a specific client .
@ -473,7 +574,7 @@ class WebPushService():
)
)
print ( f " Test sent: { result [ ' success ' ] } " )
print ( f " Test sent: { result [ ' success ' ] } " )
return jsonify ( success = result [ " success " ] , message = result [ " message " ] )
return jsonify ( success = result [ " success " ] , message = result [ " message " ] ) , 200
else :
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 ) } " )
return jsonify ( { " success " : False , " message " : " Subscription not found " } ) , 404
return jsonify ( { " success " : False , " message " : " Subscription not found " } ) , 404