mirror of
				https://github.com/dgtlmoon/changedetection.io.git
				synced 2025-10-31 06:37:41 +00:00 
			
		
		
		
	Compare commits
	
		
			28 Commits
		
	
	
		
			sent-test-
			...
			0.49.1
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 6b1065502e | ||
|   | d4c470984a | ||
|   | 55da48f719 | ||
|   | dbd4adf23a | ||
|   | b1e700b3ff | ||
|   | 1c61b5a623 | ||
|   | e799a1cdcb | ||
|   | 938065db6f | ||
|   | 4f2d38ff49 | ||
|   | 8960f401b7 | ||
|   | 1c1f1c6f6b | ||
|   | a2a98811a5 | ||
|   | 5a0ef8fc01 | ||
|   | d90de0851d | ||
|   | 360b4f0d8b | ||
|   | 6fc04d7f1c | ||
|   | 66fb05527b | ||
|   | 202e47d728 | ||
|   | d67d396b88 | ||
|   | 05f54f0ce6 | ||
|   | 6adf10597e | ||
|   | 4419bc0e61 | ||
|   | f7e9846c9b | ||
|   | 5dea5e1def | ||
|   | 0fade0a473 | ||
|   | 121e9c20e0 | ||
|   | 12cec2d541 | ||
|   | d52e6e8e11 | 
							
								
								
									
										23
									
								
								.github/test/Dockerfile-alpine
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										23
									
								
								.github/test/Dockerfile-alpine
									
									
									
									
										vendored
									
									
								
							| @@ -2,32 +2,33 @@ | ||||
| # Test that we can still build on Alpine (musl modified libc https://musl.libc.org/) | ||||
| # Some packages wont install via pypi because they dont have a wheel available under this architecture. | ||||
|  | ||||
| FROM ghcr.io/linuxserver/baseimage-alpine:3.18 | ||||
| FROM ghcr.io/linuxserver/baseimage-alpine:3.21 | ||||
| ENV PYTHONUNBUFFERED=1 | ||||
|  | ||||
| COPY requirements.txt /requirements.txt | ||||
|  | ||||
| RUN \ | ||||
|   apk add --update --no-cache --virtual=build-dependencies \ | ||||
|  apk add --update --no-cache --virtual=build-dependencies \ | ||||
|     build-base \ | ||||
|     cargo \ | ||||
|     g++ \ | ||||
|     gcc \ | ||||
|     git \ | ||||
|     jpeg-dev \ | ||||
|     libc-dev \ | ||||
|     libffi-dev \ | ||||
|     libjpeg \ | ||||
|     libxslt-dev \ | ||||
|     make \ | ||||
|     openssl-dev \ | ||||
|     py3-wheel \ | ||||
|     python3-dev \ | ||||
|     zip \ | ||||
|     zlib-dev && \ | ||||
|   apk add --update --no-cache \ | ||||
|     libjpeg \ | ||||
|     libxslt \ | ||||
|     python3 \ | ||||
|     py3-pip && \ | ||||
|     nodejs \ | ||||
|     poppler-utils \ | ||||
|     python3 && \ | ||||
|   echo "**** pip3 install test of changedetection.io ****" && \ | ||||
|   pip3 install -U pip wheel setuptools && \ | ||||
|   pip3 install -U --no-cache-dir --find-links https://wheel-index.linuxserver.io/alpine-3.18/ -r /requirements.txt && \ | ||||
|   python3 -m venv /lsiopy  && \ | ||||
|   pip install -U pip wheel setuptools && \ | ||||
|   pip install -U --no-cache-dir --find-links https://wheel-index.linuxserver.io/alpine-3.21/ -r /requirements.txt && \ | ||||
|   apk del --purge \ | ||||
|     build-dependencies | ||||
|   | ||||
							
								
								
									
										19
									
								
								.github/workflows/containers.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										19
									
								
								.github/workflows/containers.yml
									
									
									
									
										vendored
									
									
								
							| @@ -103,6 +103,19 @@ jobs: | ||||
| #          provenance: false | ||||
|  | ||||
|       # A new tagged release is required, which builds :tag and :latest | ||||
|       - name: Docker meta :tag | ||||
|         if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.') | ||||
|         uses: docker/metadata-action@v5 | ||||
|         id: meta | ||||
|         with: | ||||
|             images: | | ||||
|                 ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io | ||||
|                 ghcr.io/dgtlmoon/changedetection.io | ||||
|             tags: | | ||||
|                 type=semver,pattern={{version}} | ||||
|                 type=semver,pattern={{major}}.{{minor}} | ||||
|                 type=semver,pattern={{major}} | ||||
|  | ||||
|       - name: Build and push :tag | ||||
|         id: docker_build_tag_release | ||||
|         if: github.event_name == 'release' && startsWith(github.event.release.tag_name, '0.') | ||||
| @@ -111,11 +124,7 @@ jobs: | ||||
|           context: ./ | ||||
|           file: ./Dockerfile | ||||
|           push: true | ||||
|           tags: | | ||||
|             ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:${{ github.event.release.tag_name }} | ||||
|             ghcr.io/dgtlmoon/changedetection.io:${{ github.event.release.tag_name }} | ||||
|             ${{ secrets.DOCKER_HUB_USERNAME }}/changedetection.io:latest | ||||
|             ghcr.io/dgtlmoon/changedetection.io:latest | ||||
|           tags: ${{ steps.meta.outputs.tags }} | ||||
|           platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8,linux/arm64/v8 | ||||
|           cache-from: type=gha | ||||
|           cache-to: type=gha,mode=max | ||||
|   | ||||
							
								
								
									
										1
									
								
								.github/workflows/pypi-release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/pypi-release.yml
									
									
									
									
										vendored
									
									
								
							| @@ -45,7 +45,6 @@ jobs: | ||||
|     - name: Test that the basic pip built package runs without error | ||||
|       run: | | ||||
|         set -ex | ||||
|         sudo pip3 install --upgrade pip  | ||||
|         pip3 install dist/changedetection.io*.whl | ||||
|         changedetection.io -d /tmp -p 10000 & | ||||
|         sleep 3 | ||||
|   | ||||
| @@ -64,14 +64,16 @@ jobs: | ||||
|           echo "Running processes in docker..." | ||||
|           docker ps | ||||
|  | ||||
|       - name: Test built container with Pytest (generally as requests/plaintext fetching) | ||||
|       - name: Run Unit Tests | ||||
|         run: | | ||||
|           # Unit tests | ||||
|           echo "run test with unittest" | ||||
|           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' | ||||
|  | ||||
|       - name: Test built container with Pytest (generally as requests/plaintext fetching) | ||||
|         run: | | ||||
|           # All tests | ||||
|           echo "run test with pytest" | ||||
|           # The default pytest logger_level is TRACE | ||||
|   | ||||
| @@ -120,7 +120,7 @@ Easily add the current web page to your changedetection.io tool, simply install | ||||
|  | ||||
| [<img src="./docs/chrome-extension-screenshot.png" style="max-width:80%;" alt="Chrome Extension to easily add the current web-page to detect a change."  title="Chrome Extension to easily add the current web-page to detect a change."  />](https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop) | ||||
|  | ||||
| [Goto the Chrome Webstore to download the extension.](https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop) | ||||
| [Goto the Chrome Webstore to download the extension.](https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop) ( Or check out the [GitHub repo](https://github.com/dgtlmoon/changedetection.io-browser-extension) )  | ||||
|  | ||||
| ## Installation | ||||
|  | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
|  | ||||
| # Read more https://github.com/dgtlmoon/changedetection.io/wiki | ||||
|  | ||||
| __version__ = '0.48.01' | ||||
| __version__ = '0.49.1' | ||||
|  | ||||
| from changedetectionio.strtobool import strtobool | ||||
| from json.decoder import JSONDecodeError | ||||
| @@ -24,6 +24,9 @@ from loguru import logger | ||||
| app = None | ||||
| datastore = None | ||||
|  | ||||
| def get_version(): | ||||
|     return __version__ | ||||
|  | ||||
| # Parent wrapper or OS sends us a SIGTERM/SIGINT, do everything required for a clean shutdown | ||||
| def sigshutdown_handler(_signo, _stack_frame): | ||||
|     global app | ||||
|   | ||||
| @@ -76,6 +76,7 @@ class Watch(Resource): | ||||
|         # Return without history, get that via another API call | ||||
|         # Properties are not returned as a JSON, so add the required props manually | ||||
|         watch['history_n'] = watch.history_n | ||||
|         # attr .last_changed will check for the last written text snapshot on change | ||||
|         watch['last_changed'] = watch.last_changed | ||||
|         watch['viewed'] = watch.viewed | ||||
|         return watch | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| from changedetectionio import apprise_plugin | ||||
| import apprise | ||||
|  | ||||
| # Create our AppriseAsset and populate it with some of our new values: | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| # include the decorator | ||||
| from apprise.decorators import notify | ||||
| from loguru import logger | ||||
| from requests.structures import CaseInsensitiveDict | ||||
|  | ||||
|  | ||||
| @notify(on="delete") | ||||
| @notify(on="deletes") | ||||
| @@ -13,70 +15,84 @@ from loguru import logger | ||||
| def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs): | ||||
|     import requests | ||||
|     import json | ||||
|     import re | ||||
|  | ||||
|     from urllib.parse import unquote_plus | ||||
|     from apprise.utils import parse_url as apprise_parse_url | ||||
|     from apprise import URLBase | ||||
|     from apprise.utils.parse import parse_url as apprise_parse_url | ||||
|  | ||||
|     url = kwargs['meta'].get('url') | ||||
|     schema = kwargs['meta'].get('schema').lower().strip() | ||||
|  | ||||
|     if url.startswith('post'): | ||||
|         r = requests.post | ||||
|     elif url.startswith('get'): | ||||
|         r = requests.get | ||||
|     elif url.startswith('put'): | ||||
|         r = requests.put | ||||
|     elif url.startswith('delete'): | ||||
|         r = requests.delete | ||||
|     # Choose POST, GET etc from requests | ||||
|     method =  re.sub(rf's$', '', schema) | ||||
|     requests_method = getattr(requests, method) | ||||
|  | ||||
|     url = url.replace('post://', 'http://') | ||||
|     url = url.replace('posts://', 'https://') | ||||
|     url = url.replace('put://', 'http://') | ||||
|     url = url.replace('puts://', 'https://') | ||||
|     url = url.replace('get://', 'http://') | ||||
|     url = url.replace('gets://', 'https://') | ||||
|     url = url.replace('put://', 'http://') | ||||
|     url = url.replace('puts://', 'https://') | ||||
|     url = url.replace('delete://', 'http://') | ||||
|     url = url.replace('deletes://', 'https://') | ||||
|  | ||||
|     headers = {} | ||||
|     params = {} | ||||
|     params = CaseInsensitiveDict({}) # Added to requests | ||||
|     auth = None | ||||
|     has_error = False | ||||
|  | ||||
|     # Convert /foobar?+some-header=hello to proper header dictionary | ||||
|     results = apprise_parse_url(url) | ||||
|     if results: | ||||
|         # Add our headers that the user can potentially over-ride if they wish | ||||
|         # to to our returned result set and tidy entries by unquoting them | ||||
|         headers = {unquote_plus(x): unquote_plus(y) | ||||
|                    for x, y in results['qsd+'].items()} | ||||
|  | ||||
|         # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation | ||||
|         # In Apprise, it relies on prefixing each request arg with "-", because it uses say &method=update as a flag for apprise | ||||
|         # but here we are making straight requests, so we need todo convert this against apprise's logic | ||||
|         for k, v in results['qsd'].items(): | ||||
|             if not k.strip('+-') in results['qsd+'].keys(): | ||||
|                 params[unquote_plus(k)] = unquote_plus(v) | ||||
|     # Add our headers that the user can potentially over-ride if they wish | ||||
|     # to to our returned result set and tidy entries by unquoting them | ||||
|     headers = CaseInsensitiveDict({unquote_plus(x): unquote_plus(y) | ||||
|                for x, y in results['qsd+'].items()}) | ||||
|  | ||||
|         # Determine Authentication | ||||
|         auth = '' | ||||
|         if results.get('user') and results.get('password'): | ||||
|             auth = (unquote_plus(results.get('user')), unquote_plus(results.get('user'))) | ||||
|         elif results.get('user'): | ||||
|             auth = (unquote_plus(results.get('user'))) | ||||
|     # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation | ||||
|     # In Apprise, it relies on prefixing each request arg with "-", because it uses say &method=update as a flag for apprise | ||||
|     # but here we are making straight requests, so we need todo convert this against apprise's logic | ||||
|     for k, v in results['qsd'].items(): | ||||
|         if not k.strip('+-') in results['qsd+'].keys(): | ||||
|             params[unquote_plus(k)] = unquote_plus(v) | ||||
|  | ||||
|     # Try to auto-guess if it's JSON | ||||
|     h = 'application/json; charset=utf-8' | ||||
|     # Determine Authentication | ||||
|     auth = '' | ||||
|     if results.get('user') and results.get('password'): | ||||
|         auth = (unquote_plus(results.get('user')), unquote_plus(results.get('user'))) | ||||
|     elif results.get('user'): | ||||
|         auth = (unquote_plus(results.get('user'))) | ||||
|  | ||||
|     # If it smells like it could be JSON and no content-type was already set, offer a default content type. | ||||
|     if body and '{' in body[:100] and not headers.get('Content-Type'): | ||||
|         json_header = 'application/json; charset=utf-8' | ||||
|         try: | ||||
|             # Try if it's JSON | ||||
|             json.loads(body) | ||||
|             headers['Content-Type'] = json_header | ||||
|         except ValueError as e: | ||||
|             logger.warning(f"Could not automatically add '{json_header}' header to the notification because the document failed to parse as JSON: {e}") | ||||
|             pass | ||||
|  | ||||
|     # POSTS -> HTTPS etc | ||||
|     if schema.lower().endswith('s'): | ||||
|         url = re.sub(rf'^{schema}', 'https', results.get('url')) | ||||
|     else: | ||||
|         url = re.sub(rf'^{schema}', 'http', results.get('url')) | ||||
|  | ||||
|     status_str = '' | ||||
|     try: | ||||
|         json.loads(body) | ||||
|         headers['Content-Type'] = h | ||||
|     except ValueError as e: | ||||
|         logger.warning(f"Could not automatically add '{h}' header to the {kwargs['meta'].get('schema')}:// notification because the document failed to parse as JSON: {e}") | ||||
|         pass | ||||
|         r = requests_method(url, | ||||
|           auth=auth, | ||||
|           data=body.encode('utf-8') if type(body) is str else body, | ||||
|           headers=headers, | ||||
|           params=params | ||||
|         ) | ||||
|  | ||||
|     r(results.get('url'), | ||||
|       auth=auth, | ||||
|       data=body.encode('utf-8') if type(body) is str else body, | ||||
|       headers=headers, | ||||
|       params=params | ||||
|       ) | ||||
|         if not (200 <= r.status_code < 300): | ||||
|             status_str = f"Error sending '{method.upper()}' request to {url} - Status: {r.status_code}: '{r.reason}'" | ||||
|             logger.error(status_str) | ||||
|             has_error = True | ||||
|         else: | ||||
|             logger.info(f"Sent '{method.upper()}' request to {url}") | ||||
|             has_error = False | ||||
|  | ||||
|     except requests.RequestException as e: | ||||
|         status_str = f"Error sending '{method.upper()}' request to {url} - {str(e)}" | ||||
|         logger.error(status_str) | ||||
|         has_error = True | ||||
|  | ||||
|     if has_error: | ||||
|         raise TypeError(status_str) | ||||
|  | ||||
|     return True | ||||
|   | ||||
| @@ -52,6 +52,7 @@ function isItemInStock() { | ||||
|         'niet leverbaar', | ||||
|         'niet op voorraad', | ||||
|         'no disponible', | ||||
|         'non disponibile', | ||||
|         'no longer in stock', | ||||
|         'no tickets available', | ||||
|         'not available', | ||||
|   | ||||
| @@ -598,17 +598,31 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|  | ||||
|             if 'notification_title' in request.form and request.form['notification_title'].strip(): | ||||
|                 n_object['notification_title'] = request.form.get('notification_title', '').strip() | ||||
|             elif datastore.data['settings']['application'].get('notification_title'): | ||||
|                 n_object['notification_title'] = datastore.data['settings']['application'].get('notification_title') | ||||
|             else: | ||||
|                 n_object['notification_title'] = "Test title" | ||||
|  | ||||
|             if 'notification_body' in request.form and request.form['notification_body'].strip(): | ||||
|                 n_object['notification_body'] = request.form.get('notification_body', '').strip() | ||||
|             elif datastore.data['settings']['application'].get('notification_body'): | ||||
|                 n_object['notification_body'] = datastore.data['settings']['application'].get('notification_body') | ||||
|             else: | ||||
|                 n_object['notification_body'] = "Test body" | ||||
|  | ||||
|             n_object['as_async'] = False | ||||
|             n_object.update(watch.extra_notification_token_values()) | ||||
|             from .notification import process_notification | ||||
|             sent_obj = process_notification(n_object, datastore) | ||||
|  | ||||
|             from . import update_worker | ||||
|             new_worker = update_worker.update_worker(update_q, notification_q, app, datastore) | ||||
|             new_worker.queue_notification_for_watch(notification_q=notification_q, n_object=n_object, watch=watch) | ||||
|         except Exception as e: | ||||
|             return make_response(f"Error: str(e)", 400) | ||||
|             e_str = str(e) | ||||
|             # Remove this text which is not important and floods the container | ||||
|             e_str = e_str.replace( | ||||
|                 "DEBUG - <class 'apprise.decorators.base.CustomNotifyPlugin.instantiate_plugin.<locals>.CustomNotifyPluginWrapper'>", | ||||
|                 '') | ||||
|  | ||||
|             return make_response(e_str, 400) | ||||
|  | ||||
|         return 'OK - Sent test notifications' | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| from typing import List | ||||
| from loguru import logger | ||||
| from lxml import etree | ||||
| from typing import List | ||||
| import json | ||||
| import re | ||||
|  | ||||
| @@ -298,8 +299,10 @@ def extract_json_as_string(content, json_filter, ensure_is_ldjson_info_type=None | ||||
| # https://github.com/dgtlmoon/changedetection.io/pull/2041#issuecomment-1848397161w | ||||
|     # Try to parse/filter out the JSON, if we get some parser error, then maybe it's embedded within HTML tags | ||||
|     try: | ||||
|         stripped_text_from_html = _parse_json(json.loads(content), json_filter) | ||||
|     except json.JSONDecodeError: | ||||
|         # .lstrip("\ufeff") strings ByteOrderMark from UTF8 and still lets the UTF work | ||||
|         stripped_text_from_html = _parse_json(json.loads(content.lstrip("\ufeff") ), json_filter) | ||||
|     except json.JSONDecodeError as e: | ||||
|         logger.warning(str(e)) | ||||
|  | ||||
|         # Foreach <script json></script> blob.. just return the first that matches json_filter | ||||
|         # As a last resort, try to parse the whole <body> | ||||
|   | ||||
| @@ -69,7 +69,7 @@ def parse_headers_from_text_file(filepath): | ||||
|         for l in f.readlines(): | ||||
|             l = l.strip() | ||||
|             if not l.startswith('#') and ':' in l: | ||||
|                 (k, v) = l.split(':') | ||||
|                 (k, v) = l.split(':', 1)  # Split only on the first colon | ||||
|                 headers[k.strip()] = v.strip() | ||||
|  | ||||
|     return headers | ||||
| @@ -247,37 +247,32 @@ class model(watch_base): | ||||
|         bump = self.history | ||||
|         return self.__newest_history_key | ||||
|  | ||||
|     # Given an arbitrary timestamp, find the closest next key | ||||
|     # For example, last_viewed = 1000 so it should return the next 1001 timestamp | ||||
|     # | ||||
|     # used for the [diff] button so it can preset a smarter from_version | ||||
|     # Given an arbitrary timestamp, find the best history key for the [diff] button so it can preset a smarter from_version | ||||
|     @property | ||||
|     def get_next_snapshot_key_to_last_viewed(self): | ||||
|     def get_from_version_based_on_last_viewed(self): | ||||
|  | ||||
|         """Unfortunately for now timestamp is stored as string key""" | ||||
|         keys = list(self.history.keys()) | ||||
|         if not keys: | ||||
|             return None | ||||
|         if len(keys) == 1: | ||||
|             return keys[0] | ||||
|  | ||||
|         last_viewed = int(self.get('last_viewed')) | ||||
|         prev_k = keys[0] | ||||
|         sorted_keys = sorted(keys, key=lambda x: int(x)) | ||||
|         sorted_keys.reverse() | ||||
|  | ||||
|         # When the 'last viewed' timestamp is greater than the newest snapshot, return second last | ||||
|         if last_viewed > int(sorted_keys[0]): | ||||
|         # When the 'last viewed' timestamp is greater than or equal the newest snapshot, return second newest | ||||
|         if last_viewed >= int(sorted_keys[0]): | ||||
|             return sorted_keys[1] | ||||
|          | ||||
|         # When the 'last viewed' timestamp is between snapshots, return the older snapshot | ||||
|         for newer, older in list(zip(sorted_keys[0:], sorted_keys[1:])): | ||||
|             if last_viewed < int(newer) and last_viewed >= int(older): | ||||
|                 return older | ||||
|  | ||||
|         for k in sorted_keys: | ||||
|             if int(k) < last_viewed: | ||||
|                 if prev_k == sorted_keys[0]: | ||||
|                     # Return the second last one so we dont recommend the same version compares itself | ||||
|                     return sorted_keys[1] | ||||
|  | ||||
|                 return prev_k | ||||
|             prev_k = k | ||||
|  | ||||
|         return keys[0] | ||||
|         # When the 'last viewed' timestamp is less than the oldest snapshot, return oldest | ||||
|         return sorted_keys[-1] | ||||
|  | ||||
|     def get_history_snapshot(self, timestamp): | ||||
|         import brotli | ||||
|   | ||||
| @@ -67,6 +67,10 @@ def process_notification(n_object, datastore): | ||||
|  | ||||
|     sent_objs = [] | ||||
|     from .apprise_asset import asset | ||||
|  | ||||
|     if 'as_async' in n_object: | ||||
|         asset.async_mode = n_object.get('as_async') | ||||
|  | ||||
|     apobj = apprise.Apprise(debug=True, asset=asset) | ||||
|  | ||||
|     if not n_object.get('notification_urls'): | ||||
| @@ -157,8 +161,6 @@ def process_notification(n_object, datastore): | ||||
|             attach=n_object.get('screenshot', None) | ||||
|         ) | ||||
|  | ||||
|         # Give apprise time to register an error | ||||
|         time.sleep(3) | ||||
|  | ||||
|         # Returns empty string if nothing found, multi-line string otherwise | ||||
|         log_value = logs.getvalue() | ||||
|   | ||||
| @@ -33,8 +33,8 @@ class difference_detection_processor(): | ||||
|  | ||||
|         url = self.watch.link | ||||
|  | ||||
|         # Protect against file://, file:/ access, check the real "link" without any meta "source:" etc prepended. | ||||
|         if re.search(r'^file:/', url.strip(), re.IGNORECASE): | ||||
|         # Protect against file:, file:/, file:// access, check the real "link" without any meta "source:" etc prepended. | ||||
|         if re.search(r'^file:', url.strip(), re.IGNORECASE): | ||||
|             if not strtobool(os.getenv('ALLOW_FILE_URI', 'false')): | ||||
|                 raise Exception( | ||||
|                     "file:// type access is denied for security reasons." | ||||
|   | ||||
| @@ -1,42 +1,52 @@ | ||||
| $(document).ready(function() { | ||||
| $(document).ready(function () { | ||||
|  | ||||
|   $('#add-email-helper').click(function (e) { | ||||
|     e.preventDefault(); | ||||
|     email = prompt("Destination email"); | ||||
|     if(email) { | ||||
|       var n = $(".notification-urls"); | ||||
|       var p=email_notification_prefix; | ||||
|       $(n).val( $.trim( $(n).val() )+"\n"+email_notification_prefix+email ); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   $('#send-test-notification').click(function (e) { | ||||
|     e.preventDefault(); | ||||
|  | ||||
|     data = { | ||||
|       notification_body: $('#notification_body').val(), | ||||
|       notification_format: $('#notification_format').val(), | ||||
|       notification_title: $('#notification_title').val(), | ||||
|       notification_urls: $('.notification-urls').val(), | ||||
|       tags: $('#tags').val(), | ||||
|       window_url: window.location.href, | ||||
|     } | ||||
|  | ||||
|  | ||||
|     $.ajax({ | ||||
|       type: "POST", | ||||
|       url: notification_base_url, | ||||
|       data : data, | ||||
|         statusCode: { | ||||
|         400: function(data) { | ||||
|           // More than likely the CSRF token was lost when the server restarted | ||||
|           alert(data.responseText); | ||||
|     $('#add-email-helper').click(function (e) { | ||||
|         e.preventDefault(); | ||||
|         email = prompt("Destination email"); | ||||
|         if (email) { | ||||
|             var n = $(".notification-urls"); | ||||
|             var p = email_notification_prefix; | ||||
|             $(n).val($.trim($(n).val()) + "\n" + email_notification_prefix + email); | ||||
|         } | ||||
|       } | ||||
|     }).done(function(data){ | ||||
|       console.log(data); | ||||
|       alert(data); | ||||
|     }) | ||||
|   }); | ||||
|     }); | ||||
|  | ||||
|     $('#send-test-notification').click(function (e) { | ||||
|         e.preventDefault(); | ||||
|  | ||||
|         data = { | ||||
|             notification_body: $('#notification_body').val(), | ||||
|             notification_format: $('#notification_format').val(), | ||||
|             notification_title: $('#notification_title').val(), | ||||
|             notification_urls: $('.notification-urls').val(), | ||||
|             tags: $('#tags').val(), | ||||
|             window_url: window.location.href, | ||||
|         } | ||||
|  | ||||
|         $('.notifications-wrapper .spinner').fadeIn(); | ||||
|         $('#notification-test-log').show(); | ||||
|         $.ajax({ | ||||
|             type: "POST", | ||||
|             url: notification_base_url, | ||||
|             data: data, | ||||
|             statusCode: { | ||||
|                 400: function (data) { | ||||
|                     $("#notification-test-log>span").text(data.responseText); | ||||
|                 }, | ||||
|             } | ||||
|         }).done(function (data) { | ||||
|             $("#notification-test-log>span").text(data); | ||||
|         }).fail(function (jqXHR, textStatus, errorThrown) { | ||||
|             // Handle connection refused or other errors | ||||
|             if (textStatus === "error" && errorThrown === "") { | ||||
|                 console.error("Connection refused or server unreachable"); | ||||
|                 $("#notification-test-log>span").text("Error: Connection refused or server is unreachable."); | ||||
|             } else { | ||||
|                 console.error("Error:", textStatus, errorThrown); | ||||
|                 $("#notification-test-log>span").text("An error occurred: " + textStatus); | ||||
|             } | ||||
|         }).always(function () { | ||||
|             $('.notifications-wrapper .spinner').hide(); | ||||
|         }) | ||||
|     }); | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -380,7 +380,15 @@ a.pure-button-selected { | ||||
| } | ||||
|  | ||||
| .notifications-wrapper { | ||||
|   padding: 0.5rem 0 1rem 0; | ||||
|   padding-top: 0.5rem; | ||||
|   #notification-test-log { | ||||
|     padding-top: 1rem; | ||||
|     white-space: pre-wrap; | ||||
|     word-break: break-word; | ||||
|     overflow-wrap: break-word; | ||||
|     max-width: 100%; | ||||
|     box-sizing: border-box; | ||||
|   } | ||||
| } | ||||
|  | ||||
| label { | ||||
|   | ||||
| @@ -780,7 +780,14 @@ a.pure-button-selected { | ||||
|   cursor: pointer; } | ||||
|  | ||||
| .notifications-wrapper { | ||||
|   padding: 0.5rem 0 1rem 0; } | ||||
|   padding-top: 0.5rem; } | ||||
|   .notifications-wrapper #notification-test-log { | ||||
|     padding-top: 1rem; | ||||
|     white-space: pre-wrap; | ||||
|     word-break: break-word; | ||||
|     overflow-wrap: break-word; | ||||
|     max-width: 100%; | ||||
|     box-sizing: border-box; } | ||||
|  | ||||
| label:hover { | ||||
|   cursor: pointer; } | ||||
|   | ||||
| @@ -24,11 +24,13 @@ | ||||
|                               </ul> | ||||
|                             </div> | ||||
|                             <div class="notifications-wrapper"> | ||||
|                               <a id="send-test-notification" class="pure-button button-secondary button-xsmall" >Send test notification</a> | ||||
|                               <a id="send-test-notification" class="pure-button button-secondary button-xsmall" >Send test notification</a> <div class="spinner"  style="display: none;"></div> | ||||
|                             {% if emailprefix %} | ||||
|                               <a id="add-email-helper" class="pure-button button-secondary button-xsmall" >Add email <img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='email.svg')}}" alt="Add an email address"> </a> | ||||
|                             {% endif %} | ||||
|                               <a href="{{url_for('notification_logs')}}" class="pure-button button-secondary button-xsmall" >Notification debug logs</a> | ||||
|                               <br> | ||||
|                                 <div id="notification-test-log" style="display: none;"><span class="pure-form-message-inline">Processing..</span></div> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                         <div id="notification-customisation" class="pure-control-group"> | ||||
|   | ||||
| @@ -191,7 +191,7 @@ | ||||
|                     {% if watch.history_n >= 2 %} | ||||
|  | ||||
|                         {%  if is_unviewed %} | ||||
|                            <a href="{{ url_for('diff_history_page', uuid=watch.uuid, from_version=watch.get_next_snapshot_key_to_last_viewed) }}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">History</a> | ||||
|                            <a href="{{ url_for('diff_history_page', uuid=watch.uuid, from_version=watch.get_from_version_based_on_last_viewed) }}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">History</a> | ||||
|                         {% else %} | ||||
|                            <a href="{{ url_for('diff_history_page', uuid=watch.uuid)}}" target="{{watch.uuid}}" class="pure-button pure-button-primary diff-link">History</a> | ||||
|                         {% endif %} | ||||
|   | ||||
| @@ -34,7 +34,7 @@ def test_execute_custom_js(client, live_server, measure_memory_usage): | ||||
|     assert b"unpaused" in res.data | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|     assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n >= 1, "Watch history had atleast 1 (everything fetched OK)" | ||||
|  | ||||
|     assert b"This text should be removed" not in res.data | ||||
|   | ||||
| @@ -48,7 +48,7 @@ def test_noproxy_option(client, live_server, measure_memory_usage): | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Watch added in Paused state, saving will unpause" in res.data | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|     res = client.get( | ||||
|         url_for("edit_page", uuid=uuid, unpause_on_save=1)) | ||||
|     assert b'No proxy' in res.data | ||||
|   | ||||
| @@ -81,7 +81,7 @@ def test_socks5(client, live_server, measure_memory_usage): | ||||
|     assert "Awesome, you made it".encode('utf-8') in res.data | ||||
|  | ||||
|     # PROXY CHECKER WIDGET CHECK - this needs more checking | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|  | ||||
|     res = client.get( | ||||
|         url_for("check_proxies.start_check", uuid=uuid), | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| #!/usr/bin/env python3 | ||||
|  | ||||
| import os.path | ||||
| import time | ||||
|  | ||||
| from flask import url_for | ||||
| from .util import live_server_setup, wait_for_all_checks, wait_for_notification_endpoint_output | ||||
| from changedetectionio import html_tools | ||||
|  | ||||
|  | ||||
| def set_original(excluding=None, add_line=None): | ||||
|   | ||||
| @@ -44,7 +44,6 @@ def set_modified_response(): | ||||
|  | ||||
|     return None | ||||
|  | ||||
|  | ||||
| def is_valid_uuid(val): | ||||
|     try: | ||||
|         uuid.UUID(str(val)) | ||||
| @@ -56,8 +55,9 @@ def is_valid_uuid(val): | ||||
| def test_setup(client, live_server, measure_memory_usage): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
|  | ||||
| def test_api_simple(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
| #    live_server_setup(live_server) | ||||
|  | ||||
|     api_key = extract_api_key_from_UI(client) | ||||
|  | ||||
| @@ -129,6 +129,9 @@ def test_api_simple(client, live_server, measure_memory_usage): | ||||
|     assert after_recheck_info['last_checked'] != before_recheck_info['last_checked'] | ||||
|     assert after_recheck_info['last_changed'] != 0 | ||||
|  | ||||
|     # #2877 When run in a slow fetcher like playwright etc | ||||
|     assert after_recheck_info['last_changed'] ==  after_recheck_info['last_checked'] | ||||
|  | ||||
|     # Check history index list | ||||
|     res = client.get( | ||||
|         url_for("watchhistory", uuid=watch_uuid), | ||||
|   | ||||
| @@ -99,7 +99,7 @@ def test_check_ldjson_price_autodetect(client, live_server, measure_memory_usage | ||||
|     assert b'ldjson-price-track-offer' in res.data | ||||
|  | ||||
|     # Accept it | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|     #time.sleep(1) | ||||
|     client.get(url_for('price_data_follower.accept', uuid=uuid, follow_redirects=True)) | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|   | ||||
| @@ -2,7 +2,6 @@ | ||||
|  | ||||
| import time | ||||
| from flask import url_for | ||||
| from urllib.request import urlopen | ||||
| from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, extract_rss_token_from_UI, \ | ||||
|     extract_UUID_from_client | ||||
|  | ||||
| @@ -69,7 +68,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|  | ||||
|     # Check the 'get latest snapshot works' | ||||
|     res = client.get(url_for("watch_get_latest_html", uuid=uuid)) | ||||
|   | ||||
| @@ -40,7 +40,7 @@ def test_check_encoding_detection(client, live_server, measure_memory_usage): | ||||
|  | ||||
|  | ||||
|     # Content type recording worked | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|     assert live_server.app.config['DATASTORE'].data['watching'][uuid]['content-type'] == "text/html" | ||||
|  | ||||
|     res = client.get( | ||||
|   | ||||
| @@ -51,7 +51,7 @@ def run_filter_test(client, live_server, content_filter): | ||||
|     assert b"1 Imported" in res.data | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|  | ||||
|     assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 0, "No filter = No filter failure" | ||||
|  | ||||
|   | ||||
| @@ -288,7 +288,7 @@ def test_clone_tag_on_import(client, live_server, measure_memory_usage): | ||||
|     assert b'test-tag' in res.data | ||||
|     assert b'another-tag' in res.data | ||||
|  | ||||
|     watch_uuid = extract_UUID_from_client(client) | ||||
|     watch_uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|     res = client.get(url_for("form_clone", uuid=watch_uuid), follow_redirects=True) | ||||
|  | ||||
|     assert b'Cloned' in res.data | ||||
| @@ -315,7 +315,7 @@ def test_clone_tag_on_quickwatchform_add(client, live_server, measure_memory_usa | ||||
|     assert b'test-tag' in res.data | ||||
|     assert b'another-tag' in res.data | ||||
|  | ||||
|     watch_uuid = extract_UUID_from_client(client) | ||||
|     watch_uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|     res = client.get(url_for("form_clone", uuid=watch_uuid), follow_redirects=True) | ||||
|  | ||||
|     assert b'Cloned' in res.data | ||||
|   | ||||
| @@ -36,7 +36,7 @@ def test_ignore(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Give the thread time to pick it up | ||||
|     wait_for_all_checks(client) | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|     # use the highlighter endpoint | ||||
|     res = client.post( | ||||
|         url_for("highlight_submit_ignore_url", uuid=uuid), | ||||
|   | ||||
| @@ -514,3 +514,15 @@ def test_check_jq_ext_filter(client, live_server, measure_memory_usage): | ||||
| def test_check_jqraw_ext_filter(client, live_server, measure_memory_usage): | ||||
|     if jq_support: | ||||
|         check_json_ext_filter('jq:.[] | select(.status | contains("Sold"))', client, live_server) | ||||
|  | ||||
| def test_jsonpath_BOM_utf8(client, live_server, measure_memory_usage): | ||||
|     from .. import html_tools | ||||
|  | ||||
|     # JSON string with BOM and correct double-quoted keys | ||||
|     json_str = '\ufeff{"name": "José", "emoji": "😊", "language": "中文", "greeting": "Привет"}' | ||||
|  | ||||
|     # See that we can find the second <script> one, which is not broken, and matches our filter | ||||
|     text = html_tools.extract_json_as_string(json_str, "json:$.name") | ||||
|     assert text == '"José"' | ||||
|  | ||||
|      | ||||
|   | ||||
| @@ -29,7 +29,7 @@ def test_content_filter_live_preview(client, live_server, measure_memory_usage): | ||||
|         data={"url": test_url, "tags": ''}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid=uuid), | ||||
|         data={ | ||||
|   | ||||
| @@ -48,7 +48,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure | ||||
|     ##################### | ||||
|     client.post( | ||||
|         url_for("settings_page"), | ||||
|         data={"application-empty_pages_are_a_change": "", | ||||
|         data={"application-empty_pages_are_a_change": "", # default, OFF, they are NOT a change | ||||
|               "requests-time_between_check-minutes": 180, | ||||
|               'application-fetch_backend': "html_requests"}, | ||||
|         follow_redirects=True | ||||
| @@ -66,6 +66,14 @@ def test_check_basic_change_detection_functionality(client, live_server, measure | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' not in res.data | ||||
|  | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|     watch = live_server.app.config['DATASTORE'].data['watching'][uuid] | ||||
|  | ||||
|     assert watch.last_changed == 0 | ||||
|     assert watch['last_checked'] != 0 | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|     # ok now do the opposite | ||||
|  | ||||
| @@ -92,6 +100,10 @@ def test_check_basic_change_detection_functionality(client, live_server, measure | ||||
|     # A totally zero byte (#2528) response should also not trigger an error | ||||
|     set_zero_byte_response() | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|  | ||||
|     # 2877 | ||||
|     assert watch.last_changed == watch['last_checked'] | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'unviewed' in res.data # A change should have registered because empty_pages_are_a_change is ON | ||||
|   | ||||
| @@ -6,7 +6,7 @@ from flask import url_for | ||||
| from loguru import logger | ||||
|  | ||||
| from .util import set_original_response, set_modified_response, set_more_modified_response, live_server_setup, wait_for_all_checks, \ | ||||
|     set_longer_modified_response | ||||
|     set_longer_modified_response, get_index | ||||
| from . util import  extract_UUID_from_client | ||||
| import logging | ||||
| import base64 | ||||
| @@ -29,7 +29,7 @@ def test_check_notification(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Re 360 - new install should have defaults set | ||||
|     res = client.get(url_for("settings_page")) | ||||
|     notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json') | ||||
|     notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json')+"?status_code=204" | ||||
|  | ||||
|     assert default_notification_body.encode() in res.data | ||||
|     assert default_notification_title.encode() in res.data | ||||
| @@ -76,7 +76,7 @@ def test_check_notification(client, live_server, measure_memory_usage): | ||||
|     testimage_png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' | ||||
|  | ||||
|  | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|     datastore = 'test-datastore' | ||||
|     with open(os.path.join(datastore, str(uuid), 'last-screenshot.png'), 'wb') as f: | ||||
|         f.write(base64.b64decode(testimage_png)) | ||||
| @@ -135,7 +135,14 @@ def test_check_notification(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     # Trigger a check | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     time.sleep(3) | ||||
|  | ||||
|     # Check no errors were recorded | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'notification-error' not in res.data | ||||
|  | ||||
|  | ||||
|     # Verify what was sent as a notification, this file should exist | ||||
|     with open("test-datastore/notification.txt", "r") as f: | ||||
|         notification_submission = f.read() | ||||
| @@ -284,7 +291,7 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me | ||||
|     # CUSTOM JSON BODY CHECK for POST:// | ||||
|     set_original_response() | ||||
|     # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#header-manipulation | ||||
|     test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?xxx={{ watch_url }}&+custom-header=123&+second=hello+world%20%22space%22" | ||||
|     test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?status_code=204&xxx={{ watch_url }}&+custom-header=123&+second=hello+world%20%22space%22" | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("settings_page"), | ||||
| @@ -319,6 +326,11 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me | ||||
|  | ||||
|     time.sleep(2) # plus extra delay for notifications to fire | ||||
|  | ||||
|  | ||||
|     # Check no errors were recorded, because we asked for 204 which is slightly uncommon but is still OK | ||||
|     res = get_index(client) | ||||
|     assert b'notification-error' not in res.data | ||||
|  | ||||
|     with open("test-datastore/notification.txt", 'r') as f: | ||||
|         x = f.read() | ||||
|         j = json.loads(x) | ||||
| @@ -360,7 +372,10 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage | ||||
|     #live_server_setup(live_server) | ||||
|     set_original_response() | ||||
|     if os.path.isfile("test-datastore/notification.txt"): | ||||
|         os.unlink("test-datastore/notification.txt") | ||||
|         os.unlink("test-datastore/notification.txt") \ | ||||
|  | ||||
|     # 1995 UTF-8 content should be encoded | ||||
|     test_body = 'change detection is cool 网站监测 内容更新了' | ||||
|  | ||||
|     # otherwise other settings would have already existed from previous tests in this file | ||||
|     res = client.post( | ||||
| @@ -368,8 +383,7 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage | ||||
|         data={ | ||||
|             "application-fetch_backend": "html_requests", | ||||
|             "application-minutes_between_check": 180, | ||||
|             #1995 UTF-8 content should be encoded | ||||
|             "application-notification_body": 'change detection is cool 网站监测 内容更新了', | ||||
|             "application-notification_body": test_body, | ||||
|             "application-notification_format": default_notification_format, | ||||
|             "application-notification_urls": "", | ||||
|             "application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}", | ||||
| @@ -399,12 +413,10 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage | ||||
|     assert res.status_code != 400 | ||||
|     assert res.status_code != 500 | ||||
|  | ||||
|     # Give apprise time to fire | ||||
|     time.sleep(4) | ||||
|  | ||||
|     with open("test-datastore/notification.txt", 'r') as f: | ||||
|         x = f.read() | ||||
|         assert 'change detection is cool 网站监测 内容更新了' in x | ||||
|         assert test_body in x | ||||
|  | ||||
|     os.unlink("test-datastore/notification.txt") | ||||
|  | ||||
|   | ||||
| @@ -373,13 +373,14 @@ def test_headers_textfile_in_request(client, live_server, measure_memory_usage): | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     with open('test-datastore/headers-testtag.txt', 'w') as f: | ||||
|         f.write("tag-header: test") | ||||
|         f.write("tag-header: test\r\nurl-header: http://example.com") | ||||
|  | ||||
|     with open('test-datastore/headers.txt', 'w') as f: | ||||
|         f.write("global-header: nice\r\nnext-global-header: nice") | ||||
|         f.write("global-header: nice\r\nnext-global-header: nice\r\nurl-header-global: http://example.com/global") | ||||
|  | ||||
|     with open('test-datastore/' + extract_UUID_from_client(client) + '/headers.txt', 'w') as f: | ||||
|         f.write("watch-header: nice") | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|     with open(f'test-datastore/{uuid}/headers.txt', 'w') as f: | ||||
|         f.write("watch-header: nice\r\nurl-header-watch: http://example.com/watch") | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
| @@ -410,6 +411,9 @@ def test_headers_textfile_in_request(client, live_server, measure_memory_usage): | ||||
|     assert b"Xxx:ooo" in res.data | ||||
|     assert b"Watch-Header:nice" in res.data | ||||
|     assert b"Tag-Header:test" in res.data | ||||
|     assert b"Url-Header:http://example.com" in res.data | ||||
|     assert b"Url-Header-Global:http://example.com/global" in res.data | ||||
|     assert b"Url-Header-Watch:http://example.com/watch" in res.data | ||||
|  | ||||
|     # Check the custom UA from system settings page made it through | ||||
|     if os.getenv('PLAYWRIGHT_DRIVER_URL'): | ||||
|   | ||||
| @@ -189,6 +189,17 @@ def _run_test_minmax_limit(client, extra_watch_edit_form): | ||||
|  | ||||
|     client.get(url_for("mark_all_viewed")) | ||||
|  | ||||
|  | ||||
|     # 2715 - Price detection (once it crosses the "lower" threshold) again with a lower price - should trigger again! | ||||
|     set_original_response(props_markup=instock_props[0], price='820.45') | ||||
|     res = client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
|     assert b'1 watches queued for rechecking.' in res.data | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'820.45' in res.data | ||||
|     assert b'unviewed' in res.data | ||||
|     client.get(url_for("mark_all_viewed")) | ||||
|  | ||||
|     # price changed to something MORE than max (1100.10), SHOULD be a change | ||||
|     set_original_response(props_markup=instock_props[0], price='1890.45') | ||||
|     client.get(url_for("form_watch_checknow"), follow_redirects=True) | ||||
| @@ -203,7 +214,7 @@ def _run_test_minmax_limit(client, extra_watch_edit_form): | ||||
|  | ||||
|  | ||||
| def test_restock_itemprop_minmax(client, live_server): | ||||
| #    live_server_setup(live_server) | ||||
|     #live_server_setup(live_server) | ||||
|     extras = { | ||||
|         "restock_settings-follow_price_changes": "y", | ||||
|         "restock_settings-price_change_min": 900.0, | ||||
| @@ -369,7 +380,7 @@ def test_change_with_notification_values(client, live_server): | ||||
|  | ||||
|     ## Now test the "SEND TEST NOTIFICATION" is working | ||||
|     os.unlink("test-datastore/notification.txt") | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|     res = client.post(url_for("ajax_callback_send_notification_test", watch_uuid=uuid), data={}, follow_redirects=True) | ||||
|     time.sleep(5) | ||||
|     assert os.path.isfile("test-datastore/notification.txt"), "Notification received" | ||||
|   | ||||
| @@ -132,7 +132,7 @@ def test_rss_xpath_filtering(client, live_server, measure_memory_usage): | ||||
|     ) | ||||
|     assert b"Watch added in Paused state, saving will unpause" in res.data | ||||
|  | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid=uuid, unpause_on_save=1), | ||||
|         data={ | ||||
|   | ||||
| @@ -39,7 +39,7 @@ def test_check_basic_scheduler_functionality(client, live_server, measure_memory | ||||
|  | ||||
|     assert b"1 Imported" in res.data | ||||
|     wait_for_all_checks(client) | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|  | ||||
|     # Setup all the days of the weeks using XXX as the placeholder for monday/tuesday/etc | ||||
|  | ||||
| @@ -104,7 +104,7 @@ def test_check_basic_global_scheduler_functionality(client, live_server, measure | ||||
|  | ||||
|     assert b"1 Imported" in res.data | ||||
|     wait_for_all_checks(client) | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|  | ||||
|     # Setup all the days of the weeks using XXX as the placeholder for monday/tuesday/etc | ||||
|  | ||||
|   | ||||
| @@ -1,9 +1,7 @@ | ||||
| import os | ||||
|  | ||||
| from flask import url_for | ||||
| from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks | ||||
| import time | ||||
|  | ||||
| from .util import live_server_setup, wait_for_all_checks | ||||
| from .. import strtobool | ||||
|  | ||||
|  | ||||
| @@ -61,54 +59,44 @@ def test_bad_access(client, live_server, measure_memory_usage): | ||||
|     assert b'Watch protocol is not permitted by SAFE_PROTOCOL_REGEX' in res.data | ||||
|  | ||||
|  | ||||
| def test_file_slashslash_access(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
| def _runner_test_various_file_slash(client, file_uri): | ||||
|  | ||||
|     test_file_path = os.path.abspath(__file__) | ||||
|  | ||||
|     # file:// is permitted by default, but it will be caught by ALLOW_FILE_URI | ||||
|     client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         data={"url": f"file://{test_file_path}", "tags": ''}, | ||||
|         data={"url": file_uri, "tags": ''}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|  | ||||
|     substrings = [b"URLs with hostname components are not permitted", b"No connection adapters were found for"] | ||||
|  | ||||
|  | ||||
|     # If it is enabled at test time | ||||
|     if strtobool(os.getenv('ALLOW_FILE_URI', 'false')): | ||||
|         res = client.get( | ||||
|             url_for("preview_page", uuid="first"), | ||||
|             follow_redirects=True | ||||
|         ) | ||||
|         if file_uri.startswith('file:///'): | ||||
|             # This one should be the full qualified path to the file and should get the contents of this file | ||||
|             res = client.get( | ||||
|                 url_for("preview_page", uuid="first"), | ||||
|                 follow_redirects=True | ||||
|             ) | ||||
|             assert b'_runner_test_various_file_slash' in res.data | ||||
|         else: | ||||
|             # This will give some error from requests or if it went to chrome, will give some other error :-) | ||||
|             assert any(s in res.data for s in substrings) | ||||
|  | ||||
|         assert b"test_file_slashslash_access" in res.data | ||||
|     else: | ||||
|         # Default should be here | ||||
|         assert b'file:// type access is denied for security reasons.' in res.data | ||||
|     res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) | ||||
|     assert b'Deleted' in res.data | ||||
|  | ||||
| def test_file_slash_access(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
|  | ||||
|     # file: is NOT permitted by default, so it will be caught by ALLOW_FILE_URI check | ||||
|  | ||||
|     test_file_path = os.path.abspath(__file__) | ||||
|  | ||||
|     # file:// is permitted by default, but it will be caught by ALLOW_FILE_URI | ||||
|     client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         data={"url": f"file:/{test_file_path}", "tags": ''}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
|  | ||||
|     # If it is enabled at test time | ||||
|     if strtobool(os.getenv('ALLOW_FILE_URI', 'false')): | ||||
|         # So it should permit it, but it should fall back to the 'requests' library giving an error | ||||
|         # (but means it gets passed to playwright etc) | ||||
|         assert b"URLs with hostname components are not permitted" in res.data | ||||
|     else: | ||||
|         # Default should be here | ||||
|         assert b'file:// type access is denied for security reasons.' in res.data | ||||
|     _runner_test_various_file_slash(client, file_uri=f"file://{test_file_path}") | ||||
|     _runner_test_various_file_slash(client, file_uri=f"file:/{test_file_path}") | ||||
|     _runner_test_various_file_slash(client, file_uri=f"file:{test_file_path}") # CVE-2024-56509 | ||||
|  | ||||
| def test_xss(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
|   | ||||
							
								
								
									
										64
									
								
								changedetectionio/tests/unit/test_semver.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								changedetectionio/tests/unit/test_semver.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | ||||
| #!/usr/bin/env python3 | ||||
|  | ||||
| # run from dir above changedetectionio/ dir | ||||
| # python3 -m unittest changedetectionio.tests.unit.test_semver | ||||
|  | ||||
| import re | ||||
| import unittest | ||||
|  | ||||
|  | ||||
| # The SEMVER regex | ||||
| SEMVER_REGEX = r"^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" | ||||
|  | ||||
| # Compile the regex | ||||
| semver_pattern = re.compile(SEMVER_REGEX) | ||||
|  | ||||
| class TestSemver(unittest.TestCase): | ||||
|     def test_valid_versions(self): | ||||
|         """Test valid semantic version strings""" | ||||
|         valid_versions = [ | ||||
|             "1.0.0", | ||||
|             "0.1.0", | ||||
|             "0.0.1", | ||||
|             "1.0.0-alpha", | ||||
|             "1.0.0-alpha.1", | ||||
|             "1.0.0-0.3.7", | ||||
|             "1.0.0-x.7.z.92", | ||||
|             "1.0.0-alpha+001", | ||||
|             "1.0.0+20130313144700", | ||||
|             "1.0.0-beta+exp.sha.5114f85" | ||||
|         ] | ||||
|         for version in valid_versions: | ||||
|             with self.subTest(version=version): | ||||
|                 self.assertIsNotNone(semver_pattern.match(version), f"Version {version} should be valid") | ||||
|  | ||||
|     def test_invalid_versions(self): | ||||
|         """Test invalid semantic version strings""" | ||||
|         invalid_versions = [ | ||||
|             "0.48.06", | ||||
|             "1.0", | ||||
|             "1.0.0-", | ||||
| # Seems to pass the semver.org regex? | ||||
| #            "1.0.0-alpha-", | ||||
|             "1.0.0+", | ||||
|             "1.0.0-alpha+", | ||||
|             "1.0.0-", | ||||
|             "01.0.0", | ||||
|             "1.01.0", | ||||
|             "1.0.01", | ||||
|             ".1.0.0", | ||||
|             "1..0.0" | ||||
|         ] | ||||
|         for version in invalid_versions: | ||||
|             with self.subTest(version=version): | ||||
|                 res = semver_pattern.match(version) | ||||
|                 self.assertIsNone(res, f"Version '{version}' should be invalid") | ||||
|  | ||||
|     def test_our_version(self): | ||||
|         from changedetectionio import get_version | ||||
|         our_version = get_version() | ||||
|         self.assertIsNotNone(semver_pattern.match(our_version), f"Our version '{our_version}' should be a valid SEMVER string") | ||||
|  | ||||
|  | ||||
| if __name__ == '__main__': | ||||
|     unittest.main() | ||||
| @@ -16,7 +16,6 @@ class TestDiffBuilder(unittest.TestCase): | ||||
|         watch = Watch.model(datastore_path='/tmp', default={}) | ||||
|         watch.ensure_data_dir_exists() | ||||
|  | ||||
|         watch['last_viewed'] = 110 | ||||
|  | ||||
|         # Contents from the browser are always returned from the browser/requests/etc as str, str is basically UTF-16 in python | ||||
|         watch.save_history_text(contents="hello world", timestamp=100, snapshot_id=str(uuid_builder.uuid4())) | ||||
| @@ -25,31 +24,42 @@ class TestDiffBuilder(unittest.TestCase): | ||||
|         watch.save_history_text(contents="hello world", timestamp=112, snapshot_id=str(uuid_builder.uuid4())) | ||||
|         watch.save_history_text(contents="hello world", timestamp=115, snapshot_id=str(uuid_builder.uuid4())) | ||||
|         watch.save_history_text(contents="hello world", timestamp=117, snapshot_id=str(uuid_builder.uuid4())) | ||||
|      | ||||
|         p = watch.get_from_version_based_on_last_viewed | ||||
|         assert p == "100", "Correct 'last viewed' timestamp was detected" | ||||
|  | ||||
|         p = watch.get_next_snapshot_key_to_last_viewed | ||||
|         assert p == "112", "Correct last-viewed timestamp was detected" | ||||
|         watch['last_viewed'] = 110 | ||||
|         p = watch.get_from_version_based_on_last_viewed | ||||
|         assert p == "109", "Correct 'last viewed' timestamp was detected" | ||||
|  | ||||
|         # When there is only one step of difference from the end of the list, it should return second-last change | ||||
|         watch['last_viewed'] = 116 | ||||
|         p = watch.get_next_snapshot_key_to_last_viewed | ||||
|         assert p == "115", "Correct 'second last' last-viewed timestamp was detected when using the last timestamp" | ||||
|         p = watch.get_from_version_based_on_last_viewed | ||||
|         assert p == "115", "Correct 'last viewed' timestamp was detected" | ||||
|  | ||||
|         watch['last_viewed'] = 99 | ||||
|         p = watch.get_next_snapshot_key_to_last_viewed | ||||
|         assert p == "100" | ||||
|         p = watch.get_from_version_based_on_last_viewed | ||||
|         assert p == "100", "When the 'last viewed' timestamp is less than the oldest snapshot, return oldest" | ||||
|  | ||||
|         watch['last_viewed'] = 200 | ||||
|         p = watch.get_next_snapshot_key_to_last_viewed | ||||
|         assert p == "115", "When the 'last viewed' timestamp is greater than the newest snapshot, return second last " | ||||
|         p = watch.get_from_version_based_on_last_viewed | ||||
|         assert p == "115", "When the 'last viewed' timestamp is greater than the newest snapshot, return second newest" | ||||
|  | ||||
|         watch['last_viewed'] = 109 | ||||
|         p = watch.get_next_snapshot_key_to_last_viewed | ||||
|         p = watch.get_from_version_based_on_last_viewed | ||||
|         assert p == "109", "Correct when its the same time" | ||||
|  | ||||
|         # new empty one | ||||
|         watch = Watch.model(datastore_path='/tmp', default={}) | ||||
|         p = watch.get_next_snapshot_key_to_last_viewed | ||||
|         p = watch.get_from_version_based_on_last_viewed | ||||
|         assert p == None, "None when no history available" | ||||
|  | ||||
|         watch.save_history_text(contents="hello world", timestamp=100, snapshot_id=str(uuid_builder.uuid4())) | ||||
|         p = watch.get_from_version_based_on_last_viewed | ||||
|         assert p == "100", "Correct with only one history snapshot" | ||||
|  | ||||
|         watch['last_viewed'] = 200 | ||||
|         p = watch.get_from_version_based_on_last_viewed | ||||
|         assert p == "100", "Correct with only one history snapshot" | ||||
|  | ||||
| if __name__ == '__main__': | ||||
|     unittest.main() | ||||
|   | ||||
| @@ -76,6 +76,14 @@ def set_more_modified_response(): | ||||
|     return None | ||||
|  | ||||
|  | ||||
| def set_empty_text_response(): | ||||
|     test_return_data = """<html><body></body></html>""" | ||||
|  | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|  | ||||
|     return None | ||||
|  | ||||
| def wait_for_notification_endpoint_output(): | ||||
|     '''Apprise can take a few seconds to fire''' | ||||
|     #@todo - could check the apprise object directly instead of looking for this file | ||||
| @@ -215,9 +223,10 @@ def live_server_setup(live_server): | ||||
|     def test_method(): | ||||
|         return request.method | ||||
|  | ||||
|     # Where we POST to as a notification | ||||
|     @live_server.app.route('/test_notification_endpoint', methods=['POST', 'GET']) | ||||
|     # Where we POST to as a notification, also use a space here to test URL escaping is OK across all tests that use this. ( #2868 ) | ||||
|     @live_server.app.route('/test_notification endpoint', methods=['POST', 'GET']) | ||||
|     def test_notification_endpoint(): | ||||
|  | ||||
|         with open("test-datastore/notification.txt", "wb") as f: | ||||
|             # Debug method, dump all POST to file also, used to prove #65 | ||||
|             data = request.stream.read() | ||||
| @@ -235,8 +244,11 @@ def live_server_setup(live_server): | ||||
|                 f.write(request.content_type) | ||||
|  | ||||
|         print("\n>> Test notification endpoint was hit.\n", data) | ||||
|         return "Text was set" | ||||
|  | ||||
|         content = "Text was set" | ||||
|         status_code = request.args.get('status_code',200) | ||||
|         resp = make_response(content, status_code) | ||||
|         return resp | ||||
|  | ||||
|     # Just return the verb in the request | ||||
|     @live_server.app.route('/test-basicauth', methods=['GET']) | ||||
| @@ -273,15 +285,43 @@ def live_server_setup(live_server): | ||||
|             <p id="remove">This text should be removed</p> | ||||
|               <form onsubmit="event.preventDefault();"> | ||||
|             <!-- obfuscated text so that we dont accidentally get a false positive due to conversion of the source :) ---> | ||||
|                 <button name="test-button" onclick="getElementById('remove').remove();getElementById('some-content').innerHTML = atob('SSBzbWVsbCBKYXZhU2NyaXB0IGJlY2F1c2UgdGhlIGJ1dHRvbiB3YXMgcHJlc3NlZCE=')">Click here</button> | ||||
|                 <div id=some-content></div> | ||||
|                 <button name="test-button" onclick=" | ||||
|                 getElementById('remove').remove(); | ||||
|                 getElementById('some-content').innerHTML = atob('SSBzbWVsbCBKYXZhU2NyaXB0IGJlY2F1c2UgdGhlIGJ1dHRvbiB3YXMgcHJlc3NlZCE='); | ||||
|                 getElementById('reflect-text').innerHTML = getElementById('test-input-text').value; | ||||
|                 ">Click here</button> | ||||
|                  | ||||
|                 <div id="some-content"></div> | ||||
|                  | ||||
|                 <pre> | ||||
|                 {header_text.lower()} | ||||
|                 </pre> | ||||
|               </body> | ||||
|                  | ||||
|                 <br> | ||||
|                 <!-- used for testing that the jinja2 compiled here ---> | ||||
|                 <input type="text" value="" id="test-input-text" /><br> | ||||
|                 <div id="reflect-text">Waiting to reflect text from #test-input-text here</div> | ||||
|               </form> | ||||
|                  | ||||
|            </body> | ||||
|          </html>""", 200) | ||||
|         resp.headers['Content-Type'] = 'text/html' | ||||
|         return resp | ||||
|  | ||||
|     live_server.start() | ||||
|  | ||||
| def get_index(client): | ||||
|     import inspect | ||||
|     # Get the caller's frame (parent function) | ||||
|     frame = inspect.currentframe() | ||||
|     caller_frame = frame.f_back  # Go back to the caller's frame | ||||
|     caller_name = caller_frame.f_code.co_name | ||||
|     caller_line = caller_frame.f_lineno | ||||
|  | ||||
|     print(f"Called by: {caller_name}, Line: {caller_line}") | ||||
|  | ||||
|     res = client.get(url_for("index")) | ||||
|     with open(f"test-datastore/index-{caller_name}-{caller_line}.html", 'wb') as f: | ||||
|         f.write(res.data) | ||||
|  | ||||
|     return res | ||||
|   | ||||
| @@ -2,14 +2,16 @@ | ||||
|  | ||||
| import os | ||||
| from flask import url_for | ||||
| from ..util import live_server_setup, wait_for_all_checks, extract_UUID_from_client | ||||
| from ..util import live_server_setup, wait_for_all_checks, get_index | ||||
|  | ||||
| def test_setup(client, live_server, measure_memory_usage): | ||||
| def test_setup(client, live_server): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
|  | ||||
| # Add a site in paused mode, add an invalid filter, we should still have visual selector data ready | ||||
| def test_visual_selector_content_ready(client, live_server, measure_memory_usage): | ||||
|     live_server.stop() | ||||
|     live_server.start() | ||||
|  | ||||
|     import os | ||||
|     import json | ||||
| @@ -27,7 +29,7 @@ def test_visual_selector_content_ready(client, live_server, measure_memory_usage | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"Watch added in Paused state, saving will unpause" in res.data | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid=uuid, unpause_on_save=1), | ||||
|         data={ | ||||
| @@ -87,7 +89,9 @@ def test_visual_selector_content_ready(client, live_server, measure_memory_usage | ||||
|  | ||||
| def test_basic_browserstep(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     #live_server_setup(live_server) | ||||
|     live_server.stop() | ||||
|     live_server.start() | ||||
|  | ||||
|     assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test" | ||||
|  | ||||
|     test_url = url_for('test_interactive_html_endpoint', _external=True) | ||||
| @@ -108,9 +112,13 @@ def test_basic_browserstep(client, live_server, measure_memory_usage): | ||||
|             "url": test_url, | ||||
|             "tags": "", | ||||
|             'fetch_backend': "html_webdriver", | ||||
|             'browser_steps-0-operation': 'Click element', | ||||
|             'browser_steps-0-selector': 'button[name=test-button]', | ||||
|             'browser_steps-0-optional_value': '', | ||||
|             'browser_steps-0-operation': 'Enter text in field', | ||||
|             'browser_steps-0-selector': '#test-input-text', | ||||
|             # Should get set to the actual text (jinja2 rendered) | ||||
|             'browser_steps-0-optional_value': "Hello-Jinja2-{% now  'Europe/Berlin', '%Y-%m-%d' %}", | ||||
|             'browser_steps-1-operation': 'Click element', | ||||
|             'browser_steps-1-selector': 'button[name=test-button]', | ||||
|             'browser_steps-1-optional_value': '', | ||||
|             # For now, cookies doesnt work in headers because it must be a full cookiejar object | ||||
|             'headers': "testheader: yes\buser-agent: MyCustomAgent", | ||||
|         }, | ||||
| @@ -119,7 +127,7 @@ def test_basic_browserstep(client, live_server, measure_memory_usage): | ||||
|     assert b"unpaused" in res.data | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|     assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n >= 1, "Watch history had atleast 1 (everything fetched OK)" | ||||
|  | ||||
|     assert b"This text should be removed" not in res.data | ||||
| @@ -132,13 +140,32 @@ def test_basic_browserstep(client, live_server, measure_memory_usage): | ||||
|     assert b"This text should be removed" not in res.data | ||||
|     assert b"I smell JavaScript because the button was pressed" in res.data | ||||
|  | ||||
|     assert b'Hello-Jinja2-20' in res.data | ||||
|  | ||||
|     assert b"testheader: yes" in res.data | ||||
|     assert b"user-agent: mycustomagent" in res.data | ||||
|     live_server.stop() | ||||
|  | ||||
| def test_non_200_errors_report_browsersteps(client, live_server): | ||||
|  | ||||
|     live_server.stop() | ||||
|     live_server.start() | ||||
|  | ||||
|     four_o_four_url =  url_for('test_endpoint', status_code=404, _external=True) | ||||
|     four_o_four_url = four_o_four_url.replace('localhost.localdomain', 'cdio') | ||||
|     four_o_four_url = four_o_four_url.replace('localhost', 'cdio') | ||||
|  | ||||
|     res = client.post( | ||||
|         url_for("form_quick_watch_add"), | ||||
|         data={"url": four_o_four_url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     assert b"Watch added in Paused state, saving will unpause" in res.data | ||||
|     assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test" | ||||
|  | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|  | ||||
|     # now test for 404 errors | ||||
|     res = client.post( | ||||
|         url_for("edit_page", uuid=uuid, unpause_on_save=1), | ||||
| @@ -153,12 +180,14 @@ def test_basic_browserstep(client, live_server, measure_memory_usage): | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     assert b"unpaused" in res.data | ||||
|  | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get(url_for("index")) | ||||
|     res = get_index(client) | ||||
|  | ||||
|     assert b'Error - 404' in res.data | ||||
|  | ||||
|     client.get( | ||||
|         url_for("form_delete", uuid="all"), | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|     ) | ||||
|   | ||||
| @@ -28,6 +28,8 @@ class update_worker(threading.Thread): | ||||
|  | ||||
|     def queue_notification_for_watch(self, notification_q, n_object, watch): | ||||
|         from changedetectionio import diff | ||||
|         from changedetectionio.notification import default_notification_format_for_watch | ||||
|  | ||||
|         dates = [] | ||||
|         trigger_text = '' | ||||
|  | ||||
| @@ -44,6 +46,10 @@ class update_worker(threading.Thread): | ||||
|         else: | ||||
|             snapshot_contents = "No snapshot/history available, the watch should fetch atleast once." | ||||
|  | ||||
|         # If we ended up here with "System default" | ||||
|         if n_object.get('notification_format') == default_notification_format_for_watch: | ||||
|             n_object['notification_format'] = self.datastore.data['settings']['application'].get('notification_format') | ||||
|  | ||||
|         html_colour_enable = False | ||||
|         # HTML needs linebreak, but MarkDown and Text can use a linefeed | ||||
|         if n_object.get('notification_format') == 'HTML': | ||||
| @@ -237,7 +243,6 @@ class update_worker(threading.Thread): | ||||
|                 os.unlink(full_path) | ||||
|  | ||||
|     def run(self): | ||||
|         now = time.time() | ||||
|          | ||||
|         while not self.app.config.exit.is_set(): | ||||
|             update_handler = None | ||||
| @@ -248,6 +253,7 @@ class update_worker(threading.Thread): | ||||
|                 pass | ||||
|  | ||||
|             else: | ||||
|                 fetch_start_time = time.time() | ||||
|                 uuid = queued_item_data.item.get('uuid') | ||||
|                 self.current_uuid = uuid | ||||
|                 if uuid in list(self.datastore.data['watching'].keys()) and self.datastore.data['watching'][uuid].get('url'): | ||||
| @@ -262,7 +268,6 @@ class update_worker(threading.Thread): | ||||
|                     watch = self.datastore.data['watching'].get(uuid) | ||||
|  | ||||
|                     logger.info(f"Processing watch UUID {uuid} Priority {queued_item_data.priority} URL {watch['url']}") | ||||
|                     now = time.time() | ||||
|  | ||||
|                     try: | ||||
|                         # Processor is what we are using for detecting the "Change" | ||||
| @@ -282,6 +287,10 @@ class update_worker(threading.Thread): | ||||
|  | ||||
|                         update_handler.call_browser() | ||||
|  | ||||
|                         # In reality, the actual time of when the change was detected could be a few seconds after this | ||||
|                         # For example it should include when the page stopped rendering if using a playwright/chrome type fetch | ||||
|                         fetch_start_time = time.time() | ||||
|  | ||||
|                         changed_detected, update_obj, contents = update_handler.run_changedetection(watch=watch) | ||||
|  | ||||
|                         # Re #342 | ||||
| @@ -506,7 +515,7 @@ class update_worker(threading.Thread): | ||||
|  | ||||
|                     if not self.datastore.data['watching'].get(uuid): | ||||
|                         continue | ||||
|                     # | ||||
|  | ||||
|                     # Different exceptions mean that we may or may not want to bump the snapshot, trigger notifications etc | ||||
|                     if process_changedetection_results: | ||||
|  | ||||
| @@ -519,8 +528,6 @@ class update_worker(threading.Thread): | ||||
|                                 except Exception as e: | ||||
|                                     logger.warning(f"UUID: {uuid} Extract <title> as watch title was enabled, but couldn't find a <title>.") | ||||
|  | ||||
|                         # Now update after running everything | ||||
|                         timestamp = round(time.time()) | ||||
|                         try: | ||||
|                             self.datastore.update_watch(uuid=uuid, update_obj=update_obj) | ||||
|  | ||||
| @@ -536,24 +543,28 @@ class update_worker(threading.Thread): | ||||
|  | ||||
|                                 # Small hack so that we sleep just enough to allow 1 second  between history snapshots | ||||
|                                 # this is because history.txt indexes/keys snapshots by epoch seconds and we dont want dupe keys | ||||
|  | ||||
|                                 if watch.newest_history_key and int(timestamp) == int(watch.newest_history_key): | ||||
|                                 # @also - the keys are one per second at the most (for now) | ||||
|                                 if watch.newest_history_key and int(fetch_start_time) == int(watch.newest_history_key): | ||||
|                                     logger.warning( | ||||
|                                         f"Timestamp {timestamp} already exists, waiting 1 seconds so we have a unique key in history.txt") | ||||
|                                     timestamp = str(int(timestamp) + 1) | ||||
|                                         f"Timestamp {fetch_start_time} already exists, waiting 1 seconds so we have a unique key in history.txt") | ||||
|                                     fetch_start_time += 1 | ||||
|                                     time.sleep(1) | ||||
|  | ||||
|                                 watch.save_history_text(contents=contents, | ||||
|                                                         timestamp=timestamp, | ||||
|                                                         timestamp=int(fetch_start_time), | ||||
|                                                         snapshot_id=update_obj.get('previous_md5', 'none')) | ||||
|  | ||||
|                                 if update_handler.fetcher.content: | ||||
|                                     watch.save_last_fetched_html(contents=update_handler.fetcher.content, timestamp=timestamp) | ||||
|  | ||||
|                                 empty_pages_are_a_change = self.datastore.data['settings']['application'].get('empty_pages_are_a_change', False) | ||||
|                                 if update_handler.fetcher.content or (not update_handler.fetcher.content and empty_pages_are_a_change): | ||||
|                                     # attribute .last_changed is then based on this data | ||||
|                                     watch.save_last_fetched_html(contents=update_handler.fetcher.content, timestamp=int(fetch_start_time)) | ||||
|  | ||||
|                                 # Notifications should only trigger on the second time (first time, we gather the initial snapshot) | ||||
|                                 if watch.history_n >= 2: | ||||
|                                     logger.info(f"Change detected in UUID {uuid} - {watch['url']}") | ||||
|                                     if not watch.get('notification_muted'): | ||||
|                                         # @todo only run this if notifications exist | ||||
|                                         self.send_content_changed_notification(watch_uuid=uuid) | ||||
|  | ||||
|                         except Exception as e: | ||||
| @@ -575,15 +586,15 @@ class update_worker(threading.Thread): | ||||
|                     except Exception as e: | ||||
|                         pass | ||||
|  | ||||
|                     self.datastore.update_watch(uuid=uuid, update_obj={'fetch_time': round(time.time() - now, 3), | ||||
|                                                                        'last_checked': round(time.time()), | ||||
|                     self.datastore.update_watch(uuid=uuid, update_obj={'fetch_time': round(time.time() - fetch_start_time, 3), | ||||
|                                                                        'last_checked': int(fetch_start_time), | ||||
|                                                                        'check_count': count | ||||
|                                                                        }) | ||||
|  | ||||
|  | ||||
|                 self.current_uuid = None  # Done | ||||
|                 self.q.task_done() | ||||
|                 logger.debug(f"Watch {uuid} done in {time.time()-now:.2f}s") | ||||
|                 logger.debug(f"Watch {uuid} done in {time.time()-fetch_start_time:.2f}s") | ||||
|  | ||||
|                 # Give the CPU time to interrupt | ||||
|                 time.sleep(0.1) | ||||
|   | ||||
| @@ -12,9 +12,6 @@ services: | ||||
|   #    environment: | ||||
|   #        Default listening port, can also be changed with the -p option | ||||
|   #      - PORT=5000 | ||||
|  | ||||
|   #      - PUID=1000 | ||||
|   #      - PGID=1000 | ||||
|   # | ||||
|   #        Log levels are in descending order. (TRACE is the most detailed one) | ||||
|   #        Log output levels: TRACE, DEBUG(default), INFO, SUCCESS, WARNING, ERROR, CRITICAL | ||||
|   | ||||
| @@ -35,7 +35,7 @@ dnspython==2.6.1 # related to eventlet fixes | ||||
| # jq not available on Windows so must be installed manually | ||||
|  | ||||
| # Notification library | ||||
| apprise==1.9.0 | ||||
| apprise==1.9.2 | ||||
|  | ||||
| # apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315 | ||||
| # use any version other than 2.0.x due to https://github.com/eclipse/paho.mqtt.python/issues/814 | ||||
| @@ -95,3 +95,8 @@ babel | ||||
| # Needed for > 3.10, https://github.com/microsoft/playwright-python/issues/2096 | ||||
| greenlet >= 3.0.3 | ||||
|  | ||||
| # Pinned or it causes problems with flask_expects_json which seems unmaintained | ||||
| referencing==0.35.1 | ||||
|  | ||||
| # Scheduler - Windows seemed to miss a lot of default timezone info (even "UTC" !) | ||||
| tzdata | ||||
|   | ||||
		Reference in New Issue
	
	Block a user