Merge branch 'master' into browser-ui

This commit is contained in:
dgtlmoon committed 2026-09-18 15:18:41 +02:00
commit 51d2e9101b
5 files changed
+92 -10

No files matched your search

+18 -1
View File
@@ -9,6 +9,10 @@ import json
# Number of URLs above which import switches to background processing
IMPORT_SWITCH_TO_BACKGROUND_THRESHOLD = 20
# Query params whose accepted values are wider than the `enum:` in the spec and are checked
# separately further down (plugin processors aren't listed in the static spec enum).
ENUM_VALIDATED_ELSEWHERE = {'processor'}
def default_content_type(content_type='text/plain'):
"""Decorator to set a default Content-Type header if none is provided."""
@@ -143,10 +147,23 @@ class Import(Resource):
# Convert to appropriate type based on schema
try:
converted_value = convert_query_param_to_type(param_value, schema_properties[param_name])
extras[param_name] = converted_value
except (ValueError, json.JSONDecodeError) as e:
return f"Invalid value for parameter '{param_name}': {str(e)}", 400
# Enforce any `enum:` declared in the OpenAPI spec (notification_format, method,
# conditions_match_logic ...). /api/v1/watch gets this for free because its JSON body is
# unmarshalled against the spec, but the import query params never were - so something
# like ?notification_format=Text used to be stored verbatim and then raise
# "Invalid notification format" at notification-send time, long after the import.
# `processor` is exempt: plugin processors are legal but aren't in the static spec enum,
# it has its own check against available_processors() below.
allowed_values = schema_properties[param_name].get('enum')
if allowed_values and param_name not in ENUM_VALIDATED_ELSEWHERE and converted_value not in allowed_values:
return (f"Invalid value for parameter '{param_name}': '{param_value}'. "
f"Must be one of: {', '.join(str(v) for v in allowed_values)}"), 400
extras[param_name] = converted_value
# Validate processor if provided
if 'processor' in extras:
from changedetectionio.processors import available_processors
@@ -12,6 +12,11 @@ $action-sidebar-content-slot: 1100px;
// Desktop left rail — fixed positioning frame, full-height, but only the
// inner item-list block captures hover. Empty area is pointer-events:none so
// the top header stays clickable behind it.
.action-sidebar-inner {
background-color: var(--color-background-page); // fallback under the gradient
background-attachment: fixed, fixed;
}
.action-sidebar {
display: flex;
flex-direction: column;
@@ -25,7 +30,7 @@ $action-sidebar-content-slot: 1100px;
position: sticky;
top: 0;
height: 100vh;
background: rgba(255, 255, 255, 0.05);
z-index: 60;
pointer-events: none; // empty space doesn't intercept clicks/hovers
@media only screen and (max-width: $desktop-wide-breakpoint) {
@@ -89,8 +94,6 @@ $action-sidebar-content-slot: 1100px;
left: 0;
bottom: 0;
z-index: 1;
background-color: var(--color-background-page); // fallback under the gradient
background-attachment: fixed, fixed;
}
// Hover-to-expand only fires in minimal mode, and not for 'minimal' (no-expand).
@@ -468,10 +471,17 @@ body.actionside-bar-on {
padding-left: $common-gap;
}
.actionsidebar-expanded {
#logo-short {
display: none;
}
}
.actionsidebar-minimal {
#checking-now-stats-sidebar {
display: none;
}
#cdio-logo {
#logo-expanded {
display: none;
@@ -479,16 +489,15 @@ body.actionside-bar-on {
}
&.actionsidebar-expanded {
#checking-now-stats-sidebar{
#checking-now-stats-sidebar {
display: block;
}
#cdio-logo {
#logo-expanded {
display: inline-block;
}
#logo-short {
display: none;
}
}
}
+1 -1
View File
@@ -1 +1 @@
.header{color:var(--color-text-menu-heading)}.header a{color:var(--color-text-menu-heading)}.header .bare-btn{color:var(--color-text-menu-heading)}ul#top-right-menu{list-style:none;margin-left:auto;padding:0;margin-top:0;margin-right:0;margin-bottom:0;display:grid;gap:1.1rem;grid-auto-flow:column;grid-auto-columns:max-content;align-items:center}ul#top-right-menu .toggle-button{padding:0}.current-diff-url{flex:1 1 auto;min-width:0;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;white-space:nowrap;text-align:left;margin:0 .55rem;-webkit-mask-image:linear-gradient(to right, #000 calc(100% - 2.5em), transparent);mask-image:linear-gradient(to right, #000 calc(100% - 2.5em), transparent)}.current-diff-url::-webkit-scrollbar{display:none}.current-diff-url.js-fades{-webkit-mask-image:none;mask-image:none}.current-diff-url.js-fades.fade-right{-webkit-mask-image:linear-gradient(to right, #000 calc(100% - 2.5em), transparent);mask-image:linear-gradient(to right, #000 calc(100% - 2.5em), transparent)}.current-diff-url.js-fades.fade-left{-webkit-mask-image:linear-gradient(to right, transparent, #000 2.5em);mask-image:linear-gradient(to right, transparent, #000 2.5em)}.current-diff-url.js-fades.fade-left.fade-right{-webkit-mask-image:linear-gradient(to right, transparent, #000 2.5em, #000 calc(100% - 2.5em), transparent);mask-image:linear-gradient(to right, transparent, #000 2.5em, #000 calc(100% - 2.5em), transparent)}.current-diff-url span{overflow:visible;white-space:nowrap}.pure-menu-horizontal{padding:.55rem;display:flex;justify-content:space-between;align-items:center}.pure-menu-horizontal svg{height:1.3rem}.fi{height:1.3rem;cursor:pointer}#pure-menu-horizontal-spinner{height:2px;background:linear-gradient(-75deg, #ff6000, #ff8f00, #ffdd00, #ed0000);background-size:400% 400%;animation:gradient 200s ease infinite;opacity:.8;position:fixed;bottom:0;left:0;width:100%;z-index:100;pointer-events:none}.status-pill{display:inline-flex;align-items:center;gap:8px;height:30px;padding:0 12px;border-radius:var(--common-round-border);border:1px solid hsla(0,0%,100%,.25);background:hsla(0,0%,100%,.1);color:var(--color-text-menu-heading);font-size:.78rem;font-weight:600;white-space:nowrap;text-decoration:none;font-family:inherit;cursor:pointer}.status-pill:hover{background:hsla(0,0%,100%,.18)}.status-pill .live-dot{width:8px;height:8px;border-radius:50%;background:#42dd53;box-shadow:0 0 0 3px rgba(66,221,83,.3);animation:status-pill-pulse 2s infinite}.status-pill.paused .live-dot{background:#e83d3d;box-shadow:0 0 0 3px rgba(232,61,61,.3);animation:none;width:12px;height:12px}.status-pill .action-icon{width:16px;height:16px}.status-pill.muted{opacity:.8}.status-pill.muted .action-icon{color:#e8a33d}@keyframes status-pill-pulse{0%,100%{opacity:1}50%{opacity:.4}}.menu-pop-wrap{position:relative}.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;border:0;border-radius:var(--common-round-border);background:rgba(0,0,0,0);color:var(--color-text-menu-heading);cursor:pointer}.icon-btn svg{width:18px;height:18px;fill:none;stroke:currentColor}.icon-btn:hover{background:hsla(0,0%,100%,.14)}.menu-pop{display:none;position:absolute;top:calc(100% + 8px);right:0;min-width:220px;padding:6px;border:1px solid var(--color-border-table-cell);border-radius:12px;background:var(--color-background);box-shadow:0 18px 50px rgba(0,0,0,.18);z-index:80}.menu-pop.open{display:block}.menu-pop .mi{display:flex;align-items:center;gap:10px;width:100%;padding:9px 10px;border:0;border-radius:8px;background:rgba(0,0,0,0);color:var(--color-text);font-size:.85rem;text-align:left;text-decoration:none;white-space:nowrap;cursor:pointer}.menu-pop .mi:hover{background:var(--color-background-menu-link-hover)}.menu-pop .mi .ico{display:inline-flex;color:var(--color-text-input-description)}.menu-pop .mi .ico svg{width:16px;height:16px;fill:none;stroke:currentColor}.menu-pop .mi .right{margin-left:auto;font-size:.75rem;color:var(--color-text-input-description)}.top-menu-list .action-label{color:var(--color-text-menu-heading)}@media only screen and (max-width: 768px){.top-menu-list .action-label{display:none}}#inline-menu-extras-group{list-style:none;margin:0;padding:0;display:grid;gap:.55rem;grid-auto-flow:column;grid-auto-columns:max-content;align-items:center}@media only screen and (max-width: 980px){#nav-menu #menu-settings span{display:none}}:root{--body-main-text-size: 0.9rem;--color-white: #fff;--color-grey-50: #111;--color-grey-100: #262626;--color-grey-200: #333;--color-grey-300: #444;--color-grey-325: #555;--color-grey-350: #565d64;--color-grey-400: #666;--color-grey-500: #777;--color-grey-600: #999;--color-grey-700: #cbcbcb;--color-grey-750: #ddd;--color-grey-800: #e0e0e0;--color-grey-850: #eee;--color-grey-900: #f2f2f2;--color-black: #000;--color-dark-red: #a00;--color-light-red: #dd0000;--color-background-page: var(--color-grey-100);--color-background-gradient-first: #5ad8f7;--color-background-gradient-second: #2f5Line truncated
.header{color:var(--color-text-menu-heading)}.header a{color:var(--color-text-menu-heading)}.header .bare-btn{color:var(--color-text-menu-heading)}ul#top-right-menu{list-style:none;margin-left:auto;padding:0;margin-top:0;margin-right:0;margin-bottom:0;display:grid;gap:1.1rem;grid-auto-flow:column;grid-auto-columns:max-content;align-items:center}ul#top-right-menu .toggle-button{padding:0}.current-diff-url{flex:1 1 auto;min-width:0;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;white-space:nowrap;text-align:left;margin:0 .55rem;-webkit-mask-image:linear-gradient(to right, #000 calc(100% - 2.5em), transparent);mask-image:linear-gradient(to right, #000 calc(100% - 2.5em), transparent)}.current-diff-url::-webkit-scrollbar{display:none}.current-diff-url.js-fades{-webkit-mask-image:none;mask-image:none}.current-diff-url.js-fades.fade-right{-webkit-mask-image:linear-gradient(to right, #000 calc(100% - 2.5em), transparent);mask-image:linear-gradient(to right, #000 calc(100% - 2.5em), transparent)}.current-diff-url.js-fades.fade-left{-webkit-mask-image:linear-gradient(to right, transparent, #000 2.5em);mask-image:linear-gradient(to right, transparent, #000 2.5em)}.current-diff-url.js-fades.fade-left.fade-right{-webkit-mask-image:linear-gradient(to right, transparent, #000 2.5em, #000 calc(100% - 2.5em), transparent);mask-image:linear-gradient(to right, transparent, #000 2.5em, #000 calc(100% - 2.5em), transparent)}.current-diff-url span{overflow:visible;white-space:nowrap}.pure-menu-horizontal{padding:.55rem;display:flex;justify-content:space-between;align-items:center}.pure-menu-horizontal svg{height:1.3rem}.fi{height:1.3rem;cursor:pointer}#pure-menu-horizontal-spinner{height:2px;background:linear-gradient(-75deg, #ff6000, #ff8f00, #ffdd00, #ed0000);background-size:400% 400%;animation:gradient 200s ease infinite;opacity:.8;position:fixed;bottom:0;left:0;width:100%;z-index:100;pointer-events:none}.status-pill{display:inline-flex;align-items:center;gap:8px;height:30px;padding:0 12px;border-radius:var(--common-round-border);border:1px solid hsla(0,0%,100%,.25);background:hsla(0,0%,100%,.1);color:var(--color-text-menu-heading);font-size:.78rem;font-weight:600;white-space:nowrap;text-decoration:none;font-family:inherit;cursor:pointer}.status-pill:hover{background:hsla(0,0%,100%,.18)}.status-pill .live-dot{width:8px;height:8px;border-radius:50%;background:#42dd53;box-shadow:0 0 0 3px rgba(66,221,83,.3);animation:status-pill-pulse 2s infinite}.status-pill.paused .live-dot{background:#e83d3d;box-shadow:0 0 0 3px rgba(232,61,61,.3);animation:none;width:12px;height:12px}.status-pill .action-icon{width:16px;height:16px}.status-pill.muted{opacity:.8}.status-pill.muted .action-icon{color:#e8a33d}@keyframes status-pill-pulse{0%,100%{opacity:1}50%{opacity:.4}}.menu-pop-wrap{position:relative}.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;border:0;border-radius:var(--common-round-border);background:rgba(0,0,0,0);color:var(--color-text-menu-heading);cursor:pointer}.icon-btn svg{width:18px;height:18px;fill:none;stroke:currentColor}.icon-btn:hover{background:hsla(0,0%,100%,.14)}.menu-pop{display:none;position:absolute;top:calc(100% + 8px);right:0;min-width:220px;padding:6px;border:1px solid var(--color-border-table-cell);border-radius:12px;background:var(--color-background);box-shadow:0 18px 50px rgba(0,0,0,.18);z-index:80}.menu-pop.open{display:block}.menu-pop .mi{display:flex;align-items:center;gap:10px;width:100%;padding:9px 10px;border:0;border-radius:8px;background:rgba(0,0,0,0);color:var(--color-text);font-size:.85rem;text-align:left;text-decoration:none;white-space:nowrap;cursor:pointer}.menu-pop .mi:hover{background:var(--color-background-menu-link-hover)}.menu-pop .mi .ico{display:inline-flex;color:var(--color-text-input-description)}.menu-pop .mi .ico svg{width:16px;height:16px;fill:none;stroke:currentColor}.menu-pop .mi .right{margin-left:auto;font-size:.75rem;color:var(--color-text-input-description)}.top-menu-list .action-label{color:var(--color-text-menu-heading)}@media only screen and (max-width: 768px){.top-menu-list .action-label{display:none}}#inline-menu-extras-group{list-style:none;margin:0;padding:0;display:grid;gap:.55rem;grid-auto-flow:column;grid-auto-columns:max-content;align-items:center}@media only screen and (max-width: 980px){#nav-menu #menu-settings span{display:none}}:root{--body-main-text-size: 0.9rem;--color-white: #fff;--color-grey-50: #111;--color-grey-100: #262626;--color-grey-200: #333;--color-grey-300: #444;--color-grey-325: #555;--color-grey-350: #565d64;--color-grey-400: #666;--color-grey-500: #777;--color-grey-600: #999;--color-grey-700: #cbcbcb;--color-grey-750: #ddd;--color-grey-800: #e0e0e0;--color-grey-850: #eee;--color-grey-900: #f2f2f2;--color-black: #000;--color-dark-red: #a00;--color-light-red: #dd0000;--color-background-page: var(--color-grey-100);--color-background-gradient-first: #5ad8f7;--color-background-gradient-second: #2f5Line truncated
@@ -56,6 +56,62 @@ def test_openapi_merged_spec_contains_restock_fields():
f"WatchBase.processor_config_restock_diff should $ref the schema, got: {ref}"
def test_openapi_notification_format_enum_matches_code():
"""
Unit test: the notification_format enum in api-spec.yaml must stay in step with
valid_notification_formats, otherwise the API either rejects a format the UI offers or
accepts one that blows up when the notification object is built.
"""
from changedetectionio.api import build_merged_spec_dict
from changedetectionio.notification import valid_notification_formats
spec = build_merged_spec_dict()
spec_enum = spec['components']['schemas']['WatchBase']['properties']['notification_format'].get('enum')
assert spec_enum is not None, "notification_format must declare an enum in api-spec.yaml"
assert set(spec_enum) == set(valid_notification_formats.keys()), \
(f"api-spec.yaml notification_format enum {spec_enum} does not match "
f"valid_notification_formats {list(valid_notification_formats.keys())}")
def test_openapi_import_rejects_invalid_enum_query_param(client, live_server, measure_memory_usage, datastore_path):
"""
/api/v1/import takes watch config as query params - those must honour the `enum:` in the spec.
'Text' is the classic one: it was the display name in an old release, so people still pass it,
and a stored 'Text' makes every notification for that watch raise ValueError at send time.
"""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
res = client.post(
url_for("import") + "?notification_format=Text",
data='https://website1.com',
headers={'x-api-key': api_key, 'content-type': 'text/plain'},
)
assert res.status_code == 400, f"Expected 400 but got {res.status_code}"
assert b'notification_format' in res.data
assert not live_server.app.config['DATASTORE'].data['watching'], "Nothing should have been imported"
# Another enum field on the same code path
res = client.post(
url_for("import") + "?method=FETCH",
data='https://website1.com',
headers={'x-api-key': api_key, 'content-type': 'text/plain'},
)
assert res.status_code == 400, f"Expected 400 but got {res.status_code}"
# ...and a valid value still imports and is stored
res = client.post(
url_for("import") + "?notification_format=htmlcolor",
data='https://website1.com',
headers={'x-api-key': api_key, 'content-type': 'text/plain'},
)
assert res.status_code == 200, f"Expected 200 but got {res.status_code}"
watch = live_server.app.config['DATASTORE'].data['watching'][res.json[0]]
assert watch.get('notification_format') == 'htmlcolor'
delete_all_watches(client)
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."""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
+1 -1
View File
@@ -25,7 +25,7 @@ requests-file
# urllib3==1.26.19 # Unpinned - let requests decide compatible version
# If specific version needed for security, use urllib3>=1.26.19,<3.0
chardet>2.3.0
chardet>=7.6.0
wtforms~=3.2
jsonpath-ng~=1.8.0