Compare commits

...

10 Commits

Author SHA1 Message Date
dgtlmoon
a5386a05fe Merge branch '2877-update-checked-time' into only-call-send_content_changed_notification-on-change 2025-01-06 20:03:03 +01:00
dgtlmoon
11524108c2 remove comment 2025-01-06 19:47:32 +01:00
dgtlmoon
0f28446804 Be more specific about 'change time' 2025-01-06 19:42:45 +01:00
dgtlmoon
f454e7ced9 Only call send_content_changed_notification on change 2025-01-06 16:10:02 +01:00
dgtlmoon
e12ba25074 Re #2877 Follow last_checked time improvements 2025-01-06 16:06:26 +01:00
William Brawner
202e47d728 Update Apprise to 1.9.1 (#2876) 2025-01-02 20:06:25 +01:00
Florian Kretschmer
d67d396b88 Builder/Docker - Remove PUID and PGID ( they were not used ) (#2852) 2024-12-27 13:03:36 +01:00
MoshiMoshi0
05f54f0ce6 UI - Fix diff not starting from last viewed snapshot (#2744) (#2856) 2024-12-27 13:03:10 +01:00
dgtlmoon
6adf10597e 0.48.05 2024-12-27 11:24:56 +01:00
dgtlmoon
4419bc0e61 Fixing test for CVE-2024-56509 (#2864) 2024-12-27 11:09:52 +01:00
14 changed files with 105 additions and 69 deletions

View File

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

View File

@@ -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

View File

@@ -14,7 +14,7 @@ def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs):
import requests
import json
from urllib.parse import unquote_plus
from apprise.utils import parse_url as apprise_parse_url
from apprise.utils.parse import parse_url as apprise_parse_url
from apprise import URLBase
url = kwargs['meta'].get('url')

View File

@@ -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

View File

@@ -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 %}

View File

@@ -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),

View File

@@ -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

View File

@@ -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

View File

@@ -69,22 +69,29 @@ def _runner_test_various_file_slash(client, file_uri):
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')):
# 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
assert b"_runner_test_various_file_slash" in res.data # Can read this file OK
else:
# Default should be here
assert b'file:// type access is denied for security reasons.' in res.data
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)
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 permitted by default, but it will be caught by ALLOW_FILE_URI
# file: is NOT permitted by default, so it will be caught by ALLOW_FILE_URI check
test_file_path = os.path.abspath(__file__)
_runner_test_various_file_slash(client, file_uri=f"file://{test_file_path}")

View File

@@ -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()

View File

@@ -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

View File

@@ -243,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
@@ -254,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'):
@@ -268,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"
@@ -288,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
@@ -512,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:
@@ -525,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)
@@ -542,25 +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)
# 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'):
self.send_content_changed_notification(watch_uuid=uuid)
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'):
self.send_content_changed_notification(watch_uuid=uuid)
except Exception as e:
# Catch everything possible here, so that if a worker crashes, we don't lose it until restart!
@@ -581,15 +585,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)

View File

@@ -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

View File

@@ -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.1
# 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