This commit is contained in:
dgtlmoon
2026-01-06 16:48:06 +01:00
parent 50c2e9761e
commit c5eeafe2dd
3 changed files with 162 additions and 15 deletions
@@ -160,6 +160,7 @@ $(function() {
let currentSelectedId = null;
// Time displays now show actual date/time from backend instead of relative format
// Note: All user-controlled content is HTML-escaped on the backend for security
// Function to render event details
function renderEventDetails(event) {
@@ -240,6 +241,29 @@ $(function() {
html += '</div>';
}
// Payload (what was sent)
if (event.payload) {
html += '<div class="detail-section">';
html += '<h6>Payload</h6>';
if (event.payload.notification_title) {
html += '<div class="detail-row"><span class="detail-label">Title:</span>';
html += `<span class="detail-value"><code>${event.payload.notification_title}</code></span></div>`;
}
if (event.payload.notification_body) {
html += '<div class="detail-row"><span class="detail-label">Body:</span>';
html += `<span class="detail-value"><pre class="payload-content">${event.payload.notification_body}</pre></span></div>`;
}
if (event.payload.notification_format) {
html += '<div class="detail-row"><span class="detail-label">Format:</span>';
html += `<span class="detail-value"><code>${event.payload.notification_format}</code></span></div>`;
}
html += '</div>';
}
// Apprise logs
if (event.apprise_logs) {
html += '<div class="detail-section">';
@@ -403,18 +403,43 @@ def get_pending_notifications(limit=50):
retry_number = 1 # Default to 1 (first retry after initial failure)
total_attempts = NOTIFICATION_RETRY_COUNT + 1 # Initial attempt + retries
watch_uuid = notification_data.get('uuid')
retry_attempts = []
notification_urls = []
# NOTE: Use "is not None" instead of truthiness check because Huey objects can evaluate to False
if watch_uuid and huey is not None and hasattr(huey.storage, 'path'):
try:
import os
import json
import glob
attempts_dir = os.path.join(huey.storage.path, 'retry_attempts')
if os.path.exists(attempts_dir):
attempt_files = [f for f in os.listdir(attempts_dir) if f.startswith(f"{watch_uuid}.")]
if len(attempt_files) > 0:
# Load retry attempt files to get notification_urls and payload
attempt_pattern = os.path.join(attempts_dir, f"{watch_uuid}.*.json")
for attempt_file in sorted(glob.glob(attempt_pattern)):
try:
with open(attempt_file, 'r') as f:
attempt_data = json.load(f)
# Format timestamp for display
attempt_time = attempt_data.get('timestamp')
if attempt_time:
from changedetectionio.notification_service import timestamp_to_localtime
attempt_data['timestamp_formatted'] = timestamp_to_localtime(attempt_time)
retry_attempts.append(attempt_data)
except Exception as ae:
logger.debug(f"Unable to load retry attempt file {attempt_file}: {ae}")
if len(retry_attempts) > 0:
# Current retry number = number of attempt files
# (1 file = 1st retry, 2 files = 2nd retry, etc.)
retry_number = len(attempt_files)
logger.debug(f"Watch {watch_uuid[:8]}: Found {len(attempt_files)} retry files, currently on retry #{retry_number}/{total_attempts}")
retry_number = len(retry_attempts)
logger.debug(f"Watch {watch_uuid[:8]}: Found {len(retry_attempts)} retry files, currently on retry #{retry_number}/{total_attempts}")
# Extract notification_urls from latest retry attempt
latest_attempt = retry_attempts[-1]
attempt_notification_data = latest_attempt.get('notification_data', {})
if attempt_notification_data:
notification_urls = attempt_notification_data.get('notification_urls', [])
else:
# Directory exists but no files yet - first retry
retry_number = 1
@@ -440,6 +465,8 @@ def get_pending_notifications(limit=50):
'queued_at_formatted': queued_at_formatted,
'retry_number': retry_number,
'total_retries': total_attempts,
'retry_attempts': retry_attempts,
'notification_urls': notification_urls,
})
except Exception as e:
logger.debug(f"Error processing scheduled item: {e}")
@@ -506,6 +533,7 @@ def get_all_notification_events(limit=100):
'watch_title': success.get('watch_url', 'Unknown')[:50],
'notification_urls': success.get('notification_urls', []),
'apprise_logs': '\n'.join(success.get('apprise_logs', [])) if isinstance(success.get('apprise_logs'), list) else success.get('apprise_logs', ''),
'payload': success.get('payload'),
'error': None
})
@@ -514,13 +542,18 @@ def get_all_notification_events(limit=100):
for item in pending:
status = 'retrying' if item.get('status') == 'retrying' else 'queued'
# Get apprise logs for this task if available
# Get apprise logs and payload for this task if available
apprise_logs = None
payload = None
task_id = item.get('task_id')
if task_id:
log_data = get_task_apprise_log(task_id)
if log_data and log_data.get('apprise_log'):
apprise_logs = log_data.get('apprise_log')
# Get payload from retry attempts if available
retry_attempts = item.get('retry_attempts', [])
if retry_attempts:
payload = retry_attempts[-1].get('payload')
events.append({
'id': task_id,
@@ -536,20 +569,27 @@ def get_all_notification_events(limit=100):
'retry_at': item.get('retry_at_timestamp'),
'retry_at_formatted': item.get('retry_at_formatted'),
'apprise_logs': apprise_logs,
'payload': payload,
'error': None
})
# 3. Get failed notifications (dead letter)
failed = get_failed_notifications(limit=limit)
for item in failed:
# Get apprise logs for failed tasks
# Get apprise logs and payload for failed tasks
apprise_logs = None
payload = None
task_id = item.get('task_id')
if task_id:
log_data = get_task_apprise_log(task_id)
if log_data and log_data.get('apprise_log'):
apprise_logs = log_data.get('apprise_log')
# Get payload from retry attempts (has the most recent attempt data)
retry_attempts = item.get('retry_attempts', [])
if retry_attempts:
payload = retry_attempts[-1].get('payload')
events.append({
'id': task_id,
'status': 'failed',
@@ -560,12 +600,33 @@ def get_all_notification_events(limit=100):
'watch_title': item.get('notification_data', {}).get('watch_url', 'Unknown')[:50],
'notification_urls': item.get('notification_data', {}).get('notification_urls', []),
'apprise_logs': apprise_logs,
'payload': payload,
'error': item.get('error')
})
# Sort by timestamp (newest first)
events.sort(key=lambda x: x.get('timestamp', 0) or 0, reverse=True)
# HTML escape user-controlled fields to prevent XSS in UI
from changedetectionio.jinja2_custom.safe_jinja import render_fully_escaped
for event in events:
# Escape apprise logs
if event.get('apprise_logs'):
event['apprise_logs'] = render_fully_escaped(event['apprise_logs'])
# Escape error messages
if event.get('error'):
event['error'] = render_fully_escaped(event['error'])
# Escape payload fields (notification title, body, format)
if event.get('payload') and isinstance(event['payload'], dict):
if event['payload'].get('notification_title'):
event['payload']['notification_title'] = render_fully_escaped(event['payload']['notification_title'])
if event['payload'].get('notification_body'):
event['payload']['notification_body'] = render_fully_escaped(event['payload']['notification_body'])
if event['payload'].get('notification_format'):
event['payload']['notification_format'] = render_fully_escaped(event['payload']['notification_format'])
# Limit results
return events[:limit]
@@ -1222,7 +1283,7 @@ def _extract_notification_urls(n_object):
return []
def _store_successful_notification(n_object, apprise_logs):
def _store_successful_notification(n_object, apprise_logs, payload=None):
"""Store successful notification record and cleanup retry attempts."""
import os
import json
@@ -1251,6 +1312,7 @@ def _store_successful_notification(n_object, apprise_logs):
'watch_uuid': watch_uuid,
'notification_urls': _extract_notification_urls(n_object),
'apprise_logs': apprise_logs if apprise_logs else [],
'payload': payload # What was actually sent to Apprise
}
success_file = os.path.join(success_dir, f"success-{unique_id}.json")
@@ -1263,7 +1325,7 @@ def _store_successful_notification(n_object, apprise_logs):
_cleanup_old_success_notifications(success_dir, keep=50)
def _store_retry_attempt(n_object, error):
def _store_retry_attempt(n_object, error, payload=None):
"""Store retry attempt details after failure."""
import os
import json
@@ -1306,7 +1368,8 @@ def _store_retry_attempt(n_object, error):
'watch_url': n_object.get('watch_url'),
'error': str(error),
'will_retry': attempt_number <= NOTIFICATION_RETRY_COUNT,
'notification_data': n_object # Full notification context for verification
'notification_data': n_object, # Full notification context for verification
'payload': payload # What was attempted to be sent to Apprise
}
with open(attempt_file, 'w') as f:
@@ -1379,33 +1442,72 @@ def send_notification_task(n_object: NotificationContextData):
_reload_notification_config(n_object, watch, datastore)
# Send notification with Apprise log capture
sent_obj, apprise_logs = _capture_apprise_logs(
sent_objs, apprise_logs = _capture_apprise_logs(
lambda: process_notification(n_object, datastore)
)
# Extract rendered notification from first item in list (has actual title/body after Jinja rendering)
rendered_notification = None
if sent_objs and len(sent_objs) > 0:
first_sent = sent_objs[0]
rendered_notification = {
'notification_title': first_sent.get('title'),
'notification_body': first_sent.get('body'),
'notification_format': n_object.get('notification_format'),
'notification_urls': n_object.get('notification_urls'),
}
# Log success
now = datetime.now()
_add_to_debug_log(
notification_debug_log,
f"{now.strftime('%c')} - SENDING - {json.dumps(sent_obj)}"
f"{now.strftime('%c')} - SENDING - {json.dumps(sent_objs)}"
)
# Store success record and cleanup retries
# Store success record with rendered notification payload
try:
_store_successful_notification(n_object, apprise_logs)
_store_successful_notification(n_object, apprise_logs, payload=rendered_notification)
except Exception as e:
logger.error(f"Failed to store delivered notification: {e}", exc_info=True)
logger.success(f"Notification sent successfully for {n_object.get('watch_url')}")
return sent_obj
return sent_objs
except Exception as e:
# Log error
logger.error(f"Watch URL: {n_object.get('watch_url')} Error: {str(e)}")
# Try to render the notification to show what was actually attempted
# This ensures RETRYING and FAILED notifications show rendered content, not templates
attempted_payload = None
try:
from changedetectionio.jinja2_custom import render as jinja_render
from changedetectionio.notification.handler import create_notification_parameters
# Render the notification the same way process_notification does
notification_parameters = create_notification_parameters(n_object, datastore)
rendered_title = jinja_render(template_str=n_object.get('notification_title', ''), **notification_parameters)
rendered_body = jinja_render(template_str=n_object.get('notification_body', ''), **notification_parameters)
attempted_payload = {
'notification_urls': n_object.get('notification_urls'),
'notification_title': rendered_title,
'notification_body': rendered_body,
'notification_format': n_object.get('notification_format'),
}
except Exception as render_error:
# If rendering fails, fall back to raw template
logger.debug(f"Unable to render notification for retry attempt, using raw template: {render_error}")
attempted_payload = {
'notification_urls': n_object.get('notification_urls'),
'notification_title': n_object.get('notification_title'),
'notification_body': n_object.get('notification_body'),
'notification_format': n_object.get('notification_format'),
}
# Store retry attempt
try:
_store_retry_attempt(n_object, e)
_store_retry_attempt(n_object, e, payload=attempted_payload)
except Exception as store_error:
logger.debug(f"Unable to store retry attempt: {store_error}")
@@ -96,6 +96,27 @@ def test_notification_dead_letter_retry(client, live_server, measure_memory_usag
failed_count_before = len(failed_before)
assert failed_count_before > 0, "Should have at least one failed notification before retry"
# Verify payload is present in failed notification
first_failed = failed_before[0]
retry_attempts = first_failed.get('retry_attempts', [])
assert len(retry_attempts) > 0, "Failed notification should have retry attempts"
last_attempt = retry_attempts[-1]
payload = last_attempt.get('payload')
assert payload is not None, "Retry attempt should have payload"
assert 'notification_urls' in payload, "Payload should contain notification_urls"
assert 'notification_title' in payload, "Payload should contain notification_title"
assert 'notification_body' in payload, "Payload should contain notification_body"
assert 'notification_format' in payload, "Payload should contain notification_format"
# Verify the actual content we set
assert payload.get('notification_title') == 'Test Dead Letter', "Payload title should match what was set"
assert 'dead letter queue' in payload.get('notification_body', '').lower(), "Payload body should contain expected text"
assert payload.get('notification_format') == 'text', "Payload format should be text"
assert 'broken-url-xxxxxxxx-will-fail-456' in str(payload.get('notification_urls', '')), "Payload should contain the broken URL"
logging.info(f"✓ Payload verified: title='{payload.get('notification_title')}', format={payload.get('notification_format')}")
logging.info(f"✓ Payload body: '{payload.get('notification_body')[:50]}...'")
logging.info(f"Dead-letter queue has {failed_count_before} failed notification(s) before retry")
# Fix the notification URL before retrying so the retry will succeed