diff --git a/.github/workflows/test-stack-reusable-workflow.yml b/.github/workflows/test-stack-reusable-workflow.yml index 57dd02fe1fd..039f2f107a0 100644 --- a/.github/workflows/test-stack-reusable-workflow.yml +++ b/.github/workflows/test-stack-reusable-workflow.yml @@ -71,6 +71,7 @@ jobs: docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_watch_model' docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_jinja2_security' docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_semver' + docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_browser_notifications' - name: Test built container with Pytest (generally as requests/plaintext fetching) run: | diff --git a/changedetectionio/blueprint/browser_notifications/__init__.py b/changedetectionio/blueprint/browser_notifications/__init__.py new file mode 100644 index 00000000000..29606b619f0 --- /dev/null +++ b/changedetectionio/blueprint/browser_notifications/__init__.py @@ -0,0 +1 @@ +# Browser notifications blueprint \ No newline at end of file diff --git a/changedetectionio/blueprint/browser_notifications/browser_notifications.py b/changedetectionio/blueprint/browser_notifications/browser_notifications.py new file mode 100644 index 00000000000..2f30abe0b59 --- /dev/null +++ b/changedetectionio/blueprint/browser_notifications/browser_notifications.py @@ -0,0 +1,76 @@ +from flask import Blueprint, jsonify, request +from loguru import logger + + +def construct_blueprint(datastore): + browser_notifications_blueprint = Blueprint('browser_notifications', __name__) + + @browser_notifications_blueprint.route("/test", methods=['POST']) + def test_browser_notification(): + """Send a test browser notification using the apprise handler""" + try: + from changedetectionio.notification.apprise_plugin.custom_handlers import apprise_browser_notification_handler + + # Check if there are any subscriptions + browser_subscriptions = datastore.data.get('settings', {}).get('application', {}).get('browser_subscriptions', []) + if not browser_subscriptions: + return jsonify({'success': False, 'message': 'No browser subscriptions found'}), 404 + + # Get notification data from request or use defaults + data = request.get_json() or {} + title = data.get('title', 'Test Notification') + body = data.get('body', 'This is a test notification from changedetection.io') + + # Use the apprise handler directly + success = apprise_browser_notification_handler( + body=body, + title=title, + notify_type='info', + meta={'url': 'browser://test'} + ) + + if success: + subscription_count = len(browser_subscriptions) + return jsonify({ + 'success': True, + 'message': f'Test notification sent successfully to {subscription_count} subscriber(s)' + }) + else: + return jsonify({'success': False, 'message': 'Failed to send test notification'}), 500 + + except ImportError: + logger.error("Browser notification handler not available") + return jsonify({'success': False, 'message': 'Browser notification handler not available'}), 500 + except Exception as e: + logger.error(f"Failed to send test browser notification: {e}") + return jsonify({'success': False, 'message': f'Error: {str(e)}'}), 500 + + @browser_notifications_blueprint.route("/clear", methods=['POST']) + def clear_all_browser_notifications(): + """Clear all browser notification subscriptions from the datastore""" + try: + # Get current subscription count + browser_subscriptions = datastore.data.get('settings', {}).get('application', {}).get('browser_subscriptions', []) + subscription_count = len(browser_subscriptions) + + # Clear all subscriptions + if 'settings' not in datastore.data: + datastore.data['settings'] = {} + if 'application' not in datastore.data['settings']: + datastore.data['settings']['application'] = {} + + datastore.data['settings']['application']['browser_subscriptions'] = [] + datastore.needs_write = True + + logger.info(f"Cleared {subscription_count} browser notification subscriptions") + + return jsonify({ + 'success': True, + 'message': f'Cleared {subscription_count} browser notification subscription(s)' + }) + + except Exception as e: + logger.error(f"Failed to clear all browser notifications: {e}") + return jsonify({'success': False, 'message': f'Clear all failed: {str(e)}'}), 500 + + return browser_notifications_blueprint \ No newline at end of file diff --git a/changedetectionio/flask_app.py b/changedetectionio/flask_app.py index 6e52d4ed919..921b597e115 100644 --- a/changedetectionio/flask_app.py +++ b/changedetectionio/flask_app.py @@ -39,6 +39,11 @@ from changedetectionio import __version__ from changedetectionio import queuedWatchMetaData from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, CreateWatch, Import, SystemInfo, Tag, Tags, Notifications, WatchFavicon +from changedetectionio.notification.BrowserNotifications import ( + BrowserNotificationsVapidPublicKey, + BrowserNotificationsSubscribe, + BrowserNotificationsUnsubscribe +) from changedetectionio.api.Search import Search from .time_handler import is_within_schedule @@ -94,6 +99,7 @@ logger.warning(f"Unable to set locale {default_locale}, locale is not installed maybe?") watch_api = Api(app, decorators=[csrf.exempt]) +browser_notification_api = Api(app, decorators=[csrf.exempt]) def init_app_secret(datastore_path): secret = "" @@ -336,6 +342,11 @@ def check_authentication(): watch_api.add_resource(Notifications, '/api/v1/notifications', resource_class_kwargs={'datastore': datastore}) + + # Browser notification endpoints + browser_notification_api.add_resource(BrowserNotificationsVapidPublicKey, '/browser-notifications-api/vapid-public-key') + browser_notification_api.add_resource(BrowserNotificationsSubscribe, '/browser-notifications-api/subscribe') + browser_notification_api.add_resource(BrowserNotificationsUnsubscribe, '/browser-notifications-api/unsubscribe') @login_manager.user_loader def user_loader(email): @@ -489,10 +500,29 @@ def static_content(group, filename): except FileNotFoundError: abort(404) + @app.route("/service-worker.js", methods=['GET']) + def service_worker(): + from flask import make_response + try: + # Serve from the changedetectionio/static/js directory + static_js_path = os.path.join(os.path.dirname(__file__), 'static', 'js') + response = make_response(send_from_directory(static_js_path, "service-worker.js")) + response.headers['Content-Type'] = 'application/javascript' + response.headers['Service-Worker-Allowed'] = '/' + response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' + response.headers['Pragma'] = 'no-cache' + response.headers['Expires'] = '0' + return response + except FileNotFoundError: + abort(404) + import changedetectionio.blueprint.browser_steps as browser_steps app.register_blueprint(browser_steps.construct_blueprint(datastore), url_prefix='/browser-steps') + import changedetectionio.blueprint.browser_notifications.browser_notifications as browser_notifications + app.register_blueprint(browser_notifications.construct_blueprint(datastore), url_prefix='/browser-notifications') + from changedetectionio.blueprint.imports import construct_blueprint as construct_import_blueprint app.register_blueprint(construct_import_blueprint(datastore, update_q, queuedWatchMetaData), url_prefix='/imports') diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py index 1630907e817..10ce05928e5 100644 --- a/changedetectionio/forms.py +++ b/changedetectionio/forms.py @@ -707,6 +707,7 @@ def __init__(self, formdata=None, obj=None, prefix="", data=None, meta=None, **k processor = RadioField( label=u"Processor - What do you want to achieve?", choices=processors.available_processors(), default="text_json_diff") timezone = StringField("Timezone for watch schedule", render_kw={"list": "timezones"}, validators=[validateTimeZoneName()]) webdriver_delay = IntegerField('Wait seconds before extracting text', validators=[validators.Optional(), validators.NumberRange(min=1, message="Should contain one or more seconds")]) + class importForm(Form): diff --git a/changedetectionio/model/App.py b/changedetectionio/model/App.py index b87e0482271..ed256455590 100644 --- a/changedetectionio/model/App.py +++ b/changedetectionio/model/App.py @@ -66,6 +66,11 @@ class model(dict): 'socket_io_enabled': True, 'favicons_enabled': True }, + 'vapid': { + 'private_key': None, + 'public_key': None, + 'contact_email': None + }, } } } diff --git a/changedetectionio/notification/BrowserNotifications.py b/changedetectionio/notification/BrowserNotifications.py new file mode 100644 index 00000000000..a71882af4b2 --- /dev/null +++ b/changedetectionio/notification/BrowserNotifications.py @@ -0,0 +1,217 @@ +import json +from flask import request, current_app +from flask_restful import Resource, marshal_with, fields +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""" + + @marshal_with(browser_notifications_fields) + def post(self): + try: + data = request.get_json() + if not data: + return {'success': False, 'message': 'No data provided'}, 400 + + subscription = data.get('subscription') + + 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['settings']['application']: + datastore.data['settings']['application']['browser_subscriptions'] = [] + + # Check if subscription already exists + existing_subscriptions = datastore.data['settings']['application']['browser_subscriptions'] + for existing_sub in existing_subscriptions: + if existing_sub.get('endpoint') == subscription.get('endpoint'): + return {'success': True, 'message': 'Already subscribed to browser notifications'} + + # Add new subscription + datastore.data['settings']['application']['browser_subscriptions'].append(subscription) + datastore.needs_write = True + + logger.info(f"New browser notification subscription: {subscription.get('endpoint')}") + + return {'success': True, 'message': 'Successfully subscribed to browser notifications'} + + 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""" + + @marshal_with(browser_notifications_fields) + def post(self): + try: + data = request.get_json() + if not data: + return {'success': False, 'message': 'No data provided'}, 400 + + subscription = data.get('subscription') + + 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 + browser_subscriptions = datastore.data.get('settings', {}).get('application', {}).get('browser_subscriptions', []) + if not browser_subscriptions: + return {'success': True, 'message': 'No subscriptions found'} + + # Remove subscription with matching endpoint + endpoint = subscription.get('endpoint') + original_count = len(browser_subscriptions) + + datastore.data['settings']['application']['browser_subscriptions'] = [ + sub for sub in browser_subscriptions + if sub.get('endpoint') != endpoint + ] + + removed_count = original_count - len(datastore.data['settings']['application']['browser_subscriptions']) + + if removed_count > 0: + datastore.needs_write = True + logger.info(f"Removed {removed_count} browser notification subscription(s)") + return {'success': True, 'message': 'Successfully unsubscribed from browser notifications'} + else: + return {'success': True, 'message': 'No matching subscription found'} + + 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 + + title = data.get('title', 'Test Notification') + body = data.get('body', 'This is a test notification from changedetection.io') + + # Get datastore to check if subscriptions exist + datastore = current_app.config.get('DATASTORE') + if not datastore: + return {'success': False, 'message': 'Datastore not available', 'sent_count': 0}, 500 + + # Check if there are subscriptions before attempting to send + browser_subscriptions = datastore.data.get('settings', {}).get('application', {}).get('browser_subscriptions', []) + if not browser_subscriptions: + return {'success': False, 'message': 'No subscriptions found', 'sent_count': 0}, 404 + + # Use the apprise handler directly + try: + from changedetectionio.notification.apprise_plugin.custom_handlers import apprise_browser_notification_handler + + # Call the apprise handler with test data + success = apprise_browser_notification_handler( + body=body, + title=title, + notify_type='info', + meta={'url': 'browser://test'} + ) + + # Count how many subscriptions we have after sending (some may have been removed if invalid) + final_subscriptions = datastore.data.get('settings', {}).get('application', {}).get('browser_subscriptions', []) + sent_count = len(browser_subscriptions) # Original count + + if success: + return { + 'success': True, + 'message': f'Test notification sent successfully to {sent_count} subscriber(s)', + 'sent_count': sent_count + } + else: + return { + 'success': False, + 'message': 'Failed to send test notification', + 'sent_count': 0 + }, 500 + + except ImportError: + return {'success': False, 'message': 'Browser notification handler 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 + + + + diff --git a/changedetectionio/notification/apprise_plugin/browser_notification_helpers.py b/changedetectionio/notification/apprise_plugin/browser_notification_helpers.py new file mode 100644 index 00000000000..85a97af5501 --- /dev/null +++ b/changedetectionio/notification/apprise_plugin/browser_notification_helpers.py @@ -0,0 +1,273 @@ +""" +Browser notification helpers for Web Push API +Shared utility functions for VAPID key handling and notification sending +""" + +import json +import re +import time +from loguru import logger + + +def convert_pem_private_key_for_pywebpush(private_key): + """ + Convert PEM private key to the format that pywebpush expects + + Args: + private_key: PEM private key string or already converted key + + Returns: + Vapid instance for pywebpush (avoids PEM parsing compatibility issues) + """ + try: + from py_vapid import Vapid + import tempfile + import os + + # If we get a string, assume it's PEM and create a Vapid instance from it + if isinstance(private_key, str) and private_key.startswith('-----BEGIN'): + # Write PEM to temporary file and load with Vapid.from_file + with tempfile.NamedTemporaryFile(mode='w', suffix='.pem', delete=False) as tmp_file: + tmp_file.write(private_key) + tmp_file.flush() + temp_path = tmp_file.name + + try: + # Load using Vapid.from_file - this is more compatible with pywebpush + vapid_instance = Vapid.from_file(temp_path) + os.unlink(temp_path) # Clean up + logger.debug("Successfully created Vapid instance from PEM") + return vapid_instance + except Exception as e: + os.unlink(temp_path) # Clean up even on error + logger.error(f"Failed to create Vapid instance from PEM: {e}") + # Fall back to returning the original PEM string + return private_key + else: + # Return as-is if not a PEM string + return private_key + + except Exception as e: + logger.error(f"Failed to convert private key: {e}") + return private_key + + +def convert_pem_public_key_for_browser(public_key_pem): + """ + Convert PEM public key to URL-safe base64 format for browser applicationServerKey + + Args: + public_key_pem: PEM public key string + + Returns: + URL-safe base64 encoded public key without padding + """ + try: + from cryptography.hazmat.primitives import serialization + import base64 + + # Parse PEM directly using cryptography library + pem_bytes = public_key_pem.encode() if isinstance(public_key_pem, str) else public_key_pem + + # Load the public key from PEM + public_key_crypto = serialization.load_pem_public_key(pem_bytes) + + # Get the raw public key bytes in uncompressed format (what browsers expect) + public_key_raw = public_key_crypto.public_bytes( + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.UncompressedPoint + ) + + # Convert to URL-safe base64 (remove padding) + public_key_b64 = base64.urlsafe_b64encode(public_key_raw).decode('ascii').rstrip('=') + + return public_key_b64 + + except Exception as e: + logger.error(f"Failed to convert public key format: {e}") + return None + + +def send_push_notifications(subscriptions, notification_payload, private_key, contact_email, datastore): + """ + Send push notifications to a list of subscriptions + + Args: + subscriptions: List of push subscriptions + notification_payload: Dict with notification data (title, body, etc.) + private_key: VAPID private key (will be converted if needed) + contact_email: Contact email for VAPID claims + datastore: Datastore object for updating subscriptions + + Returns: + Tuple of (success_count, total_count) + """ + try: + from pywebpush import webpush, WebPushException + except ImportError: + logger.error("pywebpush not available - cannot send browser notifications") + return 0, len(subscriptions) + + # Convert private key to format pywebpush expects + private_key_for_push = convert_pem_private_key_for_pywebpush(private_key) + + success_count = 0 + total_count = len(subscriptions) + + # Send to all subscriptions + for subscription in subscriptions[:]: # Copy list to avoid modification issues + try: + webpush( + subscription_info=subscription, + data=json.dumps(notification_payload), + vapid_private_key=private_key_for_push, + vapid_claims={ + "sub": f"mailto:{contact_email}", + "aud": f"https://{subscription['endpoint'].split('/')[2]}" + } + ) + success_count += 1 + + except WebPushException as e: + logger.warning(f"Failed to send browser notification to subscription: {e}") + # Remove invalid subscriptions (410 = Gone, 404 = Not Found) + if e.response and e.response.status_code in [404, 410]: + logger.info("Removing invalid browser notification subscription") + try: + subscriptions.remove(subscription) + datastore.needs_write = True + except ValueError: + pass # Already removed + + except Exception as e: + logger.error(f"Unexpected error sending browser notification: {e}") + + return success_count, total_count + + +def create_notification_payload(title, body, icon_path=None): + """ + Create a standard notification payload + + Args: + title: Notification title + body: Notification body + icon_path: Optional icon path (defaults to favicon) + + Returns: + Dict with notification payload + """ + return { + 'title': title, + 'body': body, + 'icon': icon_path or '/static/favicons/favicon-32x32.png', + 'badge': '/static/favicons/favicon-32x32.png', + 'timestamp': int(time.time() * 1000), + } + + +def get_vapid_config_from_datastore(datastore): + """ + Get VAPID configuration from datastore with proper error handling + + Args: + datastore: Datastore object + + Returns: + Tuple of (private_key, public_key, contact_email) or (None, None, None) if error + """ + try: + if not datastore: + return None, None, None + + vapid_config = datastore.data.get('settings', {}).get('application', {}).get('vapid', {}) + private_key = vapid_config.get('private_key') + public_key = vapid_config.get('public_key') + contact_email = vapid_config.get('contact_email', 'citizen@example.com') + + return private_key, public_key, contact_email + + except Exception as e: + logger.error(f"Failed to get VAPID config from datastore: {e}") + return None, None, None + + + +def get_browser_subscriptions(datastore): + """ + Get browser subscriptions from datastore + + Args: + datastore: Datastore object + + Returns: + List of subscriptions + """ + try: + if not datastore: + return [] + + return datastore.data.get('settings', {}).get('application', {}).get('browser_subscriptions', []) + + except Exception as e: + logger.error(f"Failed to get browser subscriptions: {e}") + return [] + + +def save_browser_subscriptions(datastore, subscriptions): + """ + Save browser subscriptions to datastore + + Args: + datastore: Datastore object + subscriptions: List of subscriptions to save + """ + try: + if not datastore: + return + + # Ensure the settings structure exists + if 'settings' not in datastore.data: + datastore.data['settings'] = {} + if 'application' not in datastore.data['settings']: + datastore.data['settings']['application'] = {} + + datastore.data['settings']['application']['browser_subscriptions'] = subscriptions + datastore.needs_write = True + + except Exception as e: + logger.error(f"Failed to save browser subscriptions: {e}") + + + + +def create_error_response(message, sent_count=0, status_code=500): + """ + Create standardized error response for API endpoints + + Args: + message: Error message + sent_count: Number of notifications sent (for test endpoints) + status_code: HTTP status code + + Returns: + Tuple of (response_dict, status_code) + """ + return {'success': False, 'message': message, 'sent_count': sent_count}, status_code + + +def create_success_response(message, sent_count=None): + """ + Create standardized success response for API endpoints + + Args: + message: Success message + sent_count: Number of notifications sent (optional) + + Returns: + Response dict + """ + response = {'success': True, 'message': message} + if sent_count is not None: + response['sent_count'] = sent_count + return response \ No newline at end of file diff --git a/changedetectionio/notification/apprise_plugin/custom_handlers.py b/changedetectionio/notification/apprise_plugin/custom_handlers.py index 1fd28b41616..344d6540a8d 100644 --- a/changedetectionio/notification/apprise_plugin/custom_handlers.py +++ b/changedetectionio/notification/apprise_plugin/custom_handlers.py @@ -1,5 +1,6 @@ import json import re +import time from urllib.parse import unquote_plus import requests @@ -110,3 +111,80 @@ def apprise_http_custom_handler( except Exception as e: logger.error(f"Unexpected error occurred while sending custom notification to {url}: {e}") return False + + +@notify(on="browser") +def apprise_browser_notification_handler( + body: str, + title: str, + notify_type: str, + meta: dict, + *args, + **kwargs, +) -> bool: + """ + Browser push notification handler for browser:// URLs + Ignores anything after browser:// and uses single default channel + """ + try: + from pywebpush import webpush, WebPushException + from flask import current_app + + # Get VAPID keys from app settings + try: + datastore = current_app.config.get('DATASTORE') + if not datastore: + logger.error("No datastore available for browser notifications") + return False + + vapid_config = datastore.data.get('settings', {}).get('application', {}).get('vapid', {}) + private_key = vapid_config.get('private_key') + public_key = vapid_config.get('public_key') + contact_email = vapid_config.get('contact_email', 'admin@changedetection.io') + + if not private_key or not public_key: + logger.error("VAPID keys not configured for browser notifications") + return False + + except Exception as e: + logger.error(f"Failed to get VAPID configuration: {e}") + return False + + # Get subscriptions from datastore + browser_subscriptions = datastore.data.get('settings', {}).get('application', {}).get('browser_subscriptions', []) + + if not browser_subscriptions: + logger.info("No browser subscriptions found") + return True # Not an error - just no subscribers + + # Import helper functions + try: + from .browser_notification_helpers import create_notification_payload, send_push_notifications + except ImportError: + logger.error("Browser notification helpers not available") + return False + + # Prepare notification payload + notification_payload = create_notification_payload(title, body) + + # 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, + datastore=datastore + ) + + # Update datastore with cleaned subscriptions + datastore.data['settings']['application']['browser_subscriptions'] = browser_subscriptions + + logger.info(f"Sent browser notifications: {success_count}/{total_count} successful") + return success_count > 0 + + except ImportError: + logger.error("pywebpush not available - cannot send browser notifications") + return False + except Exception as e: + logger.error(f"Unexpected error in browser notification handler: {e}") + return False diff --git a/changedetectionio/notification/handler.py b/changedetectionio/notification/handler.py index c1b3c125320..76b9f80009d 100644 --- a/changedetectionio/notification/handler.py +++ b/changedetectionio/notification/handler.py @@ -8,7 +8,7 @@ def process_notification(n_object, datastore): from changedetectionio.safe_jinja import render as jinja_render from . import default_notification_format_for_watch, default_notification_format, valid_notification_formats # be sure its registered - from .apprise_plugin.custom_handlers import apprise_http_custom_handler + from .apprise_plugin.custom_handlers import apprise_http_custom_handler, apprise_browser_notification_handler now = time.time() if n_object.get('notification_timestamp'): diff --git a/changedetectionio/static/favicons/site.webmanifest b/changedetectionio/static/favicons/site.webmanifest index 6d25c20e0fb..09ccf452d8f 100644 --- a/changedetectionio/static/favicons/site.webmanifest +++ b/changedetectionio/static/favicons/site.webmanifest @@ -1,6 +1,6 @@ { - "name": "", - "short_name": "", + "name": "changedetection.io", + "short_name": "changedetection", "icons": [ { "src": "android-chrome-192x192.png", @@ -15,5 +15,8 @@ ], "theme_color": "#ffffff", "background_color": "#ffffff", - "display": "standalone" + "display": "standalone", + "start_url": "/", + "scope": "/", + "gcm_sender_id": "103953800507" } diff --git a/changedetectionio/static/js/browser-notifications.js b/changedetectionio/static/js/browser-notifications.js new file mode 100644 index 00000000000..2f01a2bcbf8 --- /dev/null +++ b/changedetectionio/static/js/browser-notifications.js @@ -0,0 +1,450 @@ +/** + * changedetection.io Browser Push Notifications + * Handles service worker registration, push subscription management, and notification permissions + */ + +class BrowserNotifications { + constructor() { + this.serviceWorkerRegistration = null; + this.vapidPublicKey = null; + this.isSubscribed = false; + this.init(); + } + + async init() { + if (!this.isSupported()) { + console.warn('Push notifications are not supported in this browser'); + return; + } + + try { + // Get VAPID public key from server + await this.fetchVapidPublicKey(); + + // Register service worker + await this.registerServiceWorker(); + + // Check existing subscription state + await this.checkExistingSubscription(); + + // Initialize UI elements + this.initializeUI(); + + // Set up notification URL monitoring + this.setupNotificationUrlMonitoring(); + + } catch (error) { + console.error('Failed to initialize browser notifications:', error); + } + } + + isSupported() { + return 'serviceWorker' in navigator && + 'PushManager' in window && + 'Notification' in window; + } + + async fetchVapidPublicKey() { + try { + const response = await fetch('/browser-notifications-api/vapid-public-key'); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const data = await response.json(); + this.vapidPublicKey = data.publicKey; + } catch (error) { + console.error('Failed to fetch VAPID public key:', error); + throw error; + } + } + + async registerServiceWorker() { + try { + this.serviceWorkerRegistration = await navigator.serviceWorker.register('/service-worker.js', { + scope: '/' + }); + + console.log('Service Worker registered successfully'); + + // Wait for service worker to be ready + await navigator.serviceWorker.ready; + + } catch (error) { + console.error('Service Worker registration failed:', error); + throw error; + } + } + + initializeUI() { + // Bind event handlers to existing elements in the template + this.bindEventHandlers(); + + // Update UI based on current permission state + this.updatePermissionStatus(); + } + + bindEventHandlers() { + const enableBtn = document.querySelector('#enable-notifications-btn'); + const testBtn = document.querySelector('#test-notification-btn'); + + if (enableBtn) { + enableBtn.addEventListener('click', () => this.requestNotificationPermission()); + } + + if (testBtn) { + testBtn.addEventListener('click', () => this.sendTestNotification()); + } + } + + setupNotificationUrlMonitoring() { + // Monitor the notification URLs textarea for browser:// URLs + const notificationUrlsField = document.querySelector('textarea[name*="notification_urls"]'); + if (notificationUrlsField) { + const checkForBrowserUrls = async () => { + const urls = notificationUrlsField.value || ''; + const hasBrowserUrls = /browser:\/\//.test(urls); + + // If browser URLs are detected and we're not subscribed, auto-subscribe + if (hasBrowserUrls && !this.isSubscribed && Notification.permission === 'default') { + const shouldSubscribe = confirm('Browser notifications detected! Would you like to enable browser notifications now?'); + if (shouldSubscribe) { + await this.requestNotificationPermission(); + } + } else if (hasBrowserUrls && !this.isSubscribed && Notification.permission === 'granted') { + // Permission already granted but not subscribed - auto-subscribe silently + console.log('Auto-subscribing to browser notifications...'); + await this.subscribe(); + } + }; + + // Check immediately + checkForBrowserUrls(); + + // Check on input changes + notificationUrlsField.addEventListener('input', checkForBrowserUrls); + } + } + + async updatePermissionStatus() { + const statusElement = document.querySelector('#permission-status'); + const enableBtn = document.querySelector('#enable-notifications-btn'); + const testBtn = document.querySelector('#test-notification-btn'); + + if (!statusElement) return; + + const permission = Notification.permission; + statusElement.textContent = permission; + statusElement.className = `permission-${permission}`; + + // Show/hide controls based on permission + if (permission === 'default') { + if (enableBtn) enableBtn.style.display = 'inline-block'; + if (testBtn) testBtn.style.display = 'none'; + } else if (permission === 'granted') { + if (enableBtn) enableBtn.style.display = 'none'; + if (testBtn) testBtn.style.display = 'inline-block'; + } else { // denied + if (enableBtn) enableBtn.style.display = 'none'; + if (testBtn) testBtn.style.display = 'none'; + } + } + + async requestNotificationPermission() { + try { + const permission = await Notification.requestPermission(); + this.updatePermissionStatus(); + + if (permission === 'granted') { + console.log('Notification permission granted'); + // Automatically subscribe to browser notifications + this.subscribe(); + } else { + console.log('Notification permission denied'); + } + } catch (error) { + console.error('Error requesting notification permission:', error); + } + } + + async subscribe() { + if (Notification.permission !== 'granted') { + alert('Please enable notifications first'); + return; + } + + if (this.isSubscribed) { + console.log('Already subscribed to browser notifications'); + return; + } + + try { + // First, try to clear any existing subscription with different keys + await this.clearExistingSubscription(); + + // Create push subscription + const subscription = await this.serviceWorkerRegistration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey) + }); + + // Send subscription to server + const response = await fetch('/browser-notifications-api/subscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': document.querySelector('input[name=csrf_token]')?.value + }, + body: JSON.stringify({ + subscription: subscription.toJSON() + }) + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + // Store subscription status + this.isSubscribed = true; + + console.log('Successfully subscribed to browser notifications'); + + } catch (error) { + console.error('Failed to subscribe to browser notifications:', error); + + // Show user-friendly error message + if (error.message.includes('different applicationServerKey')) { + this.showSubscriptionConflictDialog(error); + } else { + alert(`Failed to subscribe: ${error.message}`); + } + } + } + + async unsubscribe() { + try { + if (!this.isSubscribed) return; + + // Get current subscription + const subscription = await this.serviceWorkerRegistration.pushManager.getSubscription(); + if (!subscription) { + this.isSubscribed = false; + return; + } + + // Unsubscribe from server + const response = await fetch('/browser-notifications-api/unsubscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': document.querySelector('input[name=csrf_token]')?.value + }, + body: JSON.stringify({ + subscription: subscription.toJSON() + }) + }); + + if (!response.ok) { + console.warn(`Server unsubscribe failed: ${response.status}`); + } + + // Unsubscribe locally + await subscription.unsubscribe(); + + // Update status + this.isSubscribed = false; + + console.log('Unsubscribed from browser notifications'); + + } catch (error) { + console.error('Failed to unsubscribe from browser notifications:', error); + } + } + + async sendTestNotification() { + try { + // First, check if we're subscribed + if (!this.isSubscribed) { + const shouldSubscribe = confirm('You need to subscribe to browser notifications first. Subscribe now?'); + if (shouldSubscribe) { + await this.subscribe(); + // Give a moment for subscription to complete + await new Promise(resolve => setTimeout(resolve, 1000)); + } else { + return; + } + } + + const response = await fetch('/browser-notifications/test', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': document.querySelector('input[name=csrf_token]')?.value + } + }); + + if (!response.ok) { + if (response.status === 404) { + // No subscriptions found on server - try subscribing + alert('No browser subscriptions found. Subscribing now...'); + await this.subscribe(); + return; + } + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const result = await response.json(); + alert(result.message); + console.log('Test notification result:', result); + } catch (error) { + console.error('Failed to send test notification:', error); + alert(`Failed to send test notification: ${error.message}`); + } + } + + + + + urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; + } + + async checkExistingSubscription() { + /** + * Check if we already have a valid browser subscription + * Updates this.isSubscribed based on actual browser state + */ + try { + if (!this.serviceWorkerRegistration) { + this.isSubscribed = false; + return; + } + + const existingSubscription = await this.serviceWorkerRegistration.pushManager.getSubscription(); + + if (existingSubscription) { + // We have a subscription - verify it's still valid and matches our VAPID key + const subscriptionJson = existingSubscription.toJSON(); + + // Check if the endpoint is still active (basic validation) + if (subscriptionJson.endpoint && subscriptionJson.keys) { + console.log('Found existing valid subscription'); + this.isSubscribed = true; + } else { + console.log('Found invalid subscription, clearing...'); + await existingSubscription.unsubscribe(); + this.isSubscribed = false; + } + } else { + console.log('No existing subscription found'); + this.isSubscribed = false; + } + } catch (error) { + console.warn('Failed to check existing subscription:', error); + this.isSubscribed = false; + } + } + + async clearExistingSubscription() { + /** + * Clear any existing push subscription that might conflict with our VAPID keys + */ + try { + const existingSubscription = await this.serviceWorkerRegistration.pushManager.getSubscription(); + + if (existingSubscription) { + console.log('Found existing subscription, unsubscribing...'); + await existingSubscription.unsubscribe(); + console.log('Successfully cleared existing subscription'); + } + } catch (error) { + console.warn('Failed to clear existing subscription:', error); + // Don't throw - this is just cleanup + } + } + + showSubscriptionConflictDialog(error) { + /** + * Show user-friendly dialog for subscription conflicts + */ + const message = `Browser notifications are already set up for a different changedetection.io instance or with different settings. + +To fix this: +1. Clear your existing subscription +2. Try subscribing again + +Would you like to automatically clear the old subscription and retry?`; + + if (confirm(message)) { + this.clearExistingSubscription().then(() => { + // Retry subscription after clearing + setTimeout(() => { + this.subscribe(); + }, 500); + }); + } else { + alert('To use browser notifications, please manually clear your browser notifications for this site in browser settings, then try again.'); + } + } + + async clearAllNotifications() { + /** + * Clear all browser notification subscriptions (admin function) + */ + try { + // Call the server to clear ALL subscriptions from datastore + const response = await fetch('/browser-notifications/clear', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': document.querySelector('input[name=csrf_token]')?.value + } + }); + + if (response.ok) { + const result = await response.json(); + console.log('Server response:', result.message); + + // Also clear the current browser's subscription if it exists + const existingSubscription = await this.serviceWorkerRegistration.pushManager.getSubscription(); + if (existingSubscription) { + await existingSubscription.unsubscribe(); + console.log('Cleared current browser subscription'); + } + + // Update status + this.isSubscribed = false; + + alert(result.message + '. All browser notifications have been cleared.'); + } else { + const error = await response.json(); + console.error('Server clear failed:', error.message); + alert('Failed to clear server subscriptions: ' + error.message); + } + + } catch (error) { + console.error('Failed to clear all notifications:', error); + alert('Failed to clear notifications: ' + error.message); + } + } + +} + +// Initialize when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + window.browserNotifications = new BrowserNotifications(); + }); +} else { + window.browserNotifications = new BrowserNotifications(); +} \ No newline at end of file diff --git a/changedetectionio/static/js/service-worker.js b/changedetectionio/static/js/service-worker.js new file mode 100644 index 00000000000..eeeec087751 --- /dev/null +++ b/changedetectionio/static/js/service-worker.js @@ -0,0 +1,95 @@ +// changedetection.io Service Worker for Browser Push Notifications + +self.addEventListener('install', function(event) { + console.log('Service Worker installing'); + self.skipWaiting(); +}); + +self.addEventListener('activate', function(event) { + console.log('Service Worker activating'); + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('push', function(event) { + console.log('Push message received', event); + + let notificationData = { + title: 'changedetection.io', + body: 'A watched page has changed', + icon: '/static/favicons/favicon-32x32.png', + badge: '/static/favicons/favicon-32x32.png', + tag: 'changedetection-notification', + requireInteraction: false, + timestamp: Date.now() + }; + + // Parse push data if available + if (event.data) { + try { + const pushData = event.data.json(); + notificationData = { + ...notificationData, + ...pushData + }; + } catch (e) { + console.warn('Failed to parse push data:', e); + notificationData.body = event.data.text() || notificationData.body; + } + } + + const promiseChain = self.registration.showNotification( + notificationData.title, + { + body: notificationData.body, + icon: notificationData.icon, + badge: notificationData.badge, + tag: notificationData.tag, + requireInteraction: notificationData.requireInteraction, + timestamp: notificationData.timestamp, + data: { + url: notificationData.url || '/', + timestamp: notificationData.timestamp + } + } + ); + + event.waitUntil(promiseChain); +}); + +self.addEventListener('notificationclick', function(event) { + console.log('Notification clicked', event); + + event.notification.close(); + + const targetUrl = event.notification.data?.url || '/'; + + event.waitUntil( + clients.matchAll().then(function(clientList) { + // Check if there's already a window/tab open with our app + for (let i = 0; i < clientList.length; i++) { + const client = clientList[i]; + if (client.url.includes(self.location.origin) && 'focus' in client) { + client.navigate(targetUrl); + return client.focus(); + } + } + // If no existing window, open a new one + if (clients.openWindow) { + return clients.openWindow(targetUrl); + } + }) + ); +}); + +self.addEventListener('notificationclose', function(event) { + console.log('Notification closed', event); +}); + +// Handle messages from the main thread +self.addEventListener('message', function(event) { + console.log('Service Worker received message:', event.data); + + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); \ No newline at end of file diff --git a/changedetectionio/store.py b/changedetectionio/store.py index 88e369e2c0a..f600077644c 100644 --- a/changedetectionio/store.py +++ b/changedetectionio/store.py @@ -140,6 +140,28 @@ def __init__(self, datastore_path="/datastore", include_default_watches=True, ve secret = secrets.token_hex(16) self.__data['settings']['application']['api_access_token'] = secret + # Generate VAPID keys for browser push notifications + if not self.__data['settings']['application']['vapid'].get('private_key'): + try: + from py_vapid import Vapid + vapid = Vapid() + vapid.generate_keys() + # Convert bytes to strings for JSON serialization + private_pem = vapid.private_pem() + public_pem = vapid.public_pem() + + self.__data['settings']['application']['vapid']['private_key'] = private_pem.decode() if isinstance(private_pem, bytes) else private_pem + self.__data['settings']['application']['vapid']['public_key'] = public_pem.decode() if isinstance(public_pem, bytes) else public_pem + + # Set default contact email if not present + if not self.__data['settings']['application']['vapid'].get('contact_email'): + self.__data['settings']['application']['vapid']['contact_email'] = 'citizen@example.com' + logger.info("Generated new VAPID keys for browser push notifications") + except ImportError: + logger.warning("py_vapid not available - browser notifications will not work") + except Exception as e: + logger.warning(f"Failed to generate VAPID keys: {e}") + self.needs_write = True # Finally start the thread that will manage periodic data saves to JSON diff --git a/changedetectionio/templates/_common_fields.html b/changedetectionio/templates/_common_fields.html index f6452675a48..b341b8d22f7 100644 --- a/changedetectionio/templates/_common_fields.html +++ b/changedetectionio/templates/_common_fields.html @@ -33,6 +33,34 @@ + + +
+
+ +
+

Browser push notifications! Use browser:// URLs in your notification settings to receive real-time push notifications even when this tab is closed.

+

Troubleshooting: If you get "different applicationServerKey" errors, click "Clear All Notifications" below and try again. This happens when switching between different changedetection.io instances.

+
+
+

Browser notifications: checking...

+
+
+ + + +
+
+
+
+
+
{{ render_field(form.notification_title, class="m-d notification-title", placeholder=settings_application['notification_title']) }} diff --git a/changedetectionio/templates/base.html b/changedetectionio/templates/base.html index 365a4c6dbfe..21b1456c9f1 100644 --- a/changedetectionio/templates/base.html +++ b/changedetectionio/templates/base.html @@ -35,6 +35,7 @@ + {% if socket_io_enabled %} diff --git a/changedetectionio/tests/unit/test_browser_notifications.py b/changedetectionio/tests/unit/test_browser_notifications.py new file mode 100644 index 00000000000..5497365474a --- /dev/null +++ b/changedetectionio/tests/unit/test_browser_notifications.py @@ -0,0 +1,436 @@ +""" +Tests for browser notification functionality +Tests VAPID key handling, subscription management, and notification sending +""" + +import json +import sys +import tempfile +import os +import unittest +from unittest.mock import patch, Mock, MagicMock +from py_vapid import Vapid + +from changedetectionio.notification.apprise_plugin.browser_notification_helpers import ( + convert_pem_private_key_for_pywebpush, + convert_pem_public_key_for_browser, + send_push_notifications, + create_notification_payload, + get_vapid_config_from_datastore, + get_browser_subscriptions, + save_browser_subscriptions +) + + +class TestVAPIDKeyHandling(unittest.TestCase): + """Test VAPID key generation, conversion, and validation""" + + def test_create_notification_payload(self): + """Test notification payload creation""" + payload = create_notification_payload("Test Title", "Test Body", "/test-icon.png") + + self.assertEqual(payload['title'], "Test Title") + self.assertEqual(payload['body'], "Test Body") + self.assertEqual(payload['icon'], "/test-icon.png") + self.assertEqual(payload['badge'], "/static/favicons/favicon-32x32.png") + self.assertIn('timestamp', payload) + self.assertIsInstance(payload['timestamp'], int) + + def test_create_notification_payload_defaults(self): + """Test notification payload with default values""" + payload = create_notification_payload("Title", "Body") + + self.assertEqual(payload['icon'], "/static/favicons/favicon-32x32.png") + self.assertEqual(payload['badge'], "/static/favicons/favicon-32x32.png") + + def test_convert_pem_private_key_for_pywebpush_with_valid_pem(self): + """Test conversion of valid PEM private key to Vapid instance""" + # Generate a real VAPID key + vapid = Vapid() + vapid.generate_keys() + private_pem = vapid.private_pem().decode() + + # Convert using our function + converted_key = convert_pem_private_key_for_pywebpush(private_pem) + + # Should return a Vapid instance + self.assertIsInstance(converted_key, Vapid) + + def test_convert_pem_private_key_invalid_input(self): + """Test conversion with invalid input returns original""" + invalid_key = "not-a-pem-key" + result = convert_pem_private_key_for_pywebpush(invalid_key) + self.assertEqual(result, invalid_key) + + none_key = None + result = convert_pem_private_key_for_pywebpush(none_key) + self.assertEqual(result, none_key) + + def test_convert_pem_public_key_for_browser(self): + """Test conversion of PEM public key to browser format""" + # Generate a real VAPID key pair + vapid = Vapid() + vapid.generate_keys() + public_pem = vapid.public_pem().decode() + + # Convert to browser format + browser_key = convert_pem_public_key_for_browser(public_pem) + + # Should return URL-safe base64 string + self.assertIsInstance(browser_key, str) + self.assertGreater(len(browser_key), 0) + # Should not contain padding + self.assertFalse(browser_key.endswith('=')) + + def test_convert_pem_public_key_invalid(self): + """Test public key conversion with invalid input""" + result = convert_pem_public_key_for_browser("invalid-pem") + self.assertIsNone(result) + + +class TestDatastoreIntegration(unittest.TestCase): + """Test datastore operations for VAPID and subscriptions""" + + def test_get_vapid_config_from_datastore(self): + """Test retrieving VAPID config from datastore""" + mock_datastore = Mock() + mock_datastore.data = { + 'settings': { + 'application': { + 'vapid': { + 'private_key': 'test-private-key', + 'public_key': 'test-public-key', + 'contact_email': 'test@example.com' + } + } + } + } + + private_key, public_key, contact_email = get_vapid_config_from_datastore(mock_datastore) + + self.assertEqual(private_key, 'test-private-key') + self.assertEqual(public_key, 'test-public-key') + self.assertEqual(contact_email, 'test@example.com') + + def test_get_vapid_config_missing_email(self): + """Test VAPID config with missing contact email uses default""" + mock_datastore = Mock() + mock_datastore.data = { + 'settings': { + 'application': { + 'vapid': { + 'private_key': 'test-private-key', + 'public_key': 'test-public-key' + } + } + } + } + + private_key, public_key, contact_email = get_vapid_config_from_datastore(mock_datastore) + + self.assertEqual(contact_email, 'citizen@example.com') + + def test_get_vapid_config_empty_datastore(self): + """Test VAPID config with empty datastore returns None values""" + mock_datastore = Mock() + mock_datastore.data = {} + + private_key, public_key, contact_email = get_vapid_config_from_datastore(mock_datastore) + + self.assertIsNone(private_key) + self.assertIsNone(public_key) + self.assertEqual(contact_email, 'citizen@example.com') + + def test_get_browser_subscriptions(self): + """Test retrieving browser subscriptions from datastore""" + mock_datastore = Mock() + test_subscriptions = [ + { + 'endpoint': 'https://fcm.googleapis.com/fcm/send/test1', + 'keys': {'p256dh': 'key1', 'auth': 'auth1'} + }, + { + 'endpoint': 'https://fcm.googleapis.com/fcm/send/test2', + 'keys': {'p256dh': 'key2', 'auth': 'auth2'} + } + ] + mock_datastore.data = { + 'settings': { + 'application': { + 'browser_subscriptions': test_subscriptions + } + } + } + + subscriptions = get_browser_subscriptions(mock_datastore) + + self.assertEqual(len(subscriptions), 2) + self.assertEqual(subscriptions, test_subscriptions) + + def test_get_browser_subscriptions_empty(self): + """Test getting subscriptions from empty datastore returns empty list""" + mock_datastore = Mock() + mock_datastore.data = {} + + subscriptions = get_browser_subscriptions(mock_datastore) + + self.assertEqual(subscriptions, []) + + def test_save_browser_subscriptions(self): + """Test saving browser subscriptions to datastore""" + mock_datastore = Mock() + mock_datastore.data = {'settings': {'application': {}}} + + test_subscriptions = [ + {'endpoint': 'test1', 'keys': {'p256dh': 'key1', 'auth': 'auth1'}} + ] + + save_browser_subscriptions(mock_datastore, test_subscriptions) + + self.assertEqual(mock_datastore.data['settings']['application']['browser_subscriptions'], test_subscriptions) + self.assertTrue(mock_datastore.needs_write) + + +class TestNotificationSending(unittest.TestCase): + """Test notification sending with mocked pywebpush""" + + @patch('pywebpush.webpush') + def test_send_push_notifications_success(self, mock_webpush): + """Test successful notification sending""" + mock_webpush.return_value = True + + mock_datastore = Mock() + mock_datastore.needs_write = False + + subscriptions = [ + { + 'endpoint': 'https://fcm.googleapis.com/fcm/send/test1', + 'keys': {'p256dh': 'key1', 'auth': 'auth1'} + } + ] + + # Generate a real VAPID key for testing + vapid = Vapid() + vapid.generate_keys() + private_key = vapid.private_pem().decode() + + notification_payload = { + 'title': 'Test Title', + 'body': 'Test Body' + } + + success_count, total_count = send_push_notifications( + subscriptions=subscriptions, + notification_payload=notification_payload, + private_key=private_key, + contact_email='test@example.com', + datastore=mock_datastore + ) + + self.assertEqual(success_count, 1) + self.assertEqual(total_count, 1) + self.assertTrue(mock_webpush.called) + + # Verify webpush was called with correct parameters + call_args = mock_webpush.call_args + self.assertEqual(call_args[1]['subscription_info'], subscriptions[0]) + self.assertEqual(json.loads(call_args[1]['data']), notification_payload) + self.assertIn('vapid_private_key', call_args[1]) + self.assertEqual(call_args[1]['vapid_claims']['sub'], 'mailto:test@example.com') + + @patch('pywebpush.webpush') + def test_send_push_notifications_webpush_exception(self, mock_webpush): + """Test handling of WebPushException with invalid subscription removal""" + from pywebpush import WebPushException + + # Mock a 410 response (subscription gone) + mock_response = Mock() + mock_response.status_code = 410 + + mock_webpush.side_effect = WebPushException("Subscription expired", response=mock_response) + + mock_datastore = Mock() + mock_datastore.needs_write = False + + subscriptions = [ + { + 'endpoint': 'https://fcm.googleapis.com/fcm/send/test1', + 'keys': {'p256dh': 'key1', 'auth': 'auth1'} + } + ] + + vapid = Vapid() + vapid.generate_keys() + private_key = vapid.private_pem().decode() + + success_count, total_count = send_push_notifications( + subscriptions=subscriptions, + notification_payload={'title': 'Test', 'body': 'Test'}, + private_key=private_key, + contact_email='test@example.com', + datastore=mock_datastore + ) + + self.assertEqual(success_count, 0) + self.assertEqual(total_count, 1) + self.assertTrue(mock_datastore.needs_write) # Should mark for subscription cleanup + + def test_send_push_notifications_no_pywebpush(self): + """Test graceful handling when pywebpush is not available""" + with patch.dict('sys.modules', {'pywebpush': None}): + subscriptions = [{'endpoint': 'test', 'keys': {}}] + + success_count, total_count = send_push_notifications( + subscriptions=subscriptions, + notification_payload={'title': 'Test', 'body': 'Test'}, + private_key='test-key', + contact_email='test@example.com', + datastore=Mock() + ) + + self.assertEqual(success_count, 0) + self.assertEqual(total_count, 1) + + +class TestBrowserIntegration(unittest.TestCase): + """Test browser integration aspects (file existence)""" + + def test_javascript_browser_notifications_class_exists(self): + """Test that browser notifications JavaScript file exists and has expected structure""" + js_file = "/var/www/changedetection.io/changedetectionio/static/js/browser-notifications.js" + + self.assertTrue(os.path.exists(js_file)) + + with open(js_file, 'r') as f: + content = f.read() + + # Check for key class and methods + self.assertIn('class BrowserNotifications', content) + self.assertIn('async init()', content) + self.assertIn('async subscribe()', content) + self.assertIn('async sendTestNotification()', content) + self.assertIn('setupNotificationUrlMonitoring()', content) + + def test_service_worker_exists(self): + """Test that service worker file exists""" + sw_file = "/var/www/changedetection.io/changedetectionio/static/js/service-worker.js" + + self.assertTrue(os.path.exists(sw_file)) + + with open(sw_file, 'r') as f: + content = f.read() + + # Check for key service worker functionality + self.assertIn('push', content) + self.assertIn('notificationclick', content) + + +class TestAPIEndpoints(unittest.TestCase): + """Test browser notification API endpoints""" + + def test_browser_notifications_module_exists(self): + """Test that BrowserNotifications API module exists""" + api_file = "/var/www/changedetection.io/changedetectionio/notification/BrowserNotifications.py" + + self.assertTrue(os.path.exists(api_file)) + + with open(api_file, 'r') as f: + content = f.read() + + # Check for key API classes + self.assertIn('BrowserNotificationsVapidPublicKey', content) + self.assertIn('BrowserNotificationsSubscribe', content) + self.assertIn('BrowserNotificationsUnsubscribe', content) + + def test_vapid_public_key_conversion(self): + """Test VAPID public key conversion for browser use""" + # Generate a real key pair + vapid = Vapid() + vapid.generate_keys() + public_pem = vapid.public_pem().decode() + + # Convert to browser format + browser_key = convert_pem_public_key_for_browser(public_pem) + + # Verify it's a valid URL-safe base64 string + self.assertIsInstance(browser_key, str) + self.assertGreater(len(browser_key), 80) # P-256 uncompressed point should be ~88 chars + + # Should not have padding + self.assertFalse(browser_key.endswith('=')) + + # Should only contain URL-safe base64 characters + import re + self.assertRegex(browser_key, r'^[A-Za-z0-9_-]+$') + + +class TestIntegrationFlow(unittest.TestCase): + """Test complete integration flow""" + + @patch('pywebpush.webpush') + def test_complete_notification_flow(self, mock_webpush): + """Test complete flow from subscription to notification""" + mock_webpush.return_value = True + + # Create mock datastore with VAPID keys + mock_datastore = Mock() + vapid = Vapid() + vapid.generate_keys() + + mock_datastore.data = { + 'settings': { + 'application': { + 'vapid': { + 'private_key': vapid.private_pem().decode(), + 'public_key': vapid.public_pem().decode(), + 'contact_email': 'test@example.com' + }, + 'browser_subscriptions': [ + { + 'endpoint': 'https://fcm.googleapis.com/fcm/send/test123', + 'keys': { + 'p256dh': 'test-p256dh-key', + 'auth': 'test-auth-key' + } + } + ] + } + } + } + mock_datastore.needs_write = False + + # Get configuration + private_key, public_key, contact_email = get_vapid_config_from_datastore(mock_datastore) + subscriptions = get_browser_subscriptions(mock_datastore) + + # Create notification + payload = create_notification_payload("Test Title", "Test Message") + + # Send notification + success_count, total_count = send_push_notifications( + subscriptions=subscriptions, + notification_payload=payload, + private_key=private_key, + contact_email=contact_email, + datastore=mock_datastore + ) + + # Verify success + self.assertEqual(success_count, 1) + self.assertEqual(total_count, 1) + self.assertTrue(mock_webpush.called) + + # Verify webpush call parameters + call_args = mock_webpush.call_args + self.assertIn('subscription_info', call_args[1]) + self.assertIn('vapid_private_key', call_args[1]) + self.assertIn('vapid_claims', call_args[1]) + + # Verify vapid_claims format + vapid_claims = call_args[1]['vapid_claims'] + self.assertEqual(vapid_claims['sub'], 'mailto:test@example.com') + self.assertEqual(vapid_claims['aud'], 'https://fcm.googleapis.com') + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index cb220097f0a..aeaccb1313a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -142,3 +142,6 @@ pre_commit >= 4.2.0 # For events between checking and socketio updates blinker + +# For Web Push notifications (browser notifications) +pywebpush