Files
changedetection.io/changedetectionio/api/BrowserNotifications.py
T
dgtlmoon cfdb484b36 WIP
2025-08-28 21:44:13 +02:00

288 lines
12 KiB
Python

import json
from flask import request, current_app
from flask_restful import Resource, marshal_with, fields
from changedetectionio.api import validate_openapi_request
from loguru import logger
browser_notifications_fields = {
'success': fields.Boolean,
'message': fields.String,
}
vapid_public_key_fields = {
'publicKey': fields.String,
}
test_notification_fields = {
'success': fields.Boolean,
'message': fields.String,
'sent_count': fields.Integer,
}
class BrowserNotificationsVapidPublicKey(Resource):
"""Get VAPID public key for browser push notifications"""
@marshal_with(vapid_public_key_fields)
def get(self):
try:
from changedetectionio.notification.apprise_plugin.browser_notification_helpers import (
get_vapid_config_from_datastore, convert_pem_public_key_for_browser
)
datastore = current_app.config.get('DATASTORE')
if not datastore:
return {'publicKey': None}, 500
private_key, public_key_pem, contact_email = get_vapid_config_from_datastore(datastore)
if not public_key_pem:
return {'publicKey': None}, 404
# Convert PEM format to URL-safe base64 format for browser
public_key_b64 = convert_pem_public_key_for_browser(public_key_pem)
if public_key_b64:
return {'publicKey': public_key_b64}
else:
return {'publicKey': None}, 500
except Exception as e:
logger.error(f"Failed to get VAPID public key: {e}")
return {'publicKey': None}, 500
class BrowserNotificationsSubscribe(Resource):
"""Subscribe to browser notifications for a keyword"""
@marshal_with(browser_notifications_fields)
def post(self):
try:
data = request.get_json()
if not data:
return {'success': False, 'message': 'No data provided'}, 400
keyword = data.get('keyword', '').strip()
subscription = data.get('subscription')
if not keyword:
return {'success': False, 'message': 'Keyword is required'}, 400
if not subscription:
return {'success': False, 'message': 'Subscription is required'}, 400
# Validate subscription format
required_fields = ['endpoint', 'keys']
for field in required_fields:
if field not in subscription:
return {'success': False, 'message': f'Missing subscription field: {field}'}, 400
if 'p256dh' not in subscription['keys'] or 'auth' not in subscription['keys']:
return {'success': False, 'message': 'Missing subscription keys'}, 400
# Get datastore
datastore = current_app.config.get('DATASTORE')
if not datastore:
return {'success': False, 'message': 'Datastore not available'}, 500
# Initialize browser_subscriptions if it doesn't exist
if 'browser_subscriptions' not in datastore.data:
datastore.data['browser_subscriptions'] = {}
if keyword not in datastore.data['browser_subscriptions']:
datastore.data['browser_subscriptions'][keyword] = []
# Check if subscription already exists
existing_subscriptions = datastore.data['browser_subscriptions'][keyword]
for existing_sub in existing_subscriptions:
if existing_sub.get('endpoint') == subscription.get('endpoint'):
return {'success': True, 'message': f'Already subscribed to {keyword}'}
# Add new subscription
datastore.data['browser_subscriptions'][keyword].append(subscription)
datastore.needs_write = True
logger.info(f"New browser notification subscription for keyword '{keyword}': {subscription.get('endpoint')}")
return {'success': True, 'message': f'Successfully subscribed to {keyword}'}
except Exception as e:
logger.error(f"Failed to subscribe to browser notifications: {e}")
return {'success': False, 'message': f'Subscription failed: {str(e)}'}, 500
class BrowserNotificationsUnsubscribe(Resource):
"""Unsubscribe from browser notifications for a keyword"""
@marshal_with(browser_notifications_fields)
def post(self):
try:
data = request.get_json()
if not data:
return {'success': False, 'message': 'No data provided'}, 400
keyword = data.get('keyword', '').strip()
subscription = data.get('subscription')
if not keyword:
return {'success': False, 'message': 'Keyword is required'}, 400
if not subscription or not subscription.get('endpoint'):
return {'success': False, 'message': 'Valid subscription is required'}, 400
# Get datastore
datastore = current_app.config.get('DATASTORE')
if not datastore:
return {'success': False, 'message': 'Datastore not available'}, 500
# Check if subscriptions exist for this keyword
browser_subscriptions = datastore.data.get('browser_subscriptions', {})
if keyword not in browser_subscriptions:
return {'success': True, 'message': f'No subscriptions found for {keyword}'}
# Remove subscription with matching endpoint
endpoint = subscription.get('endpoint')
subscriptions = browser_subscriptions[keyword]
original_count = len(subscriptions)
browser_subscriptions[keyword] = [
sub for sub in subscriptions
if sub.get('endpoint') != endpoint
]
removed_count = original_count - len(browser_subscriptions[keyword])
if removed_count > 0:
datastore.needs_write = True
logger.info(f"Removed {removed_count} browser notification subscription(s) for keyword '{keyword}'")
return {'success': True, 'message': f'Successfully unsubscribed from {keyword}'}
else:
return {'success': True, 'message': f'No matching subscription found for {keyword}'}
except Exception as e:
logger.error(f"Failed to unsubscribe from browser notifications: {e}")
return {'success': False, 'message': f'Unsubscribe failed: {str(e)}'}, 500
class BrowserNotificationsTest(Resource):
"""Send a test browser notification"""
@marshal_with(test_notification_fields)
def post(self):
try:
data = request.get_json()
if not data:
return {'success': False, 'message': 'No data provided', 'sent_count': 0}, 400
keyword = data.get('keyword', 'default').strip()
title = data.get('title', 'Test Notification')
body = data.get('body', 'This is a test notification from changedetection.io')
# Get datastore
datastore = current_app.config.get('DATASTORE')
if not datastore:
return {'success': False, 'message': 'Datastore not available', 'sent_count': 0}, 500
# Check VAPID configuration
vapid_config = datastore.data.get('settings', {}).get('application', {}).get('vapid', {})
if not vapid_config.get('private_key') or not vapid_config.get('public_key'):
return {'success': False, 'message': 'VAPID keys not configured', 'sent_count': 0}, 500
# Check if there are subscriptions for this keyword
browser_subscriptions = datastore.data.get('browser_subscriptions', {}).get(keyword, [])
if not browser_subscriptions:
return {'success': False, 'message': f'No subscriptions found for keyword: {keyword}', 'sent_count': 0}, 404
# Import and send notifications using the custom handler
try:
from pywebpush import webpush, WebPushException
import time
# Import helper functions
try:
from changedetectionio.notification.apprise_plugin.browser_notification_helpers import create_notification_payload, send_push_notifications
except ImportError:
return {'success': False, 'message': 'Browser notification helpers not available', 'sent_count': 0}, 500
# Prepare notification payload
notification_payload = create_notification_payload(title, body)
private_key = vapid_config.get('private_key')
contact_email = vapid_config.get('contact_email', 'admin@changedetection.io')
# Send notifications using shared helper
success_count, total_count = send_push_notifications(
subscriptions=browser_subscriptions,
notification_payload=notification_payload,
private_key=private_key,
contact_email=contact_email,
keyword=keyword,
datastore=datastore
)
# Update datastore with cleaned subscriptions
datastore.data['browser_subscriptions'][keyword] = browser_subscriptions
if success_count > 0:
return {
'success': True,
'message': f'Test notification sent successfully to {success_count} subscriber(s)',
'sent_count': success_count
}
else:
return {
'success': False,
'message': 'Failed to send test notification to any subscribers',
'sent_count': 0
}, 500
except ImportError:
return {'success': False, 'message': 'pywebpush not available', 'sent_count': 0}, 500
except Exception as e:
logger.error(f"Failed to send test browser notification: {e}")
return {'success': False, 'message': f'Test failed: {str(e)}', 'sent_count': 0}, 500
class BrowserNotificationsSubscriptions(Resource):
"""Get all browser notification subscriptions (for debugging/admin)"""
def get(self):
try:
datastore = current_app.config.get('DATASTORE')
if not datastore:
return {'subscriptions': {}}, 500
browser_subscriptions = datastore.data.get('browser_subscriptions', {})
# Return summary without sensitive data
summary = {}
for keyword, subscriptions in browser_subscriptions.items():
summary[keyword] = {
'count': len(subscriptions),
'endpoints': [sub.get('endpoint', 'unknown')[-50:] for sub in subscriptions] # Last 50 chars
}
return {'subscriptions': summary}
except Exception as e:
logger.error(f"Failed to get browser notification subscriptions: {e}")
return {'subscriptions': {}, 'error': str(e)}, 500
class BrowserNotificationsPendingKeywords(Resource):
"""Get pending keywords from session (after form submission)"""
def get(self):
try:
from flask import session
# Get keywords from session and clear them
keywords = session.pop('browser_notification_keywords', [])
return {'keywords': keywords}
except Exception as e:
logger.error(f"Failed to get pending browser notification keywords: {e}")
return {'keywords': [], 'error': str(e)}, 500