""" 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 Web.modules.database.settings as cfg from Web.modules.database.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}')