You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

1034 lines
43 KiB

"""
Web Push Notification Service
This module provides a Flask-based web service for managing and sending web push notifications
to subscribed clients. It implements the Web Push Protocol with VAPID authentication.
Key Features:
- VAPID key management for secure push notifications
- Subscription management (subscribe/unsubscribe)
- Scheduled daily notifications
- Test notification endpoints
- Support for direct and check-type notifications
Environment Variables:
ADMIN_PASSWORD: Password for admin endpoints (default: 'admin')
PUSH_SERVER_VERSION: Version identifier for the push server
SECONDS_BETWEEN_NOTIFICATIONS: Interval between notification checks (default: 300)
SQLALCHEMY_DATABASE_URI: SQLite database path (format: "sqlite:////path/to/db")
Technical Details:
- Uses Flask for web framework
- SQLAlchemy for database operations
- pywebpush for Web Push Protocol implementation
- Threading for background notification processing
- Cryptography for VAPID key generation
Security Notes:
- Implements VAPID authentication
- Admin endpoints require basic auth
- Database connections use SQLite with configurable path
- Message size limits enforced
Author: Matthew Raymer
Version: 1.0.0
"""
import structlog
logger = structlog.get_logger()
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
import json
import os
import threading
import time
import sqlalchemy.exc
import cryptography.exceptions
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__)
class WebPushService():
"""
Web Push Notification Service Manager
This class manages web push notification services including VAPID key management,
subscription handling, and notification delivery. It integrates with Flask to provide
HTTP endpoints and maintains a background thread for scheduled notifications.
Key Responsibilities:
- VAPID key management and generation
- Push notification delivery
- Subscription management
- Daily notification scheduling
- Health check endpoints
Attributes:
latest_subscription_run (str): ISO formatted timestamp of last notification run
app (Flask): Flask application instance
daily_notification_thread (threading.Thread): Background thread for notifications
Public Endpoints:
- /web-push/regenerate-vapid (POST): Regenerate VAPID keys (admin auth required)
- /web-push/ping (GET): Service health check
- /web-push/vapid (GET): Retrieve current VAPID public key
- /web-push/subscribe (POST): Register new push subscription
- /web-push/unsubscribe (POST): Remove push subscription
- /web-push/send-test (POST): Send test notification
Database Models Used:
- VAPIDKey: Stores VAPID key pairs
- Settings: Stores service configuration
- Subscription: Stores push notification subscriptions
Security Features:
- Admin authentication for sensitive endpoints
- VAPID authentication for push messages
- Input validation
- Subscription verification
- Error handling for expired subscriptions
Threading:
Runs a background thread that:
- Checks for pending notifications every SECONDS_BETWEEN_NOTIFICATIONS
- Processes notifications based on configured notification times
- Handles subscription cleanup for expired endpoints
Error Handling:
- Catches and logs push notification failures
- Handles expired subscriptions
- Manages database transaction errors
- Provides detailed error responses
Dependencies:
- Flask for web framework
- SQLAlchemy for database operations
- pywebpush for Web Push Protocol
- cryptography for key generation
Example Usage:
app = Flask(__name__)
web_push_service = WebPushService(app, "production")
# Service automatically starts background thread and initializes endpoints
Author: Matthew Raymer
Version: 1.0.0
"""
latest_subscription_run = None
def __init__(self, app, config_name: str) -> None:
"""
Initialize the Web Push Service with Flask app and configuration.
Args:
app (Flask): Flask application instance to attach service endpoints
config_name (str): Configuration profile name (e.g., "production", "development")
Initializes:
- Database connection
- VAPID keys if not present
- Background notification thread
- Service endpoints
- Configuration settings
Raises:
RuntimeError: If database initialization fails
Exception: If VAPID key generation fails
"""
# 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, 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 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
db.init_app(self.app)
# Creating a context for the application and initializing services
with self.app.app_context():
self._initialize()
# Creating and starting a thread to send daily notifications
self.daily_notification_thread = threading.Thread(
target=self._send_daily_notifications)
self.daily_notification_thread.start()
def _generate_and_save_vapid_keys(self) -> None:
"""
Generate and store new VAPID keys for push notification authentication.
This method handles the complete lifecycle of VAPID key generation:
1. Generates a new ECDSA private key using SECP256R1 curve
2. Derives the corresponding public key
3. Serializes both keys to base64 format
4. Stores the key pair in the database
Technical Details:
- Uses SECP256R1 elliptic curve for key generation
- Public key format: X9.62 uncompressed point encoding
- Private key format: PKCS#8 DER encoding
- Both keys are base64 encoded for storage
- No encryption is used for private key storage (consider as enhancement)
Database Impact:
- Creates new record in VAPIDKey table
- Previous keys should be deleted before calling this method
Security Considerations:
- Private key is stored unencrypted
- Key generation uses cryptographically secure random number generator
- Uses standard cryptographic primitives from cryptography library
Raises:
Exception: If key generation, serialization, or database operations fail
Error details are logged before re-raising
Note:
This is an internal method used during initialization and key rotation.
For key rotation, ensure all existing subscriptions are updated or
removed before rotating keys.
"""
try:
# Generating a private key using the elliptic curve SECP256R1
private_key = ec.generate_private_key(
ec.SECP256R1(), default_backend())
# Serializing and encoding the public key to base64 format
public_key_bytes = private_key.public_key().public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint
)
public_key_base64 = base64.b64encode(public_key_bytes).decode()
# Serializing and encoding the private key to base64 format
private_key_bytes = private_key.private_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
)
private_key_base64 = base64.b64encode(private_key_bytes).decode()
# Saving the keys to the database
key = VAPIDKey(public_key=public_key_base64,
private_key=private_key_base64)
db.session.add(key)
db.session.commit()
except Exception as e:
logger.error("error_generating_vapid_keys", error=str(e))
raise e
def _initialize(self) -> None:
"""
Initialize the WebPushService by ensuring VAPID keys exist.
This method is called during service startup to ensure the necessary VAPID
(Voluntary Application Server Identification) infrastructure is in place.
Workflow:
1. Checks for existing VAPID keys in database
2. If no keys exist, generates and stores new key pair
3. Maintains exactly one active VAPID key pair at all times
Database Operations:
- Reads from VAPIDKey table
- May write to VAPIDKey table if no keys exist
- Uses SQLAlchemy session for transaction management
Dependencies:
- Requires active database connection
- Must be called within Flask application context
- Depends on _generate_and_save_vapid_keys for key generation
Security Considerations:
- Ensures VAPID authentication is always available
- Maintains cryptographic identity of the service
- Critical for secure push notification delivery
Error Handling:
- Database errors are propagated to caller
- Key generation errors are propagated to caller
Note:
This is an internal method called automatically during service initialization.
Manual calls are not typically necessary unless recovering from a failure state.
"""
# Checking if there are any VAPID keys in the database
if not VAPIDKey.query.first():
# Generating and saving VAPID keys if none are found
self._generate_and_save_vapid_keys()
@staticmethod
def _send_push_notification(subscription_info: Dict, message: Dict, vapid_private_key: str) -> Dict[str, any]:
"""
Send a web push notification to a subscribed client.
This method handles the delivery of push notifications using the Web Push Protocol,
including VAPID authentication and error handling.
Args:
subscription_info (Dict): Push subscription information containing:
- endpoint: URL for the push service
- keys: Dict containing:
- p256dh: Client's public key
- auth: Client's auth secret
message (Dict): Notification payload containing:
- title: Notification title (required)
- message: Notification body (optional)
vapid_private_key (str): Base64-encoded private key for VAPID authentication
Returns:
Dict[str, any]: Result containing:
- success (bool): True if notification was sent (status code 201)
- message (str): Response text or error message
- result (Response|bool): Response object if successful, False if failed
- unsubscribed (bool): Optional, True if subscription was removed
- error (Exception): Optional, present if an error occurred
Technical Details:
- Uses pywebpush library for Web Push Protocol implementation
- Implements mandatory 1-second delay after sending
- Automatically handles subscription cleanup for 410 Gone responses
- Uses VAPID for push service authentication
Error Handling:
- Catches and processes WebPushException
- Logs errors with ISO formatted timestamps
- Handles unsubscribe (410 Gone) responses
- Removes invalid subscriptions from database
Database Impact:
- May delete from Subscription table on 410 Gone responses
- Uses SQLAlchemy session for transaction management
Security Considerations:
- Requires valid VAPID authentication
- Handles secure key transmission
- Implements proper subscription cleanup
Note:
The 1-second delay after sending is a workaround for reliability.
Consider monitoring and adjusting this delay based on service requirements.
"""
try:
result = webpush(
subscription_info=subscription_info,
data=json.dumps(message),
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
time.sleep(1)
return {"success": result.status_code == 201, "message": result.text, "result": result}
except WebPushException as ex:
now = datetime.datetime.now().isoformat()
endpoint = subscription_info['endpoint']
logger.error("push_notification_failed",
endpoint=endpoint,
error=str(ex),
timestamp=now)
unsubscribed_msg = '410 Gone'
unsubscribed = False
if unsubscribed_msg in ex.args[0]:
subscription = Subscription.query.filter_by(
endpoint=endpoint).first()
if subscription:
db.session.delete(subscription)
db.session.commit()
logger.info("subscription_deleted",
endpoint=endpoint,
reason="410_gone")
unsubscribed = True
else:
logger.warning("subscription_not_found",
endpoint=endpoint)
else:
logger.error("push_notification_error",
endpoint=endpoint,
error=ex.args[0])
return {"success": False, "message": str(ex), "error": ex, "unsubscribed": unsubscribed}
def _send_daily_notifications(self) -> None:
"""
Process and send scheduled notifications to all eligible subscribers.
This method runs in a continuous loop as a background thread, processing
notifications based on subscribers' configured notification times.
Implementation Notes:
The notification time logic stores HH:MM for desired notification times.
While direct time comparison is complex, this approach was chosen for simplicity.
A potential improvement would be to store next notification time per record
and update via SQL calculations, trading more DB updates for clearer logic.
Workflow:
1. Runs in infinite loop with configurable sleep interval
2. For each iteration:
- Checks if notification process is already running
- Determines time window for notifications
- Processes subscriptions within that window
- Updates notification tracking in settings
Time Window Logic:
- If previous notification was before today:
Start: Max(previous_end_time, yesterday_at_current_time)
End: Beginning of today (00:00)
- Otherwise:
Start: Previous notification end time
End: Current time
Database Operations:
- Reads from Settings table for notification tracking
- Updates Settings.running_notify_end_time during processing
- Updates Settings.prev_notify_end_time after completion
- Reads from Subscription table for eligible notifications
- May delete from Subscription table on failed deliveries
Notification Types:
- TITLE_DIRECT_NOTIFICATION: Shows message directly
- TITLE_DAILY_INDIVIDUAL_CHECK: Triggers client API check
Default message provided for DIRECT_NOTIFICATION if none specified
Error Handling:
- Logs all notification failures
- Handles subscription cleanup for failed deliveries
- Maintains notification tracking even on failures
- Prevents concurrent notification processing
Thread Safety:
- Uses Settings.running_notify_end_time as process lock
- Updates notification timestamps atomically
- Handles concurrent access to subscription data
Performance Considerations:
- Processes notifications in batches by time window
- Implements configurable delay between iterations
- Logs performance metrics for monitoring
Dependencies:
- Requires Flask application context
- Needs active database connection
- Uses WebPushService._send_push_notification for delivery
Configuration:
- Sleep interval: SECONDS_BETWEEN_NOTIFICATIONS environment variable
- Default message: Hardcoded for DIRECT_NOTIFICATION type
Note:
This method runs indefinitely in a background thread.
Termination occurs only when the application shuts down.
"""
while True:
now = datetime.datetime.now()
logger.info("starting_subscription_check",
timestamp=now.isoformat())
# Creating a context for the application to enable database operations
with self.app.app_context():
# Retrieve the VAPID key from the database
vapid_key = VAPIDKey.query.first()
# Determine the beginning and end time to check for subscriptions
settings = Settings.query.first()
if settings.running_notify_end_time is None:
# only do this if we're not already inside one of these loops
# 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
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,
# 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()
# 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
# 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
}
}
# 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)
logger.info("subscription_notification_result",
subscription_id=subscription.id,
success=result['success'],
message=result['message'])
settings.prev_notify_end_time = end_time.isoformat()
settings.running_notify_end_time = None
db.session.commit()
logger.info("notification_batch_complete",
count=num_subscriptions,
timestamp=now.isoformat())
else:
logger.error("failed_to_update_running_notify_end_time",
timestamp=now.isoformat())
else:
logger.info("subscription_check_skipped",
reason="already_running",
timestamp=now.isoformat())
self.latest_subscription_run = now.isoformat()
# Sleep before repeating
time.sleep(SECONDS_BETWEEN_NOTIFICATIONS)
# This is an endpoint, routed in __init__
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]:
"""
Regenerate VAPID keys for push notification authentication.
This endpoint provides secure rotation of VAPID keys used for push notification
authentication. It requires admin authentication to prevent unauthorized key rotation.
Endpoint Details:
URL: /web-push/regenerate-vapid
Method: POST
Auth Required: Basic Authentication
username: admin
password: ADMIN_PASSWORD environment variable (default: 'admin')
Authentication:
- Uses HTTP Basic Authentication
- Credentials must match configured admin values
- Returns 401 with WWW-Authenticate header on auth failure
Process Flow:
1. Validates admin credentials
2. Deletes existing VAPID keys from database
3. Generates and stores new key pair
4. Returns success/failure response
Database Impact:
- Deletes all records from VAPIDKey table
- Creates new record in VAPIDKey table
- Uses transaction to ensure atomic updates
Security Considerations:
- Requires admin authentication
- Invalidates all existing VAPID keys
- Should be used cautiously as it affects all subscriptions
- Consider notifying subscribers before rotation
Returns:
Union[
Tuple[Response, int, dict[str, str]], # Auth failure case
Tuple[Response, int] # Success/other failure cases
]:
- Success: ({"success": True, "message": str}, 200)
- Auth Failure: ({"error": str}, 401, {"WWW-Authenticate": str})
- Other Failure: ({"success": False, "message": str}, 500)
Example Usage:
curl -X POST -H "Authorization: Basic YWRtaW46YWRtaW4=" localhost:3000/web-push/regenerate-vapid
Note:
Key rotation may impact existing subscriptions. Consider implementing
a migration strategy for existing subscriptions when rotating keys.
"""
envPassword = os.getenv('ADMIN_PASSWORD', 'admin')
auth = request.authorization
if (auth is None
or auth.username is None
or auth.username != 'admin'
or auth.password is None
or auth.password != envPassword):
return (
jsonify(error='Wrong password'),
401,
{'WWW-Authenticate': 'Basic realm="Login Required"'}
)
# Creating a context for the application to enable database operations
try:
with self.app.app_context():
VAPIDKey.query.delete()
db.session.commit()
self._generate_and_save_vapid_keys()
return jsonify(success=True, message="VAPID keys regenerated successfully"), 200
except (sqlalchemy.exc.SQLAlchemyError, cryptography.exceptions.InvalidKey) as e:
return jsonify(success=False, message=f'Error regenerating VAPID keys: {str(e)}'), 500
@staticmethod
@app.route('/web-push/vapid')
def vapid() -> Response:
"""
Retrieve the current VAPID public key for push notification authentication.
This endpoint provides the public VAPID key needed by web clients to set up
push notification subscriptions. The key is used as part of the Web Push Protocol
to authenticate notification requests.
Endpoint Details:
URL: /web-push/vapid
Method: GET
Auth Required: None
Returns:
Response: JSON object containing:
vapidKey (str): Base64-encoded VAPID public key
Example Response:
{
"vapidKey": "BDd3_hVL9fZi9Ybo2UUzA284..."
}
Error Handling:
- Returns 500 if no VAPID key is found in database
- Database errors propagate to global error handler
Security Considerations:
- Public key is safe to expose
- No authentication required as this is public information
- Key rotation handled by separate admin endpoint
Usage Example:
fetch('/web-push/vapid')
.then(response => response.json())
.then(data => subscribeUserToPush(data.vapidKey));
"""
# Retrieving the VAPID key from the database
key = VAPIDKey.query.first()
# Returning the public key in a JSON response
return jsonify(vapidKey=key.public_key)
@staticmethod
@app.route('/web-push/subscribe', methods=['POST'])
def subscribe() -> Tuple[Response, int]:
"""
Register a new web push notification subscription.
This endpoint stores subscription details and sends a confirmation notification
to verify the subscription is working correctly.
Endpoint Details:
URL: /web-push/subscribe
Method: POST
Auth Required: None
Request Body:
{
"endpoint": str, # Push service URL for the subscription
"keys": {
"p256dh": str, # Client's public key
"auth": str # Client's auth secret
},
"notifyTime": {
"utcHour": int, # Hour in UTC (0-23)
"minute": int # Optional, defaults to 0 (0-59)
},
"notifyType": str, # Optional, defaults to DAILY_CHECK
# Valid values: DAILY_CHECK, DIRECT_NOTIFICATION
"message": str # Optional, max 100 chars, used with DIRECT_NOTIFICATION
}
Returns:
Tuple[Response, int]: JSON response and HTTP status
Success: ({"success": true, "message": str}, 200)
Error: ({"success": false, "message": str}, 400|500)
Error Cases:
- 400: Missing/invalid request parameters
- 400: Message exceeds 100 characters
- 400: Invalid notifyType
- 500: VAPID key not available
Security Considerations:
- Validates all input parameters
- Enforces message length limits
- Stores subscription with creation timestamp
- Associates subscription with current VAPID key
Example Usage:
fetch('/web-push/subscribe', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
endpoint: pushSubscription.endpoint,
keys: pushSubscription.keys,
notifyTime: {utcHour: 14, minute: 30},
notifyType: "DAILY_CHECK"
})
});
"""
# 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()
# Checking if the VAPID key is available
if not vapid_key:
return jsonify(success=False, message="No VAPID keys available"), 500
# Constructing the notify_time string
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:
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)
# Saving the subscription data to the database
db.session.add(subscription)
db.session.commit()
# Constructing the subscription information for the push notification
subscription_info = {
"endpoint": subscription.endpoint,
"keys": {
"p256dh": subscription.p256dh,
"auth": subscription.auth
}
}
# Creating a confirmation message for the push notification
message = {"title": "Subscription Successful",
"message": "Thank you for subscribing!"}
# Sending the confirmation push notification
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
@staticmethod
@app.route('/web-push/unsubscribe', methods=['POST'])
def unsubscribe() -> Tuple[Response, int]:
"""
Remove a web push notification subscription.
This endpoint removes subscription(s) for a given endpoint, optionally filtered
by notification type.
Endpoint Details:
URL: /web-push/unsubscribe
Method: POST
Auth Required: None
Request Body:
{
"endpoint": str, # Required: Push service URL to unsubscribe
"notifyType": str # Optional: If provided, only removes subscriptions
# of this type (DAILY_CHECK or DIRECT_NOTIFICATION)
}
Returns:
Tuple[Response, int]: JSON response and HTTP status
Success: ({"success": true, "message": str}, 200)
Error: ({"success": false, "message": str}, 400)
Error Cases:
- 400: Missing endpoint parameter
Behavior:
- If notifyType is provided: Removes only subscriptions matching both
endpoint and notifyType
- If notifyType is omitted: Removes all subscriptions for the endpoint
Example Usage:
# Remove specific notification type
fetch('/web-push/unsubscribe', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
endpoint: subscription.endpoint,
notifyType: "DAILY_CHECK"
})
});
# Remove all notifications
fetch('/web-push/unsubscribe', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
endpoint: subscription.endpoint
})
});
"""
# Retrieving the endpoint from the incoming request
content = request.json
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
if 'notifyType' in content:
notify_type = content['notifyType']
logger.info("unsubscribe_request",
endpoint=endpoint,
notify_type=notify_type)
db.session.query(
Subscription).filter(
and_(Subscription.endpoint == endpoint,
Subscription.notify_type == notify_type)
).delete(synchronize_session=False)
db.session.commit()
else:
logger.info("unsubscribe_request",
endpoint=endpoint,
notify_type="all")
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
@app.route('/web-push/send-test', methods=['POST'])
def send_test() -> Tuple[Response, int]:
"""
Send a test push notification to a specific client.
This endpoint verifies push notification functionality by sending a test message
to a specified subscription.
Endpoint Details:
URL: /web-push/send-test
Method: POST
Auth Required: None
Request Body:
{
"endpoint": str, # Required: Push service URL
"keys": {
"p256dh": str, # Required: Client's public key
"auth": str # Required: Client's auth secret
},
"title": str, # Optional: Custom notification title
# (defaults to "Test Notification")
"message": str # Optional: Custom notification message
# (defaults to "This is a test notification.")
}
Returns:
Tuple[Response, int]: JSON response and HTTP status
Success: ({"success": true, "message": str}, 200)
Error: ({"success": false, "message": str}, 400|404)
Error Cases:
- 400: Missing required subscription parameters
- 404: Subscription not found in database
Example Usage:
fetch('/web-push/send-test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
endpoint: subscription.endpoint,
keys: subscription.keys,
title: "Custom Test",
message: "Hello, World!"
})
});
Note:
This endpoint is useful for:
- Verifying subscription setup
- Testing notification delivery
- Debugging client-side notification handling
"""
# Retrieving the subscription information from the incoming request
content = request.json
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()
# If the subscription is found, call the _send_push_notification method
if subscription:
subscription_info = {
"endpoint": endpoint,
"keys": {
"p256dh": p256dh,
"auth": auth
}
}
title = "Test Notification"
if "title" in content:
title = content['title']
message = "This is a test notification."
if "message" in content:
message = content['message']
vapid_key = VAPIDKey.query.filter_by(
id=subscription.vapid_key_id).first()
result = WebPushService._send_push_notification(
subscription_info,
{"title": title, "message": message},
vapid_key.private_key
)
logger.info("test_notification_sent",
success=result["success"],
message=result["message"])
return jsonify(success=result["success"], message=result["message"]), 200
else:
logger.error("test_notification_failed",
error="subscription_not_found",
request=content)
return jsonify({"success": False, "message": "Subscription not found"}), 404
web_push_service = WebPushService(app, "app")