This commit is contained in:
dgtlmoon
2025-08-28 21:44:13 +02:00
parent db81c3c5e2
commit cfdb484b36
15 changed files with 1318 additions and 4 deletions
@@ -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
+8
View File
@@ -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
)
+31
View File
@@ -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')
+51
View File
@@ -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)
+5
View File
@@ -65,6 +65,11 @@ class model(dict):
'socket_io_enabled': True,
'favicons_enabled': True
},
'vapid': {
'private_key': None,
'public_key': None,
'contact_email': None
},
}
}
}
@@ -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
@@ -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
+1 -1
View File
@@ -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'):
@@ -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"
}
@@ -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 = '<li>No active subscriptions</li>';
return;
}
for (const [keyword] of this.subscriptions) {
const listItem = document.createElement('li');
listItem.innerHTML = `
<span>browser://${keyword}</span>
<button type="button" class="btn btn-sm btn-danger" onclick="browserNotifications.unsubscribeFromKeyword('${keyword}')" style="margin-left: 1em;">
Unsubscribe
</button>
`;
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();
}
@@ -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();
}
});
+22
View File
@@ -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
@@ -33,6 +33,31 @@
<div id="notification-test-log" style="display: none;"><span class="pure-form-message-inline">Processing..</span></div>
</div>
</div>
<!-- Browser Notification Channels (only show in global settings) -->
{% if form.browser_notification_channels is defined %}
<div class="pure-control-group">
{{ render_field(form.browser_notification_channels, rows=3, class="browser-notification-channels") }}
<div class="pure-form-message-inline">
<p><strong>Browser Notification Channels</strong> - These are automatically detected when you use <code>browser://keyword</code> URLs in your notification settings above.</p>
<p>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.</p>
<div id="browser-notification-controls" style="margin-top: 1em;">
<div id="notification-permission-status">
<p>Browser notifications: <span id="permission-status">checking...</span></p>
</div>
<div id="browser-notification-actions">
<button type="button" id="enable-notifications-btn" class="pure-button button-secondary button-xsmall" style="display: none;">
Enable Browser Notifications
</button>
<button type="button" id="test-notification-btn" class="pure-button button-secondary button-xsmall" style="display: none;">
Test Notification
</button>
</div>
</div>
</div>
</div>
{% endif %}
<div id="notification-customisation" class="pure-control-group">
<div class="pure-control-group">
{{ render_field(form.notification_title, class="m-d notification-title", placeholder=settings_application['notification_title']) }}
+1
View File
@@ -34,6 +34,7 @@
<script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script>
<script src="{{url_for('static_content', group='js', filename='csrf.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='feather-icons.min.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='browser-notifications.js')}}" defer></script>
{% if socket_io_enabled %}
<script src="{{url_for('static_content', group='js', filename='socket.io.min.js')}}"></script>
<script src="{{url_for('static_content', group='js', filename='realtime.js')}}" defer></script>
+3
View File
@@ -142,3 +142,6 @@ pre_commit >= 4.2.0
# For events between checking and socketio updates
blinker
# For Web Push notifications (browser notifications)
pywebpush