Files
changedetection.io/changedetectionio/flask_app.py
T

1674 lines
64 KiB
Python

#!/usr/bin/env python3
import gc
import hashlib
import locale
import os
import queue
import re
import sys
import threading
import time
from pathlib import Path
from threading import Event
import flask_login
import timeago
from blinker import signal
from flask import (
Flask,
abort,
flash,
redirect,
render_template,
request,
send_from_directory,
session,
url_for,
)
from flask_cors import CORS
from flask_restful import Api, abort
from changedetectionio import worker_pool
from changedetectionio.queue_handlers import NotificationQueue, RecheckPriorityQueue
from changedetectionio.strtobool import strtobool
# Create specific signals for application events
# Make this a global singleton to avoid multiple signal objects
watch_check_update = signal('watch_check_update', doc='Signal sent when a watch check is completed')
from flask_babel import Babel, get_locale, gettext
from flask_wtf import CSRFProtect
from loguru import logger
from changedetectionio import __version__, queuedWatchMetaData
from changedetectionio.api import (
CreateWatch,
Import,
Notifications,
Spec,
SystemInfo,
Tag,
Tags,
Watch,
WatchFavicon,
WatchHistory,
WatchHistoryDiff,
WatchSingleHistory,
)
from changedetectionio.api.Search import Search
from changedetectionio.blueprint.menu_modes import MENU_SIDEBAR_ACTIONMODES, MENU_SIDEBAR_ACTIONMODES_DEFAULT
from changedetectionio.favicon_utils import get_favicon_mime_type
from changedetectionio.languages import (
get_available_languages,
get_flag_for_locale,
get_language_codes,
get_timeago_locale,
)
from .thread_supervisor import start_supervised_thread
from .time_handler import default_timezone_name, is_within_schedule
IN_PYTEST = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
datastore = None
# Local
ticker_thread = None
extra_stylesheets = []
# Use bulletproof janus-based queues for sync/async reliability
update_q = RecheckPriorityQueue()
notification_q = NotificationQueue()
MAX_QUEUE_SIZE = 5000
app = Flask(__name__, static_url_path="", static_folder="static", template_folder="templates")
# Will be initialized in changedetection_app
socketio_server = None
# Enable CORS, especially useful for the Chrome extension to operate from anywhere
CORS(app)
from uuid import UUID
from werkzeug.routing import BaseConverter, ValidationError
class StrictUUIDConverter(BaseConverter):
# Special sentinel values allowed in addition to strict UUIDs
_ALLOWED_SENTINELS = frozenset({'first'})
def to_python(self, value: str) -> str:
if value in self._ALLOWED_SENTINELS:
return value
try:
u = UUID(value)
except ValueError as e:
raise ValidationError() from e
# Reject non-standard formats (braces, URNs, no-hyphens)
if str(u) != value.lower():
raise ValidationError()
return str(u)
def to_url(self, value) -> str:
return str(value)
# app setup (once)
app.url_map.converters["uuid_str"] = StrictUUIDConverter
# Flask-Compress handles HTTP compression, Socket.IO compression disabled to prevent memory leak.
# There's also a bug between flask compress and socketio that causes some kind of slow memory leak
# It's better to use compression on your reverse proxy (nginx etc) instead.
if strtobool(os.getenv("FLASK_ENABLE_COMPRESSION")):
from flask_compress import Compress as FlaskCompress
app.config['COMPRESS_MIN_SIZE'] = 2096
app.config['COMPRESS_MIMETYPES'] = [
'text/html',
'text/css',
'text/javascript',
'application/json',
'application/javascript',
'image/svg+xml',
]
# Use gzip only - smaller memory footprint than zstd/brotli (4-8KB vs 200-500KB contexts)
app.config['COMPRESS_ALGORITHM'] = ['gzip']
compress = FlaskCompress()
compress.init_app(app)
app.config['TEMPLATES_AUTO_RELOAD'] = False
# Stop browser caching of assets
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0
app.config.exit = Event()
app.config['NEW_VERSION_AVAILABLE'] = False
if os.getenv('FLASK_SERVER_NAME'):
app.config['SERVER_NAME'] = os.getenv('FLASK_SERVER_NAME')
# Babel/i18n configuration
#
# BABEL_TRANSLATION_DIRECTORIES is a ';' separated list. For each locale, Flask-Babel loads one
# catalog per directory and merges them in order (Domain.get_translations -> babel
# Translations.merge -> dict.update), so a later directory overrides an earlier one *per message*
# rather than replacing the catalog.
#
# That gives deployments an optional overlay layer: a catalog holding only the handful of msgids
# whose wording needs to differ (branding, or wording that makes no sense for that deployment,
# e.g. instructions to set an ENV var that the operator controls). Every other string in the same
# language still comes from the base catalog, and a language with no overlay file is untouched.
# Because the overlay keys off the upstream msgid, templates keep the upstream string verbatim and
# carry no diff at all.
#
# Absent or empty overlay directory == no behaviour change.
# See changedetectionio/translations_overlay/README.md
_translation_directories = [str(Path(__file__).parent / 'translations')]
_translation_overlay = os.getenv(
'TRANSLATION_OVERLAY_DIR', str(Path(__file__).parent / 'translations_overlay')
)
if os.path.isdir(_translation_overlay):
_translation_directories.append(_translation_overlay)
app.config['BABEL_TRANSLATION_DIRECTORIES'] = ';'.join(_translation_directories)
app.config['BABEL_DEFAULT_LOCALE'] = 'en_GB'
# Session configuration
# NOTE: Flask session (for locale, etc.) is separate from Flask-Login's remember-me cookie
# - Flask session stores data like session['locale'] in a signed cookie
# - Flask-Login's remember=True creates a separate authentication cookie
# - Setting PERMANENT_SESSION_LIFETIME controls how long the Flask session cookie lasts
from datetime import timedelta
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=3650) # ~10 years (effectively unlimited)
# app.config["EXPLAIN_TEMPLATE_LOADING"] = True
app.jinja_env.add_extension('jinja2.ext.loopcontrols')
# Configure Jinja2 to search for templates in plugin directories
def _configure_plugin_templates():
"""Configure Jinja2 loader to include plugin template directories."""
from jinja2 import ChoiceLoader, FileSystemLoader
from changedetectionio.pluggy_interface import get_plugin_template_paths
# Get plugin template paths
plugin_template_paths = get_plugin_template_paths()
if plugin_template_paths:
# Create a ChoiceLoader that searches app templates first, then plugin templates
loaders = [app.jinja_loader] # Keep the default app loader first
for path in plugin_template_paths:
loaders.append(FileSystemLoader(path))
app.jinja_loader = ChoiceLoader(loaders)
logger.info(
f"Configured Jinja2 to search {len(plugin_template_paths)} plugin template directories"
)
# Configure plugin templates (called after plugins are loaded)
_configure_plugin_templates()
csrf = CSRFProtect()
csrf.init_app(app)
notification_debug_log = []
# Locale for correct presentation of prices etc
default_locale = locale.getdefaultlocale()
logger.info(f"System locale default is {default_locale}")
try:
locale.setlocale(locale.LC_ALL, default_locale)
except locale.Error:
logger.warning(f"Unable to set locale {default_locale}, locale is not installed maybe?")
watch_api = Api(app, decorators=[csrf.exempt])
def init_app_secret(datastore_path):
secret = ""
path = os.path.join(datastore_path, "secret.txt")
try:
with open(path, encoding='utf-8') as f:
secret = f.read()
except FileNotFoundError:
import secrets
with open(path, "w", encoding='utf-8') as f:
secret = secrets.token_hex(32)
f.write(secret)
return secret
@app.template_global()
def get_darkmode_state():
css_dark_mode = request.cookies.get('css_dark_mode', 'false')
return 'true' if css_dark_mode and strtobool(css_dark_mode) else 'false'
@app.template_global()
def get_css_version():
"""Cache-busting token for static assets.
Changes on every upgrade (so browsers refetch CSS/JS) but is not the raw
version string - the raw version was leaking to anonymous visitors on the
login page via `?v=x.y.z`, which allows exposed instances to be fingerprinted
for known-vulnerable releases (#2190). Salted with the per-installation
app_guid so it can't be reversed to the version.
"""
salt = datastore.data.get('app_guid', '') if datastore else ''
return hashlib.sha256(f"{salt}{__version__}".encode()).hexdigest()[:10]
@app.template_global('filtered_action_url')
def _filtered_action_url(endpoint, **overrides):
"""Build a URL to `endpoint` carrying the CURRENT watch-list filters (query args)
with `overrides` merged in. Used so filter links compose AND so list actions
(mark-all-viewed, recheck-all) act on exactly the filtered view, not everything.
Keys set to None/''/0 are dropped, and pagination always resets."""
args = request.args.to_dict()
args.pop('page', None)
args.update(overrides)
args = {k: v for k, v in args.items() if v not in (None, '', 0, '0')}
return url_for(endpoint, **args)
@app.template_global('filter_url')
def _filter_url(**overrides):
"""Watch-list filter link (shorthand for filtered_action_url('watchlist.index'))."""
return _filtered_action_url('watchlist.index', **overrides)
@app.template_global()
def get_sidebar_mode_class():
"""Body class(es) that drive the left-rail behaviour (see parts/_action_sidebar.scss).
Only the modes offered by MENU_SIDEBAR_ACTIONMODES are honoured - anything else in the
datastore (a stale value from an older release, hand-edited JSON) falls back to
MENU_SIDEBAR_ACTIONMODES_DEFAULT rather than leaking through as a body class.
'expandable' -> icon-only rail, rolls out over the content on hover/focus
'pinned-expanded' -> rail always expanded, labels visible at rest
'minimal' -> icon-only rail that never expands
"""
# 'actionsidebar-minimal' - collapsed icon rail (hover-to-expand lives in CSS + static/js/sidebar.js)
# 'actionsidebar-no-expand' - opts that rail out of hover-to-expand
# 'actionside-bar-on' - always-open rail
# 'action-side-bar-expanded'- expanded logo/stats block
body_classes = {
'expandable': 'actionsidebar-minimal',
'pinned-expanded': 'actionside-bar-on action-side-bar-expanded',
'minimal': 'actionsidebar-minimal actionsidebar-no-expand',
}
mode = datastore.data['settings']['application'].get('ui', {}).get('sidebar_mode')
if mode not in {choice for choice, _label in MENU_SIDEBAR_ACTIONMODES} or mode not in body_classes:
mode = MENU_SIDEBAR_ACTIONMODES_DEFAULT
return body_classes[mode]
@app.template_global()
def get_blueprint_class():
"""Body class for the currently-executing blueprint, e.g. 'blueprint-watchlist'
or 'blueprint-ui-ui_queue' (dots → dashes), so CSS can target a section. Empty
when there's no blueprint (e.g. an error page)."""
bp = request.blueprint or ''
return ('blueprint-' + bp.replace('.', '-')) if bp else ''
@app.template_global()
def get_socketio_path():
"""Generate the correct Socket.IO path prefix for the client"""
# If behind a proxy with a sub-path, we need to respect that path
prefix = ""
if os.getenv('USE_X_SETTINGS') and 'X-Forwarded-Prefix' in request.headers:
prefix = request.headers['X-Forwarded-Prefix']
# Socket.IO will be available at {prefix}/socket.io/
return prefix
@app.template_global('is_safe_valid_url')
def _is_safe_valid_url(test_url):
from .validate_url import is_safe_valid_url
return is_safe_valid_url(test_url)
@app.template_global('get_html_head_extras')
def _get_html_head_extras():
from .pluggy_interface import collect_html_head_extras
return collect_html_head_extras()
@app.template_filter('format_number_locale')
def _jinja2_filter_format_number_locale(value: float) -> str:
"Formats for example 4000.10 to the local locale default of 4,000.10"
# Format the number with two decimal places (locale format string will return 6 decimal)
formatted_value = locale.format_string("%.2f", value, grouping=True)
return formatted_value
@app.template_filter('format_int_locale')
def _jinja2_filter_format_int_locale(value) -> str:
"Locale-grouped integer, e.g. 1000 -> 1,000 (no decimals — for counts)"
return locale.format_string("%d", int(value), grouping=True)
@app.template_filter('regex_search')
def _jinja2_filter_regex_search(value, pattern):
import re
return re.search(pattern, str(value)) is not None
@app.template_global('is_checking_now')
def _watch_is_checking_now(watch_obj, format="%Y-%m-%d %H:%M:%S"):
return worker_pool.is_watch_running(watch_obj['uuid'])
@app.template_global('get_watch_queue_position')
def _get_watch_queue_position(watch_obj):
"""Get the position of a watch in the queue"""
uuid = watch_obj['uuid']
return update_q.get_uuid_position(uuid)
@app.template_global('get_current_worker_count')
def _get_current_worker_count():
"""Get the current number of operational workers"""
return worker_pool.get_worker_count()
@app.template_global('get_worker_status_info')
def _get_worker_status_info():
"""Get detailed worker status information for display"""
status = worker_pool.get_worker_status()
running_uuids = worker_pool.get_running_uuids()
return {
'count': status['worker_count'],
'type': status['worker_type'],
'active_workers': len(running_uuids),
'processing_watches': running_uuids,
'loop_running': status.get('async_loop_running', None),
}
# We use the whole watch object from the store/JSON so we can see if there's some related status in terms of a thread
# running or something similar.
@app.template_filter('format_last_checked_time')
def _jinja2_filter_datetime(watch_obj, format="%Y-%m-%d %H:%M:%S"):
if watch_obj['last_checked'] == 0:
return gettext('Not yet')
short = datastore.data['settings']['application'].get('ui', {}).get('timeago_format') == 'short'
locale = get_timeago_locale(str(get_locale()), short=short)
try:
return timeago.format(int(watch_obj['last_checked']), time.time(), locale)
except:
# Fallback to English if locale not supported by timeago
return timeago.format(
int(watch_obj['last_checked']), time.time(), 'en_short' if short else 'en'
)
@app.template_filter('format_timestamp_timeago')
def _jinja2_filter_datetimestamp(timestamp, format="%Y-%m-%d %H:%M:%S"):
if not timestamp:
return gettext('Not yet')
short = datastore.data['settings']['application'].get('ui', {}).get('timeago_format') == 'short'
locale = get_timeago_locale(str(get_locale()), short=short)
try:
return timeago.format(int(timestamp), time.time(), locale)
except:
# Fallback to English if locale not supported by timeago
return timeago.format(int(timestamp), time.time(), 'en_short' if short else 'en')
@app.template_filter('pagination_slice')
def _jinja2_filter_pagination_slice(arr, skip):
per_page = datastore.data['settings']['application'].get('pager_size', 50)
if per_page:
return arr[skip : skip + per_page]
return arr
@app.template_filter('format_seconds_ago')
def _jinja2_filter_seconds_precise(timestamp):
if timestamp == False:
return gettext('Not yet')
return format(int(time.time() - timestamp), ',d')
@app.template_filter('format_duration')
def _jinja2_filter_format_duration(seconds):
"""Format a duration in seconds into human readable string like '5 days, 3 hours, 30 minutes'"""
from datetime import timedelta
if not seconds or seconds < 0:
return gettext('0 seconds')
td = timedelta(seconds=int(seconds))
# Calculate components
years = td.days // 365
remaining_days = td.days % 365
months = remaining_days // 30
remaining_days = remaining_days % 30
weeks = remaining_days // 7
days = remaining_days % 7
hours = td.seconds // 3600
minutes = (td.seconds % 3600) // 60
secs = td.seconds % 60
# Build parts list
parts = []
if years > 0:
parts.append(f"{years} {gettext('year') if years == 1 else gettext('years')}")
if months > 0:
parts.append(f"{months} {gettext('month') if months == 1 else gettext('months')}")
if weeks > 0:
parts.append(f"{weeks} {gettext('week') if weeks == 1 else gettext('weeks')}")
if days > 0:
parts.append(f"{days} {gettext('day') if days == 1 else gettext('days')}")
if hours > 0:
parts.append(f"{hours} {gettext('hour') if hours == 1 else gettext('hours')}")
if minutes > 0:
parts.append(f"{minutes} {gettext('minute') if minutes == 1 else gettext('minutes')}")
if secs > 0 or not parts:
parts.append(f"{secs} {gettext('second') if secs == 1 else gettext('seconds')}")
return ", ".join(parts)
@app.template_filter('fetcher_status_icons')
def _jinja2_filter_fetcher_status_icons(fetcher_name):
"""Get status icon HTML for a given fetcher.
This filter checks both built-in fetchers and plugin fetchers for status icons.
Args:
fetcher_name: The fetcher name (e.g., 'html_webdriver', 'html_js_zyte')
Returns:
str: HTML string containing status icon elements
"""
from flask import url_for
from markupsafe import Markup
from changedetectionio import content_fetchers
from changedetectionio.pluggy_interface import collect_fetcher_status_icons
icon_data = None
# First check if it's a plugin fetcher (plugins have priority)
plugin_icon_data = collect_fetcher_status_icons(fetcher_name)
if plugin_icon_data:
icon_data = plugin_icon_data
# Check if it's a built-in fetcher
elif hasattr(content_fetchers, fetcher_name):
fetcher_class = getattr(content_fetchers, fetcher_name)
if hasattr(fetcher_class, 'get_status_icon_data'):
icon_data = fetcher_class.get_status_icon_data()
# Build HTML from icon data
if icon_data and isinstance(icon_data, dict):
# Use 'group' from icon_data if specified, otherwise default to 'images'
group = icon_data.get('group', 'images')
# Try to use url_for, but fall back to manual URL building if endpoint not registered yet
try:
icon_url = url_for('static_content', group=group, filename=icon_data['filename'])
except:
# Fallback: build URL manually respecting APPLICATION_ROOT
from flask import request
app_root = request.script_root if hasattr(request, 'script_root') else ''
icon_url = f"{app_root}/static/{group}/{icon_data['filename']}"
style_attr = f' style="{icon_data["style"]}"' if icon_data.get('style') else ''
html = f'<img class="status-icon" src="{icon_url}" alt="{icon_data["alt"]}" title="{icon_data["title"]}"{style_attr}>'
return Markup(html)
return ''
_RE_SANITIZE_TAG = re.compile(r'[^a-zA-Z0-9]')
@app.template_filter('sanitize_tag_class')
def _jinja2_filter_sanitize_tag_class(tag_title):
"""Sanitize a tag title to create a valid CSS class name.
Removes all non-alphanumeric characters and converts to lowercase.
Args:
tag_title: The tag title string
Returns:
str: A sanitized string suitable for use as a CSS class name
"""
# Remove all non-alphanumeric characters and convert to lowercase
sanitized = _RE_SANITIZE_TAG.sub('', tag_title).lower()
# Ensure it starts with a letter (CSS requirement)
if sanitized and not sanitized[0].isalpha():
sanitized = 'tag' + sanitized
return sanitized if sanitized else 'tag'
# Import login_optionally_required from auth_decorator
from changedetectionio.auth_decorator import (
SHARED_DIFF_READ_ONLY_ENDPOINTS,
login_optionally_required,
)
# When nobody is logged in Flask-Login's current_user is set to an AnonymousUser object.
class User(flask_login.UserMixin):
id = None
def set_password(self, password):
return True
def get_user(self, email="defaultuser@changedetection.io"):
return self
def is_authenticated(self):
return True
def is_active(self):
return True
def is_anonymous(self):
return False
def get_id(self):
return str(self.id)
# Compare given password against JSON store or Env var
def check_password(self, password):
import base64
import hashlib
# Can be stored in env (for deployments) or in the general configs
raw_salt_pass = os.getenv("SALTED_PASS", False)
if not raw_salt_pass:
raw_salt_pass = datastore.data['settings']['application'].get('password')
raw_salt_pass = base64.b64decode(raw_salt_pass)
salt_from_storage = raw_salt_pass[:32] # 32 is the length of the salt
# Use the exact same setup you used to generate the key, but this time put in the password to check
new_key = hashlib.pbkdf2_hmac(
'sha256',
password.encode('utf-8'), # Convert the password to bytes
salt_from_storage,
100000,
)
new_key = salt_from_storage + new_key
return new_key == raw_salt_pass
pass
def clean_startup_state(datastore):
"""One-shot validation/repair of persisted settings at app startup.
Runs once when the app boots (after the datastore is loaded and fetchers are registered).
Keeps stale/invalid persisted config from silently breaking runtime behaviour. Add further
startup repairs here as needed.
"""
# If the global default fetch method points at a fetcher that is no longer available
# (e.g. a browser plugin like 'cloakbrowser' that was set as default and then uninstalled),
# fall back to 'html_requests'. Otherwise the settings UI shows no selected default and
# watches set to "System settings default" silently resolve to requests at fetch time
# (and break for browser-steps watches).
try:
from changedetectionio import content_fetchers
valid_fetchers = {name for name, _desc in content_fetchers.available_fetchers()}
cur_default = datastore.data['settings']['application'].get('fetch_backend')
if cur_default and cur_default != 'system' and cur_default not in valid_fetchers:
logger.warning(
f"Configured default fetch_backend '{cur_default}' is not an available fetcher "
f"(plugin uninstalled?) - resetting default to 'html_requests'."
)
datastore.data['settings']['application']['fetch_backend'] = 'html_requests'
except Exception as e:
logger.error(f"clean_startup_state: could not validate default fetch_backend: {e}")
def changedetection_app(config=None, datastore_o=None):
logger.trace("TRACE log is enabled")
global datastore, socketio_server
datastore = datastore_o
# Set datastore reference in notification queue for all_muted checking
notification_q.set_datastore(datastore)
# One-shot validation/repair of persisted settings that may have gone stale between runs.
clean_startup_state(datastore)
# Import and create a wrapper for is_safe_url that has access to app
from changedetectionio.is_safe_url import is_safe_url as _is_safe_url
def is_safe_url(target):
"""Wrapper for is_safe_url that passes the app instance"""
return _is_safe_url(target, app)
# so far just for read-only via tests, but this will be moved eventually to be the main source
# (instead of the global var)
app.config['DATASTORE'] = datastore_o
# Store batch mode flag to skip background threads when running in batch mode
app.config['batch_mode'] = config.get('batch_mode', False) if config else False
# Store the signal in the app config to ensure it's accessible everywhere
app.config['watch_check_update_SIGNAL'] = watch_check_update
login_manager = flask_login.LoginManager(app)
login_manager.login_view = 'login'
app.secret_key = init_app_secret(config['datastore_path'])
# Initialize Flask-Babel for i18n support
available_languages = get_available_languages()
language_codes = get_language_codes()
_locale_aliases = {
'zh-TW': 'zh_Hant_TW', # Traditional Chinese: browser sends zh-TW, we use zh_Hant_TW
'zh_TW': 'zh_Hant_TW', # Also handle underscore variant
}
_locale_match_list = language_codes + list(_locale_aliases.keys())
def get_locale():
# 1. Try to get locale from session (user explicitly selected)
if 'locale' in session:
return session['locale']
# 2. Fall back to Accept-Language header
browser_locale = request.accept_languages.best_match(_locale_match_list)
# 3. Map browser locale to our internal locale if needed
return _locale_aliases.get(browser_locale, browser_locale)
# Initialize Babel with locale selector
babel = Babel(app, locale_selector=get_locale)
# Make i18n functions available to templates
app.jinja_env.globals.update(
_=gettext,
get_locale=get_locale,
get_flag_for_locale=get_flag_for_locale,
available_languages=available_languages,
)
@app.context_processor
def inject_llm_features_disabled():
from changedetectionio.llm.evaluator import is_llm_features_disabled
return dict(llm_features_disabled=is_llm_features_disabled())
@app.context_processor
def inject_has_visual_browser():
# Whether any installed content fetcher can render the Add-Watch live preview -
# sidebar-nav.html hides the Add-Watch link without one. Same capability lookup the
# page's browser picker and /snapshot use, so they can't disagree.
from changedetectionio.blueprint.add_watch_ui import browser_config
return dict(has_visual_browser=browser_config.has_visual_browser(datastore))
# Set up a request hook to check authentication for all routes
@app.before_request
def check_authentication():
has_password_enabled = datastore.data['settings']['application'].get(
'password'
) or os.getenv("SALTED_PASS", False)
if has_password_enabled and not flask_login.current_user.is_authenticated:
# Permitted
if request.endpoint and request.endpoint == 'static_content' and request.view_args:
# Handled by static_content handler
return None
# Permitted - static flag icons need to load on login page
elif request.endpoint and request.endpoint == 'static_flags':
return None
# Permitted - language selection should work on login page.
# Both halves of the language modal must be exempt: it renders for anonymous
# users (base.html deliberately leaves it outside the is_authenticated guard),
# so exempting only set_language let you pick a language but bounced
# "Auto-detect from browser" to /login without clearing the session locale.
elif request.endpoint and request.endpoint in (
'set_language',
'ui.delete_locale_language_session_var_if_it_exists',
):
return None
# Permitted
elif request.endpoint and 'login' in request.endpoint:
return None
elif request.endpoint in SHARED_DIFF_READ_ONLY_ENDPOINTS and datastore.data['settings'][
'application'
].get('shared_diff_access'):
return None
elif request.method in flask_login.config.EXEMPT_METHODS:
return None
elif app.config.get('LOGIN_DISABLED'):
return None
# RSS access with token is allowed
elif request.blueprint == 'rss':
return None
# Socket.IO routes - need separate handling
elif request.path.startswith('/socket.io/'):
return None
# API routes - use their own auth mechanism (@auth.check_token)
elif request.path.startswith('/api/'):
return None
else:
return login_manager.unauthorized()
watch_api.add_resource(
WatchHistoryDiff,
'/api/v1/watch/<uuid_str:uuid>/difference/<string:from_timestamp>/<string:to_timestamp>',
resource_class_kwargs={'datastore': datastore},
)
watch_api.add_resource(
WatchSingleHistory,
'/api/v1/watch/<uuid_str:uuid>/history/<string:timestamp>',
resource_class_kwargs={'datastore': datastore, 'update_q': update_q},
)
watch_api.add_resource(
WatchFavicon,
'/api/v1/watch/<uuid_str:uuid>/favicon',
resource_class_kwargs={'datastore': datastore},
)
watch_api.add_resource(
WatchHistory,
'/api/v1/watch/<uuid_str:uuid>/history',
resource_class_kwargs={'datastore': datastore},
)
watch_api.add_resource(
CreateWatch,
'/api/v1/watch',
resource_class_kwargs={'datastore': datastore, 'update_q': update_q},
)
watch_api.add_resource(
Watch,
'/api/v1/watch/<uuid_str:uuid>',
resource_class_kwargs={'datastore': datastore, 'update_q': update_q},
)
watch_api.add_resource(
SystemInfo,
'/api/v1/systeminfo',
resource_class_kwargs={'datastore': datastore, 'update_q': update_q},
)
watch_api.add_resource(Import, '/api/v1/import', resource_class_kwargs={'datastore': datastore})
watch_api.add_resource(Tags, '/api/v1/tags', resource_class_kwargs={'datastore': datastore})
watch_api.add_resource(
Tag,
'/api/v1/tag',
'/api/v1/tag/<uuid_str:uuid>',
resource_class_kwargs={'datastore': datastore, 'update_q': update_q},
)
watch_api.add_resource(Search, '/api/v1/search', resource_class_kwargs={'datastore': datastore})
watch_api.add_resource(
Notifications, '/api/v1/notifications', resource_class_kwargs={'datastore': datastore}
)
watch_api.add_resource(Spec, '/api/v1/full-spec')
@login_manager.user_loader
def user_loader(email):
user = User()
user.get_user(email)
return user
@login_manager.unauthorized_handler
def unauthorized_handler():
# Pass the current request path so users are redirected back after login
return redirect(url_for('login', redirect=request.path))
@app.route('/logout', methods=['POST'])
def logout():
flask_login.logout_user()
# Check if there's a redirect parameter to return to after re-login
redirect_url = request.args.get('redirect')
# If redirect is provided and safe, pass it to login page
if redirect_url and is_safe_url(redirect_url):
return redirect(url_for('login', redirect=redirect_url))
# Otherwise just go to watchlist
return redirect(url_for('watchlist.index'))
@app.route('/set-language/<locale>', methods=['POST'])
def set_language(locale):
"""Set the user's preferred language in the session"""
if not request.cookies:
logger.error("Cannot set language without session cookie")
flash("Cannot set language without session cookie", 'error')
return redirect(url_for('watchlist.index'))
# Validate the locale against available languages
if locale in language_codes:
# Make session permanent so language preference persists across browser sessions
# NOTE: This is the Flask session cookie (separate from Flask-Login's remember-me auth cookie)
session.permanent = True
session['locale'] = locale
# CRITICAL: Flask-Babel caches the locale in the request context (ctx.babel_locale)
# We must refresh to clear this cache so the new locale takes effect immediately
# This is especially important for tests where multiple requests happen rapidly
from flask_babel import refresh
refresh()
else:
logger.error(f"Invalid locale {locale}, available: {language_codes}")
# Check if there's a redirect parameter to return to the same page
redirect_url = request.args.get('redirect')
# If redirect is provided and safe, use it
if redirect_url and is_safe_url(redirect_url):
return redirect(redirect_url)
# Otherwise redirect to watchlist
return redirect(url_for('watchlist.index'))
# https://github.com/pallets/flask/blob/93dd1709d05a1cf0e886df6223377bdab3b077fb/examples/tutorial/flaskr/__init__.py#L39
# You can divide up the stuff like this
@app.route('/login', methods=['GET', 'POST'])
def login():
# Extract and validate the redirect parameter
redirect_url = request.args.get('redirect') or request.form.get('redirect')
# Validate the redirect URL - default to watchlist if invalid
if redirect_url and is_safe_url(redirect_url):
validated_redirect = redirect_url
else:
validated_redirect = url_for('watchlist.index')
if request.method == 'GET':
if flask_login.current_user.is_authenticated:
# Already logged in - redirect immediately to the target
flash(gettext("Already logged in"))
return redirect(validated_redirect)
flash(gettext("You must be logged in, please log in."), 'error')
output = render_template("login.html", redirect_url=validated_redirect)
return output
user = User()
user.id = "defaultuser@changedetection.io"
password = request.form.get('password')
if user.check_password(password):
flask_login.login_user(user, remember=True)
# Redirect to the validated URL after successful login
return redirect(validated_redirect)
else:
flash(gettext('Incorrect password'), 'error')
return redirect(url_for('login', redirect=redirect_url if redirect_url else None))
@app.before_request
def before_request_handle_cookie_x_settings():
# Set the auth cookie path if we're running as X-settings/X-Forwarded-Prefix
if os.getenv('USE_X_SETTINGS') and 'X-Forwarded-Prefix' in request.headers:
app.config['REMEMBER_COOKIE_PATH'] = request.headers['X-Forwarded-Prefix']
app.config['SESSION_COOKIE_PATH'] = request.headers['X-Forwarded-Prefix']
return None
@app.route("/static/flags/<path:flag_path>", methods=['GET'])
def static_flags(flag_path):
"""Handle flag icon files with subdirectories"""
import re
from flask import make_response
# flag_path comes in as "1x1/de.svg" or "4x3/de.svg"
if re.match(r'^(1x1|4x3)/[a-z0-9-]+\.svg$', flag_path.lower()):
# Reconstruct the path safely with additional validation
parts = flag_path.lower().split('/')
if len(parts) != 2:
abort(404)
subdir = parts[0]
svg_file = parts[1]
# Extra validation: ensure subdir is exactly 1x1 or 4x3
if subdir not in ['1x1', '4x3']:
abort(404)
# Extra validation: ensure svg_file only contains safe characters
if not re.match(r'^[a-z0-9-]+\.svg$', svg_file):
abort(404)
try:
response = make_response(send_from_directory(f"static/flags/{subdir}", svg_file))
response.headers['Content-type'] = 'image/svg+xml'
response.headers['Cache-Control'] = 'max-age=86400, public' # Cache for 24 hours
return response
except FileNotFoundError:
abort(404)
else:
abort(404)
@app.route("/static/<string:group>/<string:filename>", methods=['GET'])
def static_content(group, filename):
import re
from flask import make_response
# Strict sanitization: only allow a-z, 0-9, and underscore (blocks .. and other traversal)
group = re.sub(r'[^a-z0-9_-]+', '', group.lower())
filename = filename
# Additional safety: reject if sanitization resulted in empty strings
if not group or not filename:
abort(404)
if group == 'screenshot':
# Could be sensitive, follow password requirements
if (
datastore.data['settings']['application']['password']
and not flask_login.current_user.is_authenticated
):
if not datastore.data['settings']['application'].get('shared_diff_access'):
abort(403)
screenshot_filename = (
"last-screenshot.png"
if not request.args.get('error_screenshot')
else "last-error-screenshot.png"
)
# These files should be in our subdirectory
try:
# set nocache, set content-type
response = make_response(
send_from_directory(
os.path.join(datastore_o.datastore_path, filename), screenshot_filename
)
)
response.headers['Content-type'] = 'image/png'
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = 0
return response
except FileNotFoundError:
abort(404)
if group == 'favicon':
# Could be sensitive, follow password requirements
if (
datastore.data['settings']['application']['password']
and not flask_login.current_user.is_authenticated
):
abort(403)
# Get the watch object
watch = datastore.data['watching'].get(filename)
if not watch:
abort(404)
favicon_filename = watch.get_favicon_filename()
if favicon_filename:
# Use cached MIME type detection
filepath = os.path.join(watch.data_dir, favicon_filename)
mime = get_favicon_mime_type(filepath)
if 'text' in mime:
logger.debug(
f"Aborting favicon request for {filename} because mimetype might be text (bad mimetype) '{mime}'"
)
abort(404)
response = make_response(send_from_directory(watch.data_dir, favicon_filename))
response.headers['Content-type'] = mime
response.headers['Cache-Control'] = (
'max-age=300, must-revalidate' # Cache for 5 minutes, then revalidate
)
return response
if group == 'visual_selector_data':
# Could be sensitive, follow password requirements
if (
datastore.data['settings']['application']['password']
and not flask_login.current_user.is_authenticated
):
abort(403)
# These files should be in our subdirectory
try:
# set nocache, set content-type,
# `filename` is actually directory UUID of the watch
watch_directory = str(os.path.join(datastore_o.datastore_path, filename))
response = None
if os.path.isfile(os.path.join(watch_directory, "elements.deflate")):
response = make_response(
send_from_directory(watch_directory, "elements.deflate")
)
response.headers['Content-Type'] = 'application/json'
response.headers['Content-Encoding'] = 'deflate'
else:
logger.error(
f'Request elements.deflate at "{watch_directory}" but was not found.'
)
abort(404)
if response:
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = "0"
return response
except FileNotFoundError:
abort(404)
# Handle plugin group specially
if group == 'plugin':
# Serve files from plugin static directories
import os as os_check
from changedetectionio.pluggy_interface import plugin_manager
for plugin_name, plugin_obj in plugin_manager.list_name_plugin():
if hasattr(plugin_obj, 'plugin_static_path'):
try:
static_path = plugin_obj.plugin_static_path()
if static_path and os_check.path.isdir(static_path):
# Check if file exists in plugin's static directory
plugin_file_path = os_check.path.join(static_path, filename)
if os_check.path.isfile(plugin_file_path):
# Found the file in a plugin
response = make_response(send_from_directory(static_path, filename))
response.headers['Cache-Control'] = (
'max-age=3600, public' # Cache for 1 hour
)
return response
except Exception as e:
logger.debug(f"Error checking plugin {plugin_name} for static file: {e}")
pass
# File not found in any plugin
abort(404)
# These files should be in our subdirectory
try:
return send_from_directory(f"static/{group}", path=filename)
except FileNotFoundError:
abort(404)
import changedetectionio.blueprint.browser_steps as browser_steps
app.register_blueprint(
browser_steps.construct_blueprint(datastore), url_prefix='/browser-steps'
)
from changedetectionio.blueprint.imports import (
construct_blueprint as construct_import_blueprint,
)
app.register_blueprint(
construct_import_blueprint(datastore, update_q, queuedWatchMetaData), url_prefix='/imports'
)
from changedetectionio.blueprint.add_watch_ui import (
construct_blueprint as construct_add_watch_ui_blueprint,
)
app.register_blueprint(construct_add_watch_ui_blueprint(datastore), url_prefix='/add-watch-ui')
import changedetectionio.blueprint.price_data_follower as price_data_follower
app.register_blueprint(
price_data_follower.construct_blueprint(datastore, update_q),
url_prefix='/price_data_follower',
)
import changedetectionio.blueprint.tags as tags
app.register_blueprint(tags.construct_blueprint(datastore), url_prefix='/tags')
import changedetectionio.blueprint.check_proxies as check_proxies
app.register_blueprint(
check_proxies.construct_blueprint(datastore=datastore), url_prefix='/check_proxy'
)
import changedetectionio.blueprint.backups as backups
app.register_blueprint(backups.construct_blueprint(datastore), url_prefix='/backups')
import changedetectionio.blueprint.settings as settings
app.register_blueprint(settings.construct_blueprint(datastore), url_prefix='/settings')
import changedetectionio.conditions.blueprint as conditions
app.register_blueprint(conditions.construct_blueprint(datastore), url_prefix='/conditions')
import changedetectionio.blueprint.rss.blueprint as rss
app.register_blueprint(rss.construct_blueprint(datastore), url_prefix='/rss')
# watchlist UI buttons etc
import changedetectionio.blueprint.ui as ui
app.register_blueprint(
ui.construct_blueprint(
datastore, update_q, worker_pool, queuedWatchMetaData, watch_check_update
)
)
import changedetectionio.blueprint.watchlist as watchlist
app.register_blueprint(
watchlist.construct_blueprint(
datastore=datastore, update_q=update_q, queuedWatchMetaData=queuedWatchMetaData
),
url_prefix='',
)
# Initialize Socket.IO server conditionally based on settings
socket_io_enabled = (
datastore.data['settings']['application'].get('ui', {}).get('socket_io_enabled', True)
)
if socket_io_enabled and app.config.get('batch_mode'):
socket_io_enabled = False
if socket_io_enabled:
from changedetectionio.realtime.socket_server import init_socketio
global socketio_server
socketio_server = init_socketio(app, datastore)
logger.info("Socket.IO server initialized")
else:
logger.info("Socket.IO server disabled via settings")
socketio_server = None
# Memory cleanup endpoint
@app.route('/gc-cleanup', methods=['GET'])
@login_optionally_required
def gc_cleanup():
from flask import jsonify
from changedetectionio.gc_cleanup import memory_cleanup
result = memory_cleanup(app)
return jsonify(
{"status": "success", "message": "Memory cleanup completed", "result": result}
)
# Worker health check endpoint
@app.route('/worker-health', methods=['GET'])
@login_optionally_required
def worker_health():
from flask import jsonify
expected_workers = int(
os.getenv("FETCH_WORKERS", datastore.data['settings']['requests']['workers'])
)
# Get basic status
status = worker_pool.get_worker_status()
# Perform health check
health_result = worker_pool.check_worker_health(
expected_count=expected_workers,
update_q=update_q,
notification_q=notification_q,
app=app,
datastore=datastore,
)
return jsonify(
{
"status": "success",
"worker_status": status,
"health_check": health_result,
"expected_workers": expected_workers,
}
)
# Queue status endpoint
@app.route('/queue-status', methods=['GET'])
@login_optionally_required
def queue_status():
from flask import jsonify, request
# Get specific UUID position if requested
target_uuid = request.args.get('uuid')
if target_uuid:
position_info = update_q.get_uuid_position(target_uuid)
return jsonify(
{"status": "success", "uuid": target_uuid, "queue_position": position_info}
)
else:
# Get pagination parameters
limit = request.args.get('limit', type=int)
offset = request.args.get('offset', type=int, default=0)
summary_only = request.args.get('summary', type=bool, default=False)
if summary_only:
# Fast summary for large queues
summary = update_q.get_queue_summary()
return jsonify({"status": "success", "queue_summary": summary})
else:
# Get queued items with pagination support
if limit is None:
# Default limit for large queues to prevent performance issues
queue_size = update_q.qsize()
if queue_size > 100:
limit = 50
logger.warning(
f"Large queue ({queue_size} items) detected, limiting to {limit} items. Use ?limit=N for more."
)
all_queued = update_q.get_all_queued_uuids(limit=limit, offset=offset)
return jsonify(
{"status": "success", "queue_size": update_q.qsize(), "queued_data": all_queued}
)
if strtobool(os.getenv('HISTORY_SNAPSHOT_FILE_ALLOW_OUTSIDE_WATCH_DATADIR', 'False')):
logger.warning(
"SECURITY WARNING: HISTORY_SNAPSHOT_FILE_ALLOW_OUTSIDE_WATCH_DATADIR is enabled — "
"snapshot reads are NOT confined to the watch data directory. "
"This disables protection against path traversal via restored backups (GHSA-8757-69j2-hx56)."
)
# Memory/CPU management -
# Freeze the startup object graph into the "permanent generation" so that the cyclic
# garbage collector never traverses it again, this allows more of the app to swap into 'cold' RAM
if (
'pytest' not in sys.modules
and 'PYTEST_CURRENT_TEST' not in os.environ
and not strtobool(os.getenv('DISABLE_GC_FREEZE', 'no'))
):
gc.collect()
gc.freeze()
logger.debug(
f"GC: froze {gc.get_freeze_count()} startup objects into the permanent generation"
)
# Start the async workers during app initialization
# Can be overridden by ENV or use the default settings
n_workers = int(os.getenv("FETCH_WORKERS", datastore.data['settings']['requests']['workers']))
logger.info(f"Starting {n_workers} workers during app initialization")
worker_pool.start_workers(n_workers, update_q, notification_q, app, datastore)
# Skip background threads in batch mode (just process queue and exit)
batch_mode = app.config.get('batch_mode', False)
if not batch_mode:
# @todo handle ctrl break
# Supervised: if the ticker ever returns or raises it is logged CRITICAL and
# restarted. A bare Thread cannot be restarted once its target returns, and a
# dead ticker means no watch is ever checked again while the process keeps
# looking healthy. Note this keeps a real Thread handle - Thread(...).start()
# returns None, so the old assignment left `ticker_thread` permanently None.
ticker_thread = start_supervised_thread(
target=ticker_thread_check_time_launch_checks,
name="TickerThread-ScheduleChecker",
exit_event=app.config.exit,
# sigshutdown_handler() sets both of these; check both so a restart
# can never race an in-progress shutdown.
is_shutting_down=lambda: bool(getattr(datastore, 'stop_thread', False)),
)
# Start configurable number of notification workers (default 1)
notification_workers = int(os.getenv("NOTIFICATION_WORKERS", "1"))
for i in range(notification_workers):
threading.Thread(
target=notification_runner, args=(i,), daemon=True, name=f"NotificationRunner-{i}"
).start()
logger.info(f"Started {notification_workers} notification worker(s)")
in_pytest = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
# Check for new release version, but not when running in test/build or pytest
if (
not os.getenv("GITHUB_REF", False)
and not strtobool(os.getenv('DISABLE_VERSION_CHECK', 'no'))
and not in_pytest
):
threading.Thread(
target=check_for_new_version, daemon=True, name="VersionChecker"
).start()
else:
logger.info("Batch mode: Skipping ticker thread, notification runner, and version checker")
# Return the Flask app - the Socket.IO will be attached to it but initialized separately
# This avoids circular dependencies
return app
# Check for new version and anonymous stats
def check_for_new_version():
import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
session = requests.Session()
session.verify = False
while not app.config.exit.is_set():
try:
r = session.post(
"https://changedetection.io/check-ver.php",
data={
'version': __version__,
'app_guid': datastore.data['app_guid'],
'watch_count': len(datastore.data['watching']),
},
)
except:
pass
try:
if "new_version" in r.text:
app.config['NEW_VERSION_AVAILABLE'] = True
except:
pass
# Check daily
app.config.exit.wait(86400)
def notification_runner(worker_id=0):
global notification_debug_log
import json
from datetime import datetime
with app.app_context():
while not app.config.exit.is_set():
try:
# Multiple workers can run concurrently (configurable via NOTIFICATION_WORKERS)
n_object = notification_q.get(block=False)
except queue.Empty:
app.config.exit.wait(1)
else:
now = datetime.now()
sent_obj = None
try:
from changedetectionio.notification.handler import process_notification
# Fallback to system config if not set
if not n_object.get('notification_body') and datastore.data['settings'][
'application'
].get('notification_body'):
n_object['notification_body'] = datastore.data['settings'][
'application'
].get('notification_body')
if not n_object.get('notification_title') and datastore.data['settings'][
'application'
].get('notification_title'):
n_object['notification_title'] = datastore.data['settings'][
'application'
].get('notification_title')
if not n_object.get('notification_format') and datastore.data['settings'][
'application'
].get('notification_format'):
n_object['notification_format'] = datastore.data['settings'][
'application'
].get('notification_format')
if n_object.get('notification_urls', {}):
sent_obj = process_notification(n_object, datastore)
except Exception as e:
logger.error(
f"Notification worker {worker_id} - Watch URL: {n_object['watch_url']} Error {str(e)}"
)
# UUID wont be present when we submit a 'test' from the global settings
if 'uuid' in n_object:
datastore.update_watch(
uuid=n_object['uuid'],
update_obj={
'last_notification_error': "Notification error detected, goto notification log."
},
)
log_lines = str(e).splitlines()
notification_debug_log += log_lines
with app.app_context():
app.config['watch_check_update_SIGNAL'].send(
app_context=app, watch_uuid=n_object.get('uuid')
)
# Process notifications
notification_debug_log += [
"{} - SENDING - {}".format(now.strftime("%c"), json.dumps(sent_obj))
]
# Trim the log length
notification_debug_log = notification_debug_log[-100:]
# Threaded runner, look for new watches to feed into the Queue.
def ticker_thread_check_time_launch_checks():
import random
proxy_last_called_time = {}
last_health_check = 0
recheck_time_minimum_seconds = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3))
logger.debug(f"System env MINIMUM_SECONDS_RECHECK_TIME {recheck_time_minimum_seconds}")
# Workers are now started during app initialization, not here
WAIT_TIME_BETWEEN_LOOP = 1.0 if not IN_PYTEST else 0.01
if IN_PYTEST:
# The time between loops should be less than the first .sleep/wait in def wait_for_all_checks() of tests/util.py
logger.warning(
f"Looks like we're in PYTEST! Setting time between searching for items to add to the queue to {WAIT_TIME_BETWEEN_LOOP}s"
)
while not app.config.exit.is_set():
# Periodic worker health check (every 60 seconds)
now = time.time()
if now - last_health_check > 60:
expected_workers = int(
os.getenv("FETCH_WORKERS", datastore.data['settings']['requests']['workers'])
)
health_result = worker_pool.check_worker_health(
expected_count=expected_workers,
update_q=update_q,
notification_q=notification_q,
app=app,
datastore=datastore,
)
if health_result['status'] != 'healthy':
logger.warning(f"Worker health check: {health_result['message']}")
last_health_check = now
# Get a list of watches by UUID that are currently fetching data
running_uuids = worker_pool.get_running_uuids()
# Build set of queued UUIDs once for O(1) lookup instead of O(n) per watch
queued_uuids = {q_item.item['uuid'] for q_item in update_q.queue}
# Re #232 - Deepcopy the data incase it changes while we're iterating through it all
watch_uuid_list = []
while True:
try:
# Get a list of watches sorted by last_checked, [1] because it gets passed a tuple
# This is so we examine the most over-due first
for k in sorted(
datastore.data['watching'].items(),
key=lambda item: item[1].get('last_checked', 0),
):
watch_uuid_list.append(k[0])
except RuntimeError:
# RuntimeError: dictionary changed size during iteration
time.sleep(0.1)
watch_uuid_list = []
else:
break
recheck_time_system_seconds = int(datastore.threshold_seconds)
# Check for watches outside of the time threshold to put in the thread queue.
for watch_index, uuid in enumerate(watch_uuid_list):
# Re #438 - Check queue size every 100 watches for CPU efficiency (not every watch)
if watch_index % 100 == 0:
current_queue_size = update_q.qsize()
if current_queue_size >= MAX_QUEUE_SIZE:
logger.debug(
f"Queue size limit reached ({current_queue_size}/{MAX_QUEUE_SIZE}), stopping scheduler this iteration."
)
break
# Check if all checks are paused - this loop could get stuck on very long lists of watches, best to check here.
if datastore.data['settings']['application'].get('all_paused', False):
app.config.exit.wait(1)
break
now = time.time()
watch = datastore.data['watching'].get(uuid)
if not watch:
logger.error(f"Watch: {uuid} no longer present.")
continue
# No need todo further processing if it's paused
if watch['paused']:
continue
# @todo - Maybe make this a hook?
# Time schedule limit - Decide between watch or global settings
scheduler_source = None
if watch.get('time_between_check_use_default'):
time_schedule_limit = datastore.data['settings']['requests'].get(
'time_schedule_limit', {}
)
scheduler_source = 'system/global settings'
else:
time_schedule_limit = watch.get('time_schedule_limit')
scheduler_source = 'watch'
tz_name = default_timezone_name(
datastore.data['settings']['application'].get('scheduler_timezone_default')
)
if time_schedule_limit and time_schedule_limit.get('enabled'):
logger.trace(
f"{uuid} Time scheduler - Using scheduler settings from {scheduler_source}"
)
try:
result = is_within_schedule(
time_schedule_limit=time_schedule_limit, default_tz=tz_name
)
if not result:
logger.trace(f"{uuid} Time scheduler - not within schedule skipping.")
continue
except Exception as e:
# `continue`, never `return` — this runs inside the ticker thread's
# main `while not exit.is_set()` loop, so returning here killed the
# scheduler outright and no watch was ever checked again until
# restart. One watch with a bad schedule must not stop the others.
logger.error(
f"{uuid} - Recheck scheduler, error handling timezone, check skipped - TZ name '{tz_name}' - {str(e)}"
)
continue
# If they supplied an individual entry minutes to threshold.
threshold = (
recheck_time_system_seconds
if watch.get('time_between_check_use_default')
else watch.threshold_seconds()
)
# #580 - Jitter plus/minus amount of time to make the check seem more random to the server
jitter = datastore.data['settings']['requests'].get('jitter_seconds', 0)
if jitter > 0:
if watch.jitter_seconds == 0:
watch.jitter_seconds = random.uniform(-abs(jitter), jitter)
seconds_since_last_recheck = now - watch['last_checked']
if (
seconds_since_last_recheck >= (threshold + watch.jitter_seconds)
and seconds_since_last_recheck >= recheck_time_minimum_seconds
):
if uuid not in running_uuids and uuid not in queued_uuids:
# Proxies can be set to have a limit on seconds between which they can be called
watch_proxy = datastore.get_preferred_proxy_for_watch(uuid=uuid)
if watch_proxy and watch_proxy in list(datastore.proxy_list.keys()):
# Proxy may also have some threshold minimum
proxy_list_reuse_time_minimum = int(
datastore.proxy_list.get(watch_proxy, {}).get('reuse_time_minimum', 0)
)
if proxy_list_reuse_time_minimum:
proxy_last_used_time = proxy_last_called_time.get(watch_proxy, 0)
time_since_proxy_used = int(time.time() - proxy_last_used_time)
if time_since_proxy_used < proxy_list_reuse_time_minimum:
# Not enough time difference reached, skip this watch
logger.debug(
f"> Skipped UUID {uuid} "
f"using proxy '{watch_proxy}', not "
f"enough time between proxy requests "
f"{time_since_proxy_used}s/{proxy_list_reuse_time_minimum}s"
)
continue
else:
# Record the last used time
proxy_last_called_time[watch_proxy] = int(time.time())
# Use Epoch time as priority, so we get a "sorted" PriorityQueue, but we can still push a priority 1 into it.
priority = int(time.time())
# Into the queue with you
queued_successfully = worker_pool.queue_item_async_safe(
update_q,
queuedWatchMetaData.PrioritizedItem(priority=priority, item={'uuid': uuid}),
)
if queued_successfully:
logger.debug(
f"> Queued watch UUID {uuid} "
f"Checked at {watch['last_checked']} "
f"queued at {now:0.2f} priority {priority} "
f"jitter {watch.jitter_seconds:0.2f}s, "
f"{now - watch['last_checked']:0.2f}s since Checked"
)
else:
logger.critical(
f"CRITICAL: Failed to queue watch UUID {uuid} in ticker thread!"
)
# Reset for next time
watch.jitter_seconds = 0
# Should be low so we can break this out in testing
app.config.exit.wait(WAIT_TIME_BETWEEN_LOOP)