Compare commits

..
1 Commits
Author SHA1 Message Date
GuoQing LiuandGitHub 385f4059ee Merge edca412856 into 775ce22204 2026-07-14 13:01:56 +08:00
16 changed files with 38 additions and 236 deletions
@@ -274,13 +274,6 @@ http {
include proxy.conf;
}
location /api/logout {
auth_request off;
rewrite ^/api(/.*)$ $1 break;
proxy_pass http://frigate_api;
include proxy.conf;
}
# Allow unauthenticated access to the first_time_login endpoint
# so the login page can load help text before authentication.
location /api/auth/first_time_login {
+5 -13
View File
@@ -415,7 +415,7 @@ def create_encoded_jwt(user, role, expiration, secret):
)
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure):
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, secure):
# TODO: ideally this would set secure as well, but that requires TLS
# SameSite is intentionally left unset (browsers default to Lax). Setting
# SameSite=Lax/Strict would stop the cookie from being sent in cross-origin
@@ -427,7 +427,7 @@ def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure
key=cookie_name,
value=encoded_jwt,
httponly=True,
max_age=max_age,
expires=expiration,
secure=secure,
)
@@ -762,7 +762,7 @@ def auth(request: Request):
success_response,
JWT_COOKIE_NAME,
new_encoded_jwt,
JWT_SESSION_LENGTH,
new_expiration,
JWT_COOKIE_SECURE,
)
@@ -875,11 +875,7 @@ def login(request: Request, body: AppPostLoginBody):
encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token)
response = Response("", 200)
set_jwt_cookie(
response,
JWT_COOKIE_NAME,
encoded_jwt,
JWT_SESSION_LENGTH,
JWT_COOKIE_SECURE,
response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE
)
# Clear admin_first_time_login flag after successful admin login so the
# UI stops showing the first-time login documentation link.
@@ -1041,11 +1037,7 @@ async def update_password(
)
# Set new JWT cookie on response
set_jwt_cookie(
response,
JWT_COOKIE_NAME,
encoded_jwt,
JWT_SESSION_LENGTH,
JWT_COOKIE_SECURE,
response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE
)
return response
+7 -28
View File
@@ -7,7 +7,7 @@ import operator
import time
from datetime import datetime
from functools import reduce
from typing import Any, Literal
from typing import Any
import cv2
from fastapi import APIRouter, Body, Depends, HTTPException, Request
@@ -37,7 +37,6 @@ from frigate.api.defs.response.chat_response import (
from frigate.api.defs.tags import Tags
from frigate.api.event import _build_attribute_filter_clause, events
from frigate.config import FrigateConfig
from frigate.config.classification import SemanticSearchModelEnum
from frigate.genai.prompts import (
build_chat_system_prompt,
get_attribute_classifications,
@@ -87,23 +86,10 @@ def get_tools(request: Request) -> JSONResponse:
tools = get_tool_definitions(
semantic_search_enabled=semantic_search_enabled,
attribute_classifications=attribute_classifications,
embeddings_language=_embeddings_language(config),
)
return JSONResponse(content={"tools": tools})
def _embeddings_language(config: FrigateConfig) -> Literal["english", "multi"]:
"""Return the language capability of the configured embeddings model.
JinaV1 is English-only; every other option (JinaV2 or a GenAI embeddings
provider) handles multiple languages.
"""
if config.semantic_search.model == SemanticSearchModelEnum.jinav1:
return "english"
return "multi"
def _resolve_zones(
zones: list[str],
config: FrigateConfig,
@@ -112,14 +98,11 @@ def _resolve_zones(
"""Map zone names to their canonical config keys, case-insensitively.
LLMs frequently echo a user's casing ("Front Yard") instead of the
configured key ("front_yard"), or fall back to a zone's friendly name
("Front Walkway") instead of its ID ("front_walk"). The downstream zone
filter is a SQLite GLOB over the JSON-encoded zones column, which stores
config keys and is case-sensitive — so an unnormalized name silently
returns zero matches. Build a lookup over the relevant cameras' configured
zones, keyed by both the config key and the friendly name, and substitute
when we find a match; unknown names pass through so behavior matches what
the model asked for.
configured key ("front_yard"). The downstream zone filter is a SQLite GLOB
over the JSON-encoded zones column, which is case-sensitive — so an
unnormalized name silently returns zero matches. Build a lookup over the
relevant cameras' configured zones and substitute when we find a match;
unknown names pass through so behavior matches what the model asked for.
"""
if not zones:
return zones
@@ -129,11 +112,8 @@ def _resolve_zones(
camera_config = config.cameras.get(camera_id)
if camera_config is None:
continue
for zone_name, zone_config in camera_config.zones.items():
for zone_name in camera_config.zones.keys():
lookup.setdefault(zone_name.lower(), zone_name)
lookup.setdefault(
zone_config.get_formatted_name(zone_name).lower(), zone_name
)
return [lookup.get(z.lower(), z) for z in zones]
@@ -1154,7 +1134,6 @@ async def chat_completion(
tools = get_tool_definitions(
semantic_search_enabled=semantic_search_enabled,
attribute_classifications=attribute_classifications,
embeddings_language=_embeddings_language(config),
)
conversation = []
+3 -7
View File
@@ -1,11 +1,9 @@
import re
import sqlite3
from typing import Any
import regex
from playhouse.sqliteq import SqliteQueueDatabase
REGEXP_TIMEOUT_SECONDS = 1.0
class SqliteVecQueueDatabase(SqliteQueueDatabase):
def __init__(
@@ -36,10 +34,8 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
if item is None:
return False
try:
return (
regex.search(expr, item, timeout=REGEXP_TIMEOUT_SECONDS) is not None
)
except (regex.error, TimeoutError):
return re.search(expr, item) is not None
except re.error:
return False
conn.create_function("REGEXP", 2, regexp)
+6 -20
View File
@@ -6,7 +6,7 @@ transport.
"""
import datetime
from typing import Any, Literal
from typing import Any
from playhouse.shortcuts import model_to_dict
@@ -249,7 +249,6 @@ def get_attribute_classifications(config: FrigateConfig) -> list[dict[str, Any]]
def get_tool_definitions(
semantic_search_enabled: bool = False,
attribute_classifications: list[dict[str, Any]] | None = None,
embeddings_language: Literal["english", "multi"] = "multi",
) -> list[dict[str, Any]]:
"""
Get OpenAI-compatible tool definitions for Frigate.
@@ -259,9 +258,7 @@ def get_tool_definitions(
tool exposes an additional `semantic_query` parameter for descriptive
queries (e.g. "person riding a lawn mower") and find_similar_objects is
included. When attribute classification models are configured, an
`attribute` parameter is exposed for filtering by their labels. When the
embeddings model only understands English (JinaV1), the `semantic_query`
description instructs the model to write the query in English.
`attribute` parameter is exposed for filtering by their labels.
"""
search_objects_properties: dict[str, Any] = {
"camera": {
@@ -352,14 +349,6 @@ def get_tool_definitions(
"When set, combine with label/time/camera/zone filters as "
"usual (e.g. label='person', semantic_query='riding a lawn "
"mower', after='2024-05-01T00:00:00Z')."
+ (
" The configured embeddings model only understands "
"English, so always write semantic_query in English, "
"translating the user's description if they phrased it "
"in another language."
if embeddings_language == "english"
else ""
)
),
}
@@ -693,17 +682,14 @@ def build_chat_system_prompt(
if camera_config.friendly_name
else camera_id.replace("_", " ").title()
)
zone_descriptors = [
f"{zone_config.get_formatted_name(zone_name)} (ID: {zone_name})"
for zone_name, zone_config in camera_config.zones.items()
]
zone_names = list(camera_config.zones.keys())
if not has_speed_zone:
has_speed_zone = any(
zone.distances for zone in camera_config.zones.values()
)
if zone_descriptors:
if zone_names:
cameras_info.append(
f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_descriptors)})"
f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_names)})"
)
else:
cameras_info.append(f" - {friendly_name} (ID: {camera_id})")
@@ -713,7 +699,7 @@ def build_chat_system_prompt(
cameras_section = (
"\n\nAvailable cameras:\n"
+ "\n".join(cameras_info)
+ "\n\nWhen users refer to cameras or zones by their friendly name (e.g., 'Back Deck Camera', 'Front Walkway'), use the corresponding ID (e.g., 'back_deck_cam', 'front_walk') in tool calls. Tool results also identify zones by their ID, so when presenting cameras or zones back to the user, translate the ID to its friendly name."
+ "\n\nWhen users refer to cameras by their friendly name (e.g., 'Back Deck Camera'), use the corresponding camera ID (e.g., 'back_deck_cam') in tool calls."
)
speed_units_section = ""
-54
View File
@@ -1,54 +0,0 @@
"""Tests for the REGEXP function registered on the main Frigate database.
Regression coverage for GHSA-q8jx-q884-jcq9: an attacker-controlled
catastrophic (ReDoS) pattern reaching the REGEXP sink must not be able to
stall the serialized database worker thread.
"""
import sqlite3
import time
import unittest
from frigate.db.sqlitevecq import REGEXP_TIMEOUT_SECONDS, SqliteVecQueueDatabase
class TestRegexpFunction(unittest.TestCase):
def setUp(self) -> None:
# autostart=False keeps the queue worker thread from spinning up; we
# only need the REGEXP registration, exercised on our own connection.
self.db = SqliteVecQueueDatabase(":memory:", autostart=False)
self.conn = sqlite3.connect(":memory:")
self.db._register_regexp(self.conn)
def tearDown(self) -> None:
self.conn.close()
def _regexp(self, value: str | None, pattern: str) -> int | None:
# SQLite maps "value REGEXP pattern" to regexp(pattern, value).
return self.conn.execute("SELECT ? REGEXP ?", (value, pattern)).fetchone()[0]
def test_normal_patterns_still_match(self) -> None:
self.assertTrue(self._regexp("ABC123", "^ABC"))
self.assertTrue(self._regexp("ABC123", "ABC.*"))
self.assertTrue(self._regexp("ABC123", "[0-9]+$"))
self.assertFalse(self._regexp("ABC123", "^XYZ"))
def test_null_value_does_not_match(self) -> None:
self.assertFalse(self._regexp(None, ".*"))
def test_invalid_pattern_does_not_raise(self) -> None:
self.assertFalse(self._regexp("ABC123", "(unclosed"))
def test_catastrophic_pattern_is_time_bounded(self) -> None:
# Without the timeout this evaluation backtracks for minutes to hours
# and wedges the whole database thread (GHSA-q8jx-q884-jcq9).
catastrophic = "(a{2,})+c"
subject = "a" * 4000
start = time.monotonic()
result = self._regexp(subject, catastrophic)
elapsed = time.monotonic() - start
# The pattern does not match; the guarantee is that it returns quickly.
self.assertFalse(result)
self.assertLess(elapsed, REGEXP_TIMEOUT_SECONDS + 2.0)
@@ -169,13 +169,6 @@ const detect: SectionConfigOverrides = {
resolution: ["width", "height", "fps"],
tracking: ["min_initialized", "max_disappeared"],
},
uiSchema: {
annotation_offset: {
"ui:options": {
signed: true,
},
},
},
hiddenFields: ["enabled_in_config"],
advancedFields: [
"min_initialized",
@@ -57,7 +57,6 @@ const record: SectionConfigOverrides = {
"ui:options": {
suppressMultiSchema: true,
ffmpegPresetField: "hwaccel_args",
ffmpegGlobalFieldPath: "export.hwaccel_args",
},
},
},
@@ -17,4 +17,3 @@ export {
isSubtreeModified,
} from "./overrides";
export { getSizedFieldClassName } from "./fieldSizing";
export { getNumericInputMode } from "./inputMode";
@@ -1,51 +0,0 @@
import type { RJSFSchema } from "@rjsf/utils";
type NumericInputOptions = {
signed?: boolean;
};
/**
* Derive the on-screen keyboard hint for a schema field.
*
* Numeric config fields render as text inputs because RJSF's NumberField
* relies on the widget echoing raw strings back, so that trailing "." and "0"
* characters survive while a value is being typed. That means the numeric
* keypad has to be requested explicitly. Desktop browsers ignore inputMode, so
* this only affects virtual keyboards.
*
* Fields accepting negative values opt out, since the iOS numeric and decimal
* keypads have no minus key. Most numeric fields declare no minimum even
* though they are non-negative, so signed fields are marked explicitly with
* ui:options.signed.
*
* Args:
* schema: The JSON schema for the field being rendered
* options: The resolved ui:options for the field
*
* Returns:
* The inputMode to apply, or undefined to leave the keyboard alone
*/
export function getNumericInputMode(
schema: RJSFSchema,
options: unknown,
): "numeric" | "decimal" | undefined {
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
const isInteger = types.includes("integer");
if (!isInteger && !types.includes("number")) {
return undefined;
}
const numericOptions =
typeof options === "object" && options !== null
? (options as NumericInputOptions)
: undefined;
const minimum = schema.minimum ?? schema.exclusiveMinimum;
if (numericOptions?.signed || (minimum ?? 0) < 0) {
return undefined;
}
return isInteger ? "numeric" : "decimal";
}
@@ -120,12 +120,6 @@ export function FfmpegArgsWidget(props: WidgetProps) {
id,
} = props;
const presetField = options?.ffmpegPresetField as PresetField | undefined;
// Path to this field within its config section. This is usually the same as
// the preset field, but the two diverge when the field sits below the
// section root: record.export.hwaccel_args uses the hwaccel_args preset list
// while living at export.hwaccel_args inside the record section.
const globalFieldPath =
(options?.ffmpegGlobalFieldPath as string | undefined) ?? presetField;
const allowInherit = options?.allowInherit === true;
const hideDescription = options?.hideDescription === true;
const useSplitLayout = options?.splitLayout !== false;
@@ -137,18 +131,11 @@ export function FfmpegArgsWidget(props: WidgetProps) {
// Extract the global value for this specific field to detect inheritance
const globalFieldValue = useMemo(() => {
if (
!showUseGlobalSetting ||
!formContext?.globalValue ||
!globalFieldPath
) {
if (!showUseGlobalSetting || !formContext?.globalValue || !presetField) {
return undefined;
}
return get(
formContext.globalValue as Record<string, unknown>,
globalFieldPath,
);
}, [showUseGlobalSetting, formContext?.globalValue, globalFieldPath]);
return get(formContext.globalValue as Record<string, unknown>, presetField);
}, [showUseGlobalSetting, formContext?.globalValue, presetField]);
const { data } = useSWR<FfmpegPresetResponse>("ffmpeg/presets");
@@ -2,7 +2,7 @@
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { getNumericInputMode, getSizedFieldClassName } from "../utils";
import { getSizedFieldClassName } from "../utils";
export function TextWidget(props: WidgetProps) {
const {
@@ -28,7 +28,6 @@ export function TextWidget(props: WidgetProps) {
id={id}
className={cn(fieldClassName)}
type="text"
inputMode={getNumericInputMode(schema, options)}
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || (options.placeholder as string) || ""}
@@ -127,12 +127,8 @@ export default function ObjectTrackOverlay({
},
);
const getZonesFriendlyNames = (
zones: string[],
config: FrigateConfig,
cameraId?: string,
) => {
return zones?.map((zone) => resolveZoneName(config, zone, cameraId)) ?? [];
const getZonesFriendlyNames = (zones: string[], config: FrigateConfig) => {
return zones?.map((zone) => resolveZoneName(config, zone)) ?? [];
};
const timelineResults = useMemo(() => {
@@ -155,7 +151,7 @@ export default function ObjectTrackOverlay({
data: {
...event.data,
zones_friendly_names: config
? getZonesFriendlyNames(event.data?.zones, config, event.camera)
? getZonesFriendlyNames(event.data?.zones, config)
: [],
},
}));
@@ -61,11 +61,7 @@ export function ObjectPath({
...pos.lifecycle_item?.data,
zones_friendly_names: pos.lifecycle_item?.data.zones.map(
(zone) => {
return resolveZoneName(
config,
zone,
pos.lifecycle_item?.camera,
);
return resolveZoneName(config, zone);
},
),
},
@@ -301,19 +301,11 @@ export function TrackingDetails({
[recordings, actualVideoStart],
);
const sequence = useMemo(
() =>
eventSequence?.map((item) => ({
...item,
data: {
...item.data,
zones_friendly_names: item.data?.zones?.map((zone) =>
resolveZoneName(config, zone, item.camera),
),
},
})),
[eventSequence, config],
);
eventSequence?.map((event) => {
event.data.zones_friendly_names = event.data?.zones?.map((zone) => {
return resolveZoneName(config, zone);
});
});
// Use manualOverride (set when seeking in image mode) if present so
// lifecycle rows and overlays follow image-mode seeks. Otherwise fall
@@ -857,9 +849,9 @@ export function TrackingDetails({
</div>
<div className="mt-2">
{!sequence ? (
{!eventSequence ? (
<ActivityIndicator className="size-2" size={2} />
) : sequence.length === 0 ? (
) : eventSequence.length === 0 ? (
<div className="py-2 text-muted-foreground">
{t("detail.noObjectDetailData", { ns: "views/events" })}
</div>
@@ -879,7 +871,7 @@ export function TrackingDetails({
/>
)}
<div className="space-y-2">
{sequence.map((item, idx) => {
{eventSequence.map((item, idx) => {
return (
<div
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
+1 -1
View File
@@ -1032,7 +1032,7 @@ function ObjectTimeline({
data: {
...event.data,
zones_friendly_names: event.data?.zones?.map((zone) =>
resolveZoneName(config, zone, event.camera),
resolveZoneName(config, zone),
),
},
}));