Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 646bb2896a Bump the web-minor-patch group across 1 directory with 23 updates
Bumps the web-minor-patch group with 23 updates in the /web directory:

| Package | From | To |
| --- | --- | --- |
| [framer-motion](https://github.com/motiondivision/motion) | `14.0.0` | `14.3.0` |
| [i18next](https://github.com/i18next/i18next) | `26.4.2` | `26.4.5` |
| [i18next-http-backend](https://github.com/i18next/i18next-http-backend) | `4.0.2` | `4.0.3` |
| [konva](https://github.com/konvajs/konva) | `10.7.1` | `10.9.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.52.0` | `1.57.0` |
| [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.3.0` | `19.3.1` |
| [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.3.0` | `19.3.1` |
| [react-dropzone](https://github.com/react-dropzone/react-dropzone) | `20.1.2` | `20.1.3` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.89.0` | `7.90.0` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.16` | `17.0.17` |
| [react-konva](https://github.com/konvajs/react-konva) | `19.3.0` | `19.3.2` |
| [react-zoom-pan-pinch](https://github.com/BetterTyped/react-zoom-pan-pinch) | `3.4.4` | `3.7.0` |
| [tailwind-merge](https://github.com/dcastil/tailwind-merge/tree/HEAD/packages/tailwind-merge) | `2.4.0` | `2.6.1` |
| [virtua](https://github.com/inokawa/virtua) | `0.53.3` | `0.53.5` |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.64.0` | `1.64.1` |
| [@types/lodash](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/lodash) | `4.17.25` | `4.17.26` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.6.4` | `26.6.5` |
| [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier) | `5.5.6` | `5.5.7` |
| [monaco-editor](https://github.com/microsoft/monaco-editor) | `0.52.2` | `0.54.0` |
| [postcss](https://github.com/postcss/postcss) | `8.5.29` | `8.5.30` |
| [prettier](https://github.com/prettier/prettier) | `3.9.9` | `3.9.10` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.71.1` | `8.71.2` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.3` | `8.3.4` |



Updates `framer-motion` from 14.0.0 to 14.3.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v14.0.0...v14.3.0)

Updates `i18next` from 26.4.2 to 26.4.5
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.2...v26.4.5)

Updates `i18next-http-backend` from 4.0.2 to 4.0.3
- [Changelog](https://github.com/i18next/i18next-http-backend/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next-http-backend/compare/v4.0.2...v4.0.3)

Updates `konva` from 10.7.1 to 10.9.0
- [Release notes](https://github.com/konvajs/konva/releases)
- [Changelog](https://github.com/konvajs/konva/blob/master/CHANGELOG.md)
- [Commits](https://github.com/konvajs/konva/compare/10.7.1...10.9.0)

Updates `lucide-react` from 1.52.0 to 1.57.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.57.0/packages/lucide-react)

Updates `react` from 19.3.0 to 19.3.1
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.1/packages/react)

Updates `react-dom` from 19.3.0 to 19.3.1
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.1/packages/react-dom)

Updates `react-dropzone` from 20.1.2 to 20.1.3
- [Release notes](https://github.com/react-dropzone/react-dropzone/releases)
- [Commits](https://github.com/react-dropzone/react-dropzone/compare/v20.1.2...v20.1.3)

Updates `react-hook-form` from 7.89.0 to 7.90.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.89.0...v7.90.0)

Updates `react-i18next` from 17.0.16 to 17.0.17
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.16...v17.0.17)

Updates `react-konva` from 19.3.0 to 19.3.2
- [Release notes](https://github.com/konvajs/react-konva/releases)
- [Changelog](https://github.com/konvajs/react-konva/blob/master/RELEASE_NOTES.md)
- [Commits](https://github.com/konvajs/react-konva/compare/v19.3.0...v19.3.2)

Updates `react-zoom-pan-pinch` from 3.4.4 to 3.7.0
- [Release notes](https://github.com/BetterTyped/react-zoom-pan-pinch/releases)
- [Changelog](https://github.com/BetterTyped/react-zoom-pan-pinch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/BetterTyped/react-zoom-pan-pinch/compare/v3.4.4...v3.7.0)

Updates `tailwind-merge` from 2.4.0 to 2.6.1
- [Release notes](https://github.com/dcastil/tailwind-merge/releases)
- [Commits](https://github.com/dcastil/tailwind-merge/commits/v2.6.1/packages/tailwind-merge)

Updates `virtua` from 0.53.3 to 0.53.5
- [Release notes](https://github.com/inokawa/virtua/releases)
- [Commits](https://github.com/inokawa/virtua/compare/0.53.3...0.53.5)

Updates `@playwright/test` from 1.64.0 to 1.64.1
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.64.0...v1.64.1)

Updates `@types/lodash` from 4.17.25 to 4.17.26
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/lodash)

Updates `@types/node` from 26.6.4 to 26.6.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint-plugin-prettier` from 5.5.6 to 5.5.7
- [Release notes](https://github.com/prettier/eslint-plugin-prettier/releases)
- [Changelog](https://github.com/prettier/eslint-plugin-prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-plugin-prettier/compare/v5.5.6...v5.5.7)

Updates `monaco-editor` from 0.52.2 to 0.54.0
- [Release notes](https://github.com/microsoft/monaco-editor/releases)
- [Changelog](https://github.com/microsoft/monaco-editor/blob/main/CHANGELOG.md)
- [Commits](https://github.com/microsoft/monaco-editor/compare/v0.52.2...v0.54.0)

Updates `postcss` from 8.5.29 to 8.5.30
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.29...8.5.30)

Updates `prettier` from 3.9.9 to 3.9.10
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.9...3.9.10)

Updates `typescript-eslint` from 8.71.1 to 8.71.2
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.2/packages/typescript-eslint)

Updates `vite` from 8.3.3 to 8.3.4
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.4/packages/vite)

---
updated-dependencies:
- dependency-name: framer-motion
  dependency-version: 14.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: i18next
  dependency-version: 26.4.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: i18next-http-backend
  dependency-version: 4.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: konva
  dependency-version: 10.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: lucide-react
  dependency-version: 1.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: react
  dependency-version: 19.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: react-dom
  dependency-version: 19.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: react-dropzone
  dependency-version: 20.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: react-hook-form
  dependency-version: 7.90.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: react-i18next
  dependency-version: 17.0.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: react-konva
  dependency-version: 19.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: react-zoom-pan-pinch
  dependency-version: 3.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: tailwind-merge
  dependency-version: 2.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: virtua
  dependency-version: 0.53.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: "@playwright/test"
  dependency-version: 1.64.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: "@types/lodash"
  dependency-version: 4.17.26
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: "@types/node"
  dependency-version: 26.6.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: eslint-plugin-prettier
  dependency-version: 5.5.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: monaco-editor
  dependency-version: 0.54.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: postcss
  dependency-version: 8.5.30
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: prettier
  dependency-version: 3.9.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: typescript-eslint
  dependency-version: 8.71.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: vite
  dependency-version: 8.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-18 17:55:07 +00:00
49 changed files with 603 additions and 2300 deletions
@@ -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
View File
@@ -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
View File
@@ -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})
-2
View File
@@ -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
+12 -46
View File
@@ -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={
+66 -2
View File
@@ -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
-1
View File
@@ -622,7 +622,6 @@ class FrigateApp:
username="admin",
role="admin",
password_hash=password_hash,
password_changed_at=datetime.datetime.now(),
notification_tokens=[],
).execute()
+2 -12
View File
@@ -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:
+1 -7
View File
@@ -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
View File
@@ -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(
+1 -1
View File
@@ -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,
-8
View File
@@ -224,14 +224,6 @@ class RecordConfig(FrigateBaseModel):
self.detections.pre_capture,
)
@property
def event_post_capture(self) -> int:
"""Largest post-capture window used to fetch candidate reviews."""
return max(
self.alerts.post_capture,
self.detections.post_capture,
)
def get_review_pre_capture(self, severity: SeverityEnum) -> int:
if severity == SeverityEnum.alert:
return self.alerts.pre_capture
+11 -12
View File
@@ -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
View File
@@ -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
+19 -76
View File
@@ -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)
+6 -23
View File
@@ -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 []
+3 -11
View File
@@ -471,14 +471,9 @@ class RecordingMaintainer(threading.Thread):
for key, recordings in grouped_recordings.items():
camera, stream_type = key
# get all reviews whose post capture reaches the oldest cache file
# or with end_time None; shared across stream types
# get all reviews with the end time after the start of the oldest
# cache file or with end_time None; shared across stream types
if camera not in reviews_by_camera:
post_capture = (
self.config.cameras[camera].record.event_post_capture
if camera in self.config.cameras
else 0
)
reviews_by_camera[camera] = (
ReviewSegment.select(
ReviewSegment.start_time,
@@ -489,10 +484,7 @@ class RecordingMaintainer(threading.Thread):
.where(
ReviewSegment.camera == camera,
(ReviewSegment.end_time == None)
| (
ReviewSegment.end_time
>= min_start_per_camera[camera] - post_capture
),
| (ReviewSegment.end_time >= min_start_per_camera[camera]),
)
.order_by(ReviewSegment.start_time)
)
+17 -253
View File
@@ -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 -93
View File
@@ -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)
-76
View File
@@ -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()
-50
View File
@@ -6,8 +6,6 @@ import unittest
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from peewee import SqliteDatabase
# Mock complex imports before importing maintainer, saving originals so we can
# restore them after import and avoid polluting sys.modules for other tests.
_MOCKED_MODULES = [
@@ -22,7 +20,6 @@ for name in _MOCKED_MODULES:
# Now import the class under test
from frigate.config import FrigateConfig, RetainModeEnum # noqa: E402
from frigate.models import ReviewSegment # noqa: E402
from frigate.record.maintainer import RecordingMaintainer, SegmentInfo # noqa: E402
# Restore original modules (or remove mock if there was no original)
@@ -84,53 +81,6 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
f"Expected a single warning for unexpected files, got {len(matching)}",
)
async def test_ended_review_stays_eligible_through_post_capture(self):
config = MagicMock(spec=FrigateConfig)
camera_config = MagicMock()
camera_config.record.sub.enabled = False
camera_config.record.event_post_capture = 60
config.cameras = {"test_cam": camera_config}
maintainer = RecordingMaintainer(config, MagicMock())
maintainer.requestor = MagicMock()
maintainer.recordings_publisher = MagicMock()
maintainer.validate_and_move_segment = AsyncMock(return_value=None)
db = SqliteDatabase(":memory:")
with db.bind_ctx([ReviewSegment]):
db.create_tables([ReviewSegment])
# oldest cached segment starts 40s after the review ended
segment_start = datetime.datetime(
2026, 10, 19, 13, 1, 40, tzinfo=datetime.UTC
)
ReviewSegment.create(
id="review",
camera="test_cam",
start_time=segment_start.timestamp() - 50,
end_time=segment_start.timestamp() - 40,
severity="alert",
thumb_path="",
data={},
)
with tempfile.TemporaryDirectory() as cache_dir:
name = segment_start.strftime("%Y%m%d%H%M%S%z")
Path(cache_dir, f"test_cam@{name}.mp4").touch()
with (
patch("frigate.record.maintainer.CACHE_DIR", cache_dir),
patch(
"frigate.record.maintainer.psutil.process_iter", return_value=[]
),
):
await maintainer.move_files()
reviews = maintainer.validate_and_move_segment.call_args.args[1]
self.assertEqual(
[segment_start.timestamp() - 40], [r.end_time for r in reviews]
)
async def test_drops_quiet_segment_when_only_motion_retention(self):
# Regression: when motion retention is enabled but a segment has no
# motion and no review overlaps it, the segment must still be dropped.
+5 -29
View File
@@ -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):
-67
View File
@@ -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)
+26 -76
View File
@@ -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()
-45
View File
@@ -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
View File
@@ -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:
-45
View File
@@ -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.
+1 -6
View File
@@ -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
-44
View File
@@ -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 =
"![leaked alt](https://evil.example/x.png?d=secret) " +
"![local](/api/events/abc/thumbnail.webp) " +
"[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);
});
});
+222 -188
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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"
-29
View File
@@ -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 =
+31 -28
View File
@@ -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)) {