Compare commits

..

16 Commits

Author SHA1 Message Date
dgtlmoon
6a738ba0d7 Speed up worker check from 1 per second to 3x per second 2025-10-28 10:51:36 +01:00
dgtlmoon
2116b2cb93 CVE-2025-62780 - Stored XSS in Watch update via API 2025-10-28 10:09:30 +01:00
dgtlmoon
8f580ac96b 0.50.33
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-27 18:56:51 +01:00
dgtlmoon
a8cadc3d16 Fixing wrong notification type in <select> that lead to wrong type of notifications (plaintext vs html) being sent #3558 (#3559) 2025-10-27 18:56:01 +01:00
dgtlmoon
c9290d73e0 HTML - Shorten whitespace around timezone names 2025-10-27 17:08:05 +01:00
dgtlmoon
2db5e906e9 Update 21 for #3496 - Fixing update of timezone setting 2025-10-27 16:46:56 +01:00
dgtlmoon
0751bd371a OpenAPI specification, fixing enum for notification type, and notification_muted (#3557) Re #3556 2025-10-27 14:01:07 +01:00
dependabot[bot]
3ffa0805e9 Update brotli requirement from ~=1.0 to ~=1.1 (#3553)
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
2025-10-27 10:29:28 +01:00
dependabot[bot]
3335270692 Update wtforms requirement from ~=3.0 to ~=3.2 (#3551) 2025-10-27 10:28:37 +01:00
dependabot[bot]
a7573b10ec Build - Actions / Bump the all group with 2 updates (#3550) 2025-10-27 10:27:54 +01:00
dependabot[bot]
df945ad743 Update python-socketio requirement from ~=5.13.0 to ~=5.14.2 (#3552) 2025-10-27 10:27:36 +01:00
dependabot[bot]
4536e95205 RSS - Update feedgen requirement from ~=0.9 to ~=1.0 (#3554) 2025-10-27 10:27:16 +01:00
dgtlmoon
1479d7bd46 0.50.32
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
2025-10-25 19:28:36 +02:00
dgtlmoon
9ba2094f75 Tests - API - Import - Removed 'content-type': 'text/plain' from the test because this should be assumed. 2025-10-25 19:04:09 +02:00
dgtlmoon
8aa012ba8e API - Import - Automatically assume text/plain content type on Import (makes it easier for changedetection to add new URLs) #3547 #3542 2025-10-25 18:47:09 +02:00
dgtlmoon
8bc6b10db1 Notifications - Keep monospaced layout of history/difference sent to HTML style notifications, Fixes to Markdown #3540 (#3544) 2025-10-25 18:44:46 +02:00
32 changed files with 210 additions and 110 deletions

View File

@@ -21,7 +21,7 @@ jobs:
- name: Build a binary wheel and a source tarball - name: Build a binary wheel and a source tarball
run: python3 -m build run: python3 -m build
- name: Store the distribution packages - name: Store the distribution packages
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: python-package-distributions name: python-package-distributions
path: dist/ path: dist/
@@ -34,7 +34,7 @@ jobs:
- build - build
steps: steps:
- name: Download all the dists - name: Download all the dists
uses: actions/download-artifact@v5 uses: actions/download-artifact@v6
with: with:
name: python-package-distributions name: python-package-distributions
path: dist/ path: dist/
@@ -93,7 +93,7 @@ jobs:
steps: steps:
- name: Download all the dists - name: Download all the dists
uses: actions/download-artifact@v5 uses: actions/download-artifact@v6
with: with:
name: python-package-distributions name: python-package-distributions
path: dist/ path: dist/

View File

@@ -282,7 +282,7 @@ jobs:
- name: Store everything including test-datastore - name: Store everything including test-datastore
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: test-cdio-basic-tests-output-py${{ env.PYTHON_VERSION }} name: test-cdio-basic-tests-output-py${{ env.PYTHON_VERSION }}
path: . path: .

View File

@@ -2,7 +2,7 @@
# Read more https://github.com/dgtlmoon/changedetection.io/wiki # Read more https://github.com/dgtlmoon/changedetection.io/wiki
__version__ = '0.50.31' __version__ = '0.50.33'
from changedetectionio.strtobool import strtobool from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError

View File

@@ -3,15 +3,30 @@ from changedetectionio.strtobool import strtobool
from flask_restful import abort, Resource from flask_restful import abort, Resource
from flask import request from flask import request
import validators import validators
from functools import wraps
from . import auth, validate_openapi_request from . import auth, validate_openapi_request
def default_content_type(content_type='text/plain'):
"""Decorator to set a default Content-Type header if none is provided."""
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
if not request.content_type:
# Set default content type in the request environment
request.environ['CONTENT_TYPE'] = content_type
return f(*args, **kwargs)
return wrapper
return decorator
class Import(Resource): class Import(Resource):
def __init__(self, **kwargs): def __init__(self, **kwargs):
# datastore is a black box dependency # datastore is a black box dependency
self.datastore = kwargs['datastore'] self.datastore = kwargs['datastore']
@auth.check_token @auth.check_token
@default_content_type('text/plain') #3547 #3542
@validate_openapi_request('importWatches') @validate_openapi_request('importWatches')
def post(self): def post(self):
"""Import a list of watched URLs.""" """Import a list of watched URLs."""

View File

@@ -1,5 +1,7 @@
import os import os
from changedetectionio.strtobool import strtobool from changedetectionio.strtobool import strtobool
from changedetectionio.html_tools import is_safe_url
from flask_expects_json import expects_json from flask_expects_json import expects_json
from changedetectionio import queuedWatchMetaData from changedetectionio import queuedWatchMetaData
@@ -121,6 +123,10 @@ class Watch(Resource):
if validation_error: if validation_error:
return validation_error, 400 return validation_error, 400
# XSS etc protection
if request.json.get('url') and not is_safe_url(request.json.get('url')):
return "Invalid URL", 400
watch.update(request.json) watch.update(request.json)
return "OK", 200 return "OK", 200

View File

@@ -39,7 +39,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore):
try: try:
# Use native janus async interface - no threads needed! # Use native janus async interface - no threads needed!
queued_item_data = await asyncio.wait_for(q.async_get(), timeout=1.0) queued_item_data = await asyncio.wait_for(q.async_get(), timeout=0.3)
except asyncio.TimeoutError: except asyncio.TimeoutError:
# No jobs available, continue loop # No jobs available, continue loop

View File

@@ -240,9 +240,7 @@ nav
<p> <p>
{{ render_field(form.application.form.scheduler_timezone_default) }} {{ render_field(form.application.form.scheduler_timezone_default) }}
<datalist id="timezones" style="display: none;"> <datalist id="timezones" style="display: none;">
{% for tz_name in available_timezones %} {%- for timezone in available_timezones -%}<option value="{{ timezone }}">{{ timezone }}</option>{%- endfor -%}
<option value="{{ tz_name }}">{{ tz_name }}</option>
{% endfor %}
</datalist> </datalist>
</p> </p>
</div> </div>

View File

@@ -76,14 +76,14 @@ def _handle_operations(op, uuids, datastore, worker_handler, update_q, queuedWat
elif (op == 'notification-default'): elif (op == 'notification-default'):
from changedetectionio.notification import ( from changedetectionio.notification import (
default_notification_format_for_watch USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
) )
for uuid in uuids: for uuid in uuids:
if datastore.data['watching'].get(uuid): if datastore.data['watching'].get(uuid):
datastore.data['watching'][uuid]['notification_title'] = None datastore.data['watching'][uuid]['notification_title'] = None
datastore.data['watching'][uuid]['notification_body'] = None datastore.data['watching'][uuid]['notification_body'] = None
datastore.data['watching'][uuid]['notification_urls'] = [] datastore.data['watching'][uuid]['notification_urls'] = []
datastore.data['watching'][uuid]['notification_format'] = default_notification_format_for_watch datastore.data['watching'][uuid]['notification_format'] = USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
if emit_flash: if emit_flash:
flash(f"{len(uuids)} watches set to use default notification settings") flash(f"{len(uuids)} watches set to use default notification settings")

View File

@@ -75,7 +75,6 @@ class Fetcher():
self.screenshot = None self.screenshot = None
self.xpath_data = None self.xpath_data = None
# Keep headers and status_code as they're small # Keep headers and status_code as they're small
logger.trace("Fetcher content cleared from memory")
@abstractmethod @abstractmethod
def get_error(self): def get_error(self):

View File

@@ -133,6 +133,11 @@ def get_socketio_path():
# Socket.IO will be available at {prefix}/socket.io/ # Socket.IO will be available at {prefix}/socket.io/
return prefix return prefix
@app.template_global('is_safe_url')
def _is_safe_url(test_url):
from .html_tools import is_safe_url
return is_safe_url(test_url)
@app.template_filter('format_number_locale') @app.template_filter('format_number_locale')
def _jinja2_filter_format_number_locale(value: float) -> str: def _jinja2_filter_format_number_locale(value: float) -> str:

View File

@@ -550,7 +550,7 @@ def validate_url(test_url):
# This should be wtforms.validators. # This should be wtforms.validators.
raise ValidationError(message) raise ValidationError(message)
from .model.Watch import is_safe_url from changedetectionio.html_tools import is_safe_url
if not is_safe_url(test_url): if not is_safe_url(test_url):
# This should be wtforms.validators. # This should be wtforms.validators.
raise ValidationError('Watch protocol is not permitted by SAFE_PROTOCOL_REGEX or incorrect URL format') raise ValidationError('Watch protocol is not permitted by SAFE_PROTOCOL_REGEX or incorrect URL format')
@@ -741,7 +741,6 @@ class quickWatchForm(Form):
edit_and_watch_submit_button = SubmitField('Edit > Watch', render_kw={"class": "pure-button pure-button-primary"}) edit_and_watch_submit_button = SubmitField('Edit > Watch', render_kw={"class": "pure-button pure-button-primary"})
# Common to a single watch and the global settings # Common to a single watch and the global settings
class commonSettingsForm(Form): class commonSettingsForm(Form):
from . import processors from . import processors
@@ -754,7 +753,7 @@ class commonSettingsForm(Form):
fetch_backend = RadioField(u'Fetch Method', choices=content_fetchers.available_fetchers(), validators=[ValidateContentFetcherIsReady()]) fetch_backend = RadioField(u'Fetch Method', choices=content_fetchers.available_fetchers(), validators=[ValidateContentFetcherIsReady()])
notification_body = TextAreaField('Notification Body', default='{{ watch_url }} had a change.', validators=[validators.Optional(), ValidateJinja2Template()]) notification_body = TextAreaField('Notification Body', default='{{ watch_url }} had a change.', validators=[validators.Optional(), ValidateJinja2Template()])
notification_format = SelectField('Notification format', choices=valid_notification_formats.keys()) notification_format = SelectField('Notification format', choices=list(valid_notification_formats.items()))
notification_title = StringField('Notification Title', default='ChangeDetection.io Notification - {{ watch_url }}', validators=[validators.Optional(), ValidateJinja2Template()]) notification_title = StringField('Notification Title', default='ChangeDetection.io Notification - {{ watch_url }}', validators=[validators.Optional(), ValidateJinja2Template()])
notification_urls = StringListField('Notification URL List', validators=[validators.Optional(), ValidateAppRiseServers(), ValidateJinja2Template()]) notification_urls = StringListField('Notification URL List', validators=[validators.Optional(), ValidateAppRiseServers(), ValidateJinja2Template()])
processor = RadioField( label=u"Processor - What do you want to achieve?", choices=processors.available_processors(), default="text_json_diff") processor = RadioField( label=u"Processor - What do you want to achieve?", choices=processors.available_processors(), default="text_json_diff")

View File

@@ -13,6 +13,7 @@ TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.I | re.S)
META_CS = re.compile(r'<meta[^>]+charset=["\']?\s*([a-z0-9_\-:+.]+)', re.I) META_CS = re.compile(r'<meta[^>]+charset=["\']?\s*([a-z0-9_\-:+.]+)', re.I)
META_CT = re.compile(r'<meta[^>]+http-equiv=["\']?content-type["\']?[^>]*content=["\'][^>]*charset=([a-z0-9_\-:+.]+)', re.I) META_CT = re.compile(r'<meta[^>]+http-equiv=["\']?content-type["\']?[^>]*content=["\'][^>]*charset=([a-z0-9_\-:+.]+)', re.I)
SAFE_PROTOCOL_REGEX='^(http|https|ftp|file):'
# 'price' , 'lowPrice', 'highPrice' are usually under here # 'price' , 'lowPrice', 'highPrice' are usually under here
# All of those may or may not appear on different websites - I didnt find a way todo case-insensitive searching here # All of those may or may not appear on different websites - I didnt find a way todo case-insensitive searching here
@@ -22,6 +23,21 @@ class JSONNotFound(ValueError):
def __init__(self, msg): def __init__(self, msg):
ValueError.__init__(self, msg) ValueError.__init__(self, msg)
def is_safe_url(test_url):
import os
# See https://github.com/dgtlmoon/changedetection.io/issues/1358
# Remove 'source:' prefix so we dont get 'source:javascript:' etc
# 'source:' is a valid way to tell us to return the source
r = re.compile(re.escape('source:'), re.IGNORECASE)
test_url = r.sub('', test_url)
pattern = re.compile(os.getenv('SAFE_PROTOCOL_REGEX', SAFE_PROTOCOL_REGEX), re.IGNORECASE)
if not pattern.match(test_url.strip()):
return False
return True
# Doesn't look like python supports forward slash auto enclosure in re.findall # Doesn't look like python supports forward slash auto enclosure in re.findall
# So convert it to inline flag "(?i)foobar" type configuration # So convert it to inline flag "(?i)foobar" type configuration

View File

@@ -1,5 +1,5 @@
from blinker import signal from blinker import signal
from changedetectionio.html_tools import is_safe_url
from changedetectionio.strtobool import strtobool from changedetectionio.strtobool import strtobool
from changedetectionio.jinja2_custom import render as jinja_render from changedetectionio.jinja2_custom import render as jinja_render
from . import watch_base from . import watch_base
@@ -21,23 +21,6 @@ FAVICON_RESAVE_THRESHOLD_SECONDS=86400
minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3)) minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3))
mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7} mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7}
def is_safe_url(test_url):
# See https://github.com/dgtlmoon/changedetection.io/issues/1358
# Remove 'source:' prefix so we dont get 'source:javascript:' etc
# 'source:' is a valid way to tell us to return the source
r = re.compile(re.escape('source:'), re.IGNORECASE)
test_url = r.sub('', test_url)
pattern = re.compile(os.getenv('SAFE_PROTOCOL_REGEX', SAFE_PROTOCOL_REGEX), re.IGNORECASE)
if not pattern.match(test_url.strip()):
return False
return True
class model(watch_base): class model(watch_base):
__newest_history_key = None __newest_history_key = None
__history_n = 0 __history_n = 0

View File

@@ -2,7 +2,7 @@ import os
import uuid import uuid
from changedetectionio import strtobool from changedetectionio import strtobool
default_notification_format_for_watch = 'System default' USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH = 'System default'
CONDITIONS_MATCH_LOGIC_DEFAULT = 'ALL' CONDITIONS_MATCH_LOGIC_DEFAULT = 'ALL'
class watch_base(dict): class watch_base(dict):
@@ -44,7 +44,7 @@ class watch_base(dict):
'method': 'GET', 'method': 'GET',
'notification_alert_count': 0, 'notification_alert_count': 0,
'notification_body': None, 'notification_body': None,
'notification_format': default_notification_format_for_watch, 'notification_format': USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH,
'notification_muted': False, 'notification_muted': False,
'notification_screenshot': False, # Include the latest screenshot if available and supported by the apprise URL 'notification_screenshot': False, # Include the latest screenshot if available and supported by the apprise URL
'notification_title': None, 'notification_title': None,

View File

@@ -1,17 +1,16 @@
from changedetectionio.model import default_notification_format_for_watch from changedetectionio.model import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
default_notification_format = 'HTML Color' default_notification_format = 'htmlcolor'
default_notification_body = '{{watch_url}} had a change.\n---\n{{diff}}\n---\n' default_notification_body = '{{watch_url}} had a change.\n---\n{{diff}}\n---\n'
default_notification_title = 'ChangeDetection.io Notification - {{watch_url}}' default_notification_title = 'ChangeDetection.io Notification - {{watch_url}}'
# The values (markdown etc) are from apprise NotifyFormat, # The values (markdown etc) are from apprise NotifyFormat,
# But to avoid importing the whole heavy module just use the same strings here. # But to avoid importing the whole heavy module just use the same strings here.
valid_notification_formats = { valid_notification_formats = {
'Plain Text': 'text', 'text': 'Plain Text',
'HTML': 'html', 'html': 'HTML',
'HTML Color': 'htmlcolor', 'htmlcolor': 'HTML Color',
'Markdown to HTML': 'markdown', 'markdown': 'Markdown to HTML',
# Used only for editing a watch (not for global) # Used only for editing a watch (not for global)
default_notification_format_for_watch: default_notification_format_for_watch USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH: USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
} }

View File

@@ -63,13 +63,13 @@ def notification_format_align_with_apprise(n_format : str):
:return: :return:
""" """
if n_format.lower().startswith('html'): if n_format.startswith('html'):
# Apprise only knows 'html' not 'htmlcolor' etc, which shouldnt matter here # Apprise only knows 'html' not 'htmlcolor' etc, which shouldnt matter here
n_format = NotifyFormat.HTML.value n_format = NotifyFormat.HTML.value
elif n_format.lower().startswith('markdown'): elif n_format.startswith('markdown'):
# probably the same but just to be safe # probably the same but just to be safe
n_format = NotifyFormat.MARKDOWN.value n_format = NotifyFormat.MARKDOWN.value
elif n_format.lower().startswith('text'): elif n_format.startswith('text'):
# probably the same but just to be safe # probably the same but just to be safe
n_format = NotifyFormat.TEXT.value n_format = NotifyFormat.TEXT.value
else: else:
@@ -241,7 +241,7 @@ def apply_service_tweaks(url, n_body, n_title, requested_output_format):
def process_notification(n_object: NotificationContextData, datastore): def process_notification(n_object: NotificationContextData, datastore):
from changedetectionio.jinja2_custom import render as jinja_render from changedetectionio.jinja2_custom import render as jinja_render
from . import default_notification_format_for_watch, default_notification_format, valid_notification_formats from . import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH, default_notification_format, valid_notification_formats
# be sure its registered # be sure its registered
from .apprise_plugin.custom_handlers import apprise_http_custom_handler from .apprise_plugin.custom_handlers import apprise_http_custom_handler
# Register custom Discord plugin # Register custom Discord plugin
@@ -257,18 +257,17 @@ def process_notification(n_object: NotificationContextData, datastore):
# Insert variables into the notification content # Insert variables into the notification content
notification_parameters = create_notification_parameters(n_object, datastore) notification_parameters = create_notification_parameters(n_object, datastore)
requested_output_format = valid_notification_formats.get( requested_output_format = n_object.get('notification_format', default_notification_format)
n_object.get('notification_format', default_notification_format), logger.debug(f"Requested notification output format: '{requested_output_format}'")
valid_notification_formats[default_notification_format],
)
# If we arrived with 'System default' then look it up # If we arrived with 'System default' then look it up
if requested_output_format == default_notification_format_for_watch and datastore.data['settings']['application'].get('notification_format') != default_notification_format_for_watch: if requested_output_format == USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH:
# Initially text or whatever # Initially text or whatever
requested_output_format = datastore.data['settings']['application'].get('notification_format', valid_notification_formats[default_notification_format]).lower() requested_output_format = datastore.data['settings']['application'].get('notification_format', default_notification_format)
requested_output_format_original = requested_output_format requested_output_format_original = requested_output_format
# Now clean it up so it fits perfectly with apprise
requested_output_format = notification_format_align_with_apprise(n_format=requested_output_format) requested_output_format = notification_format_align_with_apprise(n_format=requested_output_format)
logger.trace(f"Complete notification body including Jinja and placeholders calculated in {time.time() - now:.2f}s") logger.trace(f"Complete notification body including Jinja and placeholders calculated in {time.time() - now:.2f}s")

View File

@@ -9,7 +9,8 @@ for both sync and async workers
from loguru import logger from loguru import logger
import time import time
from changedetectionio.notification import default_notification_format from changedetectionio.model import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
from changedetectionio.notification import default_notification_format, valid_notification_formats
# This gets modified on notification time (handler.py) depending on the required notification output # This gets modified on notification time (handler.py) depending on the required notification output
CUSTOM_LINEBREAK_PLACEHOLDER='@BR@' CUSTOM_LINEBREAK_PLACEHOLDER='@BR@'
@@ -48,15 +49,28 @@ class NotificationContextData(dict):
if kwargs: if kwargs:
self.update(kwargs) self.update(kwargs)
n_format = self.get('notification_format')
if n_format and not valid_notification_formats.get(n_format):
raise ValueError(f'Invalid notification format: "{n_format}"')
def set_random_for_validation(self): def set_random_for_validation(self):
import random, string import random, string
"""Randomly fills all dict keys with random strings (for validation/testing).""" """Randomly fills all dict keys with random strings (for validation/testing).
So we can test the output in the notification body
"""
for key in self.keys(): for key in self.keys():
if key in ['uuid', 'time', 'watch_uuid']: if key in ['uuid', 'time', 'watch_uuid']:
continue continue
rand_str = 'RANDOM-PLACEHOLDER-'+''.join(random.choices(string.ascii_letters + string.digits, k=12)) rand_str = 'RANDOM-PLACEHOLDER-'+''.join(random.choices(string.ascii_letters + string.digits, k=12))
self[key] = rand_str self[key] = rand_str
def __setitem__(self, key, value):
if key == 'notification_format' and isinstance(value, str) and not value.startswith('RANDOM-PLACEHOLDER-'):
if not valid_notification_formats.get(value):
raise ValueError(f'Invalid notification format: "{value}"')
super().__setitem__(key, value)
class NotificationService: class NotificationService:
""" """
Standalone notification service that handles all notification functionality Standalone notification service that handles all notification functionality
@@ -72,7 +86,7 @@ class NotificationService:
Queue a notification for a watch with full diff rendering and template variables Queue a notification for a watch with full diff rendering and template variables
""" """
from changedetectionio import diff from changedetectionio import diff
from changedetectionio.notification import default_notification_format_for_watch from changedetectionio.notification import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
if not isinstance(n_object, NotificationContextData): if not isinstance(n_object, NotificationContextData):
raise TypeError(f"Expected NotificationContextData, got {type(n_object)}") raise TypeError(f"Expected NotificationContextData, got {type(n_object)}")
@@ -94,7 +108,7 @@ class NotificationService:
snapshot_contents = "No snapshot/history available, the watch should fetch atleast once." snapshot_contents = "No snapshot/history available, the watch should fetch atleast once."
# If we ended up here with "System default" # If we ended up here with "System default"
if n_object.get('notification_format') == default_notification_format_for_watch: if n_object.get('notification_format') == USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH:
n_object['notification_format'] = self.datastore.data['settings']['application'].get('notification_format') n_object['notification_format'] = self.datastore.data['settings']['application'].get('notification_format')
@@ -141,7 +155,7 @@ class NotificationService:
Individual watch settings > Tag settings > Global settings Individual watch settings > Tag settings > Global settings
""" """
from changedetectionio.notification import ( from changedetectionio.notification import (
default_notification_format_for_watch, USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH,
default_notification_body, default_notification_body,
default_notification_title default_notification_title
) )
@@ -149,7 +163,7 @@ class NotificationService:
# Would be better if this was some kind of Object where Watch can reference the parent datastore etc # Would be better if this was some kind of Object where Watch can reference the parent datastore etc
v = watch.get(var_name) v = watch.get(var_name)
if v and not watch.get('notification_muted'): if v and not watch.get('notification_muted'):
if var_name == 'notification_format' and v == default_notification_format_for_watch: if var_name == 'notification_format' and v == USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH:
return self.datastore.data['settings']['application'].get('notification_format') return self.datastore.data['settings']['application'].get('notification_format')
return v return v
@@ -166,7 +180,7 @@ class NotificationService:
# Otherwise could be defaults # Otherwise could be defaults
if var_name == 'notification_format': if var_name == 'notification_format':
return default_notification_format_for_watch return USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
if var_name == 'notification_body': if var_name == 'notification_body':
return default_notification_body return default_notification_body
if var_name == 'notification_title': if var_name == 'notification_title':
@@ -221,7 +235,6 @@ class NotificationService:
if not watch: if not watch:
return return
n_format = self.datastore.data['settings']['application'].get('notification_format', default_notification_format)
filter_list = ", ".join(watch['include_filters']) filter_list = ", ".join(watch['include_filters'])
# @todo - This could be a markdown template on the disk, apprise will convert the markdown to HTML+Plaintext parts in the email, and then 'markup_text_links_to_html_links' is not needed # @todo - This could be a markdown template on the disk, apprise will convert the markdown to HTML+Plaintext parts in the email, and then 'markup_text_links_to_html_links' is not needed
body = f"""Hello, body = f"""Hello,
@@ -238,9 +251,9 @@ Thanks - Your omniscient changedetection.io installation.
n_object = NotificationContextData({ n_object = NotificationContextData({
'notification_title': 'Changedetection.io - Alert - CSS/xPath filter was not present in the page', 'notification_title': 'Changedetection.io - Alert - CSS/xPath filter was not present in the page',
'notification_body': body, 'notification_body': body,
'notification_format': n_format, 'notification_format': self._check_cascading_vars('notification_format', watch),
'markup_text_links_to_html_links': n_format.lower().startswith('html')
}) })
n_object['markup_text_links_to_html_links'] = n_object.get('notification_format').startswith('html')
if len(watch['notification_urls']): if len(watch['notification_urls']):
n_object['notification_urls'] = watch['notification_urls'] n_object['notification_urls'] = watch['notification_urls']
@@ -268,7 +281,7 @@ Thanks - Your omniscient changedetection.io installation.
if not watch: if not watch:
return return
threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts') threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts')
n_format = self.datastore.data['settings']['application'].get('notification_format', default_notification_format).lower()
step = step_n + 1 step = step_n + 1
# @todo - This could be a markdown template on the disk, apprise will convert the markdown to HTML+Plaintext parts in the email, and then 'markup_text_links_to_html_links' is not needed # @todo - This could be a markdown template on the disk, apprise will convert the markdown to HTML+Plaintext parts in the email, and then 'markup_text_links_to_html_links' is not needed
@@ -287,9 +300,9 @@ Thanks - Your omniscient changedetection.io installation.
n_object = NotificationContextData({ n_object = NotificationContextData({
'notification_title': f"Changedetection.io - Alert - Browser step at position {step} could not be run", 'notification_title': f"Changedetection.io - Alert - Browser step at position {step} could not be run",
'notification_body': body, 'notification_body': body,
'notification_format': n_format, 'notification_format': self._check_cascading_vars('notification_format', watch),
'markup_text_links_to_html_links': n_format.lower().startswith('html')
}) })
n_object['markup_text_links_to_html_links'] = n_object.get('notification_format').startswith('html')
if len(watch['notification_urls']): if len(watch['notification_urls']):
n_object['notification_urls'] = watch['notification_urls'] n_object['notification_urls'] = watch['notification_urls']

View File

@@ -1,11 +1,12 @@
from changedetectionio.strtobool import strtobool from changedetectionio.strtobool import strtobool
from changedetectionio.html_tools import is_safe_url
from flask import ( from flask import (
flash flash
) )
from .html_tools import TRANSLATE_WHITESPACE_TABLE from .html_tools import TRANSLATE_WHITESPACE_TABLE
from . model import App, Watch from .model import App, Watch, USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
from copy import deepcopy, copy from copy import deepcopy, copy
from os import path, unlink from os import path, unlink
from threading import Lock from threading import Lock
@@ -340,7 +341,6 @@ class ChangeDetectionStore:
logger.error(f"Error fetching metadata for shared watch link {url} {str(e)}") logger.error(f"Error fetching metadata for shared watch link {url} {str(e)}")
flash("Error fetching metadata for {}".format(url), 'error') flash("Error fetching metadata for {}".format(url), 'error')
return False return False
from .model.Watch import is_safe_url
if not is_safe_url(url): if not is_safe_url(url):
flash('Watch protocol is not permitted by SAFE_PROTOCOL_REGEX', 'error') flash('Watch protocol is not permitted by SAFE_PROTOCOL_REGEX', 'error')
return None return None
@@ -987,10 +987,35 @@ class ChangeDetectionStore:
self.data['settings']['application']['ui']['use_page_title_in_list'] = self.data['settings']['application'].get('extract_title_as_title') self.data['settings']['application']['ui']['use_page_title_in_list'] = self.data['settings']['application'].get('extract_title_as_title')
def update_21(self): def update_21(self):
if self.data['settings']['application'].get('timezone'):
self.data['settings']['application']['scheduler_timezone_default'] = self.data['settings']['application'].get('timezone') self.data['settings']['application']['scheduler_timezone_default'] = self.data['settings']['application'].get('timezone')
del self.data['settings']['application']['timezone'] del self.data['settings']['application']['timezone']
# Some notification formats got the wrong name type
def update_22(self):
from .notification import valid_notification_formats
sys_n_format = self.data['settings']['application'].get('notification_format')
key_exists_as_value = next((k for k, v in valid_notification_formats.items() if v == sys_n_format), None)
if key_exists_as_value: # key of "Plain text"
logger.success(f"['settings']['application']['notification_format'] '{sys_n_format}' -> '{key_exists_as_value}'")
self.data['settings']['application']['notification_format'] = key_exists_as_value
for uuid, watch in self.data['watching'].items():
n_format = self.data['watching'][uuid].get('notification_format')
key_exists_as_value = next((k for k, v in valid_notification_formats.items() if v == n_format), None)
if key_exists_as_value and key_exists_as_value != USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH: # key of "Plain text"
logger.success(f"['watching'][{uuid}]['notification_format'] '{n_format}' -> '{key_exists_as_value}'")
self.data['watching'][uuid]['notification_format'] = key_exists_as_value # should be 'text' or whatever
for uuid, tag in self.data['settings']['application']['tags'].items():
n_format = self.data['settings']['application']['tags'][uuid].get('notification_format')
key_exists_as_value = next((k for k, v in valid_notification_formats.items() if v == n_format), None)
if key_exists_as_value and key_exists_as_value != USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH: # key of "Plain text"
logger.success(f"['settings']['application']['tags'][{uuid}]['notification_format'] '{n_format}' -> '{key_exists_as_value}'")
self.data['settings']['application']['tags'][uuid]['notification_format'] = key_exists_as_value # should be 'text' or whatever
def add_notification_url(self, notification_url): def add_notification_url(self, notification_url):
logger.debug(f">>> Adding new notification_url - '{notification_url}'") logger.debug(f">>> Adding new notification_url - '{notification_url}'")

View File

@@ -266,9 +266,7 @@
<li id="timezone-info"> <li id="timezone-info">
{{ render_field(form.time_schedule_limit.timezone, placeholder=timezone_default_config) }} <span id="local-time-in-tz"></span> {{ render_field(form.time_schedule_limit.timezone, placeholder=timezone_default_config) }} <span id="local-time-in-tz"></span>
<datalist id="timezones" style="display: none;"> <datalist id="timezones" style="display: none;">
{% for timezone in available_timezones %} {%- for timezone in available_timezones -%}<option value="{{ timezone }}">{{ timezone }}</option>{%- endfor -%}
<option value="{{ timezone }}">{{ timezone }}</option>
{% endfor %}
</datalist> </datalist>
</li> </li>
</ul> </ul>

View File

@@ -53,7 +53,7 @@
<a class="pure-menu-heading" href="{{url_for('watchlist.index')}}"> <a class="pure-menu-heading" href="{{url_for('watchlist.index')}}">
<strong>Change</strong>Detection.io</a> <strong>Change</strong>Detection.io</a>
{% endif %} {% endif %}
{% if current_diff_url %} {% if current_diff_url and is_safe_url(current_diff_url) %}
<a class="current-diff-url" href="{{ current_diff_url }}"> <a class="current-diff-url" href="{{ current_diff_url }}">
<span style="max-width: 30%; overflow: hidden">{{ current_diff_url }}</span></a> <span style="max-width: 30%; overflow: hidden">{{ current_diff_url }}</span></a>
{% else %} {% else %}

View File

@@ -53,7 +53,7 @@ def test_check_notification_email_formats_default_HTML(client, live_server, meas
data={"application-notification_urls": notification_url, data={"application-notification_urls": notification_url,
"application-notification_title": "fallback-title " + default_notification_title, "application-notification_title": "fallback-title " + default_notification_title,
"application-notification_body": "some text\nfallback-body<br> " + default_notification_body, "application-notification_body": "some text\nfallback-body<br> " + default_notification_body,
"application-notification_format": 'HTML', "application-notification_format": 'html',
"requests-time_between_check-minutes": 180, "requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"}, 'application-fetch_backend': "html_requests"},
follow_redirects=True follow_redirects=True
@@ -122,7 +122,7 @@ def test_check_notification_plaintext_format(client, live_server, measure_memory
data={"application-notification_urls": notification_url, data={"application-notification_urls": notification_url,
"application-notification_title": "fallback-title " + default_notification_title, "application-notification_title": "fallback-title " + default_notification_title,
"application-notification_body": "some text\n" + default_notification_body, "application-notification_body": "some text\n" + default_notification_body,
"application-notification_format": 'Plain Text', "application-notification_format": 'text',
"requests-time_between_check-minutes": 180, "requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"}, 'application-fetch_backend': "html_requests"},
follow_redirects=True follow_redirects=True
@@ -174,7 +174,7 @@ def test_check_notification_html_color_format(client, live_server, measure_memor
data={"application-notification_urls": notification_url, data={"application-notification_urls": notification_url,
"application-notification_title": "fallback-title " + default_notification_title, "application-notification_title": "fallback-title " + default_notification_title,
"application-notification_body": f"some text\n{default_notification_body}\nMore output test\n{ALL_MARKUP_TOKENS}", "application-notification_body": f"some text\n{default_notification_body}\nMore output test\n{ALL_MARKUP_TOKENS}",
"application-notification_format": 'HTML Color', "application-notification_format": 'htmlcolor',
"requests-time_between_check-minutes": 180, "requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"}, 'application-fetch_backend': "html_requests"},
follow_redirects=True follow_redirects=True
@@ -245,7 +245,7 @@ def test_check_notification_markdown_format(client, live_server, measure_memory_
data={"application-notification_urls": notification_url, data={"application-notification_urls": notification_url,
"application-notification_title": "fallback-title " + default_notification_title, "application-notification_title": "fallback-title " + default_notification_title,
"application-notification_body": "*header*\n\nsome text\n" + default_notification_body, "application-notification_body": "*header*\n\nsome text\n" + default_notification_body,
"application-notification_format": 'Markdown to HTML', "application-notification_format": 'markdown',
"requests-time_between_check-minutes": 180, "requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"}, 'application-fetch_backend': "html_requests"},
follow_redirects=True follow_redirects=True
@@ -329,7 +329,7 @@ def test_check_notification_email_formats_default_Text_override_HTML(client, liv
data={"application-notification_urls": notification_url, data={"application-notification_urls": notification_url,
"application-notification_title": "fallback-title " + default_notification_title, "application-notification_title": "fallback-title " + default_notification_title,
"application-notification_body": notification_body, "application-notification_body": notification_body,
"application-notification_format": 'Plain Text', "application-notification_format": 'text',
"requests-time_between_check-minutes": 180, "requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"}, 'application-fetch_backend': "html_requests"},
follow_redirects=True follow_redirects=True
@@ -379,7 +379,7 @@ def test_check_notification_email_formats_default_Text_override_HTML(client, liv
url_for("ui.ui_edit.edit_page", uuid="first"), url_for("ui.ui_edit.edit_page", uuid="first"),
data={ data={
"url": test_url, "url": test_url,
"notification_format": 'HTML', "notification_format": 'html',
'fetch_backend': "html_requests", 'fetch_backend': "html_requests",
"time_between_check_use_default": "y"}, "time_between_check_use_default": "y"},
follow_redirects=True follow_redirects=True
@@ -438,7 +438,7 @@ def test_check_plaintext_document_plaintext_notification_smtp(client, live_serve
data={"application-notification_urls": notification_url, data={"application-notification_urls": notification_url,
"application-notification_title": "fallback-title " + default_notification_title, "application-notification_title": "fallback-title " + default_notification_title,
"application-notification_body": f"{notification_body}\nMore output test\n{ALL_MARKUP_TOKENS}", "application-notification_body": f"{notification_body}\nMore output test\n{ALL_MARKUP_TOKENS}",
"application-notification_format": 'Plain Text', "application-notification_format": 'text',
"requests-time_between_check-minutes": 180, "requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"}, 'application-fetch_backend': "html_requests"},
follow_redirects=True follow_redirects=True
@@ -490,7 +490,7 @@ def test_check_plaintext_document_html_notifications(client, live_server, measur
data={"application-notification_urls": notification_url, data={"application-notification_urls": notification_url,
"application-notification_title": "fallback-title " + default_notification_title, "application-notification_title": "fallback-title " + default_notification_title,
"application-notification_body": f"{notification_body}\nMore output test\n{ALL_MARKUP_TOKENS}", "application-notification_body": f"{notification_body}\nMore output test\n{ALL_MARKUP_TOKENS}",
"application-notification_format": 'HTML', "application-notification_format": 'html',
"requests-time_between_check-minutes": 180, "requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"}, 'application-fetch_backend': "html_requests"},
follow_redirects=True follow_redirects=True
@@ -568,7 +568,7 @@ def test_check_plaintext_document_html_color_notifications(client, live_server,
data={"application-notification_urls": notification_url, data={"application-notification_urls": notification_url,
"application-notification_title": "fallback-title " + default_notification_title, "application-notification_title": "fallback-title " + default_notification_title,
"application-notification_body": f"{notification_body}\nMore output test\n{ALL_MARKUP_TOKENS}", "application-notification_body": f"{notification_body}\nMore output test\n{ALL_MARKUP_TOKENS}",
"application-notification_format": 'HTML Color', "application-notification_format": 'htmlcolor',
"requests-time_between_check-minutes": 180, "requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"}, 'application-fetch_backend': "html_requests"},
follow_redirects=True follow_redirects=True
@@ -640,7 +640,7 @@ def test_check_html_document_plaintext_notification(client, live_server, measure
data={"application-notification_urls": notification_url, data={"application-notification_urls": notification_url,
"application-notification_title": "fallback-title " + default_notification_title, "application-notification_title": "fallback-title " + default_notification_title,
"application-notification_body": f"{notification_body}\nMore output test\n{ALL_MARKUP_TOKENS}", "application-notification_body": f"{notification_body}\nMore output test\n{ALL_MARKUP_TOKENS}",
"application-notification_format": 'Plain Text', "application-notification_format": 'text',
"requests-time_between_check-minutes": 180, "requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"}, 'application-fetch_backend': "html_requests"},
follow_redirects=True follow_redirects=True

View File

@@ -124,7 +124,7 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa
"application-notification_body": 'triggered text was -{{triggered_text}}- ### 网站监测 内容更新了 ####', "application-notification_body": 'triggered text was -{{triggered_text}}- ### 网站监测 内容更新了 ####',
# https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation
"application-notification_urls": test_notification_url, "application-notification_urls": test_notification_url,
"application-notification_format": 'Plain Text', "application-notification_format": 'text',
"application-minutes_between_check": 180, "application-minutes_between_check": 180,
"application-fetch_backend": "html_requests" "application-fetch_backend": "html_requests"
}, },

View File

@@ -370,7 +370,7 @@ def test_api_watch_PUT_update(client, live_server, measure_memory_usage):
###################################################### ######################################################
# HTTP PUT try a field that doenst exist # HTTP PUT try a field that doesn't exist
# HTTP PUT an update # HTTP PUT an update
res = client.put( res = client.put(
@@ -383,6 +383,17 @@ def test_api_watch_PUT_update(client, live_server, measure_memory_usage):
# Message will come from `flask_expects_json` # Message will come from `flask_expects_json`
assert b'Additional properties are not allowed' in res.data assert b'Additional properties are not allowed' in res.data
# Try a XSS URL
res = client.put(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps({
'url': 'javascript:alert(document.domain)'
}),
)
assert res.status_code == 400
# Cleanup everything # Cleanup everything
delete_all_watches(client) delete_all_watches(client)
@@ -394,7 +405,8 @@ def test_api_import(client, live_server, measure_memory_usage):
res = client.post( res = client.post(
url_for("import") + "?tag=import-test", url_for("import") + "?tag=import-test",
data='https://website1.com\r\nhttps://website2.com', data='https://website1.com\r\nhttps://website2.com',
headers={'x-api-key': api_key, 'content-type': 'text/plain'}, # We removed 'content-type': 'text/plain', the Import API should assume this if none is set #3547 #3542
headers={'x-api-key': api_key},
follow_redirects=True follow_redirects=True
) )

View File

@@ -86,7 +86,7 @@ def test_filter_doesnt_exist_then_exists_should_get_notification(client, live_se
"Diff Full: {{diff_full}}\n" "Diff Full: {{diff_full}}\n"
"Diff as Patch: {{diff_patch}}\n" "Diff as Patch: {{diff_patch}}\n"
":-)", ":-)",
"notification_format": 'Plain Text'} "notification_format": 'text'}
notification_form_data.update({ notification_form_data.update({
"url": test_url, "url": test_url,

View File

@@ -63,7 +63,7 @@ def run_filter_test(client, live_server, content_filter, app_notification_format
"Diff Full: {{diff_full}}\n" "Diff Full: {{diff_full}}\n"
"Diff as Patch: {{diff_patch}}\n" "Diff as Patch: {{diff_patch}}\n"
":-)", ":-)",
"notification_format": 'Plain Text', "notification_format": 'text',
"fetch_backend": "html_requests", "fetch_backend": "html_requests",
"filter_failure_notification_send": 'y', "filter_failure_notification_send": 'y',
"time_between_check_use_default": "y", "time_between_check_use_default": "y",
@@ -175,13 +175,13 @@ def run_filter_test(client, live_server, content_filter, app_notification_format
def test_check_include_filters_failure_notification(client, live_server, measure_memory_usage): def test_check_include_filters_failure_notification(client, live_server, measure_memory_usage):
# # live_server_setup(live_server) # Setup on conftest per function # # live_server_setup(live_server) # Setup on conftest per function
run_filter_test(client=client, live_server=live_server, content_filter='#nope-doesnt-exist', app_notification_format=valid_notification_formats.get('HTML Color')) run_filter_test(client=client, live_server=live_server, content_filter='#nope-doesnt-exist', app_notification_format=valid_notification_formats.get('htmlcolor'))
# Check markup send conversion didnt affect plaintext preference # Check markup send conversion didnt affect plaintext preference
run_filter_test(client=client, live_server=live_server, content_filter='#nope-doesnt-exist', app_notification_format=valid_notification_formats.get('Plain Text')) run_filter_test(client=client, live_server=live_server, content_filter='#nope-doesnt-exist', app_notification_format=valid_notification_formats.get('text'))
def test_check_xpath_filter_failure_notification(client, live_server, measure_memory_usage): def test_check_xpath_filter_failure_notification(client, live_server, measure_memory_usage):
# # live_server_setup(live_server) # Setup on conftest per function # # live_server_setup(live_server) # Setup on conftest per function
run_filter_test(client=client, live_server=live_server, content_filter='//*[@id="nope-doesnt-exist"]', app_notification_format=valid_notification_formats.get('HTML Color')) run_filter_test(client=client, live_server=live_server, content_filter='//*[@id="nope-doesnt-exist"]', app_notification_format=valid_notification_formats.get('htmlcolor'))
# Test that notification is never sent # Test that notification is never sent

View File

@@ -195,7 +195,7 @@ def test_group_tag_notification(client, live_server, measure_memory_usage):
"Diff as Patch: {{diff_patch}}\n" "Diff as Patch: {{diff_patch}}\n"
":-)", ":-)",
"notification_screenshot": True, "notification_screenshot": True,
"notification_format": 'Plain Text', "notification_format": 'text',
"title": "test-tag"} "title": "test-tag"}
res = client.post( res = client.post(

View File

@@ -13,10 +13,10 @@ import base64
from changedetectionio.notification import ( from changedetectionio.notification import (
default_notification_body, default_notification_body,
default_notification_format, default_notification_format,
default_notification_title, default_notification_title, valid_notification_formats
valid_notification_formats,
) )
from ..diff import HTML_CHANGED_STYLE from ..diff import HTML_CHANGED_STYLE
from ..model import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
# Hard to just add more live server URLs when one test is already running (I think) # Hard to just add more live server URLs when one test is already running (I think)
@@ -47,6 +47,14 @@ def test_check_notification(client, live_server, measure_memory_usage):
assert b"Settings updated." in res.data assert b"Settings updated." in res.data
res = client.get(url_for("settings.settings_page"))
for k,v in valid_notification_formats.items():
if k == USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH:
continue
assert f'value="{k}"'.encode() in res.data # Should be by key NOT value
assert f'value="{v}"'.encode() not in res.data # Should be by key NOT value
# When test mode is in BASE_URL env mode, we should see this already configured # When test mode is in BASE_URL env mode, we should see this already configured
env_base_url = os.getenv('BASE_URL', '').strip() env_base_url = os.getenv('BASE_URL', '').strip()
if len(env_base_url): if len(env_base_url):
@@ -101,7 +109,7 @@ def test_check_notification(client, live_server, measure_memory_usage):
"Diff as Patch: {{diff_patch}}\n" "Diff as Patch: {{diff_patch}}\n"
":-)", ":-)",
"notification_screenshot": True, "notification_screenshot": True,
"notification_format": 'Plain Text'} "notification_format": 'text'}
notification_form_data.update({ notification_form_data.update({
"url": test_url, "url": test_url,
@@ -267,7 +275,7 @@ def test_notification_validation(client, live_server, measure_memory_usage):
# data={"notification_urls": 'json://localhost/foobar', # data={"notification_urls": 'json://localhost/foobar',
# "notification_title": "", # "notification_title": "",
# "notification_body": "", # "notification_body": "",
# "notification_format": 'Plain Text', # "notification_format": 'text',
# "url": test_url, # "url": test_url,
# "tag": "my tag", # "tag": "my tag",
# "title": "my title", # "title": "my title",
@@ -521,7 +529,7 @@ def _test_color_notifications(client, notification_body_token):
"application-fetch_backend": "html_requests", "application-fetch_backend": "html_requests",
"application-minutes_between_check": 180, "application-minutes_between_check": 180,
"application-notification_body": notification_body_token, "application-notification_body": notification_body_token,
"application-notification_format": "HTML Color", "application-notification_format": "htmlcolor",
"application-notification_urls": test_notification_url, "application-notification_urls": test_notification_url,
"application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}", "application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}",
}, },

View File

@@ -30,7 +30,7 @@ def test_check_notification_error_handling(client, live_server, measure_memory_u
data={"notification_urls": f"{broken_notification_url}\r\n{working_notification_url}", data={"notification_urls": f"{broken_notification_url}\r\n{working_notification_url}",
"notification_title": "xxx", "notification_title": "xxx",
"notification_body": "xxxxx", "notification_body": "xxxxx",
"notification_format": 'Plain Text', "notification_format": 'text',
"url": test_url, "url": test_url,
"tags": "", "tags": "",
"title": "", "title": "",

View File

@@ -1,6 +1,8 @@
import os import os
from flask import url_for from flask import url_for
from changedetectionio.tests.util import set_modified_response
from .util import live_server_setup, wait_for_all_checks, delete_all_watches from .util import live_server_setup, wait_for_all_checks, delete_all_watches
from .. import strtobool from .. import strtobool
@@ -132,6 +134,26 @@ def test_xss(client, live_server, measure_memory_usage):
assert b"<img src=x onerror=alert(" not in res.data assert b"<img src=x onerror=alert(" not in res.data
assert b"&lt;img" in res.data assert b"&lt;img" in res.data
# Check that even forcing an update directly still doesnt get to the frontend
set_original_response()
XSS_HACK = 'javascript:alert(document.domain)'
uuid = client.application.config.get('DATASTORE').add_watch(url=url_for('test_endpoint', _external=True))
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
set_modified_response()
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
live_server.app.config['DATASTORE'].data['watching'][uuid]['url']=XSS_HACK
res = client.get(url_for("ui.ui_views.preview_page", uuid=uuid))
assert XSS_HACK.encode('utf-8') not in res.data and res.status_code == 200
client.get(url_for("ui.ui_views.diff_history_page", uuid=uuid))
assert XSS_HACK.encode('utf-8') not in res.data and res.status_code == 200
res = client.get(url_for("watchlist.index"))
assert XSS_HACK.encode('utf-8') not in res.data and res.status_code == 200
def test_xss_watch_last_error(client, live_server, measure_memory_usage): def test_xss_watch_last_error(client, live_server, measure_memory_usage):
set_original_response() set_original_response()

View File

@@ -2,6 +2,9 @@
import sys import sys
import os import os
from changedetectionio.model import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..')) sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
from changedetectionio.widgets import TernaryNoneBooleanField from changedetectionio.widgets import TernaryNoneBooleanField
@@ -93,7 +96,7 @@ def test_custom_text():
print(f"Does NOT contain 'System default': {'System default' not in boolean_html}") print(f"Does NOT contain 'System default': {'System default' not in boolean_html}")
print(f"Does NOT contain 'Default': {'Default' not in boolean_html}") print(f"Does NOT contain 'Default': {'Default' not in boolean_html}")
assert 'Enabled' in boolean_html and 'Disabled' in boolean_html assert 'Enabled' in boolean_html and 'Disabled' in boolean_html
assert 'System default' not in boolean_html and 'Default' not in boolean_html assert USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH not in boolean_html and 'Default' not in boolean_html
# Test FontAwesome field # Test FontAwesome field
print("\n--- FontAwesome Icons Field ---") print("\n--- FontAwesome Icons Field ---")

View File

@@ -28,7 +28,7 @@ info:
For example: `x-api-key: YOUR_API_KEY` For example: `x-api-key: YOUR_API_KEY`
version: 0.1.1 version: 0.1.2
contact: contact:
name: ChangeDetection.io name: ChangeDetection.io
url: https://github.com/dgtlmoon/changedetection.io url: https://github.com/dgtlmoon/changedetection.io
@@ -143,7 +143,7 @@ components:
paused: paused:
type: boolean type: boolean
description: Whether the web page change monitor (watch) is paused description: Whether the web page change monitor (watch) is paused
muted: notification_muted:
type: boolean type: boolean
description: Whether notifications are muted description: Whether notifications are muted
method: method:
@@ -207,7 +207,7 @@ components:
maxLength: 5000 maxLength: 5000
notification_format: notification_format:
type: string type: string
enum: [Text, HTML, Markdown] enum: ['text', 'html', 'htmlcolor', 'markdown', 'System default']
description: Format for notifications description: Format for notifications
track_ldjson_price_data: track_ldjson_price_data:
type: boolean type: boolean
@@ -406,7 +406,7 @@ paths:
page_title: "The HTML <title> from the page" page_title: "The HTML <title> from the page"
tags: ["550e8400-e29b-41d4-a716-446655440000"] tags: ["550e8400-e29b-41d4-a716-446655440000"]
paused: false paused: false
muted: false notification_muted: false
method: "GET" method: "GET"
fetch_backend: "html_requests" fetch_backend: "html_requests"
last_checked: 1640995200 last_checked: 1640995200
@@ -419,7 +419,7 @@ paths:
page_title: "The HTML <title> from the page" page_title: "The HTML <title> from the page"
tags: ["330e8400-e29b-41d4-a716-446655440001"] tags: ["330e8400-e29b-41d4-a716-446655440001"]
paused: false paused: false
muted: true notification_muted: true
method: "GET" method: "GET"
fetch_backend: "html_webdriver" fetch_backend: "html_webdriver"
last_checked: 1640998800 last_checked: 1640998800
@@ -1224,7 +1224,7 @@ paths:
title: "Example Website Monitor" title: "Example Website Monitor"
tags: ["550e8400-e29b-41d4-a716-446655440000"] tags: ["550e8400-e29b-41d4-a716-446655440000"]
paused: false paused: false
muted: false notification_muted: false
/import: /import:
post: post:

View File

@@ -1,5 +1,5 @@
# eventlet>=0.38.0 # Removed - replaced with threading mode for better Python 3.12+ compatibility # eventlet>=0.38.0 # Removed - replaced with threading mode for better Python 3.12+ compatibility
feedgen~=0.9 feedgen~=1.0
feedparser~=6.0 # For parsing RSS/Atom feeds feedparser~=6.0 # For parsing RSS/Atom feeds
flask-compress flask-compress
# 0.6.3 included compatibility fix for werkzeug 3.x (2.x had deprecation of url handlers) # 0.6.3 included compatibility fix for werkzeug 3.x (2.x had deprecation of url handlers)
@@ -12,7 +12,7 @@ janus # Thread-safe async/sync queue bridge
flask_wtf~=1.2 flask_wtf~=1.2
flask~=3.1 flask~=3.1
flask-socketio~=5.5.1 flask-socketio~=5.5.1
python-socketio~=5.13.0 python-socketio~=5.14.2
python-engineio~=4.12.3 python-engineio~=4.12.3
inscriptis~=2.2 inscriptis~=2.2
pytz pytz
@@ -22,7 +22,7 @@ validators~=0.35
# Set these versions together to avoid a RequestsDependencyWarning # Set these versions together to avoid a RequestsDependencyWarning
# >= 2.26 also adds Brotli support if brotli is installed # >= 2.26 also adds Brotli support if brotli is installed
brotli~=1.0 brotli~=1.1
requests[socks] requests[socks]
requests-file requests-file
@@ -30,7 +30,7 @@ requests-file
# If specific version needed for security, use urllib3>=1.26.19,<3.0 # If specific version needed for security, use urllib3>=1.26.19,<3.0
chardet>2.3.0 chardet>2.3.0
wtforms~=3.0 wtforms~=3.2
jsonpath-ng~=1.5.3 jsonpath-ng~=1.5.3
# dnspython - Used by paho-mqtt for MQTT broker resolution # dnspython - Used by paho-mqtt for MQTT broker resolution