mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-10-02 01:58:50 +00:00
WIP
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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']) }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -142,3 +142,6 @@ pre_commit >= 4.2.0
|
||||
|
||||
# For events between checking and socketio updates
|
||||
blinker
|
||||
|
||||
# For Web Push notifications (browser notifications)
|
||||
pywebpush
|
||||
|
||||
Reference in New Issue
Block a user