@ -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 t he 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