mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-03-02 09:59:57 +00:00
Compare commits
12 Commits
llm
...
python-314
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fe332ad95 | ||
|
|
b65a01ec02 | ||
|
|
b984426666 | ||
|
|
1889a10ef6 | ||
|
|
f66ae4fceb | ||
|
|
fb14229888 | ||
|
|
6d1081f5bc | ||
|
|
9e907d8466 | ||
|
|
6d6a0fd7ef | ||
|
|
1537e58fc2 | ||
|
|
5669509255 | ||
|
|
1d72716c69 |
6
.github/workflows/pypi-release.yml
vendored
6
.github/workflows/pypi-release.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
- name: Build a binary wheel and a source tarball
|
- name: Build a binary wheel and a source tarball
|
||||||
run: python3 -m build
|
run: python3 -m build
|
||||||
- name: Store the distribution packages
|
- name: Store the distribution packages
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: python-package-distributions
|
name: python-package-distributions
|
||||||
path: dist/
|
path: dist/
|
||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
- build
|
- build
|
||||||
steps:
|
steps:
|
||||||
- name: Download all the dists
|
- name: Download all the dists
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: python-package-distributions
|
name: python-package-distributions
|
||||||
path: dist/
|
path: dist/
|
||||||
@@ -93,7 +93,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download all the dists
|
- name: Download all the dists
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: python-package-distributions
|
name: python-package-distributions
|
||||||
path: dist/
|
path: dist/
|
||||||
|
|||||||
9
.github/workflows/test-only.yml
vendored
9
.github/workflows/test-only.yml
vendored
@@ -53,3 +53,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: '3.13'
|
python-version: '3.13'
|
||||||
skip-pypuppeteer: true
|
skip-pypuppeteer: true
|
||||||
|
|
||||||
|
|
||||||
|
test-application-3-14:
|
||||||
|
#if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||||
|
needs: lint-code
|
||||||
|
uses: ./.github/workflows/test-stack-reusable-workflow.yml
|
||||||
|
with:
|
||||||
|
python-version: '3.14'
|
||||||
|
skip-pypuppeteer: false
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
docker save test-changedetectionio -o /tmp/test-changedetectionio.tar
|
docker save test-changedetectionio -o /tmp/test-changedetectionio.tar
|
||||||
|
|
||||||
- name: Upload Docker image artifact
|
- name: Upload Docker image artifact
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
||||||
path: /tmp/test-changedetectionio.tar
|
path: /tmp/test-changedetectionio.tar
|
||||||
@@ -88,7 +88,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download Docker image artifact
|
- name: Download Docker image artifact
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
||||||
path: /tmp
|
path: /tmp
|
||||||
@@ -116,7 +116,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download Docker image artifact
|
- name: Download Docker image artifact
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
||||||
path: /tmp
|
path: /tmp
|
||||||
@@ -165,14 +165,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Store test artifacts
|
- name: Store test artifacts
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: test-cdio-basic-tests-output-py${{ env.PYTHON_VERSION }}
|
name: test-cdio-basic-tests-output-py${{ env.PYTHON_VERSION }}
|
||||||
path: output-logs
|
path: output-logs
|
||||||
|
|
||||||
- name: Store CLI test output
|
- name: Store CLI test output
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: test-cdio-cli-opts-output-py${{ env.PYTHON_VERSION }}
|
name: test-cdio-cli-opts-output-py${{ env.PYTHON_VERSION }}
|
||||||
path: cli-opts-output.txt
|
path: cli-opts-output.txt
|
||||||
@@ -188,7 +188,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download Docker image artifact
|
- name: Download Docker image artifact
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
||||||
path: /tmp
|
path: /tmp
|
||||||
@@ -230,7 +230,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download Docker image artifact
|
- name: Download Docker image artifact
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
||||||
path: /tmp
|
path: /tmp
|
||||||
@@ -270,7 +270,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download Docker image artifact
|
- name: Download Docker image artifact
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
||||||
path: /tmp
|
path: /tmp
|
||||||
@@ -306,7 +306,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download Docker image artifact
|
- name: Download Docker image artifact
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
||||||
path: /tmp
|
path: /tmp
|
||||||
@@ -334,7 +334,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download Docker image artifact
|
- name: Download Docker image artifact
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
||||||
path: /tmp
|
path: /tmp
|
||||||
@@ -504,7 +504,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download Docker image artifact
|
- name: Download Docker image artifact
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
||||||
path: /tmp
|
path: /tmp
|
||||||
@@ -544,7 +544,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download Docker image artifact
|
- name: Download Docker image artifact
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
||||||
path: /tmp
|
path: /tmp
|
||||||
@@ -574,7 +574,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download Docker image artifact
|
- name: Download Docker image artifact
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
||||||
path: /tmp
|
path: /tmp
|
||||||
@@ -598,7 +598,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download Docker image artifact
|
- name: Download Docker image artifact
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
||||||
path: /tmp
|
path: /tmp
|
||||||
@@ -643,7 +643,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download Docker image artifact
|
- name: Download Docker image artifact
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
name: test-changedetectionio-${{ env.PYTHON_VERSION }}
|
||||||
path: /tmp
|
path: /tmp
|
||||||
@@ -820,7 +820,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload upgrade test logs
|
- name: Upload upgrade test logs
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: upgrade-test-logs-py${{ env.PYTHON_VERSION }}
|
name: upgrade-test-logs-py${{ env.PYTHON_VERSION }}
|
||||||
path: /tmp/upgrade-test.log
|
path: /tmp/upgrade-test.log
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
||||||
# Semver means never use .01, or 00. Should be .1.
|
# Semver means never use .01, or 00. Should be .1.
|
||||||
__version__ = '0.54.1'
|
__version__ = '0.54.3'
|
||||||
|
|
||||||
from changedetectionio.strtobool import strtobool
|
from changedetectionio.strtobool import strtobool
|
||||||
from json.decoder import JSONDecodeError
|
from json.decoder import JSONDecodeError
|
||||||
|
|||||||
21
changedetectionio/api/Spec.py
Normal file
21
changedetectionio/api/Spec.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import functools
|
||||||
|
from flask import make_response
|
||||||
|
from flask_restful import Resource
|
||||||
|
|
||||||
|
|
||||||
|
@functools.cache
|
||||||
|
def _get_spec_yaml():
|
||||||
|
"""Build and cache the merged spec as a YAML string (only serialized once per process)."""
|
||||||
|
import yaml
|
||||||
|
from changedetectionio.api import build_merged_spec_dict
|
||||||
|
return yaml.dump(build_merged_spec_dict(), default_flow_style=False, allow_unicode=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Spec(Resource):
|
||||||
|
def get(self):
|
||||||
|
"""Return the merged OpenAPI spec including all registered processor extensions."""
|
||||||
|
return make_response(
|
||||||
|
_get_spec_yaml(),
|
||||||
|
200,
|
||||||
|
{'Content-Type': 'application/yaml'}
|
||||||
|
)
|
||||||
@@ -3,29 +3,18 @@ from flask import request, abort
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@functools.cache
|
@functools.cache
|
||||||
def get_openapi_spec():
|
def build_merged_spec_dict():
|
||||||
"""Lazy load OpenAPI spec and dependencies only when validation is needed."""
|
|
||||||
import os
|
|
||||||
import yaml # Lazy import - only loaded when API validation is actually used
|
|
||||||
from openapi_core import OpenAPI # Lazy import - saves ~10.7 MB on startup
|
|
||||||
|
|
||||||
spec_path = os.path.join(os.path.dirname(__file__), '../../docs/api-spec.yaml')
|
|
||||||
if not os.path.exists(spec_path):
|
|
||||||
# Possibly for pip3 packages
|
|
||||||
spec_path = os.path.join(os.path.dirname(__file__), '../docs/api-spec.yaml')
|
|
||||||
|
|
||||||
with open(spec_path, 'r', encoding='utf-8') as f:
|
|
||||||
spec_dict = yaml.safe_load(f)
|
|
||||||
_openapi_spec = OpenAPI.from_dict(spec_dict)
|
|
||||||
return _openapi_spec
|
|
||||||
|
|
||||||
@functools.cache
|
|
||||||
def get_openapi_schema_dict():
|
|
||||||
"""
|
"""
|
||||||
Get the raw OpenAPI spec dictionary for schema access.
|
Load the base OpenAPI spec and merge in any per-processor api.yaml extensions.
|
||||||
|
|
||||||
Used by Import endpoint to validate and convert query parameters.
|
Each processor can provide an api.yaml file alongside its __init__.py that defines
|
||||||
Returns the YAML dict directly (not the OpenAPI object).
|
additional schemas (e.g., processor_config_restock_diff). These are merged into
|
||||||
|
WatchBase.properties so the spec accurately reflects what the API accepts.
|
||||||
|
|
||||||
|
Plugin processors (via pluggy) are also supported - they just need an api.yaml
|
||||||
|
next to their processor module.
|
||||||
|
|
||||||
|
Returns the merged dict (cached - do not mutate the returned value).
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import yaml
|
import yaml
|
||||||
@@ -35,7 +24,59 @@ def get_openapi_schema_dict():
|
|||||||
spec_path = os.path.join(os.path.dirname(__file__), '../docs/api-spec.yaml')
|
spec_path = os.path.join(os.path.dirname(__file__), '../docs/api-spec.yaml')
|
||||||
|
|
||||||
with open(spec_path, 'r', encoding='utf-8') as f:
|
with open(spec_path, 'r', encoding='utf-8') as f:
|
||||||
return yaml.safe_load(f)
|
spec_dict = yaml.safe_load(f)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from changedetectionio.processors import find_processors, get_parent_module
|
||||||
|
for module, proc_name in find_processors():
|
||||||
|
parent = get_parent_module(module)
|
||||||
|
if not parent or not hasattr(parent, '__file__'):
|
||||||
|
continue
|
||||||
|
api_yaml_path = os.path.join(os.path.dirname(parent.__file__), 'api.yaml')
|
||||||
|
if not os.path.exists(api_yaml_path):
|
||||||
|
continue
|
||||||
|
with open(api_yaml_path, 'r', encoding='utf-8') as f:
|
||||||
|
proc_spec = yaml.safe_load(f)
|
||||||
|
# Merge schemas
|
||||||
|
proc_schemas = proc_spec.get('components', {}).get('schemas', {})
|
||||||
|
spec_dict['components']['schemas'].update(proc_schemas)
|
||||||
|
# Inject processor_config_{name} into WatchBase if the schema is defined
|
||||||
|
schema_key = f'processor_config_{proc_name}'
|
||||||
|
if schema_key in proc_schemas:
|
||||||
|
spec_dict['components']['schemas']['WatchBase']['properties'][schema_key] = {
|
||||||
|
'$ref': f'#/components/schemas/{schema_key}'
|
||||||
|
}
|
||||||
|
# Append x-code-samples from processor paths into existing path operations
|
||||||
|
for path, path_item in proc_spec.get('paths', {}).items():
|
||||||
|
if path not in spec_dict.get('paths', {}):
|
||||||
|
continue
|
||||||
|
for method, operation in path_item.items():
|
||||||
|
if method not in spec_dict['paths'][path]:
|
||||||
|
continue
|
||||||
|
if 'x-code-samples' in operation:
|
||||||
|
existing = spec_dict['paths'][path][method].get('x-code-samples', [])
|
||||||
|
spec_dict['paths'][path][method]['x-code-samples'] = existing + operation['x-code-samples']
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to merge processor API specs: {e}")
|
||||||
|
|
||||||
|
return spec_dict
|
||||||
|
|
||||||
|
|
||||||
|
@functools.cache
|
||||||
|
def get_openapi_spec():
|
||||||
|
"""Lazy load OpenAPI spec and dependencies only when validation is needed."""
|
||||||
|
from openapi_core import OpenAPI # Lazy import - saves ~10.7 MB on startup
|
||||||
|
return OpenAPI.from_dict(build_merged_spec_dict())
|
||||||
|
|
||||||
|
@functools.cache
|
||||||
|
def get_openapi_schema_dict():
|
||||||
|
"""
|
||||||
|
Get the raw OpenAPI spec dictionary for schema access.
|
||||||
|
|
||||||
|
Used by Import endpoint to validate and convert query parameters.
|
||||||
|
Returns the merged YAML dict (not the OpenAPI object).
|
||||||
|
"""
|
||||||
|
return build_merged_spec_dict()
|
||||||
|
|
||||||
@functools.cache
|
@functools.cache
|
||||||
def _resolve_schema_properties(schema_name):
|
def _resolve_schema_properties(schema_name):
|
||||||
@@ -150,5 +191,6 @@ from .Watch import Watch, WatchHistory, WatchSingleHistory, WatchHistoryDiff, Cr
|
|||||||
from .Tags import Tags, Tag
|
from .Tags import Tags, Tag
|
||||||
from .Import import Import
|
from .Import import Import
|
||||||
from .SystemInfo import SystemInfo
|
from .SystemInfo import SystemInfo
|
||||||
|
from .Spec import Spec
|
||||||
from .Notifications import Notifications
|
from .Notifications import Notifications
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,21 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
|||||||
default_system_settings = datastore.data['settings'],
|
default_system_settings = datastore.data['settings'],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Bridge API-stored processor_config_* values into the form's FormField sub-forms.
|
||||||
|
# The API stores processor_config_restock_diff in the tag dict; find the matching
|
||||||
|
# FormField by checking which one's sub-fields cover the config keys.
|
||||||
|
from wtforms.fields.form import FormField as WTFormField
|
||||||
|
for key, value in default.items():
|
||||||
|
if not key.startswith('processor_config_') or not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
for form_field in form:
|
||||||
|
if isinstance(form_field, WTFormField) and all(k in form_field.form._fields for k in value):
|
||||||
|
for sub_key, sub_value in value.items():
|
||||||
|
sub_field = form_field.form._fields.get(sub_key)
|
||||||
|
if sub_field is not None:
|
||||||
|
sub_field.data = sub_value
|
||||||
|
break
|
||||||
|
|
||||||
template_args = {
|
template_args = {
|
||||||
'data': default,
|
'data': default,
|
||||||
'form': form,
|
'form': form,
|
||||||
|
|||||||
@@ -117,12 +117,25 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
|||||||
processor_config = processor_instance.get_extra_watch_config(config_filename)
|
processor_config = processor_instance.get_extra_watch_config(config_filename)
|
||||||
|
|
||||||
if processor_config:
|
if processor_config:
|
||||||
|
from wtforms.fields.form import FormField
|
||||||
# Populate processor-config-* fields from JSON
|
# Populate processor-config-* fields from JSON
|
||||||
for config_key, config_value in processor_config.items():
|
for config_key, config_value in processor_config.items():
|
||||||
field_name = f'processor_config_{config_key}'
|
if not isinstance(config_value, dict):
|
||||||
if hasattr(form, field_name):
|
continue
|
||||||
getattr(form, field_name).data = config_value
|
# Try exact API-named field first (e.g., processor_config_restock_diff)
|
||||||
logger.debug(f"Loaded processor config from {config_filename}: {field_name} = {config_value}")
|
target_field = getattr(form, f'processor_config_{config_key}', None)
|
||||||
|
# Fallback: find any FormField sub-form whose fields cover config_value keys
|
||||||
|
if target_field is None:
|
||||||
|
for form_field in form:
|
||||||
|
if isinstance(form_field, FormField) and all(k in form_field.form._fields for k in config_value):
|
||||||
|
target_field = form_field
|
||||||
|
break
|
||||||
|
if target_field is not None:
|
||||||
|
for sub_key, sub_value in config_value.items():
|
||||||
|
sub_field = target_field.form._fields.get(sub_key)
|
||||||
|
if sub_field is not None:
|
||||||
|
sub_field.data = sub_value
|
||||||
|
logger.debug(f"Loaded processor config from {config_filename}: {sub_key} = {sub_value}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to load processor config: {e}")
|
logger.warning(f"Failed to load processor config: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ from loguru import logger
|
|||||||
|
|
||||||
from changedetectionio import __version__
|
from changedetectionio import __version__
|
||||||
from changedetectionio import queuedWatchMetaData
|
from changedetectionio import queuedWatchMetaData
|
||||||
from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, WatchHistoryDiff, CreateWatch, Import, SystemInfo, Tag, Tags, Notifications, WatchFavicon
|
from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, WatchHistoryDiff, CreateWatch, Import, SystemInfo, Tag, Tags, Notifications, WatchFavicon, Spec
|
||||||
from changedetectionio.api.Search import Search
|
from changedetectionio.api.Search import Search
|
||||||
from .time_handler import is_within_schedule
|
from .time_handler import is_within_schedule
|
||||||
from changedetectionio.languages import get_available_languages, get_language_codes, get_flag_for_locale, get_timeago_locale
|
from changedetectionio.languages import get_available_languages, get_language_codes, get_flag_for_locale, get_timeago_locale
|
||||||
@@ -594,6 +594,8 @@ def changedetection_app(config=None, datastore_o=None):
|
|||||||
watch_api.add_resource(Notifications, '/api/v1/notifications',
|
watch_api.add_resource(Notifications, '/api/v1/notifications',
|
||||||
resource_class_kwargs={'datastore': datastore})
|
resource_class_kwargs={'datastore': datastore})
|
||||||
|
|
||||||
|
watch_api.add_resource(Spec, '/api/v1/full-spec')
|
||||||
|
|
||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
def user_loader(email):
|
def user_loader(email):
|
||||||
user = User()
|
user = User()
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ def get_timeago_locale(flask_locale):
|
|||||||
'no': 'nb_NO', # Norwegian Bokmål
|
'no': 'nb_NO', # Norwegian Bokmål
|
||||||
'hi': 'in_HI', # Hindi
|
'hi': 'in_HI', # Hindi
|
||||||
'cs': 'en', # Czech not supported by timeago, fallback to English
|
'cs': 'en', # Czech not supported by timeago, fallback to English
|
||||||
|
'uk': 'uk', # Ukrainian
|
||||||
'en_GB': 'en', # British English - timeago uses 'en'
|
'en_GB': 'en', # British English - timeago uses 'en'
|
||||||
'en_US': 'en', # American English - timeago uses 'en'
|
'en_US': 'en', # American English - timeago uses 'en'
|
||||||
}
|
}
|
||||||
@@ -67,6 +68,7 @@ LANGUAGE_DATA = {
|
|||||||
'tr': {'flag': 'fi fi-tr fis', 'name': 'Türkçe'},
|
'tr': {'flag': 'fi fi-tr fis', 'name': 'Türkçe'},
|
||||||
'ar': {'flag': 'fi fi-sa fis', 'name': 'العربية'},
|
'ar': {'flag': 'fi fi-sa fis', 'name': 'العربية'},
|
||||||
'hi': {'flag': 'fi fi-in fis', 'name': 'हिन्दी'},
|
'hi': {'flag': 'fi fi-in fis', 'name': 'हिन्दी'},
|
||||||
|
'uk': {'flag': 'fi fi-ua fis', 'name': 'Українська'},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class FormattableTimestamp(str):
|
|||||||
|
|
||||||
{{ change_datetime }} → '2024-01-15 10:30:00 UTC'
|
{{ change_datetime }} → '2024-01-15 10:30:00 UTC'
|
||||||
{{ change_datetime(format='%Y') }} → '2024'
|
{{ change_datetime(format='%Y') }} → '2024'
|
||||||
|
{{ change_datetime(format='%A') }} → 'Monday'
|
||||||
{{ change_datetime(format='%Y-%m-%d') }} → '2024-01-15'
|
{{ change_datetime(format='%Y-%m-%d') }} → '2024-01-15'
|
||||||
|
|
||||||
Being a str subclass means it is natively JSON serializable.
|
Being a str subclass means it is natively JSON serializable.
|
||||||
@@ -87,6 +88,62 @@ class FormattableTimestamp(str):
|
|||||||
return self._dt.isoformat()
|
return self._dt.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class FormattableDiff(str):
|
||||||
|
"""
|
||||||
|
A str subclass representing a rendered diff. As a plain string it renders
|
||||||
|
with the default options for that variant, but can be called with custom
|
||||||
|
arguments in Jinja2 templates:
|
||||||
|
|
||||||
|
{{ diff }} → default diff output
|
||||||
|
{{ diff(lines=5) }} → truncate to 5 lines
|
||||||
|
{{ diff(added_only=true) }} → only show added lines
|
||||||
|
{{ diff(removed_only=true) }} → only show removed lines
|
||||||
|
{{ diff(context=3) }} → 3 lines of context around changes
|
||||||
|
{{ diff(word_diff=false) }} → line-level diff instead of word-level
|
||||||
|
{{ diff(lines=10, added_only=true) }} → combine args
|
||||||
|
{{ diff_added(lines=5) }} → works on any diff_* variant too
|
||||||
|
|
||||||
|
Being a str subclass means it is natively JSON serializable.
|
||||||
|
"""
|
||||||
|
def __new__(cls, prev_snapshot, current_snapshot, **base_kwargs):
|
||||||
|
if prev_snapshot or current_snapshot:
|
||||||
|
from changedetectionio import diff as diff_module
|
||||||
|
rendered = diff_module.render_diff(prev_snapshot, current_snapshot, **base_kwargs)
|
||||||
|
else:
|
||||||
|
rendered = ''
|
||||||
|
instance = super().__new__(cls, rendered)
|
||||||
|
instance._prev = prev_snapshot
|
||||||
|
instance._current = current_snapshot
|
||||||
|
instance._base_kwargs = base_kwargs
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def __call__(self, lines=None, added_only=False, removed_only=False, context=0,
|
||||||
|
word_diff=None, case_insensitive=False, ignore_junk=False):
|
||||||
|
from changedetectionio import diff as diff_module
|
||||||
|
kwargs = dict(self._base_kwargs)
|
||||||
|
|
||||||
|
if added_only:
|
||||||
|
kwargs['include_removed'] = False
|
||||||
|
if removed_only:
|
||||||
|
kwargs['include_added'] = False
|
||||||
|
if context:
|
||||||
|
kwargs['context_lines'] = int(context)
|
||||||
|
if word_diff is not None:
|
||||||
|
kwargs['word_diff'] = bool(word_diff)
|
||||||
|
if case_insensitive:
|
||||||
|
kwargs['case_insensitive'] = True
|
||||||
|
if ignore_junk:
|
||||||
|
kwargs['ignore_junk'] = True
|
||||||
|
|
||||||
|
result = diff_module.render_diff(self._prev or '', self._current or '', **kwargs)
|
||||||
|
|
||||||
|
if lines is not None:
|
||||||
|
result = '\n'.join(result.splitlines()[:int(lines)])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# What is passed around as notification context, also used as the complete list of valid {{ tokens }}
|
# What is passed around as notification context, also used as the complete list of valid {{ tokens }}
|
||||||
class NotificationContextData(dict):
|
class NotificationContextData(dict):
|
||||||
def __init__(self, initial_data=None, **kwargs):
|
def __init__(self, initial_data=None, **kwargs):
|
||||||
@@ -95,15 +152,15 @@ class NotificationContextData(dict):
|
|||||||
'base_url': None,
|
'base_url': None,
|
||||||
'change_datetime': FormattableTimestamp(time.time()),
|
'change_datetime': FormattableTimestamp(time.time()),
|
||||||
'current_snapshot': None,
|
'current_snapshot': None,
|
||||||
'diff': None,
|
'diff': FormattableDiff('', ''),
|
||||||
'diff_added': None,
|
'diff_clean': FormattableDiff('', '', include_change_type_prefix=False),
|
||||||
'diff_added_clean': None,
|
'diff_added': FormattableDiff('', '', include_removed=False),
|
||||||
'diff_clean': None,
|
'diff_added_clean': FormattableDiff('', '', include_removed=False, include_change_type_prefix=False),
|
||||||
'diff_full': None,
|
'diff_full': FormattableDiff('', '', include_equal=True),
|
||||||
'diff_full_clean': None,
|
'diff_full_clean': FormattableDiff('', '', include_equal=True, include_change_type_prefix=False),
|
||||||
'diff_patch': None,
|
'diff_patch': FormattableDiff('', '', patch_format=True),
|
||||||
'diff_removed': None,
|
'diff_removed': FormattableDiff('', '', include_added=False),
|
||||||
'diff_removed_clean': None,
|
'diff_removed_clean': FormattableDiff('', '', include_added=False, include_change_type_prefix=False),
|
||||||
'diff_url': None,
|
'diff_url': None,
|
||||||
'markup_text_links_to_html_links': False, # If automatic conversion of plaintext to HTML should happen
|
'markup_text_links_to_html_links': False, # If automatic conversion of plaintext to HTML should happen
|
||||||
'notification_timestamp': time.time(),
|
'notification_timestamp': time.time(),
|
||||||
@@ -140,7 +197,7 @@ class NotificationContextData(dict):
|
|||||||
So we can test the output in the notification body
|
So we can test the output in the notification body
|
||||||
"""
|
"""
|
||||||
for key in self.keys():
|
for key in self.keys():
|
||||||
if key in ['uuid', 'time', 'watch_uuid', 'change_datetime']:
|
if key in ['uuid', 'time', 'watch_uuid', 'change_datetime'] or key.startswith('diff'):
|
||||||
continue
|
continue
|
||||||
rand_str = 'RANDOM-PLACEHOLDER-'+''.join(random.choices(string.ascii_letters + string.digits, k=12))
|
rand_str = 'RANDOM-PLACEHOLDER-'+''.join(random.choices(string.ascii_letters + string.digits, k=12))
|
||||||
self[key] = rand_str
|
self[key] = rand_str
|
||||||
@@ -169,13 +226,12 @@ def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snap
|
|||||||
Returns:
|
Returns:
|
||||||
dict: Only the diff placeholders that were found in notification_scan_text, with rendered content
|
dict: Only the diff placeholders that were found in notification_scan_text, with rendered content
|
||||||
"""
|
"""
|
||||||
from changedetectionio import diff
|
|
||||||
import re
|
import re
|
||||||
from functools import lru_cache
|
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
# Define specifications for each diff variant
|
# Define base kwargs for each diff variant — these become the stored defaults
|
||||||
|
# on the FormattableDiff object, so {{ diff(lines=5) }} overrides on top of them
|
||||||
diff_specs = {
|
diff_specs = {
|
||||||
'diff': {'word_diff': word_diff},
|
'diff': {'word_diff': word_diff},
|
||||||
'diff_clean': {'word_diff': word_diff, 'include_change_type_prefix': False},
|
'diff_clean': {'word_diff': word_diff, 'include_change_type_prefix': False},
|
||||||
@@ -188,22 +244,15 @@ def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snap
|
|||||||
'diff_removed_clean': {'word_diff': word_diff, 'include_added': False, 'include_change_type_prefix': False},
|
'diff_removed_clean': {'word_diff': word_diff, 'include_added': False, 'include_change_type_prefix': False},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Memoize render_diff to avoid duplicate renders with same kwargs
|
|
||||||
@lru_cache(maxsize=4)
|
|
||||||
def cached_render(kwargs_tuple):
|
|
||||||
return diff.render_diff(prev_snapshot, current_snapshot, **dict(kwargs_tuple))
|
|
||||||
|
|
||||||
ret = {}
|
ret = {}
|
||||||
rendered_count = 0
|
rendered_count = 0
|
||||||
# Only check and render diff keys that exist in NotificationContextData
|
# Only create FormattableDiff objects for diff keys actually used in the notification text
|
||||||
for key in NotificationContextData().keys():
|
for key in NotificationContextData().keys():
|
||||||
if key.startswith('diff') and key in diff_specs:
|
if key.startswith('diff') and key in diff_specs:
|
||||||
# Check if this placeholder is actually used in the notification text
|
# Check if this placeholder is actually used in the notification text
|
||||||
pattern = rf"(?<![A-Za-z0-9_]){re.escape(key)}(?![A-Za-z0-9_])"
|
pattern = rf"(?<![A-Za-z0-9_]){re.escape(key)}(?![A-Za-z0-9_])"
|
||||||
if re.search(pattern, notification_scan_text, re.IGNORECASE):
|
if re.search(pattern, notification_scan_text, re.IGNORECASE):
|
||||||
kwargs = diff_specs[key]
|
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, **diff_specs[key])
|
||||||
# Convert dict to sorted tuple for cache key (handles duplicate kwarg combinations)
|
|
||||||
ret[key] = cached_render(tuple(sorted(kwargs.items())))
|
|
||||||
rendered_count += 1
|
rendered_count += 1
|
||||||
|
|
||||||
if rendered_count:
|
if rendered_count:
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ Some suggestions for the future
|
|||||||
|
|
||||||
- `graphical`
|
- `graphical`
|
||||||
|
|
||||||
|
## API schema extension (`api.yaml`)
|
||||||
|
|
||||||
|
A processor can extend the Watch/Tag API schema by placing an `api.yaml` alongside its `__init__.py`.
|
||||||
|
Define a `components.schemas.processor_config_<name>` entry and it will be merged into `WatchBase` at startup,
|
||||||
|
making `processor_config_<name>` a valid field on all watch create/update API calls.
|
||||||
|
The fully merged spec is served live at `/api/v1/full-spec`.
|
||||||
|
|
||||||
|
See `restock_diff/api.yaml` for a working example.
|
||||||
|
|
||||||
## Todo
|
## Todo
|
||||||
|
|
||||||
- Make each processor return a extra list of sub-processed (so you could configure a single processor in different ways)
|
- Make each processor return a extra list of sub-processed (so you could configure a single processor in different ways)
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
import asyncio
|
||||||
import re
|
import re
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
from changedetectionio.browser_steps.browser_steps import browser_steps_get_valid_steps
|
from changedetectionio.browser_steps.browser_steps import browser_steps_get_valid_steps
|
||||||
from changedetectionio.content_fetchers.base import Fetcher
|
from changedetectionio.content_fetchers.base import Fetcher
|
||||||
from changedetectionio.strtobool import strtobool
|
from changedetectionio.strtobool import strtobool
|
||||||
|
from changedetectionio.validate_url import is_private_hostname
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
import os
|
import os
|
||||||
|
from urllib.parse import urlparse
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
SCREENSHOT_FORMAT_JPEG = 'JPEG'
|
SCREENSHOT_FORMAT_JPEG = 'JPEG'
|
||||||
@@ -95,6 +98,23 @@ class difference_detection_processor():
|
|||||||
self.last_raw_content_checksum = None
|
self.last_raw_content_checksum = None
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_iana_url(self):
|
||||||
|
"""Pre-flight SSRF check — runs DNS lookup in executor to avoid blocking the event loop.
|
||||||
|
Covers all fetchers (requests, playwright, puppeteer, plugins) since every fetch goes
|
||||||
|
through call_browser().
|
||||||
|
"""
|
||||||
|
if strtobool(os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')):
|
||||||
|
return
|
||||||
|
parsed = urlparse(self.watch.link)
|
||||||
|
if not parsed.hostname:
|
||||||
|
return
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
if await loop.run_in_executor(None, is_private_hostname, parsed.hostname):
|
||||||
|
raise Exception(
|
||||||
|
f"Fetch blocked: '{self.watch.link}' resolves to a private/reserved IP address. "
|
||||||
|
f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow."
|
||||||
|
)
|
||||||
|
|
||||||
async def call_browser(self, preferred_proxy_id=None):
|
async def call_browser(self, preferred_proxy_id=None):
|
||||||
|
|
||||||
from requests.structures import CaseInsensitiveDict
|
from requests.structures import CaseInsensitiveDict
|
||||||
@@ -108,6 +128,8 @@ class difference_detection_processor():
|
|||||||
"file:// type access is denied for security reasons."
|
"file:// type access is denied for security reasons."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await self.validate_iana_url()
|
||||||
|
|
||||||
# Requests, playwright, other browser via wss:// etc, fetch_extra_something
|
# Requests, playwright, other browser via wss:// etc, fetch_extra_something
|
||||||
prefer_fetch_backend = self.watch.get('fetch_backend', 'system')
|
prefer_fetch_backend = self.watch.get('fetch_backend', 'system')
|
||||||
|
|
||||||
|
|||||||
@@ -67,10 +67,6 @@ class Watch(BaseWatch):
|
|||||||
super().__init__(*arg, **kw)
|
super().__init__(*arg, **kw)
|
||||||
self['restock'] = Restock(kw['default']['restock']) if kw.get('default') and kw['default'].get('restock') else Restock()
|
self['restock'] = Restock(kw['default']['restock']) if kw.get('default') and kw['default'].get('restock') else Restock()
|
||||||
|
|
||||||
self['restock_settings'] = kw['default']['restock_settings'] if kw.get('default',{}).get('restock_settings') else {
|
|
||||||
'follow_price_changes': True,
|
|
||||||
'in_stock_processing' : 'in_stock_only'
|
|
||||||
} #@todo update
|
|
||||||
|
|
||||||
def clear_watch(self):
|
def clear_watch(self):
|
||||||
super().clear_watch()
|
super().clear_watch()
|
||||||
|
|||||||
149
changedetectionio/processors/restock_diff/api.yaml
Normal file
149
changedetectionio/processors/restock_diff/api.yaml
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
processor_config_restock_diff:
|
||||||
|
type: object
|
||||||
|
description: Configuration for the restock_diff processor (restock and price tracking)
|
||||||
|
properties:
|
||||||
|
in_stock_processing:
|
||||||
|
type: string
|
||||||
|
enum: [in_stock_only, all_changes, 'off']
|
||||||
|
default: in_stock_only
|
||||||
|
description: |
|
||||||
|
When to trigger on stock changes:
|
||||||
|
- `in_stock_only`: Only trigger on Out Of Stock -> In Stock transitions
|
||||||
|
- `all_changes`: Trigger on any availability change
|
||||||
|
- `off`: Disable stock/availability tracking
|
||||||
|
follow_price_changes:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
description: Monitor and track price changes
|
||||||
|
price_change_min:
|
||||||
|
type: [number, 'null']
|
||||||
|
description: Trigger a notification when the price drops below this value
|
||||||
|
price_change_max:
|
||||||
|
type: [number, 'null']
|
||||||
|
description: Trigger a notification when the price rises above this value
|
||||||
|
price_change_threshold_percent:
|
||||||
|
type: [number, 'null']
|
||||||
|
minimum: 0
|
||||||
|
maximum: 100
|
||||||
|
description: Minimum price change percentage since the original price to trigger a notification
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/watch:
|
||||||
|
post:
|
||||||
|
x-code-samples:
|
||||||
|
- lang: 'curl'
|
||||||
|
label: 'Restock & price tracking'
|
||||||
|
source: |
|
||||||
|
curl -X POST "http://localhost:5000/api/v1/watch" \
|
||||||
|
-H "x-api-key: YOUR_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"url": "https://example.com/product",
|
||||||
|
"processor": "restock_diff",
|
||||||
|
"processor_config_restock_diff": {
|
||||||
|
"in_stock_processing": "in_stock_only",
|
||||||
|
"follow_price_changes": true,
|
||||||
|
"price_change_threshold_percent": 5
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
- lang: 'Python'
|
||||||
|
label: 'Restock & price tracking'
|
||||||
|
source: |
|
||||||
|
import requests
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'x-api-key': 'YOUR_API_KEY',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
'url': 'https://example.com/product',
|
||||||
|
'processor': 'restock_diff',
|
||||||
|
'processor_config_restock_diff': {
|
||||||
|
'in_stock_processing': 'in_stock_only',
|
||||||
|
'follow_price_changes': True,
|
||||||
|
'price_change_threshold_percent': 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response = requests.post('http://localhost:5000/api/v1/watch',
|
||||||
|
headers=headers, json=data)
|
||||||
|
print(response.json())
|
||||||
|
|
||||||
|
/watch/{uuid}:
|
||||||
|
put:
|
||||||
|
x-code-samples:
|
||||||
|
- lang: 'curl'
|
||||||
|
label: 'Update restock config'
|
||||||
|
source: |
|
||||||
|
curl -X PUT "http://localhost:5000/api/v1/watch/YOUR-UUID" \
|
||||||
|
-H "x-api-key: YOUR_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"processor_config_restock_diff": {
|
||||||
|
"in_stock_processing": "all_changes",
|
||||||
|
"follow_price_changes": true,
|
||||||
|
"price_change_min": 10.00,
|
||||||
|
"price_change_max": 500.00
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
- lang: 'Python'
|
||||||
|
label: 'Update restock config'
|
||||||
|
source: |
|
||||||
|
import requests
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'x-api-key': 'YOUR_API_KEY',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
uuid = 'YOUR-UUID'
|
||||||
|
data = {
|
||||||
|
'processor_config_restock_diff': {
|
||||||
|
'in_stock_processing': 'all_changes',
|
||||||
|
'follow_price_changes': True,
|
||||||
|
'price_change_min': 10.00,
|
||||||
|
'price_change_max': 500.00,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response = requests.put(f'http://localhost:5000/api/v1/watch/{uuid}',
|
||||||
|
headers=headers, json=data)
|
||||||
|
print(response.text)
|
||||||
|
|
||||||
|
/tag/{uuid}:
|
||||||
|
put:
|
||||||
|
x-code-samples:
|
||||||
|
- lang: 'curl'
|
||||||
|
label: 'Set restock config on group/tag'
|
||||||
|
source: |
|
||||||
|
curl -X PUT "http://localhost:5000/api/v1/tag/YOUR-TAG-UUID" \
|
||||||
|
-H "x-api-key: YOUR_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"overrides_watch": true,
|
||||||
|
"processor_config_restock_diff": {
|
||||||
|
"in_stock_processing": "in_stock_only",
|
||||||
|
"follow_price_changes": true,
|
||||||
|
"price_change_threshold_percent": 10
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
- lang: 'Python'
|
||||||
|
label: 'Set restock config on group/tag'
|
||||||
|
source: |
|
||||||
|
import requests
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'x-api-key': 'YOUR_API_KEY',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
tag_uuid = 'YOUR-TAG-UUID'
|
||||||
|
data = {
|
||||||
|
'overrides_watch': True,
|
||||||
|
'processor_config_restock_diff': {
|
||||||
|
'in_stock_processing': 'in_stock_only',
|
||||||
|
'follow_price_changes': True,
|
||||||
|
'price_change_threshold_percent': 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response = requests.put(f'http://localhost:5000/api/v1/tag/{tag_uuid}',
|
||||||
|
headers=headers, json=data)
|
||||||
|
print(response.text)
|
||||||
@@ -31,7 +31,7 @@ class RestockSettingsForm(Form):
|
|||||||
follow_price_changes = BooleanField(_l('Follow price changes'), default=True)
|
follow_price_changes = BooleanField(_l('Follow price changes'), default=True)
|
||||||
|
|
||||||
class processor_settings_form(processor_text_json_diff_form):
|
class processor_settings_form(processor_text_json_diff_form):
|
||||||
restock_settings = FormField(RestockSettingsForm)
|
processor_config_restock_diff = FormField(RestockSettingsForm)
|
||||||
|
|
||||||
def extra_tab_content(self):
|
def extra_tab_content(self):
|
||||||
return _l('Restock & Price Detection')
|
return _l('Restock & Price Detection')
|
||||||
@@ -50,29 +50,29 @@ class processor_settings_form(processor_text_json_diff_form):
|
|||||||
{% from '_helpers.html' import render_field, render_checkbox_field, render_button %}
|
{% from '_helpers.html' import render_field, render_checkbox_field, render_button %}
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
toggleOpacity('#restock_settings-follow_price_changes', '.price-change-minmax', true);
|
toggleOpacity('#processor_config_restock_diff-follow_price_changes', '.price-change-minmax', true);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<fieldset id="restock-fieldset-price-group">
|
<fieldset id="restock-fieldset-price-group">
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<fieldset class="pure-group inline-radio">
|
<fieldset class="pure-group inline-radio">
|
||||||
{{ render_field(form.restock_settings.in_stock_processing) }}
|
{{ render_field(form.processor_config_restock_diff.in_stock_processing) }}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="pure-group">
|
<fieldset class="pure-group">
|
||||||
{{ render_checkbox_field(form.restock_settings.follow_price_changes) }}
|
{{ render_checkbox_field(form.processor_config_restock_diff.follow_price_changes) }}
|
||||||
<span class="pure-form-message-inline">Changes in price should trigger a notification</span>
|
<span class="pure-form-message-inline">Changes in price should trigger a notification</span>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="pure-group price-change-minmax">
|
<fieldset class="pure-group price-change-minmax">
|
||||||
{{ render_field(form.restock_settings.price_change_min, placeholder=watch.get('restock', {}).get('price')) }}
|
{{ render_field(form.processor_config_restock_diff.price_change_min, placeholder=watch.get('restock', {}).get('price')) }}
|
||||||
<span class="pure-form-message-inline">Minimum amount, Trigger a change/notification when the price drops <i>below</i> this value.</span>
|
<span class="pure-form-message-inline">Minimum amount, Trigger a change/notification when the price drops <i>below</i> this value.</span>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="pure-group price-change-minmax">
|
<fieldset class="pure-group price-change-minmax">
|
||||||
{{ render_field(form.restock_settings.price_change_max, placeholder=watch.get('restock', {}).get('price')) }}
|
{{ render_field(form.processor_config_restock_diff.price_change_max, placeholder=watch.get('restock', {}).get('price')) }}
|
||||||
<span class="pure-form-message-inline">Maximum amount, Trigger a change/notification when the price rises <i>above</i> this value.</span>
|
<span class="pure-form-message-inline">Maximum amount, Trigger a change/notification when the price rises <i>above</i> this value.</span>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="pure-group price-change-minmax">
|
<fieldset class="pure-group price-change-minmax">
|
||||||
{{ render_field(form.restock_settings.price_change_threshold_percent) }}
|
{{ render_field(form.processor_config_restock_diff.price_change_threshold_percent) }}
|
||||||
<span class="pure-form-message-inline">Price must change more than this % to trigger a change since the first check.</span><br>
|
<span class="pure-form-message-inline">Price must change more than this % to trigger a change since the first check.</span><br>
|
||||||
<span class="pure-form-message-inline">For example, If the product is $1,000 USD originally, <strong>2%</strong> would mean it has to change more than $20 since the first check.</span><br>
|
<span class="pure-form-message-inline">For example, If the product is $1,000 USD originally, <strong>2%</strong> would mean it has to change more than $20 since the first check.</span><br>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -450,13 +450,18 @@ class perform_site_check(difference_detection_processor):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Which restock settings to compare against?
|
# Which restock settings to compare against?
|
||||||
restock_settings = watch.get('restock_settings', {})
|
# Settings are stored in restock_diff.json (migrated from watch.json by update_30).
|
||||||
|
_extra_config = self.get_extra_watch_config('restock_diff.json')
|
||||||
|
restock_settings = _extra_config.get('restock_diff') or {
|
||||||
|
'follow_price_changes': True,
|
||||||
|
'in_stock_processing': 'in_stock_only',
|
||||||
|
}
|
||||||
|
|
||||||
# See if any tags have 'activate for individual watches in this tag/group?' enabled and use the first we find
|
# See if any tags have 'activate for individual watches in this tag/group?' enabled and use the first we find
|
||||||
for tag_uuid in watch.get('tags'):
|
for tag_uuid in watch.get('tags'):
|
||||||
tag = self.datastore.data['settings']['application']['tags'].get(tag_uuid, {})
|
tag = self.datastore.data['settings']['application']['tags'].get(tag_uuid, {})
|
||||||
if tag.get('overrides_watch'):
|
if tag.get('overrides_watch'):
|
||||||
restock_settings = tag.get('restock_settings', {})
|
restock_settings = tag.get('processor_config_restock_diff') or {}
|
||||||
logger.info(f"Watch {watch.get('uuid')} - Tag '{tag.get('title')}' selected for restock settings override")
|
logger.info(f"Watch {watch.get('uuid')} - Tag '{tag.get('title')}' selected for restock settings override")
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ def handle_watch_update(socketio, **kwargs):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Socket.IO error in handle_watch_update: {str(e)}")
|
logger.error(f"Socket.IO error in handle_watch_update: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
def init_socketio(app, datastore):
|
def init_socketio(app, datastore):
|
||||||
"""Initialize SocketIO with the main Flask app"""
|
"""Initialize SocketIO with the main Flask app"""
|
||||||
import platform
|
import platform
|
||||||
|
|||||||
@@ -730,3 +730,48 @@ class DatastoreUpdatesMixin:
|
|||||||
# (left this out by accident in previous update, added tags={} in the changedetection.json save_to_disk)
|
# (left this out by accident in previous update, added tags={} in the changedetection.json save_to_disk)
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
|
|
||||||
|
def update_30(self):
|
||||||
|
"""Migrate restock_settings out of watch.json into restock_diff.json processor config file.
|
||||||
|
|
||||||
|
Previously, restock_diff processor settings (in_stock_processing, follow_price_changes, etc.)
|
||||||
|
were stored directly in the watch dict (watch.json). They now belong in a separate per-watch
|
||||||
|
processor config file (restock_diff.json) consistent with the processor_config_* API system.
|
||||||
|
|
||||||
|
For tags: restock_settings key is renamed to processor_config_restock_diff in the tag dict,
|
||||||
|
matching what the API writes when updating a tag.
|
||||||
|
|
||||||
|
Safe to re-run: skips watches that already have a restock_diff.json, skips tags that already
|
||||||
|
have processor_config_restock_diff set.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
# --- Watches ---
|
||||||
|
for uuid, watch in self.data['watching'].items():
|
||||||
|
if watch.get('processor') != 'restock_diff':
|
||||||
|
continue
|
||||||
|
restock_settings = watch.get('restock_settings')
|
||||||
|
if not restock_settings:
|
||||||
|
continue
|
||||||
|
|
||||||
|
data_dir = watch.data_dir
|
||||||
|
if data_dir:
|
||||||
|
watch.ensure_data_dir_exists()
|
||||||
|
filepath = os.path.join(data_dir, 'restock_diff.json')
|
||||||
|
if not os.path.isfile(filepath):
|
||||||
|
with open(filepath, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump({'restock_diff': restock_settings}, f, indent=2)
|
||||||
|
logger.info(f"update_30: migrated restock_settings → {filepath}")
|
||||||
|
|
||||||
|
del self.data['watching'][uuid]['restock_settings']
|
||||||
|
watch.commit()
|
||||||
|
|
||||||
|
# --- Tags ---
|
||||||
|
for tag_uuid, tag in self.data['settings']['application']['tags'].items():
|
||||||
|
restock_settings = tag.get('restock_settings')
|
||||||
|
if not restock_settings or tag.get('processor_config_restock_diff'):
|
||||||
|
continue
|
||||||
|
tag['processor_config_restock_diff'] = restock_settings
|
||||||
|
del tag['restock_settings']
|
||||||
|
tag.commit()
|
||||||
|
logger.info(f"update_30: migrated tag {tag_uuid} restock_settings → processor_config_restock_diff")
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,12 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>{{ '{{diff}}' }}</code></td>
|
<td><code>{{ '{{diff}}' }}</code></td>
|
||||||
<td>{{ _('The diff output - only changes, additions, and removals') }}</td>
|
<td>{{ _('The diff output - only changes, additions, and removals') }}<br>
|
||||||
|
<small>
|
||||||
|
{{ _('All diff variants accept') }} <code>lines=</code>, <code>context=</code>, <code>word_diff=</code>, <code>ignore_junk=</code> {{ _('args, e.g.') }}
|
||||||
|
<code>{{ '{{diff(lines=10)}}' }}</code>, <code>{{ '{{diff_added(lines=5, context=2)}}' }}</code>
|
||||||
|
</small>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>{{ '{{diff_clean}}' }}</code></td>
|
<td><code>{{ '{{diff_clean}}' }}</code></td>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import psutil
|
import psutil
|
||||||
import time
|
import time
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
|
import multiprocessing
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import arrow
|
import arrow
|
||||||
@@ -191,6 +192,34 @@ def cleanup(datastore_path):
|
|||||||
if os.path.isfile(f):
|
if os.path.isfile(f):
|
||||||
os.unlink(f)
|
os.unlink(f)
|
||||||
|
|
||||||
|
def pytest_configure(config):
|
||||||
|
"""Configure pytest environment before tests run.
|
||||||
|
|
||||||
|
CRITICAL: Set multiprocessing start method to 'fork' for Python 3.14+ compatibility.
|
||||||
|
|
||||||
|
Python 3.14 changed the default start method from 'fork' to 'forkserver' on Linux.
|
||||||
|
The forkserver method requires all objects to be picklable, but pytest-flask's
|
||||||
|
LiveServer uses nested functions that can't be pickled.
|
||||||
|
|
||||||
|
Setting 'fork' explicitly:
|
||||||
|
- Maintains compatibility with Python 3.10-3.13 (where 'fork' was already default)
|
||||||
|
- Fixes Python 3.14 pickling errors
|
||||||
|
- Only affects Unix-like systems (Windows uses 'spawn' regardless)
|
||||||
|
|
||||||
|
See: https://github.com/python/cpython/issues/126831
|
||||||
|
See: https://docs.python.org/3/whatsnew/3.14.html
|
||||||
|
"""
|
||||||
|
# Only set if not already set (respects existing configuration)
|
||||||
|
if multiprocessing.get_start_method(allow_none=True) is None:
|
||||||
|
try:
|
||||||
|
# 'fork' is available on Unix-like systems (Linux, macOS)
|
||||||
|
# On Windows, this will have no effect as 'spawn' is the only option
|
||||||
|
multiprocessing.set_start_method('fork', force=False)
|
||||||
|
logger.debug("Set multiprocessing start method to 'fork' for Python 3.14+ compatibility")
|
||||||
|
except (ValueError, RuntimeError):
|
||||||
|
# Already set, not available on this platform, or context already created
|
||||||
|
pass
|
||||||
|
|
||||||
def pytest_addoption(parser):
|
def pytest_addoption(parser):
|
||||||
"""Add custom command-line options for pytest.
|
"""Add custom command-line options for pytest.
|
||||||
|
|
||||||
|
|||||||
@@ -807,6 +807,88 @@ def test_api_import_large_background(client, live_server, measure_memory_usage,
|
|||||||
print(f"\n✓ Successfully created {num_urls} watches in background (took {elapsed}s)")
|
print(f"\n✓ Successfully created {num_urls} watches in background (took {elapsed}s)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_restock_processor_config(client, live_server, measure_memory_usage, datastore_path):
|
||||||
|
"""
|
||||||
|
Test that processor_config_restock_diff is accepted by the API for watches using
|
||||||
|
restock_diff processor, that its schema is validated (enum values, types), and that
|
||||||
|
genuinely unknown fields are rejected with an error that originates from the
|
||||||
|
OpenAPI spec validation layer.
|
||||||
|
"""
|
||||||
|
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||||
|
test_url = url_for('test_endpoint', _external=True)
|
||||||
|
|
||||||
|
# Create a watch in restock_diff mode WITH processor_config in the POST body (matches the API docs example)
|
||||||
|
res = client.post(
|
||||||
|
url_for("createwatch"),
|
||||||
|
data=json.dumps({
|
||||||
|
"url": test_url,
|
||||||
|
"processor": "restock_diff",
|
||||||
|
"title": "Restock test",
|
||||||
|
"processor_config_restock_diff": {
|
||||||
|
"in_stock_processing": "in_stock_only",
|
||||||
|
"follow_price_changes": True,
|
||||||
|
"price_change_min": 8888888.0,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||||
|
follow_redirects=True
|
||||||
|
)
|
||||||
|
assert res.status_code == 201
|
||||||
|
watch_uuid = res.json.get('uuid')
|
||||||
|
assert is_valid_uuid(watch_uuid)
|
||||||
|
|
||||||
|
# Verify the value set on POST is reflected in the UI edit page (not just via PUT)
|
||||||
|
res = client.get(url_for("ui.ui_edit.edit_page", uuid=watch_uuid))
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert b'8888888' in res.data, "price_change_min set via POST should appear in the UI edit form"
|
||||||
|
|
||||||
|
# Valid processor_config_restock_diff update via PUT should also be accepted
|
||||||
|
res = client.put(
|
||||||
|
url_for("watch", uuid=watch_uuid),
|
||||||
|
headers={'x-api-key': api_key, 'content-type': 'application/json'},
|
||||||
|
data=json.dumps({
|
||||||
|
"processor_config_restock_diff": {
|
||||||
|
"in_stock_processing": "all_changes",
|
||||||
|
"follow_price_changes": False,
|
||||||
|
"price_change_min": 8888888.0,
|
||||||
|
"price_change_max": 9999999.0,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert res.status_code == 200, f"Valid processor_config_restock_diff should be accepted, got: {res.data}"
|
||||||
|
|
||||||
|
# Verify the updated value is still reflected in the UI edit page
|
||||||
|
res = client.get(url_for("ui.ui_edit.edit_page", uuid=watch_uuid))
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert b'8888888' in res.data, "price_change_min set via PUT should appear in the UI edit form"
|
||||||
|
|
||||||
|
# An invalid enum value inside processor_config_restock_diff should be rejected by the spec
|
||||||
|
res = client.put(
|
||||||
|
url_for("watch", uuid=watch_uuid),
|
||||||
|
headers={'x-api-key': api_key, 'content-type': 'application/json'},
|
||||||
|
data=json.dumps({
|
||||||
|
"processor_config_restock_diff": {
|
||||||
|
"in_stock_processing": "not_a_valid_enum_value"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert res.status_code == 400, "Invalid enum value in processor config should be rejected"
|
||||||
|
assert b'Validation failed' in res.data, "Rejection should come from OpenAPI spec validation layer"
|
||||||
|
|
||||||
|
# A completely unknown field should be rejected (either by OpenAPI spec validation or
|
||||||
|
# the application-level field filter — both are acceptable gatekeepers)
|
||||||
|
res = client.put(
|
||||||
|
url_for("watch", uuid=watch_uuid),
|
||||||
|
headers={'x-api-key': api_key, 'content-type': 'application/json'},
|
||||||
|
data=json.dumps({"field_that_is_not_in_the_spec_at_all": "some value"}),
|
||||||
|
)
|
||||||
|
assert res.status_code == 400, "Unknown fields should be rejected"
|
||||||
|
assert (b'Validation failed' in res.data or b'Unknown field' in res.data), \
|
||||||
|
"Rejection should come from either the OpenAPI spec validation layer or application field filter"
|
||||||
|
|
||||||
|
delete_all_watches(client)
|
||||||
|
|
||||||
|
|
||||||
def test_api_conflict_UI_password(client, live_server, measure_memory_usage, datastore_path):
|
def test_api_conflict_UI_password(client, live_server, measure_memory_usage, datastore_path):
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,50 @@ from flask import url_for
|
|||||||
from .util import live_server_setup, wait_for_all_checks, delete_all_watches
|
from .util import live_server_setup, wait_for_all_checks, delete_all_watches
|
||||||
|
|
||||||
|
|
||||||
|
def test_openapi_merged_spec_contains_restock_fields():
|
||||||
|
"""
|
||||||
|
Unit test: verify that build_merged_spec_dict() correctly merges the
|
||||||
|
restock_diff processor api.yaml into the base spec so that
|
||||||
|
WatchBase.properties includes processor_config_restock_diff with all
|
||||||
|
expected sub-fields. No live server required.
|
||||||
|
"""
|
||||||
|
from changedetectionio.api import build_merged_spec_dict
|
||||||
|
|
||||||
|
spec = build_merged_spec_dict()
|
||||||
|
schemas = spec['components']['schemas']
|
||||||
|
|
||||||
|
# The merged schema for processor_config_restock_diff should exist
|
||||||
|
assert 'processor_config_restock_diff' in schemas, \
|
||||||
|
"processor_config_restock_diff schema missing from merged spec"
|
||||||
|
|
||||||
|
restock_schema = schemas['processor_config_restock_diff']
|
||||||
|
props = restock_schema.get('properties', {})
|
||||||
|
|
||||||
|
expected_fields = {
|
||||||
|
'in_stock_processing',
|
||||||
|
'follow_price_changes',
|
||||||
|
'price_change_min',
|
||||||
|
'price_change_max',
|
||||||
|
'price_change_threshold_percent',
|
||||||
|
}
|
||||||
|
missing = expected_fields - set(props.keys())
|
||||||
|
assert not missing, f"Missing fields in processor_config_restock_diff schema: {missing}"
|
||||||
|
|
||||||
|
# in_stock_processing must be an enum with the three valid values
|
||||||
|
enum_values = set(props['in_stock_processing'].get('enum', []))
|
||||||
|
assert enum_values == {'in_stock_only', 'all_changes', 'off'}, \
|
||||||
|
f"Unexpected enum values for in_stock_processing: {enum_values}"
|
||||||
|
|
||||||
|
# WatchBase.properties must carry a $ref to the restock schema so the
|
||||||
|
# validation middleware can enforce it on every POST/PUT to /watch
|
||||||
|
watchbase_props = schemas['WatchBase']['properties']
|
||||||
|
assert 'processor_config_restock_diff' in watchbase_props, \
|
||||||
|
"processor_config_restock_diff not wired into WatchBase.properties"
|
||||||
|
ref = watchbase_props['processor_config_restock_diff'].get('$ref', '')
|
||||||
|
assert 'processor_config_restock_diff' in ref, \
|
||||||
|
f"WatchBase.processor_config_restock_diff should $ref the schema, got: {ref}"
|
||||||
|
|
||||||
|
|
||||||
def test_openapi_validation_invalid_content_type_on_create_watch(client, live_server, measure_memory_usage, datastore_path):
|
def test_openapi_validation_invalid_content_type_on_create_watch(client, live_server, measure_memory_usage, datastore_path):
|
||||||
"""Test that creating a watch with invalid content-type triggers OpenAPI validation error."""
|
"""Test that creating a watch with invalid content-type triggers OpenAPI validation error."""
|
||||||
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||||
|
|||||||
@@ -176,6 +176,76 @@ def test_api_tags_listing(client, live_server, measure_memory_usage, datastore_p
|
|||||||
assert res.status_code == 204
|
assert res.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_tag_restock_processor_config(client, live_server, measure_memory_usage, datastore_path):
|
||||||
|
"""
|
||||||
|
Test that a tag/group can be updated with processor_config_restock_diff via the API.
|
||||||
|
Since Tag extends WatchBase, processor config fields injected into WatchBase are also valid for tags.
|
||||||
|
"""
|
||||||
|
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||||
|
|
||||||
|
set_original_response(datastore_path=datastore_path)
|
||||||
|
|
||||||
|
# Create a tag
|
||||||
|
res = client.post(
|
||||||
|
url_for("tag"),
|
||||||
|
data=json.dumps({"title": "Restock Group"}),
|
||||||
|
headers={'content-type': 'application/json', 'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 201
|
||||||
|
tag_uuid = res.json.get('uuid')
|
||||||
|
|
||||||
|
# Update tag with valid processor_config_restock_diff
|
||||||
|
res = client.put(
|
||||||
|
url_for("tag", uuid=tag_uuid),
|
||||||
|
headers={'x-api-key': api_key, 'content-type': 'application/json'},
|
||||||
|
data=json.dumps({
|
||||||
|
"overrides_watch": True,
|
||||||
|
"processor_config_restock_diff": {
|
||||||
|
"in_stock_processing": "in_stock_only",
|
||||||
|
"follow_price_changes": True,
|
||||||
|
"price_change_min": 8888888
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
assert res.status_code == 200, f"PUT tag with restock config failed: {res.data}"
|
||||||
|
|
||||||
|
# Verify the config was stored via API
|
||||||
|
res = client.get(
|
||||||
|
url_for("tag", uuid=tag_uuid),
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
tag_data = res.json
|
||||||
|
assert tag_data.get('overrides_watch') == True
|
||||||
|
assert tag_data.get('processor_config_restock_diff', {}).get('in_stock_processing') == 'in_stock_only'
|
||||||
|
assert tag_data.get('processor_config_restock_diff', {}).get('price_change_min') == 8888888
|
||||||
|
|
||||||
|
# Verify the value is also reflected in the UI tag edit page
|
||||||
|
res = client.get(url_for("tags.form_tag_edit", uuid=tag_uuid))
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert b'8888888' in res.data, "price_change_min set via API should appear in the UI tag edit form"
|
||||||
|
|
||||||
|
# Invalid enum value should be rejected by OpenAPI spec validation
|
||||||
|
res = client.put(
|
||||||
|
url_for("tag", uuid=tag_uuid),
|
||||||
|
headers={'x-api-key': api_key, 'content-type': 'application/json'},
|
||||||
|
data=json.dumps({
|
||||||
|
"processor_config_restock_diff": {
|
||||||
|
"in_stock_processing": "not_a_valid_value"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
assert res.status_code == 400
|
||||||
|
assert b'Validation failed' in res.data
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
res = client.delete(
|
||||||
|
url_for("tag", uuid=tag_uuid),
|
||||||
|
headers={'x-api-key': api_key}
|
||||||
|
)
|
||||||
|
assert res.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
def test_roundtrip_API(client, live_server, measure_memory_usage, datastore_path):
|
def test_roundtrip_API(client, live_server, measure_memory_usage, datastore_path):
|
||||||
"""
|
"""
|
||||||
Test the full round trip, this way we test the default Model fits back into OpenAPI spec
|
Test the full round trip, this way we test the default Model fits back into OpenAPI spec
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore
|
|||||||
"Diff Added: {{diff_added}}\n"
|
"Diff Added: {{diff_added}}\n"
|
||||||
"Diff Removed: {{diff_removed}}\n"
|
"Diff Removed: {{diff_removed}}\n"
|
||||||
"Diff Full: {{diff_full}}\n"
|
"Diff Full: {{diff_full}}\n"
|
||||||
|
"Diff with args: {{diff(context=3)}}"
|
||||||
"Diff as Patch: {{diff_patch}}\n"
|
"Diff as Patch: {{diff_patch}}\n"
|
||||||
"Change datetime: {{change_datetime}}\n"
|
"Change datetime: {{change_datetime}}\n"
|
||||||
"Change datetime format: Weekday {{change_datetime(format='%A')}}\n"
|
"Change datetime format: Weekday {{change_datetime(format='%A')}}\n"
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ def test_itemprop_price_change(client, live_server, measure_memory_usage, datast
|
|||||||
set_original_response(props_markup=instock_props[0], price='120.45', datastore_path=datastore_path)
|
set_original_response(props_markup=instock_props[0], price='120.45', datastore_path=datastore_path)
|
||||||
res = client.post(
|
res = client.post(
|
||||||
url_for("ui.ui_edit.edit_page", uuid="first"),
|
url_for("ui.ui_edit.edit_page", uuid="first"),
|
||||||
data={"restock_settings-follow_price_changes": "", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
|
data={"processor_config_restock_diff-follow_price_changes": "", "url": test_url, "tags": "", "headers": "", 'fetch_backend': "html_requests", "time_between_check_use_default": "y"},
|
||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
)
|
)
|
||||||
assert b"Updated watch." in res.data
|
assert b"Updated watch." in res.data
|
||||||
@@ -204,9 +204,9 @@ def _run_test_minmax_limit(client, extra_watch_edit_form, datastore_path):
|
|||||||
def test_restock_itemprop_minmax(client, live_server, measure_memory_usage, datastore_path):
|
def test_restock_itemprop_minmax(client, live_server, measure_memory_usage, datastore_path):
|
||||||
|
|
||||||
extras = {
|
extras = {
|
||||||
"restock_settings-follow_price_changes": "y",
|
"processor_config_restock_diff-follow_price_changes": "y",
|
||||||
"restock_settings-price_change_min": 900.0,
|
"processor_config_restock_diff-price_change_min": 900.0,
|
||||||
"restock_settings-price_change_max": 1100.10
|
"processor_config_restock_diff-price_change_max": 1100.10
|
||||||
}
|
}
|
||||||
_run_test_minmax_limit(client, extra_watch_edit_form=extras, datastore_path=datastore_path)
|
_run_test_minmax_limit(client, extra_watch_edit_form=extras, datastore_path=datastore_path)
|
||||||
|
|
||||||
@@ -223,9 +223,9 @@ def test_restock_itemprop_with_tag(client, live_server, measure_memory_usage, da
|
|||||||
res = client.post(
|
res = client.post(
|
||||||
url_for("tags.form_tag_edit_submit", uuid="first"),
|
url_for("tags.form_tag_edit_submit", uuid="first"),
|
||||||
data={"name": "test-tag",
|
data={"name": "test-tag",
|
||||||
"restock_settings-follow_price_changes": "y",
|
"processor_config_restock_diff-follow_price_changes": "y",
|
||||||
"restock_settings-price_change_min": 900.0,
|
"processor_config_restock_diff-price_change_min": 900.0,
|
||||||
"restock_settings-price_change_max": 1100.10,
|
"processor_config_restock_diff-price_change_max": 1100.10,
|
||||||
"overrides_watch": "y", #overrides_watch should be restock_overrides_watch
|
"overrides_watch": "y", #overrides_watch should be restock_overrides_watch
|
||||||
},
|
},
|
||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
@@ -258,8 +258,8 @@ def test_itemprop_percent_threshold(client, live_server, measure_memory_usage, d
|
|||||||
|
|
||||||
res = client.post(
|
res = client.post(
|
||||||
url_for("ui.ui_edit.edit_page", uuid="first"),
|
url_for("ui.ui_edit.edit_page", uuid="first"),
|
||||||
data={"restock_settings-follow_price_changes": "y",
|
data={"processor_config_restock_diff-follow_price_changes": "y",
|
||||||
"restock_settings-price_change_threshold_percent": 5.0,
|
"processor_config_restock_diff-price_change_threshold_percent": 5.0,
|
||||||
"url": test_url,
|
"url": test_url,
|
||||||
"tags": "",
|
"tags": "",
|
||||||
"headers": "",
|
"headers": "",
|
||||||
@@ -305,8 +305,8 @@ def test_itemprop_percent_threshold(client, live_server, measure_memory_usage, d
|
|||||||
|
|
||||||
res = client.post(
|
res = client.post(
|
||||||
url_for("ui.ui_edit.edit_page", uuid=uuid),
|
url_for("ui.ui_edit.edit_page", uuid=uuid),
|
||||||
data={"restock_settings-follow_price_changes": "y",
|
data={"processor_config_restock_diff-follow_price_changes": "y",
|
||||||
"restock_settings-price_change_threshold_percent": 5.05,
|
"processor_config_restock_diff-price_change_threshold_percent": 5.05,
|
||||||
"processor": "text_json_diff",
|
"processor": "text_json_diff",
|
||||||
"url": test_url,
|
"url": test_url,
|
||||||
'fetch_backend': "html_requests",
|
'fetch_backend': "html_requests",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ def test_favicon(client, live_server, measure_memory_usage, datastore_path):
|
|||||||
favicon_base_64=SVG_BASE64
|
favicon_base_64=SVG_BASE64
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
res = client.get(url_for('static_content', group='favicon', filename=uuid))
|
res = client.get(url_for('static_content', group='favicon', filename=uuid))
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
assert len(res.data) > 10
|
assert len(res.data) > 10
|
||||||
@@ -583,13 +584,16 @@ def test_static_directory_traversal(client, live_server, measure_memory_usage, d
|
|||||||
|
|
||||||
def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memory_usage, datastore_path):
|
def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memory_usage, datastore_path):
|
||||||
"""
|
"""
|
||||||
SSRF protection: IANA-reserved/private IP addresses must be blocked by default.
|
SSRF protection: IANA-reserved/private IP addresses are blocked at fetch-time, not add-time.
|
||||||
|
|
||||||
|
Watches targeting private/reserved IPs can be *added* freely; the block happens when the
|
||||||
|
fetcher actually tries to reach the URL (via validate_iana_url() in call_browser()).
|
||||||
|
|
||||||
Covers:
|
Covers:
|
||||||
1. is_private_hostname() correctly classifies all reserved ranges
|
1. is_private_hostname() correctly classifies all reserved ranges
|
||||||
2. is_safe_valid_url() rejects private-IP URLs at add-time (env var off)
|
2. is_safe_valid_url() ALLOWS private-IP URLs at add-time (IANA check moved to fetch-time)
|
||||||
3. is_safe_valid_url() allows private-IP URLs when ALLOW_IANA_RESTRICTED_ADDRESSES=true
|
3. ALLOW_IANA_RESTRICTED_ADDRESSES has no effect on add-time; it only controls fetch-time
|
||||||
4. UI form rejects private-IP URLs and shows the standard error message
|
4. UI form accepts private-IP URLs at add-time without error
|
||||||
5. Requests fetcher blocks fetch-time DNS rebinding (fresh check on every fetch)
|
5. Requests fetcher blocks fetch-time DNS rebinding (fresh check on every fetch)
|
||||||
6. Requests fetcher blocks redirects that lead to a private IP (open-redirect bypass)
|
6. Requests fetcher blocks redirects that lead to a private IP (open-redirect bypass)
|
||||||
|
|
||||||
@@ -601,8 +605,6 @@ def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memor
|
|||||||
from changedetectionio.validate_url import is_safe_valid_url, is_private_hostname
|
from changedetectionio.validate_url import is_safe_valid_url, is_private_hostname
|
||||||
|
|
||||||
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')
|
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')
|
||||||
# Clear any URL results cached while the env var was 'true'
|
|
||||||
is_safe_valid_url.cache_clear()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 1. is_private_hostname() — unit tests across all reserved ranges
|
# 1. is_private_hostname() — unit tests across all reserved ranges
|
||||||
@@ -624,9 +626,10 @@ def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memor
|
|||||||
assert not is_private_hostname(host), f"{host} should be identified as public"
|
assert not is_private_hostname(host), f"{host} should be identified as public"
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 2. is_safe_valid_url() blocks private-IP URLs (env var off)
|
# 2. is_safe_valid_url() ALLOWS private-IP URLs at add-time
|
||||||
|
# IANA check is no longer done here — it moved to fetch-time validate_iana_url()
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
blocked_urls = [
|
private_ip_urls = [
|
||||||
'http://127.0.0.1/',
|
'http://127.0.0.1/',
|
||||||
'http://10.0.0.1/',
|
'http://10.0.0.1/',
|
||||||
'http://172.16.0.1/',
|
'http://172.16.0.1/',
|
||||||
@@ -637,23 +640,24 @@ def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memor
|
|||||||
'http://[fc00::1]/',
|
'http://[fc00::1]/',
|
||||||
'http://[fe80::1]/',
|
'http://[fe80::1]/',
|
||||||
]
|
]
|
||||||
for url in blocked_urls:
|
for url in private_ip_urls:
|
||||||
assert not is_safe_valid_url(url), f"{url} should be blocked by is_safe_valid_url"
|
assert is_safe_valid_url(url), f"{url} should be allowed by is_safe_valid_url (IANA check is at fetch-time)"
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 3. ALLOW_IANA_RESTRICTED_ADDRESSES=true bypasses the block
|
# 3. ALLOW_IANA_RESTRICTED_ADDRESSES does not affect add-time validation
|
||||||
|
# It only controls fetch-time blocking inside validate_iana_url()
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'true')
|
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'true')
|
||||||
is_safe_valid_url.cache_clear()
|
|
||||||
assert is_safe_valid_url('http://127.0.0.1/'), \
|
assert is_safe_valid_url('http://127.0.0.1/'), \
|
||||||
"Private IP should be allowed when ALLOW_IANA_RESTRICTED_ADDRESSES=true"
|
"Private IP should be allowed at add-time regardless of ALLOW_IANA_RESTRICTED_ADDRESSES"
|
||||||
|
|
||||||
# Restore the block for the remaining assertions
|
|
||||||
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')
|
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')
|
||||||
is_safe_valid_url.cache_clear()
|
assert is_safe_valid_url('http://127.0.0.1/'), \
|
||||||
|
"Private IP should be allowed at add-time regardless of ALLOW_IANA_RESTRICTED_ADDRESSES"
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 4. UI form rejects private-IP URLs
|
# 4. UI form accepts private-IP URLs at add-time
|
||||||
|
# The watch is created; the SSRF block fires later at fetch-time
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
for url in ['http://127.0.0.1/', 'http://169.254.169.254/latest/meta-data/']:
|
for url in ['http://127.0.0.1/', 'http://169.254.169.254/latest/meta-data/']:
|
||||||
res = client.post(
|
res = client.post(
|
||||||
@@ -661,8 +665,8 @@ def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memor
|
|||||||
data={'url': url, 'tags': ''},
|
data={'url': url, 'tags': ''},
|
||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
)
|
)
|
||||||
assert b'Watch protocol is not permitted or invalid URL format' in res.data, \
|
assert b'Watch protocol is not permitted or invalid URL format' not in res.data, \
|
||||||
f"UI should reject {url}"
|
f"UI should accept {url} at add-time (SSRF is blocked at fetch-time)"
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 5. Fetch-time DNS-rebinding check in the requests fetcher
|
# 5. Fetch-time DNS-rebinding check in the requests fetcher
|
||||||
@@ -708,3 +712,35 @@ def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memor
|
|||||||
request_body=None,
|
request_body=None,
|
||||||
request_method='GET',
|
request_method='GET',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unresolvable_hostname_is_allowed(client, live_server, monkeypatch):
|
||||||
|
"""
|
||||||
|
Unresolvable hostnames must NOT be blocked at add-time when ALLOW_IANA_RESTRICTED_ADDRESSES=false.
|
||||||
|
|
||||||
|
DNS failure (gaierror) at add-time does not mean the URL resolves to a private IP —
|
||||||
|
the domain may simply be offline or not yet live. Blocking it would be a false positive.
|
||||||
|
The real DNS-rebinding protection happens at fetch-time in call_browser().
|
||||||
|
"""
|
||||||
|
from changedetectionio.validate_url import is_safe_valid_url
|
||||||
|
|
||||||
|
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')
|
||||||
|
|
||||||
|
url = 'http://this-host-does-not-exist-xyz987.invalid/some/path'
|
||||||
|
|
||||||
|
# Should pass URL validation despite being unresolvable
|
||||||
|
assert is_safe_valid_url(url), \
|
||||||
|
"Unresolvable hostname should pass is_safe_valid_url — DNS failure is not a private-IP signal"
|
||||||
|
|
||||||
|
# Should be accepted via the UI form and appear in the watch list
|
||||||
|
res = client.post(
|
||||||
|
url_for('ui.ui_views.form_quick_watch_add'),
|
||||||
|
data={'url': url, 'tags': ''},
|
||||||
|
follow_redirects=True
|
||||||
|
)
|
||||||
|
assert b'Watch protocol is not permitted or invalid URL format' not in res.data, \
|
||||||
|
"UI should not reject a URL just because its hostname is unresolvable"
|
||||||
|
|
||||||
|
res = client.get(url_for('watchlist.index'))
|
||||||
|
assert b'this-host-does-not-exist-xyz987.invalid' in res.data, \
|
||||||
|
"Unresolvable hostname watch should appear in the watch overview list"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1978,7 +1978,7 @@ msgstr "Format d'heure invalide. Utilisez HH:MM."
|
|||||||
|
|
||||||
#: changedetectionio/forms.py
|
#: changedetectionio/forms.py
|
||||||
msgid "Not a valid timezone name"
|
msgid "Not a valid timezone name"
|
||||||
msgstr "Ce n'est pas un nom de fuseau horaire valide"
|
msgstr "Nom de fuseau horaire invalide"
|
||||||
|
|
||||||
#: changedetectionio/forms.py
|
#: changedetectionio/forms.py
|
||||||
msgid "not set"
|
msgid "not set"
|
||||||
@@ -2054,9 +2054,7 @@ msgstr "secondes"
|
|||||||
|
|
||||||
#: changedetectionio/forms.py
|
#: changedetectionio/forms.py
|
||||||
msgid "Notification Body and Title is required when a Notification URL is used"
|
msgid "Notification Body and Title is required when a Notification URL is used"
|
||||||
msgstr ""
|
msgstr "Le corps et le titre de la notification sont requis lorsqu'une URL de notification est utilisée"
|
||||||
"Le corps et le titre de la notification sont requis lorsqu'une URL de notification est utiliséeLe corps et le titre "
|
|
||||||
"de la notification sont requis lorsqu'une URL de notification est utilisée"
|
|
||||||
|
|
||||||
#: changedetectionio/forms.py
|
#: changedetectionio/forms.py
|
||||||
#, python-format
|
#, python-format
|
||||||
@@ -2185,11 +2183,11 @@ msgstr "Utilisez les paramètres globaux pour le temps entre la vérification et
|
|||||||
|
|
||||||
#: changedetectionio/forms.py
|
#: changedetectionio/forms.py
|
||||||
msgid "CSS/JSONPath/JQ/XPath Filters"
|
msgid "CSS/JSONPath/JQ/XPath Filters"
|
||||||
msgstr "Filtre CSS/xPath"
|
msgstr "Filtre CSS/JSONPath/JQ/XPath"
|
||||||
|
|
||||||
#: changedetectionio/forms.py
|
#: changedetectionio/forms.py
|
||||||
msgid "Remove elements"
|
msgid "Remove elements"
|
||||||
msgstr "Sélectionner par élément"
|
msgstr "Supprimer par élément"
|
||||||
|
|
||||||
#: changedetectionio/forms.py
|
#: changedetectionio/forms.py
|
||||||
msgid "Extract text"
|
msgid "Extract text"
|
||||||
@@ -2337,7 +2335,7 @@ msgstr "URL du proxy"
|
|||||||
|
|
||||||
#: changedetectionio/forms.py
|
#: changedetectionio/forms.py
|
||||||
msgid "Proxy URLs must start with http://, https:// or socks5://"
|
msgid "Proxy URLs must start with http://, https:// or socks5://"
|
||||||
msgstr "Les URL proxy doivent commencer par http://, https:// ou chaussettes5://"
|
msgstr "Les URL proxy doivent commencer par http://, https:// ou socks5://"
|
||||||
|
|
||||||
#: changedetectionio/forms.py
|
#: changedetectionio/forms.py
|
||||||
msgid "Browser connection URL"
|
msgid "Browser connection URL"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
BIN
changedetectionio/translations/uk/LC_MESSAGES/messages.mo
Normal file
BIN
changedetectionio/translations/uk/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
3026
changedetectionio/translations/uk/LC_MESSAGES/messages.po
Normal file
3026
changedetectionio/translations/uk/LC_MESSAGES/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -61,7 +61,9 @@ def normalize_url_encoding(url):
|
|||||||
def is_private_hostname(hostname):
|
def is_private_hostname(hostname):
|
||||||
"""Return True if hostname resolves to an IANA-restricted (private/reserved) IP address.
|
"""Return True if hostname resolves to an IANA-restricted (private/reserved) IP address.
|
||||||
|
|
||||||
Fails closed: unresolvable hostnames return True (block them).
|
Unresolvable hostnames return False (allow them) — DNS may be temporarily unavailable
|
||||||
|
or the domain not yet live. The actual DNS rebinding attack is mitigated by fetch-time
|
||||||
|
re-validation in requests.py, not by blocking unresolvable domains at add-time.
|
||||||
Never cached — callers that need fresh DNS resolution (e.g. at fetch time) can call
|
Never cached — callers that need fresh DNS resolution (e.g. at fetch time) can call
|
||||||
this directly without going through the lru_cached is_safe_valid_url().
|
this directly without going through the lru_cached is_safe_valid_url().
|
||||||
"""
|
"""
|
||||||
@@ -69,13 +71,15 @@ def is_private_hostname(hostname):
|
|||||||
for info in socket.getaddrinfo(hostname, None):
|
for info in socket.getaddrinfo(hostname, None):
|
||||||
ip = ipaddress.ip_address(info[4][0])
|
ip = ipaddress.ip_address(info[4][0])
|
||||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||||
|
logger.warning(f"Hostname '{hostname} - {ip} - ip.is_private = {ip.is_private}, ip.is_loopback = {ip.is_loopback}, ip.is_link_local = {ip.is_link_local}, ip.is_reserved = {ip.is_reserved}")
|
||||||
return True
|
return True
|
||||||
except socket.gaierror:
|
except socket.gaierror as e:
|
||||||
return True
|
logger.warning(f"{hostname} error checking {str(e)}")
|
||||||
|
return False
|
||||||
|
logger.info(f"Hostname '{hostname}' is NOT private/IANA restricted.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=10000)
|
|
||||||
def is_safe_valid_url(test_url):
|
def is_safe_valid_url(test_url):
|
||||||
from changedetectionio import strtobool
|
from changedetectionio import strtobool
|
||||||
from changedetectionio.jinja2_custom import render as jinja_render
|
from changedetectionio.jinja2_custom import render as jinja_render
|
||||||
@@ -138,12 +142,4 @@ def is_safe_valid_url(test_url):
|
|||||||
logger.warning(f'URL f"{test_url}" failed validation, aborting.')
|
logger.warning(f'URL f"{test_url}" failed validation, aborting.')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Block IANA-restricted (private/reserved) IP addresses unless explicitly allowed.
|
|
||||||
# This is an add-time check; fetch-time re-validation in requests.py handles DNS rebinding.
|
|
||||||
if not strtobool(os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')):
|
|
||||||
parsed = urlparse(test_url)
|
|
||||||
if parsed.hostname and is_private_hostname(parsed.hostname):
|
|
||||||
logger.warning(f'URL "{test_url}" resolves to a private/reserved IP address, aborting.')
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ tags:
|
|||||||
Retrieve system status and statistics about your changedetection.io instance, including total watch
|
Retrieve system status and statistics about your changedetection.io instance, including total watch
|
||||||
counts, uptime information, and version details.
|
counts, uptime information, and version details.
|
||||||
|
|
||||||
|
- name: Plugin API Extensions
|
||||||
|
description: |
|
||||||
|
Retrieve the live OpenAPI specification for this instance. Unlike the static spec, this endpoint
|
||||||
|
returns the fully merged spec including schemas for any processor plugins installed on this instance.
|
||||||
|
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
ApiKeyAuth:
|
ApiKeyAuth:
|
||||||
@@ -1905,3 +1910,27 @@ paths:
|
|||||||
tag_count: 5
|
tag_count: 5
|
||||||
uptime: "2 days, 3:45:12"
|
uptime: "2 days, 3:45:12"
|
||||||
version: "0.50.10"
|
version: "0.50.10"
|
||||||
|
|
||||||
|
/full-spec:
|
||||||
|
get:
|
||||||
|
operationId: getFullApiSpec
|
||||||
|
tags: [Plugin API Extensions]
|
||||||
|
summary: Get full live API spec
|
||||||
|
description: |
|
||||||
|
Return the fully merged OpenAPI specification for this instance.
|
||||||
|
|
||||||
|
Unlike the static `api-spec.yaml` shipped with the application, this endpoint returns the
|
||||||
|
spec dynamically merged with any `api.yaml` schemas provided by installed processor plugins.
|
||||||
|
Use this URL with Swagger UI or Redoc to get accurate documentation for your specific install.
|
||||||
|
security: []
|
||||||
|
x-code-samples:
|
||||||
|
- lang: 'curl'
|
||||||
|
source: |
|
||||||
|
curl -X GET "http://localhost:5000/api/v1/full-spec"
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Merged OpenAPI specification in YAML format
|
||||||
|
content:
|
||||||
|
application/yaml:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ requests-file
|
|||||||
chardet>2.3.0
|
chardet>2.3.0
|
||||||
|
|
||||||
wtforms~=3.2
|
wtforms~=3.2
|
||||||
jsonpath-ng~=1.7.0
|
jsonpath-ng~=1.8.0
|
||||||
|
|
||||||
# Fast JSON serialization for better performance
|
# Fast JSON serialization for better performance
|
||||||
orjson~=3.11
|
orjson~=3.11
|
||||||
|
|||||||
Reference in New Issue
Block a user