Browse Source

add a customizable message, plus other fixes

master
Trent Larson 3 months ago
parent
commit
6cc35fbd43
  1. 13
      CHANGELOG.md
  2. 105
      README.md
  3. 171
      app.py
  4. BIN
      data/webpush.db.empty
  5. 5
      models.py
  6. 6
      test-webpush.py

13
CHANGELOG.md

@ -5,7 +5,18 @@ 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.4.0] - 2024-03-31
## [0.5.0] - 2024-11-24
### Added
- `notifyType` when subscribing, including `DIRECT_NOTIFICATION` with a hard-coded message
- `SECONDS_BETWEEN_NOTIFICATIONS` environment variable for length of notification loop
### Changed
- DB changes:
- `alter table subscription add column notify_type varchar(32);`
- `update subscription set notify_type = 'DAILY_CHECK';`
- `alter table subscription add column created_date datetime;`
- `alter table subscription add column message varchar(100);`
## [0.4.0] - 2024-03-31 - 400d9e6eb0e8508efbb4c870a79c0008d15f2200
### Added
- Different times for users to receive notifications
- Ping endpoint

105
README.md

@ -1,5 +1,59 @@
# py-push-server
## Run the server outside Docker
Run the app:
```commandline
sh <(curl https://pkgx.sh) +python.org +virtualenv.pypa.io sh
# first time
python -m venv .
source bin/activate
# first time
pip install -r requirements.txt
cp data/webpush.db.empty data/webpush.db
# or: python init_db.py
# For local 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
```
... and see the results in a browser: http://localhost:3000/web-push/vapid
See Troubleshooting below if that doesn't work out of the box.
Run a test:
```commandline
python test-webpush.py
```
Run haproxy (on a Mac):
* Create "haproxy-config" directory for those files above, eg. /usr/local/etc/haproxy
* Comment out the `log rsyslog` and `bind *:443` lines in /usr/local/etc/haproxy/haproxy.cfg and then run:
`haproxy -f /usr/local/etc/haproxy/haproxy.cfg`
## Docker Build & Deploy
- Update CHANGELOG.md
@ -40,6 +94,7 @@ Finally, after it's started, generate a new VAPID by hitting the `regenerate-vap
## Docker Compose & HAProxy Setup
On a production server for security (eg /web-push/generate_vapid): set an environment variable `ADMIN_PASSWORD` for permissions; one way is to create a .env file with the value inside before running `docker compose` commands:
@ -237,56 +292,6 @@ timesafari-pwa.anomalistlabs.com/web-push/ web_push_backend
## Run the server outside Docker
Run the app:
```commandline
sh <(curl https://pkgx.sh) +python.org +virtualenv.pypa.io sh
# first time
python -m venv .
source bin/activate
# first time
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
```
... and see the results in a browser: http://localhost:3000/web-push/vapid
See Troubleshooting below if that doesn't work out of the box.
Run a test:
```commandline
python webpush.py
```
Run haproxy (on a Mac):
* Create "haproxy-config" directory for those files above, eg. /usr/local/etc/haproxy
* Comment out the `log rsyslog` and `bind *:443` lines in /usr/local/etc/haproxy/haproxy.cfg and then run:
`haproxy -f /usr/local/etc/haproxy/haproxy.cfg`
Troubleshooting
* If you get "no such table: vapid_key" then your file pointers are probably wrong.

171
app.py

@ -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 the 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

BIN
data/webpush.db.empty

Binary file not shown.

5
models.py

@ -16,7 +16,10 @@ class Settings(db.Model):
class Subscription(db.Model):
id = db.Column(db.Integer, primary_key=True)
auth = db.Column(db.String(255), nullable=False)
created_date = db.Column(db.String(29), nullable=True)
endpoint = db.Column(db.String(500), nullable=False)
notify_time = db.Column(db.String(5), nullable=True) # HH:MM
message = db.Column(db.String(100), nullable=True)
notify_time = db.Column(db.String(5), nullable=False) # HH:MM
notify_type = db.Column(db.String(32), nullable=True) # 'DAILY_CHECK', 'DIRECT_NOTIFICATION'
p256dh = db.Column(db.String(255), nullable=False)
vapid_key_id = db.Column(db.Integer, db.ForeignKey('vapid_key.id'), nullable=False)

6
webpush.py → test-webpush.py

@ -25,10 +25,10 @@ import sys
# sVR_s8J4JHv3h4ZmvemL5w
subscription_info = {
"endpoint": "https://fcm.googleapis.com/fcm/send/eqNQV7MVPic:APA91bGrIMxqz3sQ4wboUkmZithJHMAdrNgjm6BYcIGmgJozgEGeg23JsXLlNpnKwzBCmUXh1ciHmE_3wZakHX-Rho5f9Xovc28nun4nH7w4BMoYzX27pOw_pC4FtfAkBQaQ-8jm36jf",
"endpoint": "https://fcm.googleapis.com/fcm/send/drt0Dj1eLFE:APA91bEwLMJ69OKH23-tX183oaH__BwEYPa7woAcI9hMZ0eOc4qmzYD4qsq_dr7L_H4xQhpa79ixFi6ZlfdQQXux_Mx2jKA9kgN9cdGXqcs8ynnzPcUYMRCkTIANw6JBH159DCqu66Lm",
"keys": {
"p256dh": "BDo2fIIN7qoA5bOVXdrHATZUSPHY7030V8PKW1mIHAZHDAxS-p6RggVeI7IZoi3bGxpR713RYY8H8vu-lX5LY1w",
"auth": "sVR_s8J4JHv3h4ZmvemL5w"
"p256dh": "BKAc27ZWWY6-pwbz8BCSxH7DorffZ9lbPdTsbD8vOjNYn37OyBYtPmmNHSddChsVj0py6aPTON4J60e0jLgNIzI",
"auth": "8o5vgTKJsPqb1Ve5GazVuQ"
}
}
Loading…
Cancel
Save