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
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")
|
|
|