mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-19 21:22:50 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
646bb2896a |
@@ -36,8 +36,8 @@ else
|
||||
lefile="/config/tls/fullchain.pem"
|
||||
fi
|
||||
|
||||
tls_enabled=`python3 -I /usr/local/nginx/get_nginx_settings.py | jq -r .tls.enabled`
|
||||
listen_external_port=`python3 -I /usr/local/nginx/get_nginx_settings.py | jq -r .listen.external_port`
|
||||
tls_enabled=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .tls.enabled`
|
||||
listen_external_port=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .listen.external_port`
|
||||
|
||||
while true
|
||||
do
|
||||
|
||||
@@ -11,13 +11,30 @@ if [[ "$(id -u)" -eq 0 ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# /root survives s6-setuidgid and breaks cache writes after the drop; set
|
||||
# before opt_in_out so the opt-out marker lands where the service will look
|
||||
if [[ "$runs_as_root" -eq 0 ]]; then
|
||||
export HOME=/config
|
||||
fi
|
||||
|
||||
# detector runtimes installed at first start (pip install --user) live under
|
||||
# $HOME/.local; the dynamic loader only reads LD_LIBRARY_PATH at exec time
|
||||
export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+${LD_LIBRARY_PATH}:}${HOME}/.local/lib"
|
||||
export PATH="${PATH}:${HOME}/.local/bin"
|
||||
|
||||
# opt out of openvino telemetry
|
||||
if [ -e /usr/local/bin/opt_in_out ]; then
|
||||
/usr/local/bin/opt_in_out --opt_out > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
# Logs should be sent to stdout so that s6 can collect them
|
||||
|
||||
# Tell S6-Overlay not to restart this service
|
||||
s6-svc -O .
|
||||
|
||||
# Runs as root with root's HOME and LD_LIBRARY_PATH; -I skips user site-packages
|
||||
function set_libva_version() {
|
||||
local ffmpeg_path
|
||||
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py)
|
||||
ffmpeg_path=$(python3 /usr/local/ffmpeg/get_ffmpeg_path.py)
|
||||
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
|
||||
export LIBAVFORMAT_VERSION_MAJOR
|
||||
}
|
||||
@@ -25,32 +42,14 @@ function set_libva_version() {
|
||||
echo "[INFO] Preparing Frigate..."
|
||||
set_libva_version
|
||||
|
||||
# HOME=/root survives s6-setuidgid, so any run that is not root gets
|
||||
# HOME=/config explicitly. The runtime user owns /config, so these paths go
|
||||
# only to the service process, never to root. Detector runtimes installed at
|
||||
# first start (pip install --user) live under $HOME/.local.
|
||||
run_as=()
|
||||
runtime_home="${HOME}"
|
||||
if [[ "$runs_as_root" -eq 0 ]]; then
|
||||
runtime_home=/config
|
||||
if [[ "$(id -u)" -eq 0 ]]; then
|
||||
run_as=(s6-setuidgid frigate)
|
||||
fi
|
||||
fi
|
||||
run_as+=(env
|
||||
"HOME=${runtime_home}"
|
||||
"LD_LIBRARY_PATH=${LD_LIBRARY_PATH:+${LD_LIBRARY_PATH}:}${runtime_home}/.local/lib"
|
||||
"PATH=${PATH}:${runtime_home}/.local/bin")
|
||||
|
||||
# opt out of openvino telemetry; the marker lands under the service's HOME
|
||||
if [ -e /usr/local/bin/opt_in_out ]; then
|
||||
"${run_as[@]}" /usr/local/bin/opt_in_out --opt_out > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "[INFO] Starting Frigate..."
|
||||
|
||||
cd /opt/frigate || echo "[ERROR] Failed to change working directory to /opt/frigate"
|
||||
|
||||
# Replace the bash process with the Frigate process, redirecting stderr to stdout
|
||||
exec 2>&1
|
||||
exec "${run_as[@]}" python3 -u -m frigate
|
||||
if [[ "$(id -u)" -ne 0 || "$runs_as_root" -eq 1 ]]; then
|
||||
exec python3 -u -m frigate
|
||||
else
|
||||
exec s6-setuidgid frigate python3 -u -m frigate
|
||||
fi
|
||||
|
||||
@@ -59,33 +59,38 @@ function get_ip_and_port_from_supervisor() {
|
||||
|
||||
function set_libva_version() {
|
||||
local ffmpeg_path
|
||||
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py)
|
||||
ffmpeg_path=$(python3 /usr/local/ffmpeg/get_ffmpeg_path.py)
|
||||
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
|
||||
export LIBAVFORMAT_VERSION_MAJOR
|
||||
}
|
||||
|
||||
set_libva_version
|
||||
|
||||
# create_config.py creates the file exclusively and fails if anything
|
||||
# reappears at this path
|
||||
rm -f /dev/shm/go2rtc.yaml
|
||||
|
||||
echo "[INFO] Preparing new go2rtc config..."
|
||||
|
||||
if [[ -n "${SUPERVISOR_TOKEN:-}" ]]; then
|
||||
# Running as a Home Assistant Add-on, infer the IP address and port
|
||||
get_ip_and_port_from_supervisor
|
||||
if [[ -f "/dev/shm/go2rtc.yaml" ]]; then
|
||||
echo "[INFO] Removing stale config from last run..."
|
||||
rm /dev/shm/go2rtc.yaml
|
||||
fi
|
||||
|
||||
python3 -I /usr/local/go2rtc/create_config.py
|
||||
if [[ ! -f "/dev/shm/go2rtc.yaml" ]]; then
|
||||
echo "[INFO] Preparing new go2rtc config..."
|
||||
|
||||
if [[ -n "${SUPERVISOR_TOKEN:-}" ]]; then
|
||||
# Running as a Home Assistant Add-on, infer the IP address and port
|
||||
get_ip_and_port_from_supervisor
|
||||
fi
|
||||
|
||||
python3 /usr/local/go2rtc/create_config.py
|
||||
else
|
||||
echo "[WARNING] Unable to remove existing go2rtc config. Changes made to your frigate config file may not be recognized. Please remove the /dev/shm/go2rtc.yaml from your docker host manually."
|
||||
fi
|
||||
|
||||
# HomeKit persistence. The helper is symlink-safe; hand off to go2rtc only when dropping.
|
||||
readonly homekit_config_path="/config/go2rtc_homekit.yml"
|
||||
if [[ "$(id -u)" -eq 0 && "$runs_as_root" -eq 0 ]]; then
|
||||
python3 -I /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}" --chown
|
||||
chown -h go2rtc:go2rtc /dev/shm/go2rtc.yaml 2>/dev/null || true
|
||||
python3 /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}" --chown
|
||||
chown go2rtc:go2rtc /dev/shm/go2rtc.yaml 2>/dev/null || true
|
||||
else
|
||||
python3 -I /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}"
|
||||
python3 /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}"
|
||||
fi
|
||||
|
||||
readonly config_path="/config"
|
||||
|
||||
@@ -124,7 +124,7 @@ fi
|
||||
mkdir -p /etc/letsencrypt/www 2>/dev/null || true
|
||||
|
||||
# nginx settings are read once; both templates consume them
|
||||
nginx_settings=$(python3 -I /usr/local/nginx/get_nginx_settings.py)
|
||||
nginx_settings=$(python3 /usr/local/nginx/get_nginx_settings.py)
|
||||
|
||||
# build templates for optional FRIGATE_BASE_PATH environment variable
|
||||
echo "$nginx_settings" | \
|
||||
|
||||
@@ -198,13 +198,9 @@ if config.get("birdseye", {}).get("restream", False):
|
||||
else:
|
||||
go2rtc_config["streams"] = {"birdseye": ffmpeg_cmd}
|
||||
|
||||
# The run script removes the old file first. Exclusive create never follows a
|
||||
# link the go2rtc user planted in sticky /dev/shm. The config contains camera
|
||||
# credentials, so it is not world-readable.
|
||||
fd = os.open(
|
||||
"/dev/shm/go2rtc.yaml",
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o640,
|
||||
)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
# Write go2rtc_config to /dev/shm/go2rtc.yaml
|
||||
with open("/dev/shm/go2rtc.yaml", "w") as f:
|
||||
yaml.dump(go2rtc_config, f)
|
||||
|
||||
# config contains camera credentials; do not leave it world-readable
|
||||
os.chmod("/dev/shm/go2rtc.yaml", 0o640)
|
||||
|
||||
@@ -19,7 +19,6 @@ location /auth {
|
||||
# this cannot be spoofed
|
||||
proxy_set_header X-Go2rtc-Stream-Proxy $go2rtc_stream_proxy;
|
||||
proxy_set_header X-Server-Port $server_port;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Content-Length "";
|
||||
# Pass along auth related info
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
sys.path.insert(0, "/opt/frigate")
|
||||
from frigate.util.config import find_config_file, parse_listen_address
|
||||
from frigate.util.config import find_config_file
|
||||
|
||||
sys.path.remove("/opt/frigate")
|
||||
|
||||
@@ -35,29 +35,20 @@ ipv6_config: dict[str, Any] = networking_config.get("ipv6", {})
|
||||
ipv6_config.setdefault("enabled", False)
|
||||
|
||||
listen_config: dict[str, Any] = networking_config.get("listen", {})
|
||||
listen_config.setdefault("internal", 5000)
|
||||
listen_config.setdefault("external", 8971)
|
||||
|
||||
# Frigate's own validation runs elsewhere, and a bad value here must not stop nginx
|
||||
# from serving the safe-mode UI. Valid values are kept so a fallback never widens
|
||||
# the unauthenticated internal listener beyond the configured address.
|
||||
for key, default in (("internal", 5000), ("external", 8971)):
|
||||
try:
|
||||
value, port = parse_listen_address(listen_config.get(key, default))
|
||||
except ValueError:
|
||||
print(
|
||||
f"[WARN] Invalid networking.listen.{key}, using {default}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
value, port = default, default
|
||||
# handle case where internal port is a string with ip:port
|
||||
internal_port = listen_config["internal"]
|
||||
if type(internal_port) is str:
|
||||
internal_port = int(internal_port.split(":")[-1])
|
||||
listen_config["internal_port"] = internal_port
|
||||
|
||||
listen_config[key] = value
|
||||
listen_config[f"{key}_port"] = port
|
||||
|
||||
if listen_config["internal_port"] == listen_config["external_port"]:
|
||||
print(
|
||||
"[ERROR] networking.listen.internal and external share a port",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
# handle case where external port is a string with ip:port
|
||||
external_port = listen_config["external"]
|
||||
if type(external_port) is str:
|
||||
external_port = int(external_port.split(":")[-1])
|
||||
listen_config["external_port"] = external_port
|
||||
|
||||
base_path = os.environ.get("FRIGATE_BASE_PATH", "")
|
||||
|
||||
|
||||
@@ -117,9 +117,7 @@ auth:
|
||||
# Optional: Cookie to store the JWT token for native auth (default: shown below)
|
||||
cookie_name: frigate_token
|
||||
# Optional: Set secure flag on cookie. (default: shown below)
|
||||
# NOTE: Frigate sets the flag automatically when serving TLS. Set this to True
|
||||
# when a reverse proxy terminates TLS in front of Frigate. A proxy that connects
|
||||
# to the TLS port must serve Frigate to browsers over HTTPS
|
||||
# NOTE: This should be set to True if you are using TLS
|
||||
cookie_secure: False
|
||||
# Optional: Session length in seconds (default: shown below)
|
||||
session_length: 86400 # 24 hours
|
||||
|
||||
+33
-172
@@ -61,7 +61,6 @@ from frigate.util.builtin import (
|
||||
clean_camera_user_pass,
|
||||
deep_merge,
|
||||
flatten_config_data,
|
||||
is_masked_camera_path,
|
||||
load_labels,
|
||||
process_config_query_string,
|
||||
split_config_key_path,
|
||||
@@ -72,7 +71,6 @@ from frigate.util.config import (
|
||||
find_config_file,
|
||||
redact_credential,
|
||||
)
|
||||
from frigate.util.env import substitute_frigate_vars
|
||||
from frigate.util.live_streams import (
|
||||
generated_transcode_streams,
|
||||
sync_transcode_streams,
|
||||
@@ -404,8 +402,6 @@ def config(request: Request):
|
||||
|
||||
config["go2rtc"]["streams"][stream_name] = cleaned
|
||||
|
||||
_redact_go2rtc_credentials(config["go2rtc"])
|
||||
|
||||
config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()}
|
||||
|
||||
for index, model in enumerate(config_obj.models):
|
||||
@@ -432,28 +428,6 @@ def config(request: Request):
|
||||
return JSONResponse(content=config)
|
||||
|
||||
|
||||
def _redact_go2rtc_credentials(go2rtc: dict[str, Any]) -> None:
|
||||
"""Redact go2rtc secrets outside of stream URLs, in place."""
|
||||
for section in ("rtsp", "api", "mqtt"):
|
||||
if isinstance(go2rtc.get(section), dict):
|
||||
redact_credential(go2rtc[section], "username")
|
||||
redact_credential(go2rtc[section], "password")
|
||||
|
||||
if isinstance(go2rtc.get("ngrok"), dict):
|
||||
redact_credential(go2rtc["ngrok"], "command")
|
||||
|
||||
if isinstance(go2rtc.get("homekit"), dict):
|
||||
for accessory in go2rtc["homekit"].values():
|
||||
if isinstance(accessory, dict):
|
||||
for key in ("pin", "device_private", "pairings"):
|
||||
redact_credential(accessory, key)
|
||||
|
||||
# publish destinations carry stream keys in the URL path
|
||||
if isinstance(go2rtc.get("publish"), dict):
|
||||
for name in list(go2rtc["publish"]):
|
||||
redact_credential(go2rtc["publish"], name)
|
||||
|
||||
|
||||
@router.get("/profiles", dependencies=[Depends(allow_any_authenticated())])
|
||||
def get_profiles(request: Request):
|
||||
"""List all available profiles and the currently active profile."""
|
||||
@@ -768,24 +742,17 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
||||
)
|
||||
|
||||
|
||||
def _restore_masked_camera_paths(config_data: dict, saved: dict) -> str | None:
|
||||
"""Substitute incoming masked camera paths with the saved ones.
|
||||
def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> None:
|
||||
"""Substitute incoming `*:*` masked credentials with the in-memory ones.
|
||||
|
||||
The /config response masks ffmpeg input credentials, so the settings UI
|
||||
sends the masked path back when sibling fields (e.g. hwaccel_args) are
|
||||
edited. Each masked path is matched to the saved input that masks to the
|
||||
same value, with roles breaking a tie, so deleting or reordering inputs
|
||||
keeps each path's own credentials. The saved path is restored as written,
|
||||
keeping any {FRIGATE_*} placeholder. Mutates `config_data` in place and
|
||||
returns an error message when a masked path has no single match.
|
||||
edited. Without this we'd write `rtsp://*:*@host` into YAML and lose
|
||||
the real credentials. Mutates `config_data` in place.
|
||||
"""
|
||||
cameras = config_data.get("cameras")
|
||||
if not isinstance(cameras, dict):
|
||||
return None
|
||||
|
||||
saved_cameras = saved.get("cameras")
|
||||
if not isinstance(saved_cameras, dict):
|
||||
saved_cameras = {}
|
||||
return
|
||||
|
||||
for camera_name, camera_data in cameras.items():
|
||||
if not isinstance(camera_data, dict):
|
||||
@@ -793,122 +760,20 @@ def _restore_masked_camera_paths(config_data: dict, saved: dict) -> str | None:
|
||||
inputs = camera_data.get("ffmpeg", {}).get("inputs")
|
||||
if not isinstance(inputs, list):
|
||||
continue
|
||||
|
||||
saved_camera = saved_cameras.get(camera_name)
|
||||
saved_ffmpeg = (
|
||||
saved_camera.get("ffmpeg") if isinstance(saved_camera, dict) else None
|
||||
)
|
||||
saved_inputs = [
|
||||
inp
|
||||
for inp in (
|
||||
saved_ffmpeg.get("inputs") if isinstance(saved_ffmpeg, dict) else None
|
||||
)
|
||||
or []
|
||||
if isinstance(inp, dict) and isinstance(inp.get("path"), str)
|
||||
]
|
||||
|
||||
existing = config.cameras.get(camera_name)
|
||||
if existing is None:
|
||||
continue
|
||||
existing_paths = [inp.path for inp in existing.ffmpeg.inputs]
|
||||
for index, input_obj in enumerate(inputs):
|
||||
if not isinstance(input_obj, dict):
|
||||
continue
|
||||
path = input_obj.get("path")
|
||||
if not isinstance(path, str) or not is_masked_camera_path(path):
|
||||
if not isinstance(path, str):
|
||||
continue
|
||||
candidates = [
|
||||
inp
|
||||
for inp in saved_inputs
|
||||
if clean_camera_user_pass(_resolve_frigate_vars(inp["path"])) == path
|
||||
]
|
||||
if len({inp["path"] for inp in candidates}) > 1:
|
||||
candidates = [
|
||||
inp
|
||||
for inp in candidates
|
||||
if inp.get("roles") == input_obj.get("roles")
|
||||
]
|
||||
real_paths = {inp["path"] for inp in candidates}
|
||||
if len(real_paths) != 1:
|
||||
return (
|
||||
f"Input {index} of camera {camera_name} has masked credentials "
|
||||
"that could not be matched; resend it with real credentials"
|
||||
)
|
||||
input_obj["path"] = real_paths.pop()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_frigate_vars(value: str) -> str:
|
||||
"""Resolve {FRIGATE_*} placeholders the way /config shows them."""
|
||||
try:
|
||||
return substitute_frigate_vars(value)
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
|
||||
def _saved_config_from_memory(config: FrigateConfig) -> dict[str, Any]:
|
||||
"""Shape the in-memory camera inputs like the saved YAML."""
|
||||
return {
|
||||
"cameras": {
|
||||
name: {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": inp.path, "roles": [role.value for role in inp.roles]}
|
||||
for inp in camera.ffmpeg.inputs
|
||||
]
|
||||
}
|
||||
}
|
||||
for name, camera in config.cameras.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _config_data_to_updates(
|
||||
config_data: dict, saved: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], str | None]:
|
||||
"""Flatten a /config/set payload after restoring masked credentials.
|
||||
|
||||
`saved` is the config the masked values were taken from. Returns the flat
|
||||
updates, or an error message when a masked value can't be restored.
|
||||
"""
|
||||
error = _restore_masked_camera_paths(config_data, saved)
|
||||
if error:
|
||||
return {}, error
|
||||
|
||||
updates = flatten_config_data(config_data)
|
||||
# Convert None values to empty strings for deletion (e.g., when deleting masks)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
# Drop sentinel-valued fields so untouched credential placeholders don't
|
||||
# clobber the saved YAML value.
|
||||
updates = {k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL}
|
||||
|
||||
error = _find_masked_update(updates)
|
||||
if error:
|
||||
return {}, error
|
||||
|
||||
return updates, None
|
||||
|
||||
|
||||
def _holds_masked_value(value: Any) -> bool:
|
||||
if isinstance(value, str):
|
||||
return REDACTED_CREDENTIAL_SENTINEL in value or is_masked_camera_path(value)
|
||||
if isinstance(value, dict):
|
||||
return any(_holds_masked_value(v) for v in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_holds_masked_value(v) for v in value)
|
||||
return False
|
||||
|
||||
|
||||
def _find_masked_update(updates: dict[str, Any]) -> str | None:
|
||||
"""Return an error naming the first update that still holds a masked value.
|
||||
|
||||
Runs after the restores so a masked path or redaction sentinel that could
|
||||
not be restored is never written over the real credential.
|
||||
"""
|
||||
for key, value in updates.items():
|
||||
if _holds_masked_value(value):
|
||||
return (
|
||||
f"{key} contains masked or saved credentials; "
|
||||
"resend it with real values"
|
||||
)
|
||||
return None
|
||||
if ("://*:*@" in path or "user=*&password=*" in path) and index < len(
|
||||
existing_paths
|
||||
):
|
||||
input_obj["path"] = existing_paths[index]
|
||||
|
||||
|
||||
def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse:
|
||||
@@ -921,13 +786,13 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
try:
|
||||
updates = {}
|
||||
if body.config_data:
|
||||
updates, error = _config_data_to_updates(
|
||||
body.config_data, _saved_config_from_memory(request.app.frigate_config)
|
||||
)
|
||||
if error:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": error}, status_code=400
|
||||
)
|
||||
_restore_masked_camera_paths(body.config_data, request.app.frigate_config)
|
||||
updates = flatten_config_data(body.config_data)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
# Drop any field whose value is still the redaction sentinel
|
||||
updates = {
|
||||
k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL
|
||||
}
|
||||
|
||||
if not updates:
|
||||
return JSONResponse(
|
||||
@@ -1074,26 +939,22 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
# Filter out empty keys but keep blank values for non-empty keys
|
||||
query_string = {k: v for k, v in query_string.items() if k}
|
||||
|
||||
error = None
|
||||
|
||||
if query_string:
|
||||
updates = process_config_query_string(query_string)
|
||||
error = _find_masked_update(updates)
|
||||
elif body.config_data:
|
||||
try:
|
||||
saved_config = ruamel.yaml.YAML(typ="safe").load(old_raw_config)
|
||||
except ruamel.yaml.YAMLError:
|
||||
saved_config = None
|
||||
updates, error = _config_data_to_updates(
|
||||
body.config_data,
|
||||
saved_config if isinstance(saved_config, dict) else {},
|
||||
)
|
||||
|
||||
if error:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": error},
|
||||
status_code=400,
|
||||
_restore_masked_camera_paths(
|
||||
body.config_data, request.app.frigate_config
|
||||
)
|
||||
updates = flatten_config_data(body.config_data)
|
||||
# Convert None values to empty strings for deletion (e.g., when deleting masks)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
# Drop sentinel-valued fields so untouched credential
|
||||
# placeholders don't clobber the saved YAML value.
|
||||
updates = {
|
||||
k: v
|
||||
for k, v in updates.items()
|
||||
if v != REDACTED_CREDENTIAL_SENTINEL
|
||||
}
|
||||
|
||||
if not updates:
|
||||
return JSONResponse(
|
||||
|
||||
+38
-60
@@ -471,37 +471,8 @@ def create_encoded_jwt(user, role, expiration, secret):
|
||||
)
|
||||
|
||||
|
||||
def _user_session(user: User) -> tuple[str, int | None]:
|
||||
changed_at = user.password_changed_at
|
||||
|
||||
# floored like iat, so a login in the same second as the change stays valid
|
||||
return user.role, int(changed_at.timestamp()) if changed_at else None
|
||||
|
||||
|
||||
def load_user_sessions() -> dict[str, tuple[str, int | None]]:
|
||||
"""Map each username to its role and password change time for /auth."""
|
||||
return {user.username: _user_session(user) for user in User.select()}
|
||||
|
||||
|
||||
def refresh_user_session(request: Request, username: str) -> None:
|
||||
"""Sync the /auth session map after a user is created, changed or deleted."""
|
||||
user = User.get_or_none(User.username == username)
|
||||
if user is None:
|
||||
request.app.user_sessions.pop(username, None)
|
||||
else:
|
||||
request.app.user_sessions[username] = _user_session(user)
|
||||
|
||||
|
||||
def is_secure_request(request: Request) -> bool:
|
||||
"""Check the cookie setting and nginx's forwarded scheme for HTTPS."""
|
||||
# nginx sets X-Forwarded-Proto to its own scheme, replacing any client value
|
||||
return (
|
||||
request.app.frigate_config.auth.cookie_secure
|
||||
or request.headers.get("x-forwarded-proto") == "https"
|
||||
)
|
||||
|
||||
|
||||
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure):
|
||||
# TODO: ideally this would set secure as well, but that requires TLS
|
||||
# Starlette sets SameSite=Lax by default. The cookie is still sent to
|
||||
# same-site iframes (e.g. Home Assistant on the same host or domain), but
|
||||
# not to cross-site ones. CSRF is also mitigated by requiring a custom
|
||||
@@ -779,6 +750,7 @@ def auth(request: Request):
|
||||
fail_response.headers["location"] = "/login"
|
||||
|
||||
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
|
||||
JWT_REFRESH = request.app.frigate_config.auth.refresh_time
|
||||
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
||||
|
||||
@@ -804,27 +776,20 @@ def auth(request: Request):
|
||||
if "sub" not in token.claims:
|
||||
logger.debug("user not set in jwt token")
|
||||
return fail_response
|
||||
if "role" not in token.claims:
|
||||
logger.debug("role not set in jwt token")
|
||||
return fail_response
|
||||
if "exp" not in token.claims:
|
||||
logger.debug("exp not set in jwt token")
|
||||
return fail_response
|
||||
|
||||
user = token.claims.get("sub")
|
||||
session = request.app.user_sessions.get(user)
|
||||
if session is None:
|
||||
logger.debug("jwt user %s no longer exists", user)
|
||||
return fail_response
|
||||
|
||||
role, password_changed_at = session
|
||||
if password_changed_at is not None and (
|
||||
int(token.claims.get("iat", 0)) < password_changed_at
|
||||
):
|
||||
logger.debug("jwt token issued before password change")
|
||||
return fail_response
|
||||
role = token.claims.get("role")
|
||||
|
||||
# the token keeps the role it was issued with, so a role removed from
|
||||
# the config since then must send the user back through login
|
||||
if role not in auth_config.roles:
|
||||
logger.debug(
|
||||
"jwt user %s has role %s, which is not in the config", user, role
|
||||
)
|
||||
logger.debug("jwt role %s is not in the config", role)
|
||||
return fail_response
|
||||
|
||||
current_time = int(time.time())
|
||||
@@ -847,6 +812,25 @@ def auth(request: Request):
|
||||
# if the jwt cookie is expiring soon
|
||||
if jwt_source == "cookie" and expiration - JWT_REFRESH <= current_time:
|
||||
logger.debug("jwt token expiring soon, refreshing cookie")
|
||||
|
||||
# Check if password has been changed since token was issued
|
||||
# If so, force re-login by rejecting the refresh
|
||||
try:
|
||||
user_obj = User.get_by_id(user)
|
||||
if user_obj.password_changed_at is not None:
|
||||
token_iat = int(token.claims.get("iat", 0))
|
||||
password_changed_timestamp = int(
|
||||
user_obj.password_changed_at.timestamp()
|
||||
)
|
||||
if token_iat < password_changed_timestamp:
|
||||
logger.debug(
|
||||
"jwt token issued before password change, rejecting refresh"
|
||||
)
|
||||
return fail_response
|
||||
except DoesNotExist:
|
||||
logger.debug("user not found")
|
||||
return fail_response
|
||||
|
||||
new_expiration = current_time + JWT_SESSION_LENGTH
|
||||
new_encoded_jwt = create_encoded_jwt(
|
||||
user, role, new_expiration, request.app.jwt_token
|
||||
@@ -856,7 +840,7 @@ def auth(request: Request):
|
||||
JWT_COOKIE_NAME,
|
||||
new_encoded_jwt,
|
||||
JWT_SESSION_LENGTH,
|
||||
is_secure_request(request),
|
||||
JWT_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
success_response.headers["remote-user"] = user
|
||||
@@ -945,6 +929,7 @@ def login(request: Request, body: AppPostLoginBody):
|
||||
)
|
||||
|
||||
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
|
||||
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
||||
user = body.user
|
||||
password = body.password
|
||||
@@ -960,16 +945,13 @@ def login(request: Request, body: AppPostLoginBody):
|
||||
|
||||
password_hash = db_user.password_hash
|
||||
if verify_password(password, password_hash):
|
||||
role = db_user.role
|
||||
if role not in request.app.frigate_config.auth.roles:
|
||||
role = getattr(db_user, "role", "viewer")
|
||||
config_roles_set = set(request.app.frigate_config.auth.roles.keys())
|
||||
if role not in config_roles_set:
|
||||
logger.warning(
|
||||
"Login refused for user '%s' with role '%s', which is not in the config",
|
||||
user,
|
||||
role,
|
||||
f"User {db_user.username} has an invalid role {role}, falling back to 'viewer'."
|
||||
)
|
||||
failed_logins.record(user, time.time(), known=True)
|
||||
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
||||
|
||||
role = "viewer"
|
||||
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
||||
encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token)
|
||||
response = Response("", 200)
|
||||
@@ -978,7 +960,7 @@ def login(request: Request, body: AppPostLoginBody):
|
||||
JWT_COOKIE_NAME,
|
||||
encoded_jwt,
|
||||
JWT_SESSION_LENGTH,
|
||||
is_secure_request(request),
|
||||
JWT_COOKIE_SECURE,
|
||||
)
|
||||
# Clear admin_first_time_login flag after successful admin login so the
|
||||
# UI stops showing the first-time login documentation link.
|
||||
@@ -1044,11 +1026,9 @@ def create_user(
|
||||
User.username: body.username,
|
||||
User.password_hash: password_hash,
|
||||
User.role: role,
|
||||
User.password_changed_at: datetime.now(),
|
||||
User.notification_tokens: [],
|
||||
}
|
||||
).execute()
|
||||
refresh_user_session(request, body.username)
|
||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||
return JSONResponse(content={"username": body.username})
|
||||
|
||||
@@ -1067,7 +1047,6 @@ def delete_user(request: Request, username: str):
|
||||
)
|
||||
|
||||
User.delete_by_id(username)
|
||||
refresh_user_session(request, username)
|
||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||
return JSONResponse(content={"success": True})
|
||||
|
||||
@@ -1135,13 +1114,13 @@ async def update_password(
|
||||
User.password_changed_at: datetime.now(),
|
||||
}
|
||||
).where(User.username == username).execute()
|
||||
refresh_user_session(request, username)
|
||||
|
||||
response = JSONResponse(content={"success": True})
|
||||
|
||||
# If user changed their own password, issue a new JWT to keep them logged in
|
||||
if current_username == username:
|
||||
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
|
||||
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
||||
|
||||
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
||||
@@ -1154,7 +1133,7 @@ async def update_password(
|
||||
JWT_COOKIE_NAME,
|
||||
encoded_jwt,
|
||||
JWT_SESSION_LENGTH,
|
||||
is_secure_request(request),
|
||||
JWT_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -1194,7 +1173,6 @@ async def update_role(
|
||||
)
|
||||
|
||||
User.set_by_id(username, {User.role: body.role})
|
||||
refresh_user_session(request, username)
|
||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||
return JSONResponse(content={"success": True})
|
||||
|
||||
|
||||
@@ -196,9 +196,7 @@ def create_fastapi_app(
|
||||
key_bytes = str(secret).encode("utf-8")
|
||||
|
||||
app.jwt_token = OctKey.import_key(key_bytes)
|
||||
app.user_sessions = auth.load_user_sessions()
|
||||
else:
|
||||
app.jwt_token = None
|
||||
app.user_sessions = {}
|
||||
|
||||
return app
|
||||
|
||||
@@ -9,10 +9,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from frigate.api.auth import require_camera_access
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.jobs.motion_search import (
|
||||
MotionSearchCapacityError,
|
||||
MotionSearchUserBusyError,
|
||||
cancel_motion_search_job,
|
||||
get_motion_search_job,
|
||||
start_motion_search_job,
|
||||
@@ -98,20 +95,6 @@ class MotionSearchStatusResponse(BaseModel):
|
||||
progress: float | None = None
|
||||
|
||||
|
||||
def _per_user_identity(request: Request, config: FrigateConfig) -> str | None:
|
||||
"""Return the requesting user, or None when callers share one identity.
|
||||
|
||||
Internal-port requests are all "anonymous", and with auth disabled and no
|
||||
proxy user header every browser is "viewer".
|
||||
"""
|
||||
username = request.headers.get("remote-user")
|
||||
if not username or username == "anonymous":
|
||||
return None
|
||||
if config.auth.enabled or config.proxy.header_map.user:
|
||||
return username
|
||||
return None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{camera_name}/search/motion",
|
||||
response_model=MotionSearchStartResponse,
|
||||
@@ -155,35 +138,18 @@ async def start_motion_search(
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
job_id = start_motion_search_job(
|
||||
config=config,
|
||||
camera_name=camera_name,
|
||||
start_time=body.start_time,
|
||||
end_time=body.end_time,
|
||||
polygon_points=body.polygon_points,
|
||||
threshold=body.threshold,
|
||||
min_area=body.min_area,
|
||||
parallel=body.parallel,
|
||||
max_results=body.max_results,
|
||||
username=_per_user_identity(request, config),
|
||||
)
|
||||
except MotionSearchUserBusyError:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "A motion search is already running for this user",
|
||||
},
|
||||
status_code=409,
|
||||
)
|
||||
except MotionSearchCapacityError:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Too many motion searches are running",
|
||||
},
|
||||
status_code=429,
|
||||
)
|
||||
# Start the job using the jobs module
|
||||
job_id = start_motion_search_job(
|
||||
config=config,
|
||||
camera_name=camera_name,
|
||||
start_time=body.start_time,
|
||||
end_time=body.end_time,
|
||||
polygon_points=body.polygon_points,
|
||||
threshold=body.threshold,
|
||||
min_area=body.min_area,
|
||||
parallel=body.parallel,
|
||||
max_results=body.max_results,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Notification apis."""
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
@@ -11,7 +13,6 @@ from py_vapid import Vapid01, utils
|
||||
|
||||
from frigate.api.auth import allow_any_authenticated
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.comms.webpush import validate_push_endpoint
|
||||
from frigate.const import CONFIG_DIR
|
||||
from frigate.models import User
|
||||
|
||||
@@ -19,13 +20,76 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.notifications])
|
||||
|
||||
# Push endpoints are opaque URLs but stay well under this in practice
|
||||
MAX_ENDPOINT_LENGTH = 2048
|
||||
|
||||
# Suffixes that only ever resolve on the local network
|
||||
INTERNAL_HOST_SUFFIXES = (".local", ".localdomain", ".internal", ".home.arpa")
|
||||
|
||||
|
||||
def _validate_push_endpoint(endpoint: Any) -> str | None:
|
||||
"""Return a reason the endpoint is unusable, or None when it is valid.
|
||||
|
||||
Subscriptions are issued by the browser vendor's push service, so a valid
|
||||
endpoint is always a public https URL. Anything else is either a broken
|
||||
registration or an attempt to aim the notification sender somewhere it
|
||||
should not reach.
|
||||
"""
|
||||
if not isinstance(endpoint, str) or not endpoint:
|
||||
return "endpoint must be a url"
|
||||
|
||||
if len(endpoint) > MAX_ENDPOINT_LENGTH:
|
||||
return "endpoint is too long"
|
||||
|
||||
try:
|
||||
parsed = urlparse(endpoint)
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
return "endpoint is not a valid url"
|
||||
|
||||
if parsed.scheme != "https":
|
||||
return "endpoint must use https"
|
||||
|
||||
if parsed.username or parsed.password:
|
||||
return "endpoint must not include credentials"
|
||||
|
||||
if port is not None and port != 443:
|
||||
return "endpoint must use the default https port"
|
||||
|
||||
hostname = parsed.hostname
|
||||
|
||||
if not hostname:
|
||||
return "endpoint must include a hostname"
|
||||
|
||||
try:
|
||||
address = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
address = None
|
||||
|
||||
if address is not None:
|
||||
# A push service is never reachable at an address only this network can
|
||||
# route, so anything non-global is a misconfiguration at best
|
||||
if not address.is_global:
|
||||
return "endpoint must not use a private address"
|
||||
elif hostname == "localhost" or "." not in hostname:
|
||||
return "endpoint must use a fully qualified hostname"
|
||||
elif hostname.endswith(INTERNAL_HOST_SUFFIXES):
|
||||
return "endpoint must not use an internal hostname"
|
||||
|
||||
# The subscription token lives in the path, and webpush.py assumes there is
|
||||
# a separator after the host when it builds the VAPID audience
|
||||
if len(parsed.path) <= 1:
|
||||
return "endpoint must include a subscription path"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _validate_subscription(sub: Any) -> str | None:
|
||||
"""Return a reason the subscription is unusable, or None when it is valid."""
|
||||
if not isinstance(sub, dict):
|
||||
return "subscription must be an object"
|
||||
|
||||
reason = validate_push_endpoint(sub.get("endpoint"))
|
||||
reason = _validate_push_endpoint(sub.get("endpoint"))
|
||||
|
||||
if reason:
|
||||
return reason
|
||||
|
||||
@@ -622,7 +622,6 @@ class FrigateApp:
|
||||
username="admin",
|
||||
role="admin",
|
||||
password_hash=password_hash,
|
||||
password_changed_at=datetime.datetime.now(),
|
||||
notification_tokens=[],
|
||||
).execute()
|
||||
|
||||
|
||||
@@ -490,12 +490,7 @@ class Dispatcher:
|
||||
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
||||
"""Handle publishing to communicators."""
|
||||
for comm in self.comms:
|
||||
try:
|
||||
comm.publish(topic, payload, retain)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error publishing %s to %s", topic, type(comm).__name__
|
||||
)
|
||||
comm.publish(topic, payload, retain)
|
||||
|
||||
def publish_local(self, topic: str, payload: Any) -> None:
|
||||
"""Publish to every communicator except MQTT.
|
||||
@@ -506,12 +501,7 @@ class Dispatcher:
|
||||
if isinstance(comm, MqttClient):
|
||||
continue
|
||||
|
||||
try:
|
||||
comm.publish(topic, payload, False)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error publishing %s to %s", topic, type(comm).__name__
|
||||
)
|
||||
comm.publish(topic, payload, False)
|
||||
|
||||
def _publish_notices(self) -> None:
|
||||
if self.notice_registry is None:
|
||||
|
||||
@@ -50,13 +50,7 @@ class InterProcessCommunicator(Communicator):
|
||||
|
||||
if isinstance(raw, list):
|
||||
(topic, value) = raw
|
||||
|
||||
# REP owes a reply, so a handler error must not end the thread
|
||||
try:
|
||||
response = self._dispatcher(topic, value)
|
||||
except Exception:
|
||||
logger.exception("Error handling IPC topic %s", topic)
|
||||
response = None
|
||||
response = self._dispatcher(topic, value)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Received unexpected data type in ZMQ recv_json: {type(raw)}"
|
||||
|
||||
+5
-117
@@ -1,7 +1,6 @@
|
||||
"""Handle sending notifications for Frigate via Firebase."""
|
||||
|
||||
import datetime
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -11,9 +10,7 @@ from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from py_vapid import Vapid01
|
||||
from pywebpush import WebPusher
|
||||
from titlecase import titlecase
|
||||
@@ -44,124 +41,14 @@ class PushNotification:
|
||||
ttl: int = 0
|
||||
|
||||
|
||||
# Push endpoints are opaque URLs but stay well under this in practice
|
||||
MAX_ENDPOINT_LENGTH = 2048
|
||||
|
||||
# Suffixes that only ever resolve on the local network
|
||||
INTERNAL_HOST_SUFFIXES = (".local", ".localdomain", ".internal", ".home.arpa")
|
||||
|
||||
|
||||
def validate_push_endpoint(endpoint: Any) -> str | None:
|
||||
"""Return a reason the endpoint is unusable, or None when it is valid.
|
||||
|
||||
Subscriptions are issued by the browser vendor's push service, so a valid
|
||||
endpoint is always a public https URL. Anything else is either a broken
|
||||
registration or an attempt to aim the notification sender somewhere it
|
||||
should not reach.
|
||||
"""
|
||||
if not isinstance(endpoint, str) or not endpoint:
|
||||
return "endpoint must be a url"
|
||||
|
||||
if len(endpoint) > MAX_ENDPOINT_LENGTH:
|
||||
return "endpoint is too long"
|
||||
|
||||
try:
|
||||
parsed = urlparse(endpoint)
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
return "endpoint is not a valid url"
|
||||
|
||||
if parsed.scheme != "https":
|
||||
return "endpoint must use https"
|
||||
|
||||
if parsed.username or parsed.password:
|
||||
return "endpoint must not include credentials"
|
||||
|
||||
if port is not None and port != 443:
|
||||
return "endpoint must use the default https port"
|
||||
|
||||
hostname = parsed.hostname
|
||||
|
||||
if not hostname:
|
||||
return "endpoint must include a hostname"
|
||||
|
||||
try:
|
||||
address = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
address = None
|
||||
|
||||
if address is not None:
|
||||
# A push service is never reachable at an address only this network can
|
||||
# route, so anything non-global is a misconfiguration at best
|
||||
if not address.is_global:
|
||||
return "endpoint must not use a private address"
|
||||
elif hostname == "localhost" or "." not in hostname:
|
||||
return "endpoint must use a fully qualified hostname"
|
||||
elif "" in hostname.split("."):
|
||||
return "endpoint hostname must not contain empty labels"
|
||||
elif hostname.endswith(INTERNAL_HOST_SUFFIXES):
|
||||
return "endpoint must not use an internal hostname"
|
||||
|
||||
# The subscription token lives in the path
|
||||
if len(parsed.path) <= 1:
|
||||
return "endpoint must include a subscription path"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class _NoRedirectSession(requests.Session):
|
||||
"""Session that never follows redirects.
|
||||
|
||||
A push endpoint could otherwise redirect the send to an internal URL.
|
||||
"""
|
||||
|
||||
def request(self, *args: Any, **kwargs: Any) -> requests.Response:
|
||||
kwargs["allow_redirects"] = False
|
||||
return super().request(*args, **kwargs)
|
||||
|
||||
|
||||
_push_session = _NoRedirectSession()
|
||||
|
||||
|
||||
def _vapid_audience(endpoint: str) -> str:
|
||||
parsed = urlparse(endpoint)
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
|
||||
def _build_web_pushers(user: str, subs: list[dict[str, Any]]) -> list[WebPusher]:
|
||||
"""Build pushers for a user's stored subscriptions, skipping unusable ones."""
|
||||
pushers: list[WebPusher] = []
|
||||
|
||||
for sub in subs:
|
||||
# Stored rows may predate registration validation, so only skip the
|
||||
# endpoints that would break building the VAPID audience
|
||||
endpoint = sub.get("endpoint") if isinstance(sub, dict) else None
|
||||
|
||||
if not isinstance(endpoint, str):
|
||||
logger.warning(
|
||||
"Skipping notification subscription for %s: no endpoint", user
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
hostname = urlparse(endpoint).hostname or ""
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Skipping notification subscription for %s: unparsable endpoint", user
|
||||
)
|
||||
continue
|
||||
|
||||
if "" in hostname.split("."):
|
||||
logger.warning(
|
||||
"Skipping notification subscription for %s: invalid endpoint host %r",
|
||||
user,
|
||||
hostname,
|
||||
)
|
||||
continue
|
||||
|
||||
# WebPusher decodes the stored keys and raises on malformed ones
|
||||
try:
|
||||
pushers.append(WebPusher(sub, requests_session=_push_session))
|
||||
pushers.append(WebPusher(sub))
|
||||
except Exception:
|
||||
logger.warning("Skipping invalid notification subscription for %s", user)
|
||||
|
||||
@@ -244,7 +131,7 @@ class WebPushClient(Communicator):
|
||||
for pushers in self.web_pushers.values():
|
||||
for push in pushers:
|
||||
endpoint: str = push.subscription_info["endpoint"]
|
||||
endpoints.add(_vapid_audience(endpoint))
|
||||
endpoints.add(endpoint[0 : endpoint.index("/", 10)])
|
||||
|
||||
# create new claim
|
||||
for endpoint in endpoints:
|
||||
@@ -433,8 +320,9 @@ class WebPushClient(Communicator):
|
||||
|
||||
for pusher in self.web_pushers[notification.user]:
|
||||
endpoint = pusher.subscription_info["endpoint"]
|
||||
|
||||
headers = self.claim_headers[_vapid_audience(endpoint)].copy()
|
||||
headers = self.claim_headers[
|
||||
endpoint[: endpoint.index("/", 10)]
|
||||
].copy()
|
||||
headers["urgency"] = "high"
|
||||
|
||||
resp = pusher.send(
|
||||
|
||||
@@ -27,7 +27,7 @@ class AuthConfig(FrigateBaseModel):
|
||||
cookie_secure: bool = Field(
|
||||
default=False,
|
||||
title="Secure cookie flag",
|
||||
description="Always set the secure flag on the auth cookie. Frigate sets it automatically when serving TLS; enable this when a reverse proxy terminates TLS in front of Frigate. A proxy that connects to the TLS port must serve Frigate to browsers over HTTPS.",
|
||||
description="Set the secure flag on the auth cookie; should be true when using TLS.",
|
||||
)
|
||||
session_length: int = Field(
|
||||
default=86400,
|
||||
|
||||
+11
-12
@@ -1,12 +1,18 @@
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
|
||||
from frigate.util.config import parse_listen_address
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from .base import FrigateBaseModel
|
||||
|
||||
__all__ = ["IPv6Config", "ListenConfig", "NetworkingConfig"]
|
||||
|
||||
|
||||
def parse_listen_port(value: int | str) -> int:
|
||||
"""Return the port number from a bare port or an "address:port" value."""
|
||||
if isinstance(value, str):
|
||||
return int(value.split(":")[-1])
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class IPv6Config(FrigateBaseModel):
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
@@ -27,20 +33,13 @@ class ListenConfig(FrigateBaseModel):
|
||||
description="External listening port for Frigate (default 8971).",
|
||||
)
|
||||
|
||||
# Runs before pydantic coerces a bool to an int port, which
|
||||
# get_nginx_settings.py rejects when it reads the raw YAML
|
||||
@field_validator("internal", "external", mode="before")
|
||||
@classmethod
|
||||
def validate_listen(cls, value: int | str) -> int | str:
|
||||
return parse_listen_address(value)[0]
|
||||
|
||||
@property
|
||||
def internal_port(self) -> int:
|
||||
return parse_listen_address(self.internal)[1]
|
||||
return parse_listen_port(self.internal)
|
||||
|
||||
@property
|
||||
def external_port(self) -> int:
|
||||
return parse_listen_address(self.external)[1]
|
||||
return parse_listen_port(self.external)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_distinct_ports(self) -> "ListenConfig":
|
||||
|
||||
+1
-13
@@ -117,19 +117,7 @@ SUPPORTED_RK_SOCS = ["rk3562", "rk3566", "rk3568", "rk3576", "rk3588"]
|
||||
|
||||
REGEX_CAMERA_NAME = r"^[a-zA-Z0-9_-]+$"
|
||||
REGEX_RTSP_CAMERA_USER_PASS = r":\/\/[a-zA-Z0-9_-]+:[\S]+@"
|
||||
# A "user:" userinfo may hold "/", "#" or "@" in the password, so it runs to the
|
||||
# last "@" before the next URL. Anything else stops at the authority end. Group 1
|
||||
# is the userinfo and group 2 the rest of the URL.
|
||||
REGEX_URL_USERINFO = (
|
||||
r"://((?:[^\s/?#:\[\]]*:(?:(?!://)\S)*|[^\s/?#]+))@((?:(?!://)\S)*)"
|
||||
)
|
||||
_CREDENTIAL_QUERY_KEYS = (
|
||||
r"(?:user(?:name)?|usr|login|pass(?:word|wd)?|pwd"
|
||||
r"|(?:[a-z_]*_)?token|(?:[a-z_]*_)?secret|client_private|api_?key)"
|
||||
)
|
||||
REGEX_CREDENTIAL_QUERY_KEY = rf"(?i)[?&]{_CREDENTIAL_QUERY_KEYS}="
|
||||
REGEX_CREDENTIAL_QUERY_PARAM = rf"(?i)([?&]{_CREDENTIAL_QUERY_KEYS}=)[^&\s]*"
|
||||
REGEX_MASKED_CREDENTIAL = rf"(?i)://\*:\*@|[?&]{_CREDENTIAL_QUERY_KEYS}=\*(?![^&\s])"
|
||||
REGEX_HTTP_CAMERA_USER_PASS = r"user=[a-zA-Z0-9_-]+&password=[\S]+"
|
||||
REGEX_JSON = re.compile(r"^\s*\{")
|
||||
|
||||
# Known Driver Names
|
||||
|
||||
@@ -8,7 +8,7 @@ from collections.abc import Callable, Generator, Iterable
|
||||
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -17,6 +17,10 @@ from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import STREAM_TYPE_MAIN, UPDATE_JOB_STATE
|
||||
from frigate.jobs.job import Job
|
||||
from frigate.jobs.manager import (
|
||||
get_job_by_id,
|
||||
set_current_job,
|
||||
)
|
||||
from frigate.jobs.motion_search_batch import (
|
||||
build_segment_time_map,
|
||||
coalesce_runs,
|
||||
@@ -49,12 +53,6 @@ PROGRESS_BROADCAST_INTERVAL = 1.0
|
||||
# Output frame rate for the fixed-cadence fallback used on long-GOP cameras
|
||||
# (where keyframe sampling is too sparse). Keyframe cameras ignore this.
|
||||
FALLBACK_SAMPLE_FPS = 2.0
|
||||
# Each parallel job runs up to 4 decode workers, so this bounds total ffmpeg load.
|
||||
MAX_ACTIVE_JOBS = 3
|
||||
# Finished jobs stay pollable for this long before eviction (seconds).
|
||||
FINISHED_JOB_TTL = 600.0
|
||||
# A cancelled job stops holding a slot after this long, even if its thread is wedged.
|
||||
CANCEL_GRACE_SECONDS = 60.0
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -100,8 +98,6 @@ class MotionSearchJob(Job):
|
||||
min_area: float = 5.0
|
||||
parallel: bool = False
|
||||
max_results: int = 25
|
||||
username: str | None = None
|
||||
cancelled_at: float | None = None
|
||||
|
||||
# Track progress
|
||||
total_frames_processed: int = 0
|
||||
@@ -116,8 +112,6 @@ class MotionSearchJob(Job):
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for WebSocket transmission."""
|
||||
d = asdict(self)
|
||||
d.pop("username")
|
||||
d.pop("cancelled_at")
|
||||
if self.metrics:
|
||||
d["metrics"] = self.metrics.to_dict()
|
||||
return d
|
||||
@@ -585,7 +579,7 @@ class MotionSearchRunner(threading.Thread):
|
||||
|
||||
# Decide keyframe vs fixed-cadence sampling once from the first run's GOP
|
||||
# (keyframe structure is a per-camera constant).
|
||||
first_pts = probe_vod_keyframe_pts(ffprobe_path, first_url, self._should_stop)
|
||||
first_pts = probe_vod_keyframe_pts(ffprobe_path, first_url)
|
||||
self.use_keyframe = keyframe_sampling_eligible(first_pts)
|
||||
|
||||
logger.debug(
|
||||
@@ -647,9 +641,7 @@ class MotionSearchRunner(threading.Thread):
|
||||
time_map = build_segment_time_map(run)
|
||||
|
||||
if self.use_keyframe:
|
||||
kf_pts = probe_vod_keyframe_pts(
|
||||
self.ffprobe_path, vod_url, self._should_stop
|
||||
)
|
||||
kf_pts = probe_vod_keyframe_pts(self.ffprobe_path, vod_url)
|
||||
frames = list(
|
||||
iter_vod_frames(
|
||||
self.ffmpeg_path,
|
||||
@@ -785,15 +777,13 @@ class MotionSearchRunner(threading.Thread):
|
||||
run_results, frames = completed.pop(next_idx)
|
||||
if self._merge_run(runs[next_idx], run_results, frames, state):
|
||||
self.internal_stop_event.set()
|
||||
for pending in futures:
|
||||
pending.cancel()
|
||||
break
|
||||
next_idx += 1
|
||||
|
||||
if self.internal_stop_event.is_set():
|
||||
break
|
||||
|
||||
# Queued runs would otherwise still execute when the pool exits.
|
||||
for pending in futures:
|
||||
pending.cancel()
|
||||
else:
|
||||
for run in runs:
|
||||
if self._should_stop():
|
||||
@@ -843,36 +833,11 @@ class MotionSearchRunner(threading.Thread):
|
||||
return deduplicated
|
||||
|
||||
|
||||
class MotionSearchUserBusyError(Exception):
|
||||
"""The user already has an active motion search job."""
|
||||
|
||||
|
||||
class MotionSearchCapacityError(Exception):
|
||||
"""The global limit of active motion search jobs is reached."""
|
||||
|
||||
|
||||
# Module-level state for managing per-camera jobs
|
||||
_motion_search_jobs: dict[str, tuple[MotionSearchJob, threading.Event]] = {}
|
||||
_jobs_lock = threading.Lock()
|
||||
|
||||
|
||||
def _evict_finished_jobs(now: float) -> None:
|
||||
"""Drop jobs that finished more than FINISHED_JOB_TTL ago. Caller holds the lock."""
|
||||
expired = [
|
||||
job_id
|
||||
for job_id, (job, _) in _motion_search_jobs.items()
|
||||
if job.end_time is not None and now - job.end_time > FINISHED_JOB_TTL
|
||||
]
|
||||
for job_id in expired:
|
||||
del _motion_search_jobs[job_id]
|
||||
|
||||
|
||||
def _is_active(job: MotionSearchJob, now: float) -> bool:
|
||||
"""True while the job's thread may still be running and holding a slot."""
|
||||
if job.end_time is not None:
|
||||
return False
|
||||
return job.cancelled_at is None or now - job.cancelled_at < CANCEL_GRACE_SECONDS
|
||||
|
||||
|
||||
def stop_all_motion_search_jobs() -> None:
|
||||
"""Cancel all running motion search jobs for clean shutdown."""
|
||||
with _jobs_lock:
|
||||
@@ -892,13 +857,10 @@ def start_motion_search_job(
|
||||
min_area: float = 5.0,
|
||||
parallel: bool = False,
|
||||
max_results: int = 25,
|
||||
username: str | None = None,
|
||||
) -> str:
|
||||
"""Start a new motion search job.
|
||||
|
||||
Returns the job ID. Raises MotionSearchUserBusyError when ``username`` already
|
||||
has an active job and MotionSearchCapacityError when MAX_ACTIVE_JOBS are active.
|
||||
Pass ``username=None`` when callers share an identity to skip the per-user check.
|
||||
Returns the job ID.
|
||||
"""
|
||||
job = MotionSearchJob(
|
||||
camera=camera_name,
|
||||
@@ -909,38 +871,17 @@ def start_motion_search_job(
|
||||
min_area=min_area,
|
||||
parallel=parallel,
|
||||
max_results=max_results,
|
||||
username=username,
|
||||
)
|
||||
|
||||
cancel_event = threading.Event()
|
||||
|
||||
with _jobs_lock:
|
||||
now = datetime.now().timestamp()
|
||||
_evict_finished_jobs(now)
|
||||
|
||||
# A cancelled job keeps its thread until the runner sets end_time, so it
|
||||
# still counts toward the global cap. Its user may start a new search.
|
||||
active = [j for j, _ in _motion_search_jobs.values() if _is_active(j, now)]
|
||||
if username is not None and any(
|
||||
j.username == username and j.status != JobStatusTypesEnum.cancelled
|
||||
for j in active
|
||||
):
|
||||
raise MotionSearchUserBusyError()
|
||||
if len(active) >= MAX_ACTIVE_JOBS:
|
||||
raise MotionSearchCapacityError()
|
||||
|
||||
_motion_search_jobs[job.id] = (job, cancel_event)
|
||||
|
||||
runner: MotionSearchRunner | None = None
|
||||
try:
|
||||
runner = MotionSearchRunner(job, config, cancel_event)
|
||||
runner.start()
|
||||
except Exception:
|
||||
if runner is not None:
|
||||
runner.requestor.stop()
|
||||
with _jobs_lock:
|
||||
del _motion_search_jobs[job.id]
|
||||
raise
|
||||
set_current_job(job)
|
||||
|
||||
runner = MotionSearchRunner(job, config, cancel_event)
|
||||
runner.start()
|
||||
|
||||
logger.debug(
|
||||
"Started motion search job %s for camera %s: "
|
||||
@@ -963,7 +904,10 @@ def get_motion_search_job(job_id: str) -> MotionSearchJob | None:
|
||||
"""Get a motion search job by ID."""
|
||||
with _jobs_lock:
|
||||
job_entry = _motion_search_jobs.get(job_id)
|
||||
return job_entry[0] if job_entry else None
|
||||
if job_entry:
|
||||
return job_entry[0]
|
||||
# Check completed jobs via manager
|
||||
return cast(MotionSearchJob | None, get_job_by_id("motion_search", job_id))
|
||||
|
||||
|
||||
def cancel_motion_search_job(job_id: str) -> bool:
|
||||
@@ -984,7 +928,6 @@ def cancel_motion_search_job(job_id: str) -> bool:
|
||||
|
||||
cancel_event.set()
|
||||
job.status = JobStatusTypesEnum.cancelled
|
||||
job.cancelled_at = datetime.now().timestamp()
|
||||
job_payload = job.to_dict()
|
||||
logger.info("Cancelled motion search job %s", job_id)
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import json
|
||||
import logging
|
||||
import subprocess as sp
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Callable, Generator
|
||||
from typing import IO
|
||||
|
||||
@@ -265,16 +264,11 @@ def iter_vod_frames(
|
||||
)
|
||||
|
||||
|
||||
KEYFRAME_PROBE_TIMEOUT = 120.0
|
||||
|
||||
|
||||
def probe_vod_keyframe_pts(
|
||||
ffprobe_path: str, vod_url: str, should_stop: Callable[[], bool]
|
||||
) -> list[float]:
|
||||
def probe_vod_keyframe_pts(ffprobe_path: str, vod_url: str) -> list[float]:
|
||||
"""Return keyframe presentation timestamps (VOD stream time) in order.
|
||||
|
||||
Reads packet flags via ffprobe over the VOD URL (no decode). Returns [] on
|
||||
any failure, timeout, or stop request so the caller can fall back.
|
||||
any failure so the caller can fall back.
|
||||
"""
|
||||
cmd = [
|
||||
ffprobe_path,
|
||||
@@ -292,27 +286,16 @@ def probe_vod_keyframe_pts(
|
||||
"json",
|
||||
]
|
||||
try:
|
||||
proc = sp.Popen(cmd, stdout=sp.PIPE, stderr=sp.DEVNULL, text=True)
|
||||
except OSError:
|
||||
completed = sp.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
except (OSError, sp.SubprocessError):
|
||||
logger.warning("ffprobe failed for VOD keyframe probe")
|
||||
return []
|
||||
|
||||
deadline = time.monotonic() + KEYFRAME_PROBE_TIMEOUT
|
||||
while True:
|
||||
try:
|
||||
stdout, _ = proc.communicate(timeout=0.5)
|
||||
break
|
||||
except sp.TimeoutExpired:
|
||||
if should_stop() or time.monotonic() > deadline:
|
||||
proc.kill()
|
||||
proc.communicate()
|
||||
return []
|
||||
|
||||
if proc.returncode != 0 or not stdout:
|
||||
if completed.returncode != 0 or not completed.stdout:
|
||||
return []
|
||||
|
||||
try:
|
||||
packets = json.loads(stdout).get("packets", [])
|
||||
packets = json.loads(completed.stdout).get("packets", [])
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
|
||||
@@ -1,29 +1,22 @@
|
||||
"""Tests that /auth takes the role and revocation state from the user, not the JWT."""
|
||||
"""Tests that /auth only accepts a JWT whose role is still in the config."""
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
from joserfc import jwt
|
||||
|
||||
from frigate.api.auth import create_encoded_jwt, hash_password
|
||||
from frigate.api.auth import create_encoded_jwt
|
||||
from frigate.api.fastapi_app import create_fastapi_app
|
||||
from frigate.app import FrigateApp
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||
from frigate.const import JWT_SECRET_ENV_VAR
|
||||
from frigate.models import Event, Recordings, ReviewSegment, User
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
PASSWORD = "correct-horse-battery"
|
||||
|
||||
|
||||
@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"})
|
||||
class TestAuthJwtRole(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp(models=[Event, Recordings, ReviewSegment, User])
|
||||
super().setUp(models=[Event, Recordings, ReviewSegment])
|
||||
self.minimal_config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"auth": {"enabled": True, "roles": {"garage": ["front_door"]}},
|
||||
@@ -43,19 +36,6 @@ class TestAuthJwtRole(BaseTestHttp):
|
||||
}
|
||||
},
|
||||
}
|
||||
User.delete().execute()
|
||||
self._insert_user("admin", "admin")
|
||||
self._insert_user("bob", "garage")
|
||||
|
||||
def _insert_user(self, username: str, role: str):
|
||||
User.insert(
|
||||
{
|
||||
User.username: username,
|
||||
User.role: role,
|
||||
User.password_hash: hash_password(PASSWORD, iterations=1),
|
||||
User.notification_tokens: [],
|
||||
}
|
||||
).execute()
|
||||
|
||||
def _create_app(self):
|
||||
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||
@@ -75,243 +55,27 @@ class TestAuthJwtRole(BaseTestHttp):
|
||||
enforce_default_admin=False,
|
||||
)
|
||||
|
||||
def _token(self, app, user: str, role: str, iat: int | None = None) -> str:
|
||||
if iat is None:
|
||||
return create_encoded_jwt(
|
||||
user, role, int(time.time()) + 3600, app.jwt_token
|
||||
def _auth(self, app, role: str):
|
||||
token = create_encoded_jwt("bob", role, int(time.time()) + 3600, app.jwt_token)
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
return client.get(
|
||||
"/auth",
|
||||
headers={
|
||||
"x-server-port": "8971",
|
||||
"authorization": f"Bearer {token}",
|
||||
},
|
||||
)
|
||||
|
||||
return jwt.encode(
|
||||
{"alg": "HS256"},
|
||||
{"sub": user, "role": role, "exp": int(time.time()) + 3600, "iat": iat},
|
||||
app.jwt_token,
|
||||
)
|
||||
|
||||
def _auth(self, client, token: str, source: str = "bearer"):
|
||||
headers = {"x-server-port": "8971"}
|
||||
if source == "bearer":
|
||||
headers["authorization"] = f"Bearer {token}"
|
||||
else:
|
||||
client.cookies.set("frigate_token", token)
|
||||
|
||||
resp = client.get("/auth", headers=headers)
|
||||
client.cookies.clear()
|
||||
return resp
|
||||
|
||||
def test_configured_role_is_accepted(self):
|
||||
app = self._create_app()
|
||||
with AuthTestClient(app) as client:
|
||||
resp = self._auth(client, self._token(app, "bob", "garage"))
|
||||
resp = self._auth(self._create_app(), "garage")
|
||||
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertEqual(resp.headers["remote-user"], "bob")
|
||||
self.assertEqual(resp.headers["remote-role"], "garage")
|
||||
|
||||
def test_role_claim_is_ignored(self):
|
||||
app = self._create_app()
|
||||
with AuthTestClient(app) as client:
|
||||
resp = self._auth(client, self._token(app, "bob", "admin"))
|
||||
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertEqual(resp.headers["remote-role"], "garage")
|
||||
|
||||
def test_role_removed_from_config_is_rejected(self):
|
||||
User.update(role="removed_role").where(User.username == "bob").execute()
|
||||
app = self._create_app()
|
||||
with AuthTestClient(app) as client:
|
||||
for source in ("bearer", "cookie"):
|
||||
resp = self._auth(client, self._token(app, "bob", "garage"), source)
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
self.assertNotIn("remote-role", resp.headers)
|
||||
|
||||
def test_login_refused_for_role_removed_from_config(self):
|
||||
User.update(role="removed_role").where(User.username == "bob").execute()
|
||||
with AuthTestClient(self._create_app()) as client:
|
||||
resp = client.post("/login", json={"user": "bob", "password": PASSWORD})
|
||||
resp = self._auth(self._create_app(), "removed_role")
|
||||
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
self.assertNotIn("set-cookie", resp.headers)
|
||||
|
||||
def test_demoted_user_gets_new_role_on_next_request(self):
|
||||
app = self._create_app()
|
||||
token = self._token(app, "bob", "admin")
|
||||
User.update(role="admin").where(User.username == "bob").execute()
|
||||
app.user_sessions["bob"] = ("admin", None)
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
self.assertEqual(self._auth(client, token).headers["remote-role"], "admin")
|
||||
resp = client.put("/users/bob/role", json={"role": "viewer"})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
for source in ("bearer", "cookie"):
|
||||
resp = self._auth(client, token, source)
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertEqual(resp.headers["remote-role"], "viewer")
|
||||
|
||||
def test_deleted_user_is_rejected(self):
|
||||
app = self._create_app()
|
||||
token = self._token(app, "bob", "garage")
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
self.assertEqual(self._auth(client, token).status_code, 202)
|
||||
self.assertEqual(client.delete("/users/bob").status_code, 200)
|
||||
|
||||
for source in ("bearer", "cookie"):
|
||||
self.assertEqual(self._auth(client, token, source).status_code, 401)
|
||||
|
||||
def test_recreated_user_rejects_token_from_deleted_account(self):
|
||||
app = self._create_app()
|
||||
token = self._token(app, "bob", "garage", iat=int(time.time()) - 10)
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
self.assertEqual(client.delete("/users/bob").status_code, 200)
|
||||
resp = client.post(
|
||||
"/users",
|
||||
json={"username": "bob", "password": PASSWORD, "role": "viewer"},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
for source in ("bearer", "cookie"):
|
||||
self.assertEqual(self._auth(client, token, source).status_code, 401)
|
||||
|
||||
def test_created_user_is_accepted(self):
|
||||
app = self._create_app()
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.post(
|
||||
"/users",
|
||||
json={"username": "carol", "password": PASSWORD, "role": "viewer"},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
resp = self._auth(client, self._token(app, "carol", "viewer"))
|
||||
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertEqual(resp.headers["remote-role"], "viewer")
|
||||
|
||||
def test_password_change_revokes_older_tokens(self):
|
||||
app = self._create_app()
|
||||
old_token = self._token(app, "bob", "garage", iat=int(time.time()) - 10)
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
self.assertEqual(self._auth(client, old_token).status_code, 202)
|
||||
resp = client.put(
|
||||
"/users/bob/password",
|
||||
json={"password": "a-brand-new-password"},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
for source in ("bearer", "cookie"):
|
||||
self.assertEqual(self._auth(client, old_token, source).status_code, 401)
|
||||
|
||||
changed_at = app.user_sessions["bob"][1]
|
||||
resp = self._auth(client, self._token(app, "bob", "garage", changed_at))
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
|
||||
def test_login_in_the_same_second_as_password_change_is_valid(self):
|
||||
app = self._create_app()
|
||||
changed_at = datetime.now().replace(microsecond=500000)
|
||||
new_token = self._token(app, "bob", "garage", iat=int(changed_at.timestamp()))
|
||||
|
||||
with (
|
||||
patch("frigate.api.auth.datetime") as mock_datetime,
|
||||
AuthTestClient(app) as client,
|
||||
):
|
||||
mock_datetime.now.return_value = changed_at
|
||||
resp = client.put(
|
||||
"/users/bob/password",
|
||||
json={"password": "a-brand-new-password"},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
for source in ("bearer", "cookie"):
|
||||
self.assertEqual(self._auth(client, new_token, source).status_code, 202)
|
||||
|
||||
def test_own_password_change_cookie_stays_valid(self):
|
||||
app = self._create_app()
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put(
|
||||
"/users/bob/password",
|
||||
json={"password": "a-brand-new-password", "old_password": PASSWORD},
|
||||
headers={"remote-user": "bob", "remote-role": "garage"},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
new_token = resp.cookies["frigate_token"]
|
||||
|
||||
resp = self._auth(client, new_token, "cookie")
|
||||
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertEqual(resp.headers["remote-role"], "garage")
|
||||
|
||||
def test_admin_password_reset_revokes_older_tokens(self):
|
||||
config = FrigateConfig(
|
||||
**{**self.minimal_config, "auth": {"reset_admin_password": True}}
|
||||
)
|
||||
FrigateApp.init_auth(SimpleNamespace(config=config))
|
||||
|
||||
app = self._create_app()
|
||||
old_token = self._token(app, "admin", "admin", iat=int(time.time()) - 10)
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
for source in ("bearer", "cookie"):
|
||||
self.assertEqual(self._auth(client, old_token, source).status_code, 401)
|
||||
|
||||
def test_auth_does_not_query_users(self):
|
||||
app = self._create_app()
|
||||
|
||||
# expires inside the refresh window so the cookie refresh runs too
|
||||
token = jwt.encode(
|
||||
{"alg": "HS256"},
|
||||
{"sub": "bob", "role": "garage", "exp": int(time.time()) + 60, "iat": 0},
|
||||
app.jwt_token,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(User, "select", side_effect=AssertionError("db query")),
|
||||
AuthTestClient(app) as client,
|
||||
):
|
||||
client.cookies.set("frigate_token", token)
|
||||
resp = client.get("/auth", headers={"x-server-port": "8971"})
|
||||
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertIn("frigate_token=", resp.headers["set-cookie"])
|
||||
|
||||
def _login_cookie(self, headers: dict | None = None) -> str:
|
||||
with AuthTestClient(self._create_app()) as client:
|
||||
resp = client.post(
|
||||
"/login",
|
||||
json={"user": "bob", "password": PASSWORD},
|
||||
headers=headers or {},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
return resp.headers["set-cookie"].lower()
|
||||
|
||||
def test_login_cookie_is_secure_over_tls(self):
|
||||
cookie = self._login_cookie({"x-forwarded-proto": "https"})
|
||||
self.assertIn("; secure", cookie)
|
||||
|
||||
def test_login_cookie_is_not_secure_over_http(self):
|
||||
cookie = self._login_cookie({"x-forwarded-proto": "http"})
|
||||
self.assertNotIn("; secure", cookie)
|
||||
|
||||
def test_cookie_secure_config_forces_secure(self):
|
||||
self.minimal_config["auth"]["cookie_secure"] = True
|
||||
cookie = self._login_cookie()
|
||||
self.assertIn("; secure", cookie)
|
||||
|
||||
def test_refreshed_cookie_is_secure_over_tls(self):
|
||||
app = self._create_app()
|
||||
token = jwt.encode(
|
||||
{"alg": "HS256"},
|
||||
{"sub": "bob", "exp": int(time.time()) + 60, "iat": int(time.time())},
|
||||
app.jwt_token,
|
||||
)
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
client.cookies.set("frigate_token", token)
|
||||
resp = client.get(
|
||||
"/auth",
|
||||
headers={"x-server-port": "8971", "x-forwarded-proto": "https"},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertIn("; secure", resp.headers["set-cookie"].lower())
|
||||
self.assertNotIn("remote-role", resp.headers)
|
||||
|
||||
@@ -14,7 +14,6 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.config.holder import ConfigHolder
|
||||
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
@@ -504,358 +503,6 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
camera_path = "rtsp://10.0.0.1:554/video?password=s3cret&user=admin"
|
||||
|
||||
def _add_credentials(self):
|
||||
"""Give the config a credentialed camera path and go2rtc secrets."""
|
||||
self.minimal_config["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"] = (
|
||||
self.camera_path
|
||||
)
|
||||
self.minimal_config["go2rtc"] = {
|
||||
"rtsp": {"username": "rtspuser", "password": "rtsppass"},
|
||||
"api": {"username": "apiuser", "password": "apipass"},
|
||||
"mqtt": {"username": "mquser", "password": "mqpass"},
|
||||
"ngrok": {"command": "ngrok tcp 8555 --authtoken ngroksecret"},
|
||||
"homekit": {"front_door": {"pin": "19550224"}},
|
||||
"publish": {"front_door": "rtmp://a.rtmp.youtube.com/live2/streamkey"},
|
||||
"webrtc": {
|
||||
"candidates": ["10.0.0.5:8555"],
|
||||
"ice_servers": [
|
||||
{
|
||||
"urls": ["turn:turn.example.com:3478"],
|
||||
"username": "turnuser",
|
||||
"credential": "turnpass",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
def _read_config_file(self, config_path):
|
||||
with open(config_path) as f:
|
||||
raw = f.read()
|
||||
return raw, ruamel.yaml.YAML().load(raw)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_masked_camera_path_round_trips(self, mock_find_config):
|
||||
self._add_credentials()
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
camera = client.get("/config").json()["cameras"]["front_door"]
|
||||
inputs = camera["ffmpeg"]["inputs"]
|
||||
self.assertNotIn("s3cret", inputs[0]["path"])
|
||||
|
||||
resp = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {"inputs": inputs},
|
||||
"detect": {"fps": 7},
|
||||
}
|
||||
}
|
||||
},
|
||||
"requires_restart": 0,
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
_, saved = self._read_config_file(config_path)
|
||||
self.assertEqual(
|
||||
saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"],
|
||||
self.camera_path,
|
||||
)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
def _save(self, client, config_data):
|
||||
return client.put(
|
||||
"/config/set",
|
||||
json={"config_data": config_data, "requires_restart": 0},
|
||||
)
|
||||
|
||||
def _set_inputs(self, paths):
|
||||
self.minimal_config["cameras"]["front_door"]["ffmpeg"]["inputs"] = [
|
||||
{"path": path, "roles": [role]} for path, role in paths
|
||||
]
|
||||
|
||||
# config parsing rebuilds FRIGATE_ENV_VARS from its sources
|
||||
@patch.dict("frigate.util.env._CONTAINER_ENV", {"FRIGATE_RTSP_PASSWORD": "s3cret"})
|
||||
@patch.dict(
|
||||
"frigate.util.env.FRIGATE_ENV_VARS", {"FRIGATE_RTSP_PASSWORD": "s3cret"}
|
||||
)
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_masked_path_keeps_env_placeholder(self, mock_find_config):
|
||||
path = "rtsp://admin:{FRIGATE_RTSP_PASSWORD}@10.0.0.1:554/live"
|
||||
self._set_inputs([(path, "detect")])
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
camera = client.get("/config").json()["cameras"]["front_door"]
|
||||
self.assertNotIn("s3cret", str(camera))
|
||||
|
||||
resp = self._save(
|
||||
client,
|
||||
{
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {"inputs": camera["ffmpeg"]["inputs"]},
|
||||
"detect": {"fps": 7},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
raw, saved = self._read_config_file(config_path)
|
||||
self.assertNotIn("s3cret", raw)
|
||||
self.assertEqual(
|
||||
saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"], path
|
||||
)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_over_masked_path_round_trips(self, mock_find_config):
|
||||
path = "rtsp://10.0.0.1:554/live?x=a@b"
|
||||
self._set_inputs([(path, "detect")])
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
inputs = client.get("/config").json()["cameras"]["front_door"][
|
||||
"ffmpeg"
|
||||
]["inputs"]
|
||||
self.assertNotEqual(inputs[0]["path"], path)
|
||||
|
||||
resp = self._save(
|
||||
client,
|
||||
{"cameras": {"front_door": {"ffmpeg": {"inputs": inputs}}}},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
_, saved = self._read_config_file(config_path)
|
||||
self.assertEqual(
|
||||
saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"], path
|
||||
)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_unrestorable_masked_values_are_rejected(self, mock_find_config):
|
||||
self._add_credentials()
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
for name, request in (
|
||||
(
|
||||
"index-keyed inputs",
|
||||
{
|
||||
"json": {
|
||||
"config_data": {
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": {
|
||||
"0": {"path": "rtsp://*:*@h/x"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"requires_restart": 0,
|
||||
}
|
||||
},
|
||||
),
|
||||
(
|
||||
"go2rtc streams",
|
||||
{
|
||||
"json": {
|
||||
"config_data": {
|
||||
"go2rtc": {"streams": {"cam": ["rtsp://*:*@h/x"]}}
|
||||
},
|
||||
"requires_restart": 0,
|
||||
}
|
||||
},
|
||||
),
|
||||
(
|
||||
"query string",
|
||||
{
|
||||
"params": {
|
||||
"cameras.front_door.ffmpeg.inputs.0.path": (
|
||||
"rtsp://*:*@h/x"
|
||||
),
|
||||
"go2rtc.rtsp.password": REDACTED_CREDENTIAL_SENTINEL,
|
||||
},
|
||||
"json": {"requires_restart": 0},
|
||||
},
|
||||
),
|
||||
):
|
||||
with self.subTest(name):
|
||||
resp = client.put("/config/set", **request)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertIn("credentials", resp.json()["message"])
|
||||
|
||||
raw, _ = self._read_config_file(config_path)
|
||||
self.assertNotIn("*:*", raw)
|
||||
self.assertNotIn(REDACTED_CREDENTIAL_SENTINEL, raw)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_go2rtc_credentials_redacted_and_round_trip(self, mock_find_config):
|
||||
self._add_credentials()
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.get("/config").json()
|
||||
body = str(response)
|
||||
for secret in (
|
||||
"rtsppass",
|
||||
"apipass",
|
||||
"mqpass",
|
||||
"ngroksecret",
|
||||
"19550224",
|
||||
"streamkey",
|
||||
):
|
||||
self.assertNotIn(secret, body)
|
||||
|
||||
go2rtc = response["go2rtc"]
|
||||
self.assertEqual(go2rtc["webrtc"]["candidates"], ["10.0.0.5:8555"])
|
||||
# browsers need the TURN account to relay WebRTC
|
||||
self.assertEqual(
|
||||
go2rtc["webrtc"]["ice_servers"][0]["credential"], "turnpass"
|
||||
)
|
||||
|
||||
go2rtc.pop("ngrok")
|
||||
go2rtc["webrtc"]["candidates"] = ["10.0.0.9:8555"]
|
||||
self.assertEqual(
|
||||
self._save(client, {"go2rtc": go2rtc}).status_code, 200
|
||||
)
|
||||
|
||||
raw, saved = self._read_config_file(config_path)
|
||||
self.assertNotIn(REDACTED_CREDENTIAL_SENTINEL, raw)
|
||||
self.assertEqual(saved["go2rtc"]["rtsp"]["password"], "rtsppass")
|
||||
self.assertEqual(saved["go2rtc"]["mqtt"]["password"], "mqpass")
|
||||
self.assertEqual(
|
||||
saved["go2rtc"]["homekit"]["front_door"]["pin"], "19550224"
|
||||
)
|
||||
self.assertIn("streamkey", saved["go2rtc"]["publish"]["front_door"])
|
||||
self.assertEqual(saved["go2rtc"]["webrtc"]["candidates"], ["10.0.0.9:8555"])
|
||||
self.assertEqual(
|
||||
saved["go2rtc"]["webrtc"]["ice_servers"][0]["credential"], "turnpass"
|
||||
)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_masked_inputs_restore_by_value(self, mock_find_config):
|
||||
main = "rtsp://u:MAIN@10.0.0.1:554/main"
|
||||
sub = "rtsp://u:SUB@10.0.0.1:554/sub"
|
||||
|
||||
for name, edit, expected in (
|
||||
("delete first", lambda inputs: [inputs[1]], [sub]),
|
||||
("reorder", lambda inputs: [inputs[1], inputs[0]], [sub, main]),
|
||||
):
|
||||
with self.subTest(name):
|
||||
self._set_inputs([(main, "record"), (sub, "detect")])
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
inputs = client.get("/config").json()["cameras"]["front_door"][
|
||||
"ffmpeg"
|
||||
]["inputs"]
|
||||
resp = self._save(
|
||||
client,
|
||||
{
|
||||
"cameras": {
|
||||
"front_door": {"ffmpeg": {"inputs": edit(inputs)}}
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
_, saved = self._read_config_file(config_path)
|
||||
saved_inputs = saved["cameras"]["front_door"]["ffmpeg"]["inputs"]
|
||||
self.assertEqual([i["path"] for i in saved_inputs], expected)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_ambiguous_or_unmatched_masked_input(self, mock_find_config):
|
||||
self._set_inputs(
|
||||
[
|
||||
("rtsp://u:one@10.0.0.1:554/live", "record"),
|
||||
("rtsp://u:two@10.0.0.1:554/live", "detect"),
|
||||
]
|
||||
)
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
inputs = client.get("/config").json()["cameras"]["front_door"][
|
||||
"ffmpeg"
|
||||
]["inputs"]
|
||||
|
||||
# both inputs mask to the same value and roles no longer tell
|
||||
# them apart
|
||||
remaining = dict(inputs[1], roles=["detect", "record"])
|
||||
resp = self._save(
|
||||
client,
|
||||
{"cameras": {"front_door": {"ffmpeg": {"inputs": [remaining]}}}},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
inputs[0]["path"] = "rtsp://*:*@10.0.0.99:554/live"
|
||||
resp = self._save(
|
||||
client,
|
||||
{"cameras": {"front_door": {"ffmpeg": {"inputs": inputs}}}},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
# roles break the tie when the second input stays as it was
|
||||
resp = self._save(
|
||||
client,
|
||||
{"cameras": {"front_door": {"ffmpeg": {"inputs": [inputs[1]]}}}},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
raw, saved = self._read_config_file(config_path)
|
||||
self.assertNotIn("*:*", raw)
|
||||
self.assertEqual(
|
||||
saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"],
|
||||
"rtsp://u:two@10.0.0.1:554/live",
|
||||
)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -33,7 +33,6 @@ class TestGo2rtcProxyAuth(BaseTestHttp):
|
||||
self.minimal_config = _CONFIG
|
||||
self.app = super().create_app()
|
||||
self.app.jwt_token = OctKey.import_key(b"0" * 32)
|
||||
self.app.user_sessions = {"admin": ("admin", None), "viewer": ("viewer", None)}
|
||||
|
||||
get_patch = patch(
|
||||
"frigate.api.auth.requests.get",
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
"""Tests for motion search job limits."""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.jobs import motion_search
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
URL = "/front_door/search/motion"
|
||||
BODY = {
|
||||
"start_time": 1000,
|
||||
"end_time": 2000,
|
||||
"polygon_points": [[0, 0], [1, 0], [1, 1]],
|
||||
}
|
||||
|
||||
|
||||
class TestHttpMotionSearch(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([Event, Recordings, ReviewSegment])
|
||||
self.app = self.create_app()
|
||||
|
||||
motion_search._motion_search_jobs.clear()
|
||||
self.addCleanup(motion_search._motion_search_jobs.clear)
|
||||
|
||||
runner_patch = patch("frigate.jobs.motion_search.MotionSearchRunner")
|
||||
self.runner = runner_patch.start()
|
||||
self.addCleanup(runner_patch.stop)
|
||||
|
||||
def start(self, client: AuthTestClient, user: str, body: dict = BODY):
|
||||
return client.post(URL, json=body, headers={"remote-user": user})
|
||||
|
||||
def test_second_job_for_same_user_rejected(self):
|
||||
with AuthTestClient(self.app) as client:
|
||||
self.assertEqual(self.start(client, "alice").status_code, 200)
|
||||
self.assertEqual(self.start(client, "alice").status_code, 409)
|
||||
self.assertEqual(self.start(client, "bob").status_code, 200)
|
||||
|
||||
def test_user_can_start_again_after_job_finishes(self):
|
||||
with AuthTestClient(self.app) as client:
|
||||
job_id = self.start(client, "alice").json()["job_id"]
|
||||
job = motion_search.get_motion_search_job(job_id)
|
||||
job.end_time = datetime.now().timestamp()
|
||||
self.assertEqual(self.start(client, "alice").status_code, 200)
|
||||
self.assertIsNotNone(motion_search.get_motion_search_job(job_id))
|
||||
|
||||
def test_cancelled_job_frees_user_but_holds_global_slot_until_grace(self):
|
||||
with AuthTestClient(self.app) as client:
|
||||
job_id = self.start(client, "alice").json()["job_id"]
|
||||
with patch("frigate.jobs.motion_search.InterProcessRequestor"):
|
||||
self.assertTrue(motion_search.cancel_motion_search_job(job_id))
|
||||
job = motion_search.get_motion_search_job(job_id)
|
||||
self.assertIsNotNone(job.cancelled_at)
|
||||
self.assertIsNone(job.end_time)
|
||||
|
||||
self.assertEqual(self.start(client, "alice").status_code, 200)
|
||||
self.assertEqual(self.start(client, "bob").status_code, 200)
|
||||
self.assertEqual(self.start(client, "carol").status_code, 429)
|
||||
|
||||
job.cancelled_at -= motion_search.CANCEL_GRACE_SECONDS + 1
|
||||
self.assertEqual(self.start(client, "carol").status_code, 200)
|
||||
|
||||
def test_start_failure_releases_slot(self):
|
||||
self.runner.return_value.start.side_effect = RuntimeError("boom")
|
||||
with AuthTestClient(self.app) as client:
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.start(client, "alice")
|
||||
self.assertEqual(motion_search._motion_search_jobs, {})
|
||||
self.runner.return_value.requestor.stop.assert_called_once()
|
||||
|
||||
self.runner.return_value.start.side_effect = None
|
||||
self.assertEqual(self.start(client, "alice").status_code, 200)
|
||||
|
||||
def test_shared_identity_skips_per_user_limit(self):
|
||||
with AuthTestClient(self.app) as client:
|
||||
self.assertEqual(self.start(client, "anonymous").status_code, 200)
|
||||
self.assertEqual(self.start(client, "anonymous").status_code, 200)
|
||||
|
||||
motion_search._motion_search_jobs.clear()
|
||||
self.minimal_config["auth"] = {"enabled": False}
|
||||
app = self.create_app()
|
||||
with AuthTestClient(app) as client:
|
||||
self.assertEqual(self.start(client, "viewer").status_code, 200)
|
||||
self.assertEqual(self.start(client, "viewer").status_code, 200)
|
||||
|
||||
def test_proxy_user_header_keeps_per_user_limit_without_auth(self):
|
||||
self.minimal_config["auth"] = {"enabled": False}
|
||||
self.minimal_config["proxy"] = {"header_map": {"user": "x-forwarded-user"}}
|
||||
app = self.create_app()
|
||||
with AuthTestClient(app) as client:
|
||||
self.assertEqual(self.start(client, "alice").status_code, 200)
|
||||
self.assertEqual(self.start(client, "alice").status_code, 409)
|
||||
|
||||
def test_global_cap_enforced(self):
|
||||
with AuthTestClient(self.app) as client:
|
||||
for i in range(motion_search.MAX_ACTIVE_JOBS):
|
||||
self.assertEqual(self.start(client, f"user{i}").status_code, 200)
|
||||
self.assertEqual(self.start(client, "late").status_code, 429)
|
||||
|
||||
def test_finished_jobs_evicted_after_ttl(self):
|
||||
with AuthTestClient(self.app) as client:
|
||||
old_id = self.start(client, "alice").json()["job_id"]
|
||||
recent_id = self.start(client, "bob").json()["job_id"]
|
||||
running_id = self.start(client, "carol").json()["job_id"]
|
||||
|
||||
now = 10_000.0
|
||||
motion_search.get_motion_search_job(old_id).end_time = (
|
||||
now - motion_search.FINISHED_JOB_TTL - 1
|
||||
)
|
||||
motion_search.get_motion_search_job(recent_id).end_time = now - 1
|
||||
motion_search._evict_finished_jobs(now)
|
||||
|
||||
self.assertIsNone(motion_search.get_motion_search_job(old_id))
|
||||
self.assertIsNotNone(motion_search.get_motion_search_job(recent_id))
|
||||
self.assertIsNotNone(motion_search.get_motion_search_job(running_id))
|
||||
|
||||
def test_username_and_cancel_time_not_broadcast(self):
|
||||
job = motion_search.MotionSearchJob(username="alice", cancelled_at=1.0)
|
||||
payload = job.to_dict()
|
||||
self.assertNotIn("username", payload)
|
||||
self.assertNotIn("cancelled_at", payload)
|
||||
@@ -1,13 +1,8 @@
|
||||
"""Test camera user and password cleanup."""
|
||||
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from frigate.util.builtin import (
|
||||
clean_camera_user_pass,
|
||||
escape_special_characters,
|
||||
is_masked_camera_path,
|
||||
)
|
||||
from frigate.util.builtin import clean_camera_user_pass, escape_special_characters
|
||||
|
||||
|
||||
class TestUserPassCleanup(unittest.TestCase):
|
||||
@@ -50,90 +45,3 @@ class TestUserPassMasking(unittest.TestCase):
|
||||
escaped = clean_camera_user_pass(self.rtsp_log_message)
|
||||
print(f"The escaped is {escaped}")
|
||||
assert escaped == "Did you mean file:rtsp://*:*@192.168.1.3:554"
|
||||
|
||||
def test_masks_userinfo_and_credential_query_forms(self):
|
||||
cases = {
|
||||
"rtsp://first.last@mail.com:p%40w@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
|
||||
"rtsp://user+tag:pw@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
|
||||
"rtsp://:pw@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
|
||||
"tapo://cloudpassword@10.0.0.2": "tapo://*:*@10.0.0.2",
|
||||
"rtsp://u:p@ss@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
|
||||
"rtsp://user:pa/ss@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
|
||||
"rtsp://u:p@ss#x@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
|
||||
"rtsp://admin:2024/pw@10.0.0.2/x": "rtsp://*:*@10.0.0.2/x",
|
||||
"rtsp://admin:12#abc@10.0.0.2/x": "rtsp://*:*@10.0.0.2/x",
|
||||
"rtsp://admin:7?x@10.0.0.2/x": "rtsp://*:*@10.0.0.2/x",
|
||||
"rtsps://u:1/2@h/x": "rtsps://*:*@h/x",
|
||||
"rtsp://admin:pre&token=suf@10.0.0.2/x": "rtsp://*:*@",
|
||||
"rtsp://admin:a?pwd=b@10.0.0.2/x": "rtsp://*:*@",
|
||||
"rtsp://10.0.0.2:554/live?password=pre@suf": "rtsp://*:*@",
|
||||
"http://10.0.0.2:88/cgi?cmd=snap&usr=admin&pwd=P@ss": "http://*:*@",
|
||||
"ffmpeg:http://h:80/flv?user=u&password=p@ss#video=copy": (
|
||||
"ffmpeg:http://*:*@"
|
||||
),
|
||||
"rtsp://u:p@10.0.0.2:554/live?token=t": "rtsp://*:*@10.0.0.2:554/live?token=*",
|
||||
"http://h/x?api_key=a&apikey=b&access_token=c": (
|
||||
"http://h/x?api_key=*&apikey=*&access_token=*"
|
||||
),
|
||||
"http://h/cgi?user=admin&password=x": "http://h/cgi?user=*&password=*",
|
||||
"http://h/cgi?password=x&user=admin&channel=1": (
|
||||
"http://h/cgi?password=*&user=*&channel=1"
|
||||
),
|
||||
"http://h/x?usr=a&pwd=b": "http://h/x?usr=*&pwd=*",
|
||||
"http://h/x?username=a&password=b": "http://h/x?username=*&password=*",
|
||||
"rtsp://host/live?user=admin&password=p@ss": (
|
||||
"rtsp://host/live?user=*&password=*"
|
||||
),
|
||||
"ring:?refresh_token=abc.def": "ring:?refresh_token=*",
|
||||
"nest:?client_id=1&client_secret=s&refresh_token=t&project_id=p": (
|
||||
"nest:?client_id=1&client_secret=*&refresh_token=*&project_id=p"
|
||||
),
|
||||
"homekit://10.0.0.2:51827?client_id=c&client_private=k&device_id=d": (
|
||||
"homekit://10.0.0.2:51827?client_id=c&client_private=*&device_id=d"
|
||||
),
|
||||
"exec:ffmpeg -i rtsp://u:p@h/s -c copy -f rtsp {output}": (
|
||||
"exec:ffmpeg -i rtsp://*:*@h/s -c copy -f rtsp {output}"
|
||||
),
|
||||
"ffmpeg -i rtsp://u:p@h/1 -i http://h/snap?user=a&password=b -f null": (
|
||||
"ffmpeg -i rtsp://*:*@h/1 -i http://h/snap?user=*&password=* -f null"
|
||||
),
|
||||
}
|
||||
|
||||
for line, expected in cases.items():
|
||||
with self.subTest(line=line):
|
||||
masked = clean_camera_user_pass(line)
|
||||
self.assertEqual(masked, expected)
|
||||
self.assertTrue(is_masked_camera_path(masked))
|
||||
self.assertEqual(clean_camera_user_pass(masked), masked)
|
||||
|
||||
def test_safe_strings_unchanged(self):
|
||||
for line in (
|
||||
"rtsp://10.0.0.2:554/live",
|
||||
"ffmpeg:front_door#video=copy",
|
||||
"rtsp://127.0.0.1:8554/front_door?video=copy&audio=aac",
|
||||
"homekit://10.0.0.2:51827?device_id=d",
|
||||
"http://10.0.0.1/snap.jpg?email=me@x.com",
|
||||
"http://h/x?auth=a&author=b&authorization=c&user_agent=d&autoken=e",
|
||||
"http://h/x?private=1&passthrough=1",
|
||||
"Camera front_door is not responding",
|
||||
):
|
||||
with self.subTest(line=line):
|
||||
self.assertEqual(clean_camera_user_pass(line), line)
|
||||
self.assertFalse(is_masked_camera_path(line))
|
||||
|
||||
def test_commas_outside_credentials_survive(self):
|
||||
"""ffprobe splits multiple paths on commas left after masking."""
|
||||
for line in (
|
||||
"rtsp://a/x,rtsp://u:p@b/y",
|
||||
"rtsp://a:554/x,rtsp://u:p@b/y",
|
||||
):
|
||||
with self.subTest(line=line):
|
||||
self.assertIn(",", clean_camera_user_pass(line))
|
||||
self.assertNotIn(",", clean_camera_user_pass("rtsp://u:p,q@b/y"))
|
||||
|
||||
def test_long_line_masks_in_linear_time(self):
|
||||
"""Log lines run through the mask, so a long one must stay fast."""
|
||||
start = time.perf_counter()
|
||||
clean_camera_user_pass("rtsp://u:p" * 20000)
|
||||
clean_camera_user_pass("rtsp://" + "a." * 40000)
|
||||
self.assertLess(time.perf_counter() - start, 1)
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Tests that IPC handler errors cannot stall inter-process communication."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import zmq
|
||||
|
||||
from frigate.comms.dispatcher import Dispatcher
|
||||
from frigate.comms.inter_process import InterProcessCommunicator
|
||||
|
||||
|
||||
class TestInterProcessHandlerErrors(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.address = f"ipc://{os.path.join(self.tmp.name, 'comms')}"
|
||||
|
||||
with patch("frigate.comms.inter_process.SOCKET_REP_REQ", self.address):
|
||||
self.communicator = InterProcessCommunicator()
|
||||
|
||||
self.context = zmq.Context()
|
||||
self.requestor = self.context.socket(zmq.REQ)
|
||||
self.requestor.setsockopt(zmq.RCVTIMEO, 5000)
|
||||
self.requestor.connect(self.address)
|
||||
|
||||
def tearDown(self):
|
||||
self.requestor.close(linger=0)
|
||||
self.context.destroy(linger=0)
|
||||
self.communicator.stop()
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_handler_exception_still_replies_and_keeps_reading(self):
|
||||
def dispatcher(topic, value):
|
||||
if topic == "boom":
|
||||
raise ValueError("handler failed")
|
||||
return value
|
||||
|
||||
self.communicator.subscribe(dispatcher)
|
||||
self.communicator.start()
|
||||
|
||||
with self.assertLogs("frigate.comms.inter_process", level="ERROR"):
|
||||
self.requestor.send_json(("boom", None))
|
||||
self.assertEqual(self.requestor.recv_json(), [])
|
||||
|
||||
self.requestor.send_json(("echo", "ok"))
|
||||
self.assertEqual(self.requestor.recv_json(), "ok")
|
||||
self.assertTrue(self.communicator.reader_thread.is_alive())
|
||||
|
||||
|
||||
class TestDispatcherPublishErrors(unittest.TestCase):
|
||||
def test_failing_communicator_does_not_block_the_others(self):
|
||||
failing = MagicMock()
|
||||
failing.publish.side_effect = ValueError("publish failed")
|
||||
healthy = MagicMock()
|
||||
|
||||
with (
|
||||
patch("frigate.comms.dispatcher.CameraActivityManager"),
|
||||
patch("frigate.comms.dispatcher.AudioActivityManager"),
|
||||
):
|
||||
dispatcher = Dispatcher(
|
||||
MagicMock(), MagicMock(), MagicMock(), {}, [failing, healthy]
|
||||
)
|
||||
|
||||
healthy.publish.reset_mock()
|
||||
|
||||
with self.assertLogs("frigate.comms.dispatcher", level="ERROR"):
|
||||
dispatcher.publish("reviews", "{}")
|
||||
dispatcher.publish_local("notices", "[]")
|
||||
|
||||
healthy.publish.assert_any_call("reviews", "{}", False)
|
||||
healthy.publish.assert_any_call("notices", "[]", False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for the motion search hardware-accelerated decode helpers."""
|
||||
|
||||
import subprocess as sp
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
@@ -141,11 +140,6 @@ class TestVodDecodeCommand(unittest.TestCase):
|
||||
|
||||
|
||||
class TestProbeVodKeyframePts(unittest.TestCase):
|
||||
def _proc(self, stdout="", returncode=0):
|
||||
proc = mock.Mock(returncode=returncode)
|
||||
proc.communicate.return_value = (stdout, None)
|
||||
return proc
|
||||
|
||||
def test_parses_keyframe_packets(self):
|
||||
sample = (
|
||||
'{"packets":['
|
||||
@@ -153,37 +147,19 @@ class TestProbeVodKeyframePts(unittest.TestCase):
|
||||
'{"pts_time":"1.000000","flags":"___"},'
|
||||
'{"pts_time":"2.000000","flags":"K__"}]}'
|
||||
)
|
||||
completed = mock.Mock(stdout=sample, returncode=0)
|
||||
with mock.patch(
|
||||
"frigate.jobs.motion_search_decode.sp.Popen",
|
||||
return_value=self._proc(sample),
|
||||
"frigate.jobs.motion_search_decode.sp.run", return_value=completed
|
||||
):
|
||||
pts = probe_vod_keyframe_pts(
|
||||
"ffprobe", "http://x/index.m3u8", lambda: False
|
||||
)
|
||||
pts = probe_vod_keyframe_pts("ffprobe", "http://x/index.m3u8")
|
||||
self.assertEqual(pts, [0.0, 2.0])
|
||||
|
||||
def test_returns_empty_on_failure(self):
|
||||
with mock.patch(
|
||||
"frigate.jobs.motion_search_decode.sp.Popen",
|
||||
"frigate.jobs.motion_search_decode.sp.run",
|
||||
side_effect=OSError("boom"),
|
||||
):
|
||||
self.assertEqual(
|
||||
probe_vod_keyframe_pts("ffprobe", "http://x", lambda: False), []
|
||||
)
|
||||
|
||||
def test_stop_request_kills_probe(self):
|
||||
proc = self._proc()
|
||||
proc.communicate.side_effect = [
|
||||
sp.TimeoutExpired("ffprobe", 0.5),
|
||||
("", None),
|
||||
]
|
||||
with mock.patch(
|
||||
"frigate.jobs.motion_search_decode.sp.Popen", return_value=proc
|
||||
):
|
||||
self.assertEqual(
|
||||
probe_vod_keyframe_pts("ffprobe", "http://x", lambda: True), []
|
||||
)
|
||||
proc.kill.assert_called_once()
|
||||
self.assertEqual(probe_vod_keyframe_pts("ffprobe", "http://x"), [])
|
||||
|
||||
|
||||
class TestProbeVideoDimensions(unittest.TestCase):
|
||||
|
||||
@@ -36,73 +36,6 @@ class TestListenConfig(unittest.TestCase):
|
||||
self.assertEqual(listen.internal_port, 5001)
|
||||
self.assertEqual(listen.external_port, 8971)
|
||||
|
||||
def test_valid_listen_values_are_rebuilt(self):
|
||||
cases = {
|
||||
5000: 5000,
|
||||
"5000": 5000,
|
||||
"127.0.0.1:5000": "127.0.0.1:5000",
|
||||
"0.0.0.0:5000": "0.0.0.0:5000",
|
||||
"[::1]:5000": "[::1]:5000",
|
||||
"[::]:5000": "[::]:5000",
|
||||
"[2001:DB8:0:0::1]:5000": "[2001:db8::1]:5000",
|
||||
" 5000 ": 5000,
|
||||
"5000\n": 5000,
|
||||
" 127.0.0.1:5000 ": "127.0.0.1:5000",
|
||||
"localhost:5000": "localhost:5000",
|
||||
"frigate-host.lan:5000": "frigate-host.lan:5000",
|
||||
"*:5000": "*:5000",
|
||||
}
|
||||
|
||||
for value, expected in cases.items():
|
||||
with self.subTest(value=value):
|
||||
listen = ListenConfig(internal=value)
|
||||
|
||||
self.assertEqual(listen.internal, expected)
|
||||
self.assertEqual(listen.internal_port, 5000)
|
||||
|
||||
def test_invalid_listen_values_rejected(self):
|
||||
cases = [
|
||||
"5000; location /x/ { alias /; autoindex on; } #:5000",
|
||||
"127.0.0.1:5000; include /etc/passwd",
|
||||
"5000 ssl",
|
||||
"5000;",
|
||||
"::1:5000",
|
||||
"[127.0.0.1]:5000",
|
||||
"[fe80::1%eth0]:5000",
|
||||
"local host:5000",
|
||||
"localhost;:5000",
|
||||
"local{host}:5000",
|
||||
"host#:5000",
|
||||
"'localhost':5000",
|
||||
'"localhost":5000',
|
||||
"-host:5000",
|
||||
"host-:5000",
|
||||
"host..lan:5000",
|
||||
"**:5000",
|
||||
"*.lan:5000",
|
||||
"unix:/tmp/sock:5000",
|
||||
"5000 5001",
|
||||
":5000",
|
||||
"127.0.0.1:",
|
||||
"127.0.0.1:+5000",
|
||||
0,
|
||||
65536,
|
||||
-1,
|
||||
]
|
||||
|
||||
for value in cases:
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(ValidationError):
|
||||
ListenConfig(internal=value)
|
||||
|
||||
def test_bool_listen_value_rejected(self):
|
||||
# get_nginx_settings.py sees the raw bool and falls back to the default,
|
||||
# so accepting it as port 1 would split nginx and the auth port check
|
||||
for value in (True, False):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(ValidationError):
|
||||
ListenConfig(internal=value)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@@ -3,13 +3,8 @@
|
||||
import unittest
|
||||
from base64 import urlsafe_b64encode
|
||||
|
||||
from frigate.api.notification import _validate_subscription
|
||||
from frigate.comms.webpush import (
|
||||
_build_web_pushers,
|
||||
_NoRedirectSession,
|
||||
_vapid_audience,
|
||||
validate_push_endpoint,
|
||||
)
|
||||
from frigate.api.notification import _validate_push_endpoint, _validate_subscription
|
||||
from frigate.comms.webpush import _build_web_pushers
|
||||
|
||||
VALID_ENDPOINTS = [
|
||||
"https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLXRva2Vu",
|
||||
@@ -31,11 +26,11 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
||||
def test_accepts_real_push_service_endpoints(self):
|
||||
for endpoint in VALID_ENDPOINTS:
|
||||
with self.subTest(endpoint=endpoint):
|
||||
self.assertIsNone(validate_push_endpoint(endpoint))
|
||||
self.assertIsNone(_validate_push_endpoint(endpoint))
|
||||
|
||||
def test_rejects_http(self):
|
||||
self.assertIsNotNone(
|
||||
validate_push_endpoint("http://fcm.googleapis.com/fcm/send/token")
|
||||
_validate_push_endpoint("http://fcm.googleapis.com/fcm/send/token")
|
||||
)
|
||||
|
||||
def test_rejects_non_http_schemes(self):
|
||||
@@ -45,7 +40,7 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
||||
"//example.com/token",
|
||||
):
|
||||
with self.subTest(endpoint=endpoint):
|
||||
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
||||
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
||||
|
||||
def test_rejects_localhost(self):
|
||||
for endpoint in (
|
||||
@@ -55,7 +50,7 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
||||
"https://[::1]/token",
|
||||
):
|
||||
with self.subTest(endpoint=endpoint):
|
||||
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
||||
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
||||
|
||||
def test_rejects_private_addresses(self):
|
||||
for endpoint in (
|
||||
@@ -66,7 +61,7 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
||||
"https://0.0.0.0/token",
|
||||
):
|
||||
with self.subTest(endpoint=endpoint):
|
||||
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
||||
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
||||
|
||||
def test_rejects_internal_hostnames(self):
|
||||
for endpoint in (
|
||||
@@ -76,16 +71,16 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
||||
"https://host.home.arpa/token",
|
||||
):
|
||||
with self.subTest(endpoint=endpoint):
|
||||
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
||||
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
||||
|
||||
def test_rejects_non_default_port(self):
|
||||
self.assertIsNotNone(
|
||||
validate_push_endpoint("https://fcm.googleapis.com:8080/fcm/send/token")
|
||||
_validate_push_endpoint("https://fcm.googleapis.com:8080/fcm/send/token")
|
||||
)
|
||||
|
||||
def test_rejects_embedded_credentials(self):
|
||||
self.assertIsNotNone(
|
||||
validate_push_endpoint(
|
||||
_validate_push_endpoint(
|
||||
"https://user:pass@fcm.googleapis.com/fcm/send/token"
|
||||
)
|
||||
)
|
||||
@@ -93,26 +88,26 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
||||
def test_rejects_endpoint_without_path(self):
|
||||
for endpoint in ("https://fcm.googleapis.com", "https://fcm.googleapis.com/"):
|
||||
with self.subTest(endpoint=endpoint):
|
||||
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
||||
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
||||
|
||||
def test_rejects_empty_hostname_labels(self):
|
||||
for endpoint in (
|
||||
"https://./x",
|
||||
"https://fcm..googleapis.com/token",
|
||||
"https://.fcm.googleapis.com/token",
|
||||
"https://fcm.googleapis.com./token",
|
||||
):
|
||||
with self.subTest(endpoint=endpoint):
|
||||
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
||||
def test_rejects_endpoint_that_breaks_audience_parsing(self):
|
||||
# webpush.py locates the host by searching for a separator after index
|
||||
# 10, which raises ValueError when the url has no path at all
|
||||
endpoint = "https://fcm.googleapis.com"
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
endpoint.index("/", 10)
|
||||
|
||||
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
||||
|
||||
def test_rejects_missing_or_non_string_endpoint(self):
|
||||
for endpoint in (None, "", 5, {"url": "https://example.com/token"}):
|
||||
with self.subTest(endpoint=endpoint):
|
||||
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
||||
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
||||
|
||||
def test_rejects_overlong_endpoint(self):
|
||||
self.assertIsNotNone(
|
||||
validate_push_endpoint(f"https://fcm.googleapis.com/{'a' * 4096}")
|
||||
_validate_push_endpoint(f"https://fcm.googleapis.com/{'a' * 4096}")
|
||||
)
|
||||
|
||||
|
||||
@@ -153,6 +148,10 @@ class TestValidateSubscription(unittest.TestCase):
|
||||
self.assertIsNotNone(_validate_subscription(sub))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestBuildWebPushers(unittest.TestCase):
|
||||
def test_skips_subscriptions_with_malformed_keys(self):
|
||||
bad = _subscription(VALID_ENDPOINTS[0])
|
||||
@@ -164,52 +163,3 @@ class TestBuildWebPushers(unittest.TestCase):
|
||||
|
||||
self.assertEqual(len(pushers), 1)
|
||||
self.assertEqual(pushers[0].receiver_key, b"\x04" + bytes(64))
|
||||
|
||||
def _valid_keys_subscription(self, endpoint: str) -> dict:
|
||||
sub = _subscription(endpoint)
|
||||
sub["keys"]["p256dh"] = urlsafe_b64encode(b"\x04" + bytes(64)).decode()
|
||||
return sub
|
||||
|
||||
def test_skips_stored_rows_that_break_the_audience(self):
|
||||
good = self._valid_keys_subscription(VALID_ENDPOINTS[0])
|
||||
rows = [
|
||||
self._valid_keys_subscription("https://./secret-token"),
|
||||
self._valid_keys_subscription("https://[::1/secret-token"),
|
||||
{"keys": good["keys"]},
|
||||
"not-a-dict",
|
||||
good,
|
||||
]
|
||||
|
||||
with self.assertLogs("frigate.comms.webpush", level="WARNING") as logs:
|
||||
pushers = _build_web_pushers("viewer", rows)
|
||||
|
||||
self.assertEqual(len(logs.output), 4)
|
||||
self.assertIn("'.'", logs.output[0])
|
||||
self.assertNotIn("secret-token", "".join(logs.output))
|
||||
self.assertEqual(len(pushers), 1)
|
||||
self.assertEqual(pushers[0].subscription_info["endpoint"], VALID_ENDPOINTS[0])
|
||||
self.assertIsInstance(pushers[0].mod_or_session, _NoRedirectSession)
|
||||
|
||||
def test_keeps_stored_self_hosted_endpoints(self):
|
||||
rows = [
|
||||
self._valid_keys_subscription("https://ntfy.example.com:8443/up123"),
|
||||
self._valid_keys_subscription("https://ntfy.example.com/"),
|
||||
]
|
||||
|
||||
pushers = _build_web_pushers("viewer", rows)
|
||||
|
||||
self.assertEqual(len(pushers), 2)
|
||||
|
||||
|
||||
class TestVapidAudience(unittest.TestCase):
|
||||
def test_uses_scheme_and_host(self):
|
||||
self.assertEqual(
|
||||
_vapid_audience(VALID_ENDPOINTS[0]), "https://fcm.googleapis.com"
|
||||
)
|
||||
self.assertEqual(
|
||||
_vapid_audience(VALID_ENDPOINTS[4]), "https://fcm.googleapis.com:443"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Tests that push sends never follow redirects."""
|
||||
|
||||
import unittest
|
||||
|
||||
import requests
|
||||
from requests.adapters import BaseAdapter
|
||||
|
||||
from frigate.comms.webpush import _NoRedirectSession
|
||||
|
||||
ENDPOINT = "https://push.example.com/token"
|
||||
|
||||
|
||||
class RedirectAdapter(BaseAdapter):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.calls = 0
|
||||
|
||||
def send(self, request, **kwargs):
|
||||
self.calls += 1
|
||||
response = requests.Response()
|
||||
response.status_code = 307
|
||||
response.headers["Location"] = "http://127.0.0.1:5000/api/restart"
|
||||
response.url = request.url
|
||||
response.request = request
|
||||
return response
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class TestNoRedirectSession(unittest.TestCase):
|
||||
def test_does_not_follow_redirects(self):
|
||||
adapter = RedirectAdapter()
|
||||
session = _NoRedirectSession()
|
||||
session.mount("https://", adapter)
|
||||
session.mount("http://", adapter)
|
||||
|
||||
response = session.post(ENDPOINT, data=b"payload")
|
||||
|
||||
self.assertEqual(response.status_code, 307)
|
||||
self.assertEqual(adapter.calls, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+4
-22
@@ -21,11 +21,8 @@ import numpy as np
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from frigate.const import (
|
||||
REGEX_CREDENTIAL_QUERY_KEY,
|
||||
REGEX_CREDENTIAL_QUERY_PARAM,
|
||||
REGEX_MASKED_CREDENTIAL,
|
||||
REGEX_HTTP_CAMERA_USER_PASS,
|
||||
REGEX_RTSP_CAMERA_USER_PASS,
|
||||
REGEX_URL_USERINFO,
|
||||
STREAM_TYPE_MAIN,
|
||||
STREAM_TYPE_SUB,
|
||||
)
|
||||
@@ -120,24 +117,9 @@ def deep_merge(dct1: dict, dct2: dict, override=False, merge_lists=False) -> dic
|
||||
|
||||
|
||||
def clean_camera_user_pass(line: str) -> str:
|
||||
"""Mask URL userinfo and credential query parameters in line."""
|
||||
|
||||
line = re.sub(REGEX_URL_USERINFO, _mask_userinfo, line)
|
||||
return re.sub(REGEX_CREDENTIAL_QUERY_PARAM, r"\1*", line)
|
||||
|
||||
|
||||
def _mask_userinfo(match: re.Match[str]) -> str:
|
||||
# A credential key in the userinfo means a query password holds "@" or a
|
||||
# password holds a query key. Neither split is safe, so mask the whole URL.
|
||||
if re.search(REGEX_CREDENTIAL_QUERY_KEY, match.group(1)):
|
||||
return "://*:*@"
|
||||
|
||||
return f"://*:*@{match.group(2)}"
|
||||
|
||||
|
||||
def is_masked_camera_path(path: str) -> bool:
|
||||
"""Return True if path holds credentials masked by clean_camera_user_pass."""
|
||||
return re.search(REGEX_MASKED_CREDENTIAL, path) is not None
|
||||
"""Removes user and password from line."""
|
||||
rtsp_cleaned = re.sub(REGEX_RTSP_CAMERA_USER_PASS, "://*:*@", line)
|
||||
return re.sub(REGEX_HTTP_CAMERA_USER_PASS, "user=*&password=*", rtsp_cleaned)
|
||||
|
||||
|
||||
def escape_special_characters(path: str) -> str:
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""configuration utils."""
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from functools import cache
|
||||
from typing import Any
|
||||
@@ -128,49 +126,6 @@ def redact_credential(obj: dict[str, Any], key: str) -> None:
|
||||
obj.pop(key, None)
|
||||
|
||||
|
||||
LISTEN_HOSTNAME = re.compile(
|
||||
r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
|
||||
r"(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*"
|
||||
)
|
||||
|
||||
|
||||
def parse_listen_address(value: int | str) -> tuple[int | str, int]:
|
||||
"""Validate a networking.listen value and rebuild it from its parsed parts.
|
||||
|
||||
Accepts a port, "host:port", "*:port", or "[IPv6]:port", where host is an
|
||||
IPv4 address or an RFC 1123 hostname. nginx receives the rebuilt value
|
||||
verbatim, so anything else raises ValueError.
|
||||
|
||||
Returns:
|
||||
The normalized value (an int for a bare port) and the port
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, int | str):
|
||||
raise ValueError(f"invalid listen value: {value!r}")
|
||||
|
||||
host, sep, port_text = str(value).strip().rpartition(":")
|
||||
|
||||
if not re.fullmatch(r"[0-9]{1,5}", port_text) or not (1 <= int(port_text) <= 65535):
|
||||
raise ValueError(f"invalid listen port: {value!r}")
|
||||
|
||||
port = int(port_text)
|
||||
|
||||
if not sep:
|
||||
return port, port
|
||||
|
||||
if host.startswith("[") and host.endswith("]"):
|
||||
address = ipaddress.IPv6Address(host[1:-1])
|
||||
|
||||
if address.scope_id:
|
||||
raise ValueError(f"listen address must not have a scope ID: {value!r}")
|
||||
|
||||
return f"[{address}]:{port}", port
|
||||
|
||||
if host != "*" and not LISTEN_HOSTNAME.fullmatch(host):
|
||||
raise ValueError(f"invalid listen address: {value!r}")
|
||||
|
||||
return f"{host}:{port}", port
|
||||
|
||||
|
||||
def find_config_file() -> str:
|
||||
"""Return the path of the config file to use.
|
||||
|
||||
|
||||
@@ -338,12 +338,7 @@ def process_frames(
|
||||
continue
|
||||
|
||||
if datetime.now().astimezone(UTC) > next_region_update:
|
||||
updated_grid = requestor.send_data(REQUEST_REGION_GRID, camera_config.name)
|
||||
|
||||
# A failed request replies empty, so keep the current grid
|
||||
if updated_grid:
|
||||
region_grid = updated_grid
|
||||
|
||||
region_grid = requestor.send_data(REQUEST_REGION_GRID, camera_config.name)
|
||||
next_region_update = get_tomorrow_at_time(2)
|
||||
|
||||
try:
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -218,50 +218,6 @@ test.describe("Chat — streaming @medium", () => {
|
||||
frigateApp.page.getByText(/searching for people/i),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("assistant markdown renders only same-origin images", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const reply =
|
||||
" " +
|
||||
" " +
|
||||
"[offsite](https://evil.example/page)";
|
||||
await installChatStreamOverride(frigateApp, [
|
||||
{ type: "content", delta: reply },
|
||||
{
|
||||
type: "messages",
|
||||
messages: [
|
||||
{ role: "system", content: "sys" },
|
||||
{ role: "user", content: "show me" },
|
||||
{ role: "assistant", content: reply },
|
||||
],
|
||||
},
|
||||
{ type: "done" },
|
||||
]);
|
||||
const offsiteRequests: string[] = [];
|
||||
frigateApp.page.on("request", (req) => {
|
||||
if (req.url().includes("evil.example")) offsiteRequests.push(req.url());
|
||||
});
|
||||
await frigateApp.goto("/chat");
|
||||
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await input.fill("show me");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(frigateApp.page.getByText("leaked alt")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(
|
||||
frigateApp.page.locator('img[src*="evil.example"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
frigateApp.page.locator('img[src="/api/events/abc/thumbnail.webp"]'),
|
||||
).toHaveCount(1);
|
||||
const link = frigateApp.page.getByRole("link", { name: "offsite" });
|
||||
await expect(link).toHaveAttribute("target", "_blank");
|
||||
await expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
expect(offsiteRequests).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Chat — stop @medium", () => {
|
||||
|
||||
@@ -32,11 +32,7 @@ type CameraInput = {
|
||||
input_args?: string;
|
||||
};
|
||||
|
||||
async function installRoutes(
|
||||
page: Page,
|
||||
frontDoorInputs: CameraInput[],
|
||||
rawInputs: CameraInput[] = frontDoorInputs,
|
||||
) {
|
||||
async function installRoutes(page: Page, frontDoorInputs: CameraInput[]) {
|
||||
const config = configFactory({
|
||||
go2rtc: { streams: GO2RTC_STREAMS },
|
||||
cameras: {
|
||||
@@ -60,7 +56,7 @@ async function installRoutes(
|
||||
await page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
cameras: { front_door: { ffmpeg: { inputs: rawInputs } } },
|
||||
cameras: { front_door: { ffmpeg: { inputs: frontDoorInputs } } },
|
||||
go2rtc: { streams: GO2RTC_STREAMS },
|
||||
},
|
||||
}),
|
||||
@@ -204,64 +200,4 @@ test.describe("camera ffmpeg input source toggle @medium", () => {
|
||||
payload?.config_data?.cameras?.front_door?.ffmpeg?.inputs?.[0];
|
||||
expect(input?.input_args).not.toBe("preset-rtsp-restream");
|
||||
});
|
||||
|
||||
test("a masked path reveals its own saved path after an earlier input is deleted", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const main = "rtsp://admin:mainpw@10.0.0.1:554/main";
|
||||
const sub = "rtsp://admin:subpw@10.0.0.1:554/sub";
|
||||
await installRoutes(
|
||||
frigateApp.page,
|
||||
[
|
||||
{ path: "rtsp://*:*@10.0.0.1:554/main", roles: ["record"] },
|
||||
{ path: "rtsp://*:*@10.0.0.1:554/sub", roles: ["detect"] },
|
||||
],
|
||||
[
|
||||
{ path: main, roles: ["record"] },
|
||||
{ path: sub, roles: ["detect"] },
|
||||
],
|
||||
);
|
||||
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
|
||||
|
||||
const paths = frigateApp.page.getByRole("textbox", { name: "Input path" });
|
||||
await expect(paths).toHaveCount(2);
|
||||
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: "Delete" })
|
||||
.first()
|
||||
.click();
|
||||
await expect(paths).toHaveCount(1);
|
||||
|
||||
await paths.focus();
|
||||
await expect(paths).toHaveValue(sub);
|
||||
});
|
||||
|
||||
test("inputs with the same masked path each reveal their own path by role", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const record = "rtsp://admin:one@10.0.0.1:554/live";
|
||||
const detect = "rtsp://viewer:two@10.0.0.1:554/live";
|
||||
const masked = "rtsp://*:*@10.0.0.1:554/live";
|
||||
await installRoutes(
|
||||
frigateApp.page,
|
||||
[
|
||||
{ path: masked, roles: ["record"] },
|
||||
{ path: masked, roles: ["detect"] },
|
||||
],
|
||||
[
|
||||
{ path: record, roles: ["record"] },
|
||||
{ path: detect, roles: ["detect"] },
|
||||
],
|
||||
);
|
||||
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
|
||||
|
||||
const paths = frigateApp.page.getByRole("textbox", { name: "Input path" });
|
||||
await expect(paths).toHaveCount(2);
|
||||
|
||||
await paths.nth(0).focus();
|
||||
await expect(paths.nth(0)).toHaveValue(record);
|
||||
|
||||
await paths.nth(1).focus();
|
||||
await expect(paths.nth(1)).toHaveValue(detect);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+222
-188
@@ -45,52 +45,52 @@
|
||||
"copy-to-clipboard": "^4.0.2",
|
||||
"date-fns": "^4.4.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"framer-motion": "^14.0.0",
|
||||
"framer-motion": "^14.3.0",
|
||||
"hls.js": "^1.7.3",
|
||||
"i18next": "^26.4.2",
|
||||
"i18next-http-backend": "^4.0.2",
|
||||
"i18next": "^26.4.5",
|
||||
"i18next-http-backend": "^4.0.3",
|
||||
"idb-keyval": "^6.3.0",
|
||||
"js-yaml": "^5.4.3",
|
||||
"konva": "^10.7.1",
|
||||
"konva": "^10.9.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^1.52.0",
|
||||
"lucide-react": "^1.57.0",
|
||||
"monaco-yaml": "^5.5.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"nosleep.js": "^0.12.0",
|
||||
"react": "^19.3.0",
|
||||
"react": "^19.3.1",
|
||||
"react-apexcharts": "^2.1.1",
|
||||
"react-day-picker": "^10.0.2",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-dropzone": "^20.1.2",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-dropzone": "^20.1.3",
|
||||
"react-grid-layout": "^2.3.0",
|
||||
"react-hook-form": "^7.89.0",
|
||||
"react-i18next": "^17.0.16",
|
||||
"react-hook-form": "^7.90.0",
|
||||
"react-i18next": "^17.0.17",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-konva": "^19.3.0",
|
||||
"react-konva": "^19.3.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"react-zoom-pan-pinch": "3.4.4",
|
||||
"react-zoom-pan-pinch": "3.7.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"sonner": "^2.0.8",
|
||||
"swr": "^2.5.1",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwind-merge": "^2.6.1",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-long-press": "^3.3.0",
|
||||
"vaul": "^1.1.2",
|
||||
"virtua": "^0.53.3",
|
||||
"virtua": "^0.53.5",
|
||||
"vite-plugin-monaco-editor": "^1.1.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.64.0",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.25",
|
||||
"@types/node": "^26.6.4",
|
||||
"@types/lodash": "^4.17.26",
|
||||
"@types/node": "^26.6.5",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
@@ -98,20 +98,20 @@
|
||||
"esbuild": "^0.28.2",
|
||||
"eslint": "^10.12.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-prettier": "^5.5.7",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.13.0",
|
||||
"i18next-cli": "^1.5.11",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"monaco-editor": "^0.54.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"postcss": "^8.5.29",
|
||||
"prettier": "^3.9.9",
|
||||
"postcss": "^8.5.30",
|
||||
"prettier": "^3.9.10",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.71.1",
|
||||
"vite": "^8.3.3"
|
||||
"typescript-eslint": "^8.71.2",
|
||||
"vite": "^8.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@aashutoshrathi/word-wrap": {
|
||||
@@ -439,6 +439,17 @@
|
||||
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@dual-bundle/import-meta-resolve": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz",
|
||||
"integrity": "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/JounQin"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
@@ -1707,13 +1718,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz",
|
||||
"integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
|
||||
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.64.0"
|
||||
"playwright": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -3578,9 +3589,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.25",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz",
|
||||
"integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==",
|
||||
"version": "4.17.26",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.26.tgz",
|
||||
"integrity": "sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -3600,9 +3611,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz",
|
||||
"integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==",
|
||||
"version": "26.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
|
||||
"integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3629,9 +3640,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-reconciler": {
|
||||
"version": "0.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.33.0.tgz",
|
||||
"integrity": "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==",
|
||||
"version": "0.34.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.34.0.tgz",
|
||||
"integrity": "sha512-zVDzX0Fk1XgDP2IutTpSrxd57jwOHlX3rlPrFz68Sc+06tpgOFRlqSUK2ZiqQeQXqnIfUb2cfJQUupXBKy498w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*"
|
||||
@@ -3644,17 +3655,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz",
|
||||
"integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==",
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz",
|
||||
"integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.71.1",
|
||||
"@typescript-eslint/type-utils": "8.71.1",
|
||||
"@typescript-eslint/utils": "8.71.1",
|
||||
"@typescript-eslint/visitor-keys": "8.71.1",
|
||||
"@typescript-eslint/scope-manager": "8.71.2",
|
||||
"@typescript-eslint/type-utils": "8.71.2",
|
||||
"@typescript-eslint/utils": "8.71.2",
|
||||
"@typescript-eslint/visitor-keys": "8.71.2",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -3667,7 +3678,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.71.1",
|
||||
"@typescript-eslint/parser": "^8.71.2",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
@@ -3683,16 +3694,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.1.tgz",
|
||||
"integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==",
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz",
|
||||
"integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.71.1",
|
||||
"@typescript-eslint/types": "8.71.1",
|
||||
"@typescript-eslint/typescript-estree": "8.71.1",
|
||||
"@typescript-eslint/visitor-keys": "8.71.1",
|
||||
"@typescript-eslint/scope-manager": "8.71.2",
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/typescript-estree": "8.71.2",
|
||||
"@typescript-eslint/visitor-keys": "8.71.2",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3708,14 +3719,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz",
|
||||
"integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==",
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz",
|
||||
"integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.71.1",
|
||||
"@typescript-eslint/types": "^8.71.1",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.71.2",
|
||||
"@typescript-eslint/types": "^8.71.2",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3730,14 +3741,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.1.tgz",
|
||||
"integrity": "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==",
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz",
|
||||
"integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.71.1",
|
||||
"@typescript-eslint/visitor-keys": "8.71.1"
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/visitor-keys": "8.71.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -3748,9 +3759,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz",
|
||||
"integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==",
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz",
|
||||
"integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3765,15 +3776,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz",
|
||||
"integrity": "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==",
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz",
|
||||
"integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.71.1",
|
||||
"@typescript-eslint/typescript-estree": "8.71.1",
|
||||
"@typescript-eslint/utils": "8.71.1",
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/typescript-estree": "8.71.2",
|
||||
"@typescript-eslint/utils": "8.71.2",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -3790,9 +3801,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz",
|
||||
"integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==",
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz",
|
||||
"integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3804,16 +3815,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz",
|
||||
"integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==",
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz",
|
||||
"integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.71.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.71.1",
|
||||
"@typescript-eslint/types": "8.71.1",
|
||||
"@typescript-eslint/visitor-keys": "8.71.1",
|
||||
"@typescript-eslint/project-service": "8.71.2",
|
||||
"@typescript-eslint/tsconfig-utils": "8.71.2",
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/visitor-keys": "8.71.2",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -3832,16 +3843,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz",
|
||||
"integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==",
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz",
|
||||
"integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.71.1",
|
||||
"@typescript-eslint/types": "8.71.1",
|
||||
"@typescript-eslint/typescript-estree": "8.71.1"
|
||||
"@typescript-eslint/scope-manager": "8.71.2",
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/typescript-estree": "8.71.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -3856,13 +3867,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.1.tgz",
|
||||
"integrity": "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==",
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz",
|
||||
"integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.71.1",
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4808,6 +4819,12 @@
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz",
|
||||
"integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -5020,14 +5037,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-prettier": {
|
||||
"version": "5.5.6",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz",
|
||||
"integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==",
|
||||
"version": "5.5.7",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.7.tgz",
|
||||
"integrity": "sha512-WjHyn3GrFd+ZR0rzsLiM4zJZ35PZMOE0SUIxzGmnWNG0126yNfJKJzObmmkYDwHJfGlkz3PSlzyG9ez/LJntwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prettier-linter-helpers": "^1.0.1",
|
||||
"synckit": "^0.11.13"
|
||||
"synckit": "^0.12.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
@@ -5565,13 +5582,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.0.0.tgz",
|
||||
"integrity": "sha512-QAtHmybrDFSliyW1sexMtSQTQd+1jw2ajibEmzpuI4XdPVzQkn+dAzJMm2sW3pl9MJuSS4xN51djQ7huhlP/VA==",
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.3.0.tgz",
|
||||
"integrity": "sha512-d8WCrU0ZQn7ukKMykCsDyIOwb/taZeDqqrDBK0du5GO0AO/ynz8fdR/LIIz9Pyy3n3tGwrneR+n0egyrrLa3fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "14.0.0",
|
||||
"motion-utils": "14.0.0",
|
||||
"motion-dom": "14.3.0",
|
||||
"motion-utils": "14.1.1",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -5974,9 +5991,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.4.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
||||
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
||||
"version": "26.4.5",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.5.tgz",
|
||||
"integrity": "sha512-CY9xqbWNQ+eLGDhFX3eNl1kDO1amJixszVKjC4Nd0tvfxUFLKqarN0VUHRt+2b88U7bkNIhnL84dRKQ5mFI+fg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -6089,9 +6106,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-http-backend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.2.tgz",
|
||||
"integrity": "sha512-oay62dIB2kL7+WHzoUXBjWfL3+mwijD3pQkQkIEaRLhy6kjXxUhrRenV0gInwlCASAaJaDy94+XvYizK+cAGdA==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.3.tgz",
|
||||
"integrity": "sha512-B5GJysCsJwqiDQRpUcikWsut82Vmekix58WIyiCpYtxaZ/wY8kwJCicMRvVcMoIa1x84siiRl5vBt6iCBVO6zg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -6369,9 +6386,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/its-fine": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz",
|
||||
"integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==",
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.1.1.tgz",
|
||||
"integrity": "sha512-C5ZoVkeqSt0Aje0tzESO+iT/EQpNFNL39GkP7mCACtoZUFD6GcyerFCr6kp0ZtiII4W9HoYkY48Soy5xE/uHKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react-reconciler": "^0.28.9"
|
||||
@@ -6568,9 +6585,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/konva": {
|
||||
"version": "10.7.1",
|
||||
"resolved": "https://registry.npmjs.org/konva/-/konva-10.7.1.tgz",
|
||||
"integrity": "sha512-z/JyXPaT6tWBSEcaT70mdfN3oNQ6U6rDxlH9OkRdxlJaf23DOqfMPGptQWVvXlWfKMJQWEa+PNe9ru3zQR7ifw==",
|
||||
"version": "10.9.0",
|
||||
"resolved": "https://registry.npmjs.org/konva/-/konva-10.9.0.tgz",
|
||||
"integrity": "sha512-3NOOlcbimFDp4+/DfXGTBkfn3cn3VP1BESPySQ6ayCzgMA9JSMGJNNxeIKw5jpHL6SAl6gHsrv10DUEjNRL0Qg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "patreon",
|
||||
@@ -6961,9 +6978,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.52.0.tgz",
|
||||
"integrity": "sha512-TUgPtl5ZI9WgxOcbu4ckaC5B3l1qxPQrCMgb6OfUB9owx/OvSJfcf93SMgUWAxRw5/1XDfh9uI5F5iNGXCcGDQ==",
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.57.0.tgz",
|
||||
"integrity": "sha512-bXFZEucYhAzLD62Jse76AbTR6LiSLTn3Tqir2f1n8rZrWAdLodYy7jnRZSPIT89dRLk/IseMOUWG82wJtTyTTw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -7013,6 +7030,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -7957,10 +7986,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.52.2",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
|
||||
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
|
||||
"license": "MIT"
|
||||
"version": "0.54.0",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.54.0.tgz",
|
||||
"integrity": "sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dompurify": "3.1.7",
|
||||
"marked": "14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-languageserver-types": {
|
||||
"version": "0.4.0",
|
||||
@@ -8035,18 +8068,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.0.0.tgz",
|
||||
"integrity": "sha512-aU3ApXo1yTpeUi3JU+jbDk2TELjxPE3MbBMUP7nZHlARb6RweJK+ojmLjpuB0YsY6zhbBl1YWGo8LMsI1/Llqw==",
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.3.0.tgz",
|
||||
"integrity": "sha512-rcKO4aEJzAqDEsrH6qFzhLZxs7bNqZpiUEuEATz9LaRwt6dG7hZEbcFUSHNR8NECaKYM0Iuw6Iv6NpWEGXDI+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "14.0.0"
|
||||
"motion-utils": "14.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.0.0.tgz",
|
||||
"integrity": "sha512-jBsyjVxTTRZNeuvd/v+btVXr90LQkYl4HhRu9T2ARc+PMnvkmjsjRVeAg4Qm10b6TlTw+kykWI8gu/6LuvpLSQ==",
|
||||
"version": "14.1.1",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.1.1.tgz",
|
||||
"integrity": "sha512-3VvN5Uvf8B6qG+OfOui6Nht5EWp+vLuU3IqcAj7ItZGKqtmTjCebl5sGZF7/08gEHRnEl1AP/fIwbcEyVrcQGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
@@ -8077,9 +8110,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.19",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
||||
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
|
||||
"version": "3.3.20",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -8499,13 +8532,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
|
||||
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.64.0"
|
||||
"playwright-core": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -8515,9 +8548,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
|
||||
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -8528,9 +8561,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.29",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
|
||||
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
|
||||
"version": "8.5.30",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz",
|
||||
"integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -8547,7 +8580,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.19",
|
||||
"nanoid": "^3.3.20",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.2"
|
||||
},
|
||||
@@ -8676,9 +8709,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.9.9",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz",
|
||||
"integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==",
|
||||
"version": "3.9.10",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz",
|
||||
"integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
@@ -8891,9 +8924,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
|
||||
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
|
||||
"version": "19.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
|
||||
"integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -8951,15 +8984,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
|
||||
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
|
||||
"version": "19.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz",
|
||||
"integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.28.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.3.0"
|
||||
"react": "^19.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-draggable": {
|
||||
@@ -8977,13 +9010,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-dropzone": {
|
||||
"version": "20.1.2",
|
||||
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-20.1.2.tgz",
|
||||
"integrity": "sha512-gj2m31ZmYOZjTuW6CDfx38/WW2JlbuQ/X2Il8cOwIWa7Znp7vQSPJFtRfDbAjusBuoXzAYktI+r2R6iKMaRTPw==",
|
||||
"version": "20.1.3",
|
||||
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-20.1.3.tgz",
|
||||
"integrity": "sha512-84ilFcyPItmPIOEU0P2u976sb8UctgcMbbQHTDmAKo7+i9m1Im2Q9swEZnv3FWNg9xdbMhGG7eUMJk1l6Ltsgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"attr-accept": "^4.0.0",
|
||||
"file-selector": "^5.0.0"
|
||||
"file-selector": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
@@ -9017,9 +9050,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.89.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.89.0.tgz",
|
||||
"integrity": "sha512-vKcoCfy8RKZDrhSdqsRFp0uEOdS1AhLjOL6hR7Wmfzg1akNxEhaPf5WORx8SgEQPVXakdDyDkqQp+KNxVnnPHg==",
|
||||
"version": "7.90.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.90.0.tgz",
|
||||
"integrity": "sha512-+NdG0FLzCPhFOp2NNxaUYLF9fyZhNjx9FTBBSYWF71/l6BBlmIc/j6xlp3DbaHWLJbIG7Zd8oznA19xJClU97w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -9039,9 +9072,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.16",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.16.tgz",
|
||||
"integrity": "sha512-7uImVD9hG5yUOaP5KHVied1x0A004Di/KU031ULR35Ma+9e9H5GdnNZHgQEIByyAz+IAXBkRDTedNVzjPUH8ZA==",
|
||||
"version": "17.0.17",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.17.tgz",
|
||||
"integrity": "sha512-/gI72sAiVDJh7WAFdF0Sd9BMsVjF97W6avaCjPqgYDPj4Sq6wv9/94Gti3t8g9bYqo6A9c+xdmL3gRRH8GaM2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
@@ -9081,9 +9114,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-konva": {
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-konva/-/react-konva-19.3.0.tgz",
|
||||
"integrity": "sha512-YEDAdM0PUUUJcTzXAKXMRO2+krSE+hhjGsupWjy6UBtzyFI6f2LkwSYQresl7WwvD+j3fbMy0uj3jtnytKqk9Q==",
|
||||
"version": "19.3.2",
|
||||
"resolved": "https://registry.npmjs.org/react-konva/-/react-konva-19.3.2.tgz",
|
||||
"integrity": "sha512-Uv0xlWoRSCByHinqs/03SFL8E28fYbe59sQ+2EJpwa1O4Zlz0+T+8DaKwlma9C2rkYwYj6j8eHD41F5oJafagg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "patreon",
|
||||
@@ -9100,8 +9133,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react-reconciler": "^0.33.0",
|
||||
"its-fine": "^2.0.0",
|
||||
"@types/react-reconciler": "0.34.0",
|
||||
"its-fine": "^2.1.1",
|
||||
"react-reconciler": "0.34.0",
|
||||
"scheduler": "0.28.0"
|
||||
},
|
||||
@@ -9284,9 +9317,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-zoom-pan-pinch": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.4.4.tgz",
|
||||
"integrity": "sha512-lGTu7D9lQpYEQ6sH+NSlLA7gicgKRW8j+D/4HO1AbSV2POvKRFzdWQ8eI0r3xmOsl4dYQcY+teV6MhULeg1xBw==",
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.0.tgz",
|
||||
"integrity": "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
@@ -9800,12 +9833,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/synckit": {
|
||||
"version": "0.11.13",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz",
|
||||
"integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==",
|
||||
"version": "0.12.3",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.12.3.tgz",
|
||||
"integrity": "sha512-U+/AXT6LWsxnj2vz9HeUuPWBNgdWxjyIEjEPdbJARC9txmgPzo2VEdqnFo4hHaNyMPtycyVJrSaQYeQv/PoyyA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dual-bundle/import-meta-resolve": "^4.2.1",
|
||||
"@pkgr/core": "^0.3.6"
|
||||
},
|
||||
"engines": {
|
||||
@@ -9816,9 +9850,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.4.0.tgz",
|
||||
"integrity": "sha512-49AwoOQNKdqKPd9CViyH5wJoSKsCDjUlzL8DxuGp3P1FsGY36NJDAa18jLZcaHAUUuTj+JB8IAo8zWgBNvBF7A==",
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
|
||||
"integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -10041,16 +10075,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.71.1",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.1.tgz",
|
||||
"integrity": "sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==",
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz",
|
||||
"integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.71.1",
|
||||
"@typescript-eslint/parser": "8.71.1",
|
||||
"@typescript-eslint/typescript-estree": "8.71.1",
|
||||
"@typescript-eslint/utils": "8.71.1"
|
||||
"@typescript-eslint/eslint-plugin": "8.71.2",
|
||||
"@typescript-eslint/parser": "8.71.2",
|
||||
"@typescript-eslint/typescript-estree": "8.71.2",
|
||||
"@typescript-eslint/utils": "8.71.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -10343,9 +10377,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/virtua": {
|
||||
"version": "0.53.3",
|
||||
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.53.3.tgz",
|
||||
"integrity": "sha512-pNPb9pBWEUEG8I13s5pY90Y2B3SI2bHZqCvfyvyh862R/aLppDdgw9jZLunbXmjlR8PdKU7C/TYoxk7WphLrAw==",
|
||||
"version": "0.53.5",
|
||||
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.53.5.tgz",
|
||||
"integrity": "sha512-hhtrEli6LkMzKXhqhuStnaU5DECVLjxwr/FbrGcnkId2gyKQJ/5E6LE/T2F4OzyghEOsOCl/g+0DyxJUmkah8Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@angular/common": ">=20.0.0",
|
||||
@@ -10381,16 +10415,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz",
|
||||
"integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==",
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
|
||||
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.7",
|
||||
"postcss": "^8.5.28",
|
||||
"rolldown": "~1.2.11",
|
||||
"postcss": "^8.5.29",
|
||||
"rolldown": "~1.2.12",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
+23
-23
@@ -57,52 +57,52 @@
|
||||
"copy-to-clipboard": "^4.0.2",
|
||||
"date-fns": "^4.4.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"framer-motion": "^14.0.0",
|
||||
"framer-motion": "^14.3.0",
|
||||
"hls.js": "^1.7.3",
|
||||
"i18next": "^26.4.2",
|
||||
"i18next-http-backend": "^4.0.2",
|
||||
"i18next": "^26.4.5",
|
||||
"i18next-http-backend": "^4.0.3",
|
||||
"idb-keyval": "^6.3.0",
|
||||
"js-yaml": "^5.4.3",
|
||||
"konva": "^10.7.1",
|
||||
"konva": "^10.9.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^1.52.0",
|
||||
"lucide-react": "^1.57.0",
|
||||
"monaco-yaml": "^5.5.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"nosleep.js": "^0.12.0",
|
||||
"react": "^19.3.0",
|
||||
"react": "^19.3.1",
|
||||
"react-apexcharts": "^2.1.1",
|
||||
"react-day-picker": "^10.0.2",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-dropzone": "^20.1.2",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-dropzone": "^20.1.3",
|
||||
"react-grid-layout": "^2.3.0",
|
||||
"react-hook-form": "^7.89.0",
|
||||
"react-i18next": "^17.0.16",
|
||||
"react-hook-form": "^7.90.0",
|
||||
"react-i18next": "^17.0.17",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-konva": "^19.3.0",
|
||||
"react-konva": "^19.3.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"react-zoom-pan-pinch": "3.4.4",
|
||||
"react-zoom-pan-pinch": "3.7.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"sonner": "^2.0.8",
|
||||
"swr": "^2.5.1",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwind-merge": "^2.6.1",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-long-press": "^3.3.0",
|
||||
"vaul": "^1.1.2",
|
||||
"virtua": "^0.53.3",
|
||||
"virtua": "^0.53.5",
|
||||
"vite-plugin-monaco-editor": "^1.1.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.64.0",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.25",
|
||||
"@types/node": "^26.6.4",
|
||||
"@types/lodash": "^4.17.26",
|
||||
"@types/node": "^26.6.5",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
@@ -110,20 +110,20 @@
|
||||
"esbuild": "^0.28.2",
|
||||
"eslint": "^10.12.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-prettier": "^5.5.7",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.13.0",
|
||||
"i18next-cli": "^1.5.11",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"monaco-editor": "^0.54.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"postcss": "^8.5.29",
|
||||
"prettier": "^3.9.9",
|
||||
"postcss": "^8.5.30",
|
||||
"prettier": "^3.9.10",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.71.1",
|
||||
"vite": "^8.3.3"
|
||||
"typescript-eslint": "^8.71.2",
|
||||
"vite": "^8.3.4"
|
||||
},
|
||||
"overrides": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
},
|
||||
"cookie_secure": {
|
||||
"label": "Secure cookie flag",
|
||||
"description": "Always set the secure flag on the auth cookie. Frigate sets it automatically when serving TLS; enable this when a reverse proxy terminates TLS in front of Frigate. A proxy that connects to the TLS port must serve Frigate to browsers over HTTPS."
|
||||
"description": "Set the secure flag on the auth cookie; should be true when using TLS."
|
||||
},
|
||||
"session_length": {
|
||||
"label": "Session length",
|
||||
|
||||
@@ -56,8 +56,6 @@
|
||||
"noROI": "Please draw a region of interest",
|
||||
"noTimeRange": "Please select a time range",
|
||||
"invalidTimeRange": "End time must be after start time",
|
||||
"alreadyRunning": "You already have a motion search running. Wait for it to finish or cancel it.",
|
||||
"tooManySearches": "Too many motion searches are running. Try again when one finishes.",
|
||||
"searchFailed": "Search failed: {{message}}",
|
||||
"polygonTooSmall": "Polygon must have at least 3 points",
|
||||
"unknown": "Unknown error"
|
||||
|
||||
@@ -18,18 +18,6 @@ import { cn } from "@/lib/utils";
|
||||
import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip";
|
||||
import { parseAttachedEvent } from "@/utils/chatUtil";
|
||||
import type { ChatStats, ShowStatsMode } from "@/types/chat";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
|
||||
// Assistant output can carry prompt-injected URLs, and the browser fetches
|
||||
// images without a click, so only same-origin images render.
|
||||
function isSameOrigin(url: string | undefined): boolean {
|
||||
if (!url) return false;
|
||||
try {
|
||||
return new URL(url, baseUrl).origin === window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type MessageBubbleProps = {
|
||||
role: "user" | "assistant";
|
||||
@@ -235,23 +223,6 @@ export function MessageBubble({
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
img: ({ node: _n, src, alt, ...props }) =>
|
||||
typeof src === "string" && isSameOrigin(src) ? (
|
||||
<img src={src} alt={alt} {...props} />
|
||||
) : (
|
||||
<>{alt}</>
|
||||
),
|
||||
a: ({ node: _n, href, ...props }) =>
|
||||
isSameOrigin(href) ? (
|
||||
<a href={href} {...props} />
|
||||
) : (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import useSWR from "swr";
|
||||
import get from "lodash/get";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { useMemo, useState, type FocusEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||
@@ -23,7 +21,6 @@ type RawPathsResponse = {
|
||||
ffmpeg?: {
|
||||
inputs?: Array<{
|
||||
path?: string;
|
||||
roles?: string[];
|
||||
}>;
|
||||
};
|
||||
}
|
||||
@@ -69,35 +66,17 @@ export function CameraPathWidget(props: WidgetProps) {
|
||||
shouldFetchRawPaths ? "config/raw_paths" : null,
|
||||
);
|
||||
|
||||
const rawValue = typeof value === "string" ? value : "";
|
||||
|
||||
const roles =
|
||||
inputIndex === undefined
|
||||
? undefined
|
||||
: get(formContext?.formData, ["inputs", inputIndex, "roles"]);
|
||||
|
||||
// Match by masked value with roles breaking a tie, as the backend restores
|
||||
// on save, so deleting or reordering inputs never reveals another input's path
|
||||
const rawPath = useMemo(() => {
|
||||
if (!cameraName || !isMaskedPath(rawValue)) {
|
||||
if (!cameraName || inputIndex === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let matches = (
|
||||
rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs ?? []
|
||||
).filter(
|
||||
(input): input is { path: string; roles?: string[] } =>
|
||||
typeof input.path === "string" &&
|
||||
maskCredentials(input.path) === rawValue,
|
||||
);
|
||||
if (new Set(matches.map((input) => input.path)).size > 1) {
|
||||
matches = matches.filter((input) => isEqual(input.roles, roles));
|
||||
}
|
||||
|
||||
const paths = new Set(matches.map((input) => input.path));
|
||||
return paths.size === 1 ? [...paths][0] : undefined;
|
||||
}, [cameraName, rawPaths, rawValue, roles]);
|
||||
const path =
|
||||
rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs?.[inputIndex]?.path;
|
||||
return typeof path === "string" ? path : undefined;
|
||||
}, [cameraName, inputIndex, rawPaths]);
|
||||
|
||||
const rawValue = typeof value === "string" ? value : "";
|
||||
const resolvedValue =
|
||||
isMaskedPath(rawValue) && rawPath ? rawPath : (rawValue ?? "");
|
||||
const canReveal =
|
||||
|
||||
@@ -1,37 +1,40 @@
|
||||
// Mirrors clean_camera_user_pass and REGEX_MASKED_CREDENTIAL in the backend.
|
||||
const CREDENTIAL_QUERY_KEYS =
|
||||
"(?:user(?:name)?|usr|login|pass(?:word|wd)?|pwd|(?:[a-z_]*_)?token|(?:[a-z_]*_)?secret|client_private|api_?key)";
|
||||
const URL_USERINFO_PATTERN =
|
||||
/:\/\/((?:[^\s/?#:[\]]*:(?:(?!:\/\/)\S)*|[^\s/?#]+))@((?:(?!:\/\/)\S)*)/g;
|
||||
const CREDENTIAL_QUERY_KEY_PATTERN = new RegExp(
|
||||
`[?&]${CREDENTIAL_QUERY_KEYS}=`,
|
||||
"i",
|
||||
);
|
||||
const CREDENTIAL_QUERY_PATTERN = new RegExp(
|
||||
`([?&]${CREDENTIAL_QUERY_KEYS}=)[^&\\s]*`,
|
||||
"gi",
|
||||
);
|
||||
const MASKED_PATTERN = new RegExp(
|
||||
`://\\*:\\*@|[?&]${CREDENTIAL_QUERY_KEYS}=\\*(?![^&\\s])`,
|
||||
"i",
|
||||
);
|
||||
const MASKED_AUTH_PATTERN = /:\/\/\*:\*@/i;
|
||||
const MASKED_QUERY_PATTERN = /(?:[?&])user=\*&password=\*/i;
|
||||
|
||||
export const isMaskedPath = (value: string): boolean =>
|
||||
MASKED_PATTERN.test(value);
|
||||
MASKED_AUTH_PATTERN.test(value) || MASKED_QUERY_PATTERN.test(value);
|
||||
|
||||
export const hasCredentials = (value: string): boolean => {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isMaskedPath(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.username || parsed.password) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
parsed.searchParams.has("user") && parsed.searchParams.has("password")
|
||||
);
|
||||
} catch {
|
||||
return /:\/\/[^:@/\s]+:[^@/\s]+@/.test(value);
|
||||
}
|
||||
};
|
||||
|
||||
export const maskCredentials = (value: string): string => {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value
|
||||
.replace(URL_USERINFO_PATTERN, (_match, userinfo: string, rest: string) =>
|
||||
CREDENTIAL_QUERY_KEY_PATTERN.test(userinfo)
|
||||
? "://*:*@"
|
||||
: `://*:*@${rest}`,
|
||||
)
|
||||
.replace(CREDENTIAL_QUERY_PATTERN, "$1*");
|
||||
};
|
||||
const maskedAuth = value.replace(/:\/\/[^:@/\s]+:[^@/\s]*@/g, "://*:*@");
|
||||
|
||||
export const hasCredentials = (value: string): boolean =>
|
||||
!!value && (isMaskedPath(value) || maskCredentials(value) !== value);
|
||||
return maskedAuth
|
||||
.replace(/([?&]user=)[^&]*/gi, "$1*")
|
||||
.replace(/([?&]password=)[^&]*/gi, "$1*");
|
||||
};
|
||||
|
||||
@@ -892,20 +892,6 @@ export default function MotionSearchView({
|
||||
setIsSearching(false);
|
||||
}
|
||||
} catch (error) {
|
||||
const status = axios.isAxiosError(error)
|
||||
? error.response?.status
|
||||
: undefined;
|
||||
|
||||
if (status === 409 || status === 429) {
|
||||
toast.error(
|
||||
t(
|
||||
status === 409 ? "errors.alreadyRunning" : "errors.tooManySearches",
|
||||
),
|
||||
);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let errorMessage = t("errors.unknown");
|
||||
|
||||
if (axios.isAxiosError<{ message?: string; detail?: string }>(error)) {
|
||||
|
||||
Reference in New Issue
Block a user