Compare commits

...

12 Commits

Author SHA1 Message Date
dgtlmoon
fac3c9d71b Notification - Adding tokens {{diff_changed_from}} and {{diff_changed_to}} #3818 2026-04-09 08:09:49 +02:00
dgtlmoon
0479aa9654 UI - Minor text fix and add link to 'Restock Backup' from Imports 2026-04-09 07:20:11 +02:00
Michal Zuber
746e213398 Update Selenium RemoteConnection to use ClientConfig for timeout (#4027)
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 App Test / test-application-3-14 (push) Has been cancelled
2026-04-08 11:17:02 +02:00
skkzsh
84d97ec9cf Add Japanese translation (ja) (#4019)
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
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-04-05 07:55:58 +02:00
dgtlmoon
c8f13f5084 UI - German translation: Visual Filter: "Klare Auswahl" is very misleading #4023
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
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-04-04 06:11:38 +02:00
dgtlmoon
d74b7d5329 0.54.8 2026-04-04 06:00:23 +02:00
dgtlmoon
31a760c214 CVE-2026-35490 - Authentication Bypass via Decorator Ordering 2026-04-04 05:58:53 +02:00
dependabot[bot]
43bba5a1b6 Update openapi-core requirement from ~=0.22 to ~=0.23 (#4009)
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 App Test / test-application-3-14 (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
2026-04-03 07:18:17 +02:00
dgtlmoon
7c9eb02df4 Ensure all unit tests are run (#4022) 2026-04-03 07:16:52 +02:00
dgtlmoon
0ad4090d68 Extendable theme pluggy implementation for main theme/template <head> section (#4011)
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 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
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
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-03-27 12:28:13 +01:00
dgtlmoon
9a10353d61 Update docker-compose.yml
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 App Test / test-application-3-14 (push) Has been cancelled
2026-03-26 22:24:35 +01:00
dgtlmoon
f8236848ba Update docker-compose.yml 2026-03-26 19:23:51 +01:00
27 changed files with 3852 additions and 37 deletions

View File

@@ -99,11 +99,7 @@ jobs:
- name: Run Unit Tests
run: |
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_notification_diff'
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_watch_model'
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_jinja2_security'
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_semver'
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_html_to_text'
docker run test-changedetectionio bash -c 'cd changedetectionio;pytest tests/unit/'
# Basic pytest tests with ancillary services
basic-tests:
@@ -587,6 +583,10 @@ jobs:
run: |
docker run -e EXTRA_PACKAGES=changedetection.io-osint-processor test-changedetectionio bash -c 'cd changedetectionio;pytest -vvv -s tests/plugins/test_processor.py::test_check_plugin_processor'
- name: Plugin get_html_head_extras hook injects into base.html
run: |
docker run test-changedetectionio bash -c 'cd changedetectionio;pytest -vvv -s tests/plugins/test_html_head_extras.py'
# Container startup tests
container-tests:
runs-on: ubuntu-latest

View File

@@ -2,7 +2,7 @@
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
# Semver means never use .01, or 00. Should be .1.
__version__ = '0.54.7'
__version__ = '0.54.8'
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError

View File

@@ -98,8 +98,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
backups_blueprint.register_blueprint(construct_restore_blueprint(datastore))
backup_threads = []
@login_optionally_required
@backups_blueprint.route("/request-backup", methods=['GET'])
@login_optionally_required
def request_backup():
if any(thread.is_alive() for thread in backup_threads):
flash(gettext("A backup is already running, check back in a few minutes"), "error")
@@ -141,8 +141,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return backup_info
@login_optionally_required
@backups_blueprint.route("/download/<string:filename>", methods=['GET'])
@login_optionally_required
def download_backup(filename):
import re
filename = filename.strip()
@@ -165,9 +165,9 @@ def construct_blueprint(datastore: ChangeDetectionStore):
logger.debug(f"Backup download request for '{full_path}'")
return send_from_directory(os.path.abspath(datastore.datastore_path), filename, as_attachment=True)
@login_optionally_required
@backups_blueprint.route("/", methods=['GET'])
@backups_blueprint.route("/create", methods=['GET'])
@login_optionally_required
def create():
backups = find_backups()
output = render_template("backup_create.html",
@@ -176,8 +176,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
)
return output
@login_optionally_required
@backups_blueprint.route("/remove-backups", methods=['GET'])
@login_optionally_required
def remove_backups():
backup_filepath = os.path.join(datastore.datastore_path, BACKUP_FILENAME_FORMAT.format("*"))

View File

@@ -174,8 +174,8 @@ def construct_restore_blueprint(datastore):
restore_blueprint = Blueprint('restore', __name__, template_folder="templates")
restore_threads = []
@login_optionally_required
@restore_blueprint.route("/restore", methods=['GET'])
@login_optionally_required
def restore():
form = RestoreForm()
return render_template("backup_restore.html",
@@ -184,8 +184,8 @@ def construct_restore_blueprint(datastore):
max_upload_mb=_MAX_UPLOAD_BYTES // (1024 * 1024),
max_decompressed_mb=_MAX_DECOMPRESSED_BYTES // (1024 * 1024))
@login_optionally_required
@restore_blueprint.route("/restore/start", methods=['POST'])
@login_optionally_required
def backups_restore_start():
if any(t.is_alive() for t in restore_threads):
flash(gettext("A restore is already running, check back in a few minutes"), "error")

View File

@@ -20,8 +20,7 @@
<p>{{ _('Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout).') }}</p>
<p>{{ _('Note: This does not override the main application settings, only watches and groups.') }}</p>
<p class="pure-form-message">
{{ _('Max upload size: %(upload)s MB &nbsp;·&nbsp; Max decompressed size: %(decomp)s MB',
upload=max_upload_mb, decomp=max_decompressed_mb) }}
{{ _('Max upload size: %(upload)s MB, Max decompressed size: %(decomp)s MB', upload=max_upload_mb, decomp=max_decompressed_mb) }}
</p>
<form class="pure-form pure-form-stacked settings"

View File

@@ -268,8 +268,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return browsersteps_start_session
@login_optionally_required
@browser_steps_blueprint.route("/browsersteps_start_session", methods=['GET'])
@login_optionally_required
def browsersteps_start_session():
# A new session was requested, return sessionID
import uuid
@@ -304,8 +304,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
logger.debug("Starting connection with playwright - done")
return {'browsersteps_session_id': browsersteps_session_id}
@login_optionally_required
@browser_steps_blueprint.route("/browsersteps_image", methods=['GET'])
@login_optionally_required
def browser_steps_fetch_screenshot_image():
from flask import (
make_response,
@@ -330,8 +330,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return make_response('Unable to fetch image, is the URL correct? does the watch exist? does the step_type-n.jpeg exist?', 401)
# A request for an action was received
@login_optionally_required
@browser_steps_blueprint.route("/browsersteps_update", methods=['POST'])
@login_optionally_required
def browsersteps_ui_update():
import base64

View File

@@ -9,6 +9,7 @@
<li class="tab" id=""><a href="#url-list">{{ _('URL List') }}</a></li>
<li class="tab"><a href="#distill-io">{{ _('Distill.io') }}</a></li>
<li class="tab"><a href="#xlsx">{{ _('.XLSX & Wachete') }}</a></li>
<li class="tab"><a href="{{url_for('backups.restore.restore')}}">{{ _('Backup Restore') }}</a></li>
</ul>
</div>

View File

@@ -104,15 +104,17 @@ class fetcher(Fetcher):
from selenium.webdriver.remote.remote_connection import RemoteConnection
from selenium.webdriver.remote.webdriver import WebDriver as RemoteWebDriver
from selenium.webdriver.remote.client_config import ClientConfig
from urllib3.util import Timeout
driver = None
try:
# Create the RemoteConnection and set timeout (e.g., 30 seconds)
remote_connection = RemoteConnection(
self.browser_connection_url,
connection_timeout = int(os.getenv("WEBDRIVER_CONNECTION_TIMEOUT", 90))
client_config = ClientConfig(
remote_server_addr=self.browser_connection_url,
timeout=Timeout(connect=connection_timeout, total=connection_timeout)
)
remote_connection.set_timeout(30) # seconds
remote_connection = RemoteConnection(client_config=client_config)
# Now create the driver with the RemoteConnection
driver = RemoteWebDriver(
command_executor=remote_connection,
options=options

View File

@@ -45,6 +45,36 @@ CHANGED_INTO_PLACEMARKER_CLOSED = '@changed_into_PLACEMARKER_CLOSED'
# Compiled regex patterns for performance
WHITESPACE_NORMALIZE_RE = re.compile(r'\s+')
# Regexes built from the constants above — no brittle hardcoded strings
_EXTRACT_REMOVED_RE = re.compile(
re.escape(REMOVED_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(REMOVED_PLACEMARKER_CLOSED)
+ r'|' +
re.escape(CHANGED_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(CHANGED_PLACEMARKER_CLOSED)
)
_EXTRACT_ADDED_RE = re.compile(
re.escape(ADDED_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(ADDED_PLACEMARKER_CLOSED)
+ r'|' +
re.escape(CHANGED_INTO_PLACEMARKER_OPEN) + r'(.*?)' + re.escape(CHANGED_INTO_PLACEMARKER_CLOSED)
)
def extract_changed_from(raw_diff: str) -> str:
"""Extract only the removed/changed-from fragments from a raw diff string.
Useful for {{diff_changed_from}} — gives just the old value (e.g. old price),
not the full surrounding line. Multiple fragments joined with newlines.
"""
return '\n'.join(m.group(1) or m.group(2) for m in _EXTRACT_REMOVED_RE.finditer(raw_diff))
def extract_changed_to(raw_diff: str) -> str:
"""Extract only the added/changed-into fragments from a raw diff string.
Useful for {{diff_changed_to}} — gives just the new value (e.g. new price),
not the full surrounding line. Multiple fragments joined with newlines.
"""
return '\n'.join(m.group(1) or m.group(2) for m in _EXTRACT_ADDED_RE.finditer(raw_diff))
def render_inline_word_diff(before_line: str, after_line: str, ignore_junk: bool = False, markdown_style: str = None, tokenizer: str = 'words_and_html') -> tuple[str, bool]:
"""

View File

@@ -212,6 +212,11 @@ def _is_safe_valid_url(test_url):
from .validate_url import is_safe_valid_url
return is_safe_valid_url(test_url)
@app.template_global('get_html_head_extras')
def _get_html_head_extras():
from .pluggy_interface import collect_html_head_extras
return collect_html_head_extras()
@app.template_filter('format_number_locale')
def _jinja2_filter_format_number_locale(value: float) -> str:

View File

@@ -37,6 +37,7 @@ def get_timeago_locale(flask_locale):
'no': 'nb_NO', # Norwegian Bokmål
'hi': 'in_HI', # Hindi
'cs': 'en', # Czech not supported by timeago, fallback to English
'ja': 'ja', # Japanese
'uk': 'uk', # Ukrainian
'en_GB': 'en', # British English - timeago uses 'en'
'en_US': 'en', # American English - timeago uses 'en'

View File

@@ -88,6 +88,28 @@ class FormattableTimestamp(str):
return self._dt.isoformat()
class FormattableExtract(str):
"""
A str subclass that holds only the extracted changed fragments from a diff.
Used for {{diff_changed_from}} and {{diff_changed_to}} tokens.
{{ diff_changed_from }} → old value(s) only, e.g. "$99.99"
{{ diff_changed_to }} → new value(s) only, e.g. "$109.99"
Multiple changed fragments are joined with newlines.
Being a str subclass means it is natively JSON serializable.
"""
def __new__(cls, prev_snapshot, current_snapshot, extract_fn):
if prev_snapshot or current_snapshot:
from changedetectionio import diff as diff_module
raw = diff_module.render_diff(prev_snapshot, current_snapshot, word_diff=True)
extracted = extract_fn(raw)
else:
extracted = ''
instance = super().__new__(cls, extracted)
return instance
class FormattableDiff(str):
"""
A str subclass representing a rendered diff. As a plain string it renders
@@ -161,6 +183,8 @@ class NotificationContextData(dict):
'diff_patch': FormattableDiff('', '', patch_format=True),
'diff_removed': FormattableDiff('', '', include_added=False),
'diff_removed_clean': FormattableDiff('', '', include_added=False, include_change_type_prefix=False),
'diff_changed_from': FormattableExtract('', '', extract_fn=lambda x: x),
'diff_changed_to': FormattableExtract('', '', extract_fn=lambda x: x),
'diff_url': None,
'markup_text_links_to_html_links': False, # If automatic conversion of plaintext to HTML should happen
'notification_timestamp': time.time(),
@@ -244,16 +268,27 @@ def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snap
'diff_removed_clean': {'word_diff': word_diff, 'include_added': False, 'include_change_type_prefix': False},
}
from changedetectionio.diff import extract_changed_from, extract_changed_to
extract_specs = {
'diff_changed_from': extract_changed_from,
'diff_changed_to': extract_changed_to,
}
ret = {}
rendered_count = 0
# Only create FormattableDiff objects for diff keys actually used in the notification text
# Only create FormattableDiff/FormattableExtract objects for diff keys actually used in the notification text
for key in NotificationContextData().keys():
if key.startswith('diff') and key in diff_specs:
# Check if this placeholder is actually used in the notification text
pattern = rf"(?<![A-Za-z0-9_]){re.escape(key)}(?![A-Za-z0-9_])"
if re.search(pattern, notification_scan_text, re.IGNORECASE):
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, **diff_specs[key])
rendered_count += 1
if not key.startswith('diff'):
continue
pattern = rf"(?<![A-Za-z0-9_]){re.escape(key)}(?![A-Za-z0-9_])"
if not re.search(pattern, notification_scan_text, re.IGNORECASE):
continue
if key in diff_specs:
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, **diff_specs[key])
rendered_count += 1
elif key in extract_specs:
ret[key] = FormattableExtract(prev_snapshot, current_snapshot, extract_fn=extract_specs[key])
rendered_count += 1
if rendered_count:
logger.trace(f"Rendered {rendered_count} diff placeholder(s) {sorted(ret.keys())} in {time.time() - now:.3f}s")

View File

@@ -174,6 +174,64 @@ class ChangeDetectionSpec:
"""
pass
@hookspec
def get_html_head_extras():
"""Return HTML to inject into the <head> of every page via base.html.
Plugins can use this to add <script>, <style>, or <link> tags that should
be present on all pages. Return a raw HTML string or None.
IMPORTANT: Always use Flask's url_for() for any src/href URLs so that
sub-path deployments (nginx reverse proxy with USE_X_SETTINGS / X-Forwarded-Prefix)
work correctly. This hook is called inside a request context so url_for() is
always available.
For small amounts of CSS/JS, return them inline — no file-serving needed::
from changedetectionio.pluggy_interface import hookimpl
@hookimpl
def get_html_head_extras(self):
return (
'<style>.my-module-banner { color: red; }</style>\\n'
'<script>console.log("my_module_content loaded");</script>'
)
For larger assets, register your own lightweight Flask routes in the plugin
module and point to them with url_for() so the sub-path prefix is handled
automatically::
from flask import url_for, Response
from changedetectionio.pluggy_interface import hookimpl
from changedetectionio.flask_app import app as _app
MY_CSS = ".my-module-example { color: red; }"
MY_JS = "console.log('my_module_content loaded');"
@_app.route('/my_module_content/css')
def my_module_content_css():
return Response(MY_CSS, mimetype='text/css',
headers={'Cache-Control': 'max-age=3600'})
@_app.route('/my_module_content/js')
def my_module_content_js():
return Response(MY_JS, mimetype='application/javascript',
headers={'Cache-Control': 'max-age=3600'})
@hookimpl
def get_html_head_extras(self):
css = url_for('my_module_content_css')
js = url_for('my_module_content_js')
return (
f'<link rel="stylesheet" href="{css}">\\n'
f'<script src="{js}" defer></script>'
)
Returns:
str or None: Raw HTML string to inject inside <head>, or None
"""
pass
# Set up Plugin Manager
plugin_manager = pluggy.PluginManager(PLUGIN_NAMESPACE)
@@ -606,4 +664,20 @@ def apply_update_finalize(update_handler, watch, datastore, processing_exception
except Exception as e:
# Don't let plugin errors crash the worker
logger.error(f"Error in update_finalize hook: {e}")
logger.exception(f"update_finalize hook exception details:")
logger.exception(f"update_finalize hook exception details:")
def collect_html_head_extras():
"""Collect and combine HTML head extras from all plugins.
Called from a Flask template global so it always runs inside a request context.
This means url_for() works correctly in plugin implementations, including when the
app is deployed under a sub-path via USE_X_SETTINGS / X-Forwarded-Prefix (ProxyFix
sets SCRIPT_NAME so url_for() automatically prepends the prefix).
Returns:
str: Combined HTML string to inject inside <head>, or empty string
"""
results = plugin_manager.hook.get_html_head_extras()
parts = [r for r in results if r]
return "\n".join(parts) if parts else ""

View File

@@ -98,6 +98,14 @@
<td><code>{{ '{{diff_patch}}' }}</code></td>
<td>{{ _('The diff output - patch in unified format') }}</td>
</tr>
<tr>
<td><code>{{ '{{diff_changed_from}}' }}</code></td>
<td>{{ _('Only the changed fragments from the previous version — e.g. the old price. Multiple changes joined by newline.') }}</td>
</tr>
<tr>
<td><code>{{ '{{diff_changed_to}}' }}</code></td>
<td>{{ _('Only the changed fragments from the new version — e.g. the new price. Multiple changes joined by newline.') }}</td>
</tr>
<tr>
<td><code>{{ '{{current_snapshot}}' }}</code></td>
<td>{{ _('The current snapshot text contents value, useful when combined with JSON or CSS filters') }}

View File

@@ -45,6 +45,10 @@
<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>
{% endif %}
{%- set _html_head_extras = get_html_head_extras() -%}
{%- if _html_head_extras %}
{{ _html_head_extras | safe }}
{%- endif %}
</head>
<body class="{{extra_classes}}">

View File

@@ -0,0 +1,83 @@
"""Test that plugins can inject HTML into base.html <head> via get_html_head_extras hookimpl."""
import pytest
from flask import url_for, Response
from changedetectionio.pluggy_interface import hookimpl, plugin_manager
_MY_JS = "console.log('my_module_content loaded');"
_MY_CSS = ".my-module-example { color: red; }"
class _HeadExtrasPlugin:
"""Test plugin that injects tags pointing at its own Flask routes."""
@hookimpl
def get_html_head_extras(self):
css_url = url_for('test_plugin_my_module_content_css')
js_url = url_for('test_plugin_my_module_content_js')
return (
f'<link rel="stylesheet" id="test-head-extra-css" href="{css_url}">\n'
f'<script id="test-head-extra-js" src="{js_url}" defer></script>'
)
@pytest.fixture(scope='module')
def plugin_routes(live_server):
"""Register plugin asset routes once per module (Flask routes can't be added twice)."""
app = live_server.app
@app.route('/test-plugin/my_module_content/css')
def test_plugin_my_module_content_css():
return Response(_MY_CSS, mimetype='text/css',
headers={'Cache-Control': 'max-age=3600'})
@app.route('/test-plugin/my_module_content/js')
def test_plugin_my_module_content_js():
return Response(_MY_JS, mimetype='application/javascript',
headers={'Cache-Control': 'max-age=3600'})
@pytest.fixture
def head_extras_plugin(plugin_routes):
"""Register the hookimpl for one test then unregister it — function-scoped for clean isolation."""
plugin = _HeadExtrasPlugin()
plugin_manager.register(plugin, name="test_head_extras")
yield plugin
plugin_manager.unregister(name="test_head_extras")
def test_plugin_html_injected_into_head(client, live_server, measure_memory_usage, datastore_path, head_extras_plugin):
"""get_html_head_extras output must appear inside <head> in the rendered page."""
res = client.get(url_for("watchlist.index"), follow_redirects=True)
assert res.status_code == 200
assert b'id="test-head-extra-css"' in res.data, "Plugin <link> tag missing from rendered page"
assert b'id="test-head-extra-js"' in res.data, "Plugin <script> tag missing from rendered page"
head_end = res.data.find(b'</head>')
assert head_end != -1
for marker in (b'id="test-head-extra-css"', b'id="test-head-extra-js"'):
pos = res.data.find(marker)
assert pos != -1 and pos < head_end, f"{marker} must appear before </head>"
def test_plugin_js_route_returns_correct_content(client, live_server, measure_memory_usage, datastore_path, plugin_routes):
"""The plugin-registered JS route must return JS with the right Content-Type."""
res = client.get(url_for('test_plugin_my_module_content_js'))
assert res.status_code == 200
assert 'javascript' in res.content_type
assert _MY_JS.encode() in res.data
def test_plugin_css_route_returns_correct_content(client, live_server, measure_memory_usage, datastore_path, plugin_routes):
"""The plugin-registered CSS route must return CSS with the right Content-Type."""
res = client.get(url_for('test_plugin_my_module_content_css'))
assert res.status_code == 200
assert 'css' in res.content_type
assert _MY_CSS.encode() in res.data
def test_no_extras_without_plugin(client, live_server, measure_memory_usage, datastore_path):
"""With no hookimpl registered the markers must not appear (isolation check)."""
res = client.get(url_for("watchlist.index"), follow_redirects=True)
assert b'id="test-head-extra-css"' not in res.data
assert b'id="test-head-extra-js"' not in res.data

View File

@@ -11,10 +11,10 @@ from changedetectionio.tests.util import set_original_response, set_modified_res
set_longer_modified_response, delete_all_watches
import logging
import os
# NOTE - RELIES ON mailserver as hostname running, see github build recipes
smtp_test_server = 'mailserver'
smtp_test_server = os.getenv('SMTP_TEST_MAILSERVER', 'mailserver')
ALL_MARKUP_TOKENS = ''.join(f"TOKEN: '{t}'\n{{{{{t}}}}}\n" for t in NotificationContextData().keys())

View File

@@ -0,0 +1,85 @@
"""
Static analysis test: verify @login_optionally_required is always applied
AFTER (inner to) @blueprint.route(), not before it.
In Flask, @route() must be the outermost decorator because it registers
whatever function it receives. If @login_optionally_required is placed
above @route(), the raw unprotected function gets registered and auth is
silently bypassed (GHSA-jmrh-xmgh-x9j4).
Correct order (route outermost, auth inner):
@blueprint.route('/path')
@login_optionally_required
def view(): ...
Wrong order (auth never called):
@login_optionally_required ← registered by route, then discarded
@blueprint.route('/path')
def view(): ...
"""
import ast
import pathlib
import pytest
REPO_ROOT = pathlib.Path(__file__).parents[3] # …/changedetection.io/
SOURCE_ROOT = REPO_ROOT / "changedetectionio"
def _is_route_decorator(node: ast.expr) -> bool:
"""Return True if the decorator looks like @something.route(...)."""
return (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "route"
)
def _is_auth_decorator(node: ast.expr) -> bool:
"""Return True if the decorator is @login_optionally_required."""
return isinstance(node, ast.Name) and node.id == "login_optionally_required"
def collect_violations() -> list[str]:
violations = []
for path in SOURCE_ROOT.rglob("*.py"):
try:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
except SyntaxError:
continue
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
decorators = node.decorator_list
auth_indices = [i for i, d in enumerate(decorators) if _is_auth_decorator(d)]
route_indices = [i for i, d in enumerate(decorators) if _is_route_decorator(d)]
# Bad order: auth decorator appears at a lower index (higher up) than a route decorator
for auth_idx in auth_indices:
for route_idx in route_indices:
if auth_idx < route_idx:
rel = path.relative_to(REPO_ROOT)
violations.append(
f"{rel}:{node.lineno} — `{node.name}`: "
f"@login_optionally_required (line {decorators[auth_idx].lineno}) "
f"is above @route (line {decorators[route_idx].lineno}); "
f"auth wrapper will never be called"
)
return violations
def test_auth_decorator_order():
violations = collect_violations()
if violations:
msg = (
"\n\nFound routes where @login_optionally_required is placed ABOVE @blueprint.route().\n"
"This silently disables authentication — @route() registers the raw function\n"
"and the auth wrapper is never called.\n\n"
"Fix: move @blueprint.route() to be the outermost (topmost) decorator.\n\n"
+ "\n".join(f"{v}" for v in violations)
)
pytest.fail(msg)

View File

@@ -64,7 +64,7 @@ class TestTriggerConditions(unittest.TestCase):
"conditions": [
{"operator": ">=", "field": "extracted_number", "value": "10"},
{"operator": "<=", "field": "extracted_number", "value": "5000"},
{"operator": "in", "field": "page_text", "value": "rock"},
{"operator": "in", "field": "page_filtered_text", "value": "rock"},
#{"operator": "starts_with", "field": "page_text", "value": "I saw"},
]
}

View File

@@ -15,7 +15,9 @@ from changedetectionio.diff import (
CHANGED_PLACEMARKER_OPEN,
CHANGED_PLACEMARKER_CLOSED,
CHANGED_INTO_PLACEMARKER_OPEN,
CHANGED_INTO_PLACEMARKER_CLOSED
CHANGED_INTO_PLACEMARKER_CLOSED,
extract_changed_from,
extract_changed_to,
)
@@ -381,5 +383,72 @@ Line 3 with tabs and spaces"""
self.assertNotIn('[-Line 2-]', output)
self.assertNotIn('[+Line 2+]', output)
def test_diff_changed_from_to_word_level(self):
"""Primary use case: extract just the old/new value from a changed line (e.g. price monitoring)"""
before = "Widget costs $99.99 per month"
after = "Widget costs $109.99 per month"
raw = diff.render_diff(before, after, word_diff=True)
self.assertEqual(extract_changed_from(raw), "$99.99")
self.assertEqual(extract_changed_to(raw), "$109.99")
def test_diff_changed_from_to_multiple_changes(self):
"""Multiple changed fragments on different lines are joined with newline.
An unchanged line between the two changes ensures each is a 1-to-1 replace,
so word_diff fires per line rather than falling back to multi-line block mode."""
before = "Price $99\nunchanged\nTax $5"
after = "Price $149\nunchanged\nTax $12"
raw = diff.render_diff(before, after, word_diff=True)
self.assertEqual(extract_changed_from(raw), "$99\n$5")
self.assertEqual(extract_changed_to(raw), "$149\n$12")
def test_diff_changed_from_to_pure_insert_delete(self):
"""Pure line additions/deletions (no inline word diff) are also captured"""
before = "old line"
after = "new line"
# word_diff=False forces line-level CHANGED markers
raw = diff.render_diff(before, after, word_diff=False)
self.assertEqual(extract_changed_from(raw), "old line")
self.assertEqual(extract_changed_to(raw), "new line")
def test_diff_changed_from_to_similar_numbers(self):
"""$90.00 → $9.00 must not produce a partial match like '0.00'.
The tokenizer splits on whitespace only, so '$90.00' and '$9.00' are
each a single atomic token — diff never sees their internal characters."""
before = "for sale $90.00"
after = "for sale $9.00"
raw = diff.render_diff(before, after, word_diff=True)
self.assertEqual(extract_changed_from(raw), "$90.00")
self.assertEqual(extract_changed_to(raw), "$9.00")
def test_diff_changed_from_to_whole_line_replaced(self):
"""When every token on the line changed (no common tokens), render_inline_word_diff
takes the whole_line_replaced path using CHANGED/CHANGED_INTO markers instead of
REMOVED/ADDED. Extraction must still work via the alternation in the regex."""
before = "$99"
after = "$109"
raw = diff.render_diff(before, after, word_diff=True)
self.assertEqual(extract_changed_from(raw), "$99")
self.assertEqual(extract_changed_to(raw), "$109")
def test_diff_changed_from_to_no_change(self):
"""No changes → empty string"""
content = "nothing changed here"
raw = diff.render_diff(content, content, word_diff=True)
self.assertEqual(extract_changed_from(raw), "")
self.assertEqual(extract_changed_to(raw), "")
if __name__ == '__main__':
unittest.main()

View File

@@ -76,6 +76,7 @@ These commands read settings from `../../setup.cfg` automatically.
- `en_US` - English (US)
- `fr` - French (Français)
- `it` - Italian (Italiano)
- `ja` - Japanese (日本語)
- `ko` - Korean (한국어)
- `zh` - Chinese Simplified (中文简体)
- `zh_Hant_TW` - Chinese Traditional (繁體中文)

View File

@@ -1617,7 +1617,7 @@ msgstr "Bereich zeichnen"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "Clear selection"
msgstr "Klare Auswahl"
msgstr "Auswahl löschen"
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "One moment, fetching screenshot and element information.."

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,7 @@ services:
# - PLAYWRIGHT_DRIVER_URL=ws://browser-sockpuppet-chrome:3000
#
#
# Alternative WebDriver/selenium URL, do not use "'s or 's! (old, deprecated, does not support screenshots very well)
# Alternative WebDriver/selenium URL, do not use "'s or 's! (old, deprecated, does not support screenshots very well, Can't handle custom headers etc)
# - WEBDRIVER_URL=http://browser-selenium-chrome:4444/wd/hub
#
# WebDriver proxy settings webdriver_proxyType, webdriver_ftpProxy, webdriver_noProxy,

View File

@@ -98,7 +98,7 @@ pytest-flask ~=1.3
pytest-mock ~=3.15
# OpenAPI validation support
openapi-core[flask] ~= 0.22
openapi-core[flask] ~= 0.23
loguru