From cfdb484b36e05f8633456ee26543156b1cd844e6 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Thu, 28 Aug 2025 21:44:13 +0200 Subject: [PATCH] WIP --- changedetectionio/api/BrowserNotifications.py | 288 +++++++++++++ changedetectionio/api/__init__.py | 8 + changedetectionio/flask_app.py | 31 ++ changedetectionio/forms.py | 51 +++ changedetectionio/model/App.py | 5 + .../browser_notification_helpers.py | 304 ++++++++++++++ .../apprise_plugin/custom_handlers.py | 93 +++++ changedetectionio/notification/handler.py | 2 +- .../static/favicons/site.webmanifest | 9 +- .../static/js/browser-notifications.js | 385 ++++++++++++++++++ changedetectionio/static/js/service-worker.js | 95 +++++ changedetectionio/store.py | 22 + .../templates/_common_fields.html | 25 ++ changedetectionio/templates/base.html | 1 + requirements.txt | 3 + 15 files changed, 1318 insertions(+), 4 deletions(-) create mode 100644 changedetectionio/api/BrowserNotifications.py create mode 100644 changedetectionio/notification/apprise_plugin/browser_notification_helpers.py create mode 100644 changedetectionio/static/js/browser-notifications.js create mode 100644 changedetectionio/static/js/service-worker.js diff --git a/changedetectionio/api/BrowserNotifications.py b/changedetectionio/api/BrowserNotifications.py new file mode 100644 index 000000000..efb62ed7d --- /dev/null +++ b/changedetectionio/api/BrowserNotifications.py @@ -0,0 +1,288 @@ +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 \ No newline at end of file diff --git a/changedetectionio/api/__init__.py b/changedetectionio/api/__init__.py index 33a26ce6b..98323b887 100644 --- a/changedetectionio/api/__init__.py +++ b/changedetectionio/api/__init__.py @@ -68,3 +68,11 @@ from .Tags import Tags, Tag from .Import import Import from .SystemInfo import SystemInfo from .Notifications import Notifications +from .BrowserNotifications import ( + BrowserNotificationsVapidPublicKey, + BrowserNotificationsSubscribe, + BrowserNotificationsUnsubscribe, + BrowserNotificationsTest, + BrowserNotificationsSubscriptions, + BrowserNotificationsPendingKeywords +) diff --git a/changedetectionio/flask_app.py b/changedetectionio/flask_app.py index 6e52d4ed9..3b83fae74 100644 --- a/changedetectionio/flask_app.py +++ b/changedetectionio/flask_app.py @@ -39,6 +39,14 @@ from loguru import logger from changedetectionio import __version__ from changedetectionio import queuedWatchMetaData from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, CreateWatch, Import, SystemInfo, Tag, Tags, Notifications, WatchFavicon +from changedetectionio.api.BrowserNotifications import ( + BrowserNotificationsVapidPublicKey, + BrowserNotificationsSubscribe, + BrowserNotificationsUnsubscribe, + BrowserNotificationsTest, + BrowserNotificationsSubscriptions, + BrowserNotificationsPendingKeywords +) from changedetectionio.api.Search import Search from .time_handler import is_within_schedule @@ -336,6 +344,14 @@ def changedetection_app(config=None, datastore_o=None): watch_api.add_resource(Notifications, '/api/v1/notifications', resource_class_kwargs={'datastore': datastore}) + + # Browser notification endpoints + watch_api.add_resource(BrowserNotificationsVapidPublicKey, '/api/v1/browser-notifications/vapid-public-key') + watch_api.add_resource(BrowserNotificationsSubscribe, '/api/v1/browser-notifications/subscribe') + watch_api.add_resource(BrowserNotificationsUnsubscribe, '/api/v1/browser-notifications/unsubscribe') + watch_api.add_resource(BrowserNotificationsTest, '/api/v1/browser-notifications/test') + watch_api.add_resource(BrowserNotificationsSubscriptions, '/api/v1/browser-notifications/subscriptions') + watch_api.add_resource(BrowserNotificationsPendingKeywords, '/api/v1/browser-notifications/pending-keywords') @login_manager.user_loader def user_loader(email): @@ -489,6 +505,21 @@ def changedetection_app(config=None, datastore_o=None): 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') diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py index edf71636b..dda80552e 100644 --- a/changedetectionio/forms.py +++ b/changedetectionio/forms.py @@ -557,6 +557,56 @@ class commonSettingsForm(Form): 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")]) + + def validate(self, extra_validators=None): + """Custom validation including browser notification auto-subscription""" + # Run standard validation first + if not super().validate(extra_validators): + return False + + # Handle browser notification auto-subscription + if hasattr(self, 'notification_urls') and self.notification_urls.data: + self._handle_browser_notification_subscriptions() + + return True + + def _handle_browser_notification_subscriptions(self): + """Auto-subscribe to browser notification keywords found in notification URLs""" + try: + from flask import current_app, session + from changedetectionio.notification.apprise_plugin.browser_notification_helpers import extract_keywords_from_notification_urls + + # Extract keywords from browser:// URLs using shared helper + keywords = extract_keywords_from_notification_urls(self.notification_urls.data) + + if not keywords: + return + + # Store keywords in session for frontend to handle subscription + # We can't directly subscribe here because we need user permission and push subscription + if 'browser_notification_keywords' not in session: + session['browser_notification_keywords'] = [] + + for keyword in keywords: + if keyword not in session['browser_notification_keywords']: + session['browser_notification_keywords'].append(keyword) + + # Also update the global settings to track enabled channels + try: + datastore = current_app.config.get('DATASTORE') + if datastore: + if 'browser_notification_channels' not in datastore.data['settings']['application']: + datastore.data['settings']['application']['browser_notification_channels'] = [] + + for keyword in keywords: + if keyword not in datastore.data['settings']['application']['browser_notification_channels']: + datastore.data['settings']['application']['browser_notification_channels'].append(keyword) + datastore.needs_write = True + except Exception as e: + logger.warning(f"Failed to update browser notification channels: {e}") + + except Exception as e: + logger.error(f"Error handling browser notification subscriptions: {e}") class importForm(Form): @@ -786,6 +836,7 @@ class globalSettingsApplicationForm(commonSettingsForm): render_kw={"style": "width: 5em;"}, validators=[validators.NumberRange(min=0, message="Should contain zero or more attempts")]) + browser_notification_channels = StringListField('Browser notification channels (detected from browser:// URLs)', validators=[validators.Optional()], render_kw={"readonly": True, "placeholder": "No channels detected yet"}) ui = FormField(globalSettingsApplicationUIForm) diff --git a/changedetectionio/model/App.py b/changedetectionio/model/App.py index f7478ee92..bc77ad290 100644 --- a/changedetectionio/model/App.py +++ b/changedetectionio/model/App.py @@ -65,6 +65,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/apprise_plugin/browser_notification_helpers.py b/changedetectionio/notification/apprise_plugin/browser_notification_helpers.py new file mode 100644 index 000000000..d21b8507e --- /dev/null +++ b/changedetectionio/notification/apprise_plugin/browser_notification_helpers.py @@ -0,0 +1,304 @@ +""" +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 raw bytes format that pywebpush expects + + Args: + private_key: PEM private key string or already converted key + + Returns: + Private key in the format pywebpush expects + """ + if not isinstance(private_key, str) or not private_key.startswith('-----BEGIN'): + return private_key + + try: + from cryptography.hazmat.primitives import serialization + private_key_bytes = private_key.encode() + private_key_obj = serialization.load_pem_private_key(private_key_bytes, password=None) + + # Get raw private key bytes for pywebpush + private_key_raw = private_key_obj.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption() + ) + return private_key_raw + + except Exception as e: + logger.warning(f"Failed to convert private key format, using as-is: {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, keyword, 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 + keyword: Keyword/channel name for logging + 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(f"Removing invalid subscription for keyword {keyword}") + 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, keyword): + """ + Get browser subscriptions for a keyword from datastore + + Args: + datastore: Datastore object + keyword: Subscription keyword/channel + + Returns: + List of subscriptions for the keyword + """ + try: + if not datastore: + return [] + + return datastore.data.get('browser_subscriptions', {}).get(keyword, []) + + except Exception as e: + logger.error(f"Failed to get browser subscriptions for {keyword}: {e}") + return [] + + +def save_browser_subscriptions(datastore, keyword, subscriptions): + """ + Save browser subscriptions for a keyword to datastore + + Args: + datastore: Datastore object + keyword: Subscription keyword/channel + subscriptions: List of subscriptions to save + """ + try: + if not datastore: + return + + if 'browser_subscriptions' not in datastore.data: + datastore.data['browser_subscriptions'] = {} + + datastore.data['browser_subscriptions'][keyword] = subscriptions + datastore.needs_write = True + + except Exception as e: + logger.error(f"Failed to save browser subscriptions for {keyword}: {e}") + + +def extract_keyword_from_browser_url(url): + """ + Extract keyword from browser:// URL + + Args: + url: Browser notification URL (e.g., "browser://alerts") + + Returns: + Keyword string or None if invalid URL + """ + try: + if not url or not isinstance(url, str): + return None + + match = re.match(r'browser://([^/?#]+)', url.strip()) + return match.group(1) if match else None + + except Exception as e: + logger.warning(f"Failed to extract keyword from URL {url}: {e}") + return None + + +def extract_keywords_from_notification_urls(notification_urls): + """ + Extract all browser:// keywords from a list of notification URLs + + Args: + notification_urls: List of notification URL strings + + Returns: + List of unique keywords found + """ + keywords = [] + + if not notification_urls: + return keywords + + for url in notification_urls: + keyword = extract_keyword_from_browser_url(url) + if keyword and keyword not in keywords: + keywords.append(keyword) + + return keywords + + +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 1fd28b416..f075dea12 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,95 @@ 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 + Format: browser://keyword where keyword is the namespace for subscriptions + """ + try: + from pywebpush import webpush, WebPushException + from flask import current_app + + url: str = meta.get("url") + parsed_url = apprise_parse_url(url) + + if not parsed_url: + logger.error("Failed to parse browser notification URL") + return False + + # Extract keyword from URL - format is browser://keyword + keyword = parsed_url.get('host', 'default') + + # 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 for this keyword from datastore + browser_subscriptions = datastore.data.get('browser_subscriptions', {}).get(keyword, []) + + if not browser_subscriptions: + logger.info(f"No browser subscriptions found for keyword: {keyword}") + 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, + keyword=keyword, + datastore=datastore + ) + + # Update datastore with cleaned subscriptions + if keyword not in datastore.data.get('browser_subscriptions', {}): + if 'browser_subscriptions' not in datastore.data: + datastore.data['browser_subscriptions'] = {} + datastore.data['browser_subscriptions'][keyword] = [] + datastore.data['browser_subscriptions'][keyword] = browser_subscriptions + + logger.info(f"Sent browser notifications: {success_count}/{total_count} successful for keyword '{keyword}'") + 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 fc7628fe3..17a968525 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 6d25c20e0..09ccf452d 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 000000000..d34e55861 --- /dev/null +++ b/changedetectionio/static/js/browser-notifications.js @@ -0,0 +1,385 @@ +/** + * 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.subscriptions = new Map(); // keyword -> subscription + 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(); + + // Initialize UI elements + this.initializeUI(); + + // Load existing subscriptions + await this.loadExistingSubscriptions(); + + // Handle auto-subscription from form submission + await this.handleAutoSubscription(); + + } 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('/api/v1/browser-notifications/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()); + } + } + + 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'); + // Check for pending auto-subscriptions + this.handleAutoSubscription(); + } else { + console.log('Notification permission denied'); + } + } catch (error) { + console.error('Error requesting notification permission:', error); + } + } + + async subscribeToKeyword(keyword = null) { + if (Notification.permission !== 'granted') { + alert('Please enable notifications first'); + return; + } + + if (!keyword) { + keyword = document.querySelector('#notification-keyword-input')?.value?.trim() || 'default'; + } + + if (!keyword) { + alert('Please enter a keyword'); + return; + } + + try { + // Check if already subscribed to this keyword + if (this.subscriptions.has(keyword)) { + console.log(`Already subscribed to keyword: ${keyword}`); + return; + } + + // 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('/api/v1/browser-notifications/subscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': document.querySelector('input[name=csrf_token]')?.value + }, + body: JSON.stringify({ + keyword: keyword, + subscription: subscription.toJSON() + }) + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + // Store subscription locally + this.subscriptions.set(keyword, subscription); + + // Update UI + this.updateSubscriptionsList(); + + console.log(`Successfully subscribed to keyword: ${keyword}`); + + // Clear input + const input = document.querySelector('#notification-keyword-input'); + if (input) input.value = ''; + + } catch (error) { + console.error(`Failed to subscribe to keyword ${keyword}:`, error); + alert(`Failed to subscribe: ${error.message}`); + } + } + + async unsubscribeFromKeyword(keyword) { + try { + const subscription = this.subscriptions.get(keyword); + if (!subscription) return; + + // Unsubscribe from server + const response = await fetch('/api/v1/browser-notifications/unsubscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': document.querySelector('input[name=csrf_token]')?.value + }, + body: JSON.stringify({ + keyword: keyword, + subscription: subscription.toJSON() + }) + }); + + if (!response.ok) { + console.warn(`Server unsubscribe failed: ${response.status}`); + } + + // Remove from local storage + this.subscriptions.delete(keyword); + + // Update UI + this.updateSubscriptionsList(); + + console.log(`Unsubscribed from keyword: ${keyword}`); + + } catch (error) { + console.error(`Failed to unsubscribe from keyword ${keyword}:`, error); + } + } + + updateSubscriptionsList() { + const listElement = document.querySelector('#subscriptions-list'); + if (!listElement) return; + + listElement.innerHTML = ''; + + if (this.subscriptions.size === 0) { + listElement.innerHTML = '
  • No active subscriptions
  • '; + return; + } + + for (const [keyword] of this.subscriptions) { + const listItem = document.createElement('li'); + listItem.innerHTML = ` + browser://${keyword} + + `; + listElement.appendChild(listItem); + } + } + + async loadExistingSubscriptions() { + try { + const response = await fetch('/api/v1/browser-notifications/subscriptions'); + if (response.ok) { + const data = await response.json(); + // Note: This would require server-side implementation to track subscriptions per browser + // For now, we'll just check what the browser knows about + } + } catch (error) { + console.log('No existing subscriptions found'); + } + } + + async sendTestNotification() { + try { + // Get available channels from the form field + const channelsField = document.querySelector('textarea[name*="browser_notification_channels"]'); + const channels = channelsField?.value ? channelsField.value.split('\n').filter(c => c.trim()) : []; + const keyword = channels.length > 0 ? channels[0] : 'default'; + + const response = await fetch('/api/v1/browser-notifications/test', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': document.querySelector('input[name=csrf_token]')?.value + }, + body: JSON.stringify({ + keyword: keyword, + title: 'Test Notification', + body: `This is a test notification for channel: ${keyword}` + }) + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const result = await response.json(); + alert(`Test notification sent successfully to ${result.sent_count} subscriber(s)`); + console.log('Test notification sent'); + } 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 handleAutoSubscription() { + // Handle auto-subscription for keywords detected from browser:// URLs + try { + // Check if there are pending keywords from form submission + const response = await fetch('/api/v1/browser-notifications/pending-keywords', { + headers: { + 'X-CSRFToken': document.querySelector('input[name=csrf_token]')?.value + } + }); + + if (!response.ok) { + return; // No pending keywords or endpoint not available + } + + const data = await response.json(); + if (!data.keywords || data.keywords.length === 0) { + return; + } + + // Check if notifications are already enabled + if (Notification.permission === 'granted') { + // Auto-subscribe to all pending keywords + for (const keyword of data.keywords) { + try { + await this.subscribeToKeyword(keyword); + console.log(`Auto-subscribed to browser notifications for: ${keyword}`); + } catch (error) { + console.warn(`Failed to auto-subscribe to ${keyword}:`, error); + } + } + } else { + // Show notification to enable browser notifications for detected keywords + this.showAutoSubscriptionPrompt(data.keywords); + } + + } catch (error) { + console.log('No auto-subscription needed or failed to check:', error); + } + } + + showAutoSubscriptionPrompt(keywords) { + // Show a prompt to enable notifications for detected browser:// URLs + const keywordList = keywords.join(', '); + const message = `Browser notification channels detected: ${keywordList}\n\nWould you like to enable browser notifications for these channels?`; + + if (confirm(message)) { + this.requestNotificationPermission().then(() => { + // After permission is granted, subscribe to all keywords + keywords.forEach(keyword => { + this.subscribeToKeyword(keyword); + }); + }); + } + } +} + +// 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 000000000..eeeec0877 --- /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 020b29993..54f6c754a 100644 --- a/changedetectionio/store.py +++ b/changedetectionio/store.py @@ -140,6 +140,28 @@ class ChangeDetectionStore: 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 0d4a4a1c2..1ae41c52c 100644 --- a/changedetectionio/templates/_common_fields.html +++ b/changedetectionio/templates/_common_fields.html @@ -33,6 +33,31 @@ + + + {% if form.browser_notification_channels is defined %} +
    + {{ render_field(form.browser_notification_channels, rows=3, class="browser-notification-channels") }} +
    +

    Browser Notification Channels - These are automatically detected when you use browser://keyword URLs in your notification settings above.

    +

    When detected, you'll be prompted to enable browser notifications for these channels so you can receive real-time push notifications even when this tab is closed.

    +
    +
    +

    Browser notifications: checking...

    +
    +
    + + +
    +
    +
    +
    + {% endif %} +
    {{ 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 d07b0b5f4..0de2aa0a7 100644 --- a/changedetectionio/templates/base.html +++ b/changedetectionio/templates/base.html @@ -34,6 +34,7 @@ + {% if socket_io_enabled %} diff --git a/requirements.txt b/requirements.txt index caa072b5a..26e5b50b5 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