|
|
|
@@ -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:
|
|
|
|
|
|
|
|
|
|
# Sleeping for 24 hours before sending the next set of notifications
|
|
|
|
|
time.sleep(24 * 60 * 60)
|
|
|
|
|
# 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 regenerate_vapid(self) -> Tuple[str, int]:
|
|
|
|
|
def ping(self) -> str:
|
|
|
|
|
"""
|
|
|
|
|
Endpoint to show liveness info
|
|
|
|
|
|
|
|
|
|
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[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
|
|
|
|
|