451 lines
14 KiB
Python
451 lines
14 KiB
Python
"""
|
|
Push Notification Management System
|
|
Handles Web Push notifications for users via Service Workers
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import datetime
|
|
from bson import ObjectId
|
|
import requests
|
|
import hashlib
|
|
import logging
|
|
|
|
import settings as cfg
|
|
from settings import MongoClient
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# VAPID keys for push notifications
|
|
VAPID_PUBLIC_KEY = os.getenv('VAPID_PUBLIC_KEY', '')
|
|
VAPID_PRIVATE_KEY = os.getenv('VAPID_PRIVATE_KEY', '')
|
|
VAPID_SUBJECT = os.getenv('VAPID_SUBJECT', f'mailto:admin@{os.getenv("SERVER_NAME", "localhost")}')
|
|
|
|
VAPID_PRIVATE_PEM = os.path.join(os.path.dirname(__file__), 'vapid_private.pem')
|
|
VAPID_PUBLIC_PEM = os.path.join(os.path.dirname(__file__), 'vapid_public.pem')
|
|
|
|
# Auto-generate VAPID keys if none are provided
|
|
if not VAPID_PUBLIC_KEY or not VAPID_PRIVATE_KEY:
|
|
try:
|
|
from py_vapid import Vapid, b64urlencode
|
|
from cryptography.hazmat.primitives import serialization
|
|
|
|
vapid = Vapid()
|
|
if not os.path.exists(VAPID_PRIVATE_PEM):
|
|
vapid.generate_keys()
|
|
vapid.save_key(VAPID_PRIVATE_PEM)
|
|
vapid.save_public_key(VAPID_PUBLIC_PEM)
|
|
logger.info("Auto-generated new VAPID keys")
|
|
else:
|
|
vapid = Vapid.from_file(VAPID_PRIVATE_PEM)
|
|
|
|
raw_pub = vapid.public_key.public_bytes(
|
|
serialization.Encoding.X962,
|
|
serialization.PublicFormat.UncompressedPoint
|
|
)
|
|
|
|
VAPID_PUBLIC_KEY = b64urlencode(raw_pub)
|
|
VAPID_PRIVATE_KEY = VAPID_PRIVATE_PEM
|
|
except Exception as e:
|
|
logger.error(f'Could not load or generate VAPID keys: {e}')
|
|
|
|
# Push service endpoint (typically Firebase or Web Push Service)
|
|
PUSH_SERVICE_URL = 'https://fcm.googleapis.com/fcm/send' # Firebase Cloud Messaging
|
|
FCM_API_KEY = os.getenv('FCM_API_KEY', '') # Firebase API key
|
|
|
|
|
|
def get_push_subscriptions_collection(db=None):
|
|
"""Get MongoDB push subscriptions collection"""
|
|
if db is None:
|
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
|
db = client[cfg.MONGODB_DB]
|
|
return db['push_subscriptions']
|
|
|
|
|
|
def get_user_subscriptions(username):
|
|
"""
|
|
Get all active push subscriptions for a user
|
|
|
|
Args:
|
|
username (str): Username
|
|
|
|
Returns:
|
|
list: List of subscription documents
|
|
"""
|
|
try:
|
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
|
db = client[cfg.MONGODB_DB]
|
|
subs_col = get_push_subscriptions_collection(db)
|
|
|
|
subscriptions = list(subs_col.find({
|
|
'Username': username,
|
|
'IsActive': True
|
|
}))
|
|
|
|
client.close()
|
|
return subscriptions
|
|
except Exception as e:
|
|
logger.error(f'Error getting push subscriptions for {username}: {e}')
|
|
return []
|
|
|
|
|
|
def save_push_subscription(username, subscription_obj):
|
|
"""
|
|
Save a new push subscription for a user
|
|
|
|
Args:
|
|
username (str): Username
|
|
subscription_obj (dict): Subscription object from Service Worker
|
|
{
|
|
'endpoint': 'https://...',
|
|
'keys': {
|
|
'p256dh': '...',
|
|
'auth': '...'
|
|
}
|
|
}
|
|
|
|
Returns:
|
|
bool: Success status
|
|
"""
|
|
try:
|
|
if not subscription_obj.get('endpoint'):
|
|
logger.warning(f'Invalid subscription object for {username}')
|
|
return False
|
|
|
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
|
db = client[cfg.MONGODB_DB]
|
|
subs_col = get_push_subscriptions_collection(db)
|
|
|
|
# Create unique hash of subscription to avoid duplicates
|
|
sub_hash = hashlib.md5(
|
|
f"{username}:{subscription_obj['endpoint']}".encode()
|
|
).hexdigest()
|
|
|
|
# Check if subscription already exists
|
|
existing = subs_col.find_one({
|
|
'Username': username,
|
|
'SubscriptionHash': sub_hash
|
|
})
|
|
|
|
if existing:
|
|
# Update last used time
|
|
subs_col.update_one(
|
|
{'_id': existing['_id']},
|
|
{'$set': {
|
|
'LastUsed': datetime.datetime.now(),
|
|
'IsActive': True
|
|
}}
|
|
)
|
|
logger.info(f'Updated existing subscription for {username}')
|
|
client.close()
|
|
return True
|
|
|
|
# Save new subscription
|
|
subscription_doc = {
|
|
'Username': username,
|
|
'Endpoint': subscription_obj['endpoint'],
|
|
'Keys': subscription_obj.get('keys', {}),
|
|
'SubscriptionHash': sub_hash,
|
|
'IsActive': True,
|
|
'CreatedAt': datetime.datetime.now(),
|
|
'LastUsed': datetime.datetime.now(),
|
|
'UserAgent': subscription_obj.get('userAgent', ''),
|
|
}
|
|
|
|
subs_col.insert_one(subscription_doc)
|
|
logger.info(f'Saved new push subscription for {username}')
|
|
client.close()
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f'Error saving push subscription for {username}: {e}')
|
|
return False
|
|
|
|
|
|
def remove_push_subscription(username, endpoint):
|
|
"""
|
|
Remove a push subscription
|
|
|
|
Args:
|
|
username (str): Username
|
|
endpoint (str): Subscription endpoint URL
|
|
|
|
Returns:
|
|
bool: Success status
|
|
"""
|
|
try:
|
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
|
db = client[cfg.MONGODB_DB]
|
|
subs_col = get_push_subscriptions_collection(db)
|
|
|
|
result = subs_col.update_one(
|
|
{
|
|
'Username': username,
|
|
'Endpoint': endpoint
|
|
},
|
|
{'$set': {'IsActive': False}}
|
|
)
|
|
|
|
client.close()
|
|
return result.modified_count > 0
|
|
|
|
except Exception as e:
|
|
logger.error(f'Error removing push subscription for {username}: {e}')
|
|
return False
|
|
|
|
|
|
def send_push_notification(username, title, body, icon=None, url='/', reference=None, tag='notification'):
|
|
"""
|
|
Send a push notification to all user's subscriptions
|
|
|
|
Args:
|
|
username (str): Target username
|
|
title (str): Notification title
|
|
body (str): Notification body
|
|
icon (str, optional): Icon URL
|
|
url (str, optional): URL to open on click
|
|
reference (dict, optional): Reference data (item_id, etc)
|
|
tag (str, optional): Notification tag for grouping
|
|
|
|
Returns:
|
|
int: Number of successfully sent notifications
|
|
"""
|
|
try:
|
|
subscriptions = get_user_subscriptions(username)
|
|
|
|
if not subscriptions:
|
|
logger.debug(f'No active push subscriptions for {username}')
|
|
return 0
|
|
|
|
sent_count = 0
|
|
|
|
for subscription in subscriptions:
|
|
success = _send_to_subscription(
|
|
subscription,
|
|
title,
|
|
body,
|
|
icon,
|
|
url,
|
|
reference,
|
|
tag
|
|
)
|
|
if success:
|
|
sent_count += 1
|
|
else:
|
|
# Mark subscription as inactive if send fails
|
|
_mark_subscription_inactive(subscription['_id'])
|
|
|
|
logger.info(f'Sent push notification to {username}: {sent_count}/{len(subscriptions)} subscriptions')
|
|
return sent_count
|
|
|
|
except Exception as e:
|
|
logger.error(f'Error sending push notification to {username}: {e}')
|
|
return 0
|
|
|
|
|
|
def _send_to_subscription(subscription, title, body, icon, url, reference, tag):
|
|
"""Send push notification to a specific subscription"""
|
|
try:
|
|
payload = {
|
|
'title': title,
|
|
'body': body,
|
|
'icon': icon or '/static/img/logo-192x192.png',
|
|
'badge': '/static/img/badge-72x72.png',
|
|
'tag': tag,
|
|
'url': url,
|
|
'reference': reference or {},
|
|
}
|
|
|
|
# If using Firebase Cloud Messaging
|
|
if FCM_API_KEY and subscription.get('Endpoint', '').startswith('https://fcm.'):
|
|
return _send_fcm_notification(subscription, payload)
|
|
|
|
# Otherwise use standard Web Push Protocol
|
|
return _send_web_push_notification(subscription, payload)
|
|
|
|
except Exception as e:
|
|
logger.error(f'Error sending to subscription {subscription.get("_id")}: {e}')
|
|
return False
|
|
|
|
|
|
def _send_fcm_notification(subscription, payload):
|
|
"""Send notification via Firebase Cloud Messaging"""
|
|
try:
|
|
if not FCM_API_KEY:
|
|
logger.warning('FCM_API_KEY not configured')
|
|
return False
|
|
|
|
fcm_payload = {
|
|
'to': subscription['Endpoint'],
|
|
'notification': {
|
|
'title': payload['title'],
|
|
'body': payload['body'],
|
|
'icon': payload['icon'],
|
|
'badge': payload['badge'],
|
|
'tag': payload['tag'],
|
|
'click_action': payload['url'],
|
|
},
|
|
'data': {
|
|
'url': payload['url'],
|
|
'type': payload.get('type', 'info'),
|
|
}
|
|
}
|
|
|
|
headers = {
|
|
'Authorization': f'key={FCM_API_KEY}',
|
|
'Content-Type': 'application/json'
|
|
}
|
|
|
|
response = requests.post(
|
|
'https://fcm.googleapis.com/fcm/send',
|
|
json=fcm_payload,
|
|
headers=headers,
|
|
timeout=10
|
|
)
|
|
|
|
return response.status_code == 200
|
|
|
|
except Exception as e:
|
|
logger.error(f'FCM notification error: {e}')
|
|
return False
|
|
|
|
|
|
def _send_web_push_notification(subscription, payload):
|
|
"""Send notification using standard Web Push Protocol"""
|
|
try:
|
|
# This requires pywebpush library
|
|
from pywebpush import webpush
|
|
|
|
webpush(
|
|
subscription_info={
|
|
'endpoint': subscription['Endpoint'],
|
|
'keys': subscription.get('Keys', {})
|
|
},
|
|
data=json.dumps(payload),
|
|
vapid_private_key=VAPID_PRIVATE_KEY,
|
|
vapid_claims={'sub': VAPID_SUBJECT},
|
|
timeout=10,
|
|
ttl=3600 # Notification expires after 1 hour if device is offline
|
|
)
|
|
|
|
return True
|
|
|
|
except ImportError:
|
|
logger.warning('pywebpush not installed, install with: pip install pywebpush')
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f'Web push error: {e}')
|
|
return False
|
|
|
|
|
|
def _mark_subscription_inactive(subscription_id):
|
|
"""Mark a subscription as inactive (e.g., after failed send)"""
|
|
try:
|
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
|
db = client[cfg.MONGODB_DB]
|
|
subs_col = get_push_subscriptions_collection(db)
|
|
|
|
subs_col.update_one(
|
|
{'_id': ObjectId(subscription_id)},
|
|
{'$set': {'IsActive': False}}
|
|
)
|
|
|
|
client.close()
|
|
except Exception as e:
|
|
logger.error(f'Error marking subscription inactive: {e}')
|
|
|
|
|
|
def send_push_to_all_admins(title, body, icon=None, url='/', reference=None):
|
|
"""
|
|
Send a push notification to all admin users
|
|
|
|
Args:
|
|
title (str): Notification title
|
|
body (str): Notification body
|
|
icon (str, optional): Icon URL
|
|
url (str, optional): URL to open on click
|
|
reference (dict, optional): Reference data
|
|
|
|
Returns:
|
|
int: Total notifications sent
|
|
"""
|
|
try:
|
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
|
db = client[cfg.MONGODB_DB]
|
|
users_col = db['users']
|
|
|
|
# Get all admin users
|
|
admin_users = list(users_col.find(
|
|
{'Admin': True},
|
|
{'Username': 1}
|
|
))
|
|
|
|
client.close()
|
|
|
|
total_sent = 0
|
|
for admin_doc in admin_users:
|
|
sent = send_push_notification(
|
|
admin_doc['Username'],
|
|
title,
|
|
body,
|
|
icon,
|
|
url,
|
|
reference,
|
|
tag='admin-notification'
|
|
)
|
|
total_sent += sent
|
|
|
|
logger.info(f'Sent push to all admins: {total_sent} notifications')
|
|
return total_sent
|
|
|
|
except Exception as e:
|
|
logger.error(f'Error sending push to admins: {e}')
|
|
return 0
|
|
|
|
|
|
def cleanup_inactive_subscriptions():
|
|
"""
|
|
Remove inactive subscriptions older than 30 days
|
|
Run this periodically as a maintenance task
|
|
"""
|
|
try:
|
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
|
db = client[cfg.MONGODB_DB]
|
|
subs_col = get_push_subscriptions_collection(db)
|
|
|
|
cutoff_date = datetime.datetime.now() - datetime.timedelta(days=30)
|
|
|
|
result = subs_col.delete_many({
|
|
'IsActive': False,
|
|
'LastUsed': {'$lt': cutoff_date}
|
|
})
|
|
|
|
client.close()
|
|
logger.info(f'Cleaned up {result.deleted_count} inactive subscriptions')
|
|
return result.deleted_count
|
|
|
|
except Exception as e:
|
|
logger.error(f'Error cleaning up subscriptions: {e}')
|
|
return 0
|
|
|
|
|
|
# Database collection schema
|
|
def ensure_push_subscriptions_collection():
|
|
"""Ensure the push_subscriptions collection exists with proper indexes"""
|
|
try:
|
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
|
db = client[cfg.MONGODB_DB]
|
|
subs_col = get_push_subscriptions_collection(db)
|
|
|
|
# Create indexes
|
|
subs_col.create_index('Username')
|
|
subs_col.create_index([('Username', 1), ('IsActive', 1)])
|
|
subs_col.create_index([('CreatedAt', 1)]) # TTL-like usage
|
|
subs_col.create_index('SubscriptionHash', unique=True)
|
|
|
|
logger.info('Push subscriptions collection indexes created')
|
|
client.close()
|
|
|
|
except Exception as e:
|
|
logger.error(f'Error ensuring push subscriptions collection: {e}')
|