diff --git a/docs/docs/configuration/authentication.md b/docs/docs/configuration/authentication.md index 474998263..70f756b68 100644 --- a/docs/docs/configuration/authentication.md +++ b/docs/docs/configuration/authentication.md @@ -166,6 +166,10 @@ In this example: - If no mapping matches, Frigate falls back to `default_role` if configured. - If `role_map` is not defined, Frigate assumes the role header directly contains `admin`, `viewer`, or a custom role name. +**Note on matching semantics:** + +- Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups). + #### Port Considerations **Authenticated Port (8971)** diff --git a/frigate/api/auth.py b/frigate/api/auth.py index bfb3b81a1..e0a6ec924 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -439,10 +439,11 @@ def resolve_role( Determine the effective role for a request based on proxy headers and configuration. Order of resolution: - 1. If a role header is defined in proxy_config.header_map.role: - - If a role_map is configured, treat the header as group claims - (split by proxy_config.separator) and map to roles. - - If no role_map is configured, treat the header as role names directly. + 1. If a role header is defined in proxy_config.header_map.role: + - If a role_map is configured, treat the header as group claims + (split by proxy_config.separator) and map to roles. + Admin matches short-circuit to admin. + - If no role_map is configured, treat the header as role names directly. 2. If no valid role is found, return proxy_config.default_role if it's valid in config_roles, else 'viewer'. Args: @@ -492,6 +493,12 @@ def resolve_role( } logger.debug("Matched roles from role_map: %s", matched_roles) + # If admin matches, prioritize it to avoid accidental downgrade when + # users belong to both admin and lower-privilege groups. + if "admin" in matched_roles and "admin" in config_roles: + logger.debug("Resolved role (with role_map) to 'admin'.") + return "admin" + if matched_roles: resolved = next( (r for r in config_roles if r in matched_roles), validated_default diff --git a/frigate/test/test_proxy_auth.py b/frigate/test/test_proxy_auth.py index 61955486a..2ffad957c 100644 --- a/frigate/test/test_proxy_auth.py +++ b/frigate/test/test_proxy_auth.py @@ -31,6 +31,21 @@ class TestProxyRoleResolution(unittest.TestCase): role = resolve_role(headers, self.proxy_config, self.config_roles) self.assertEqual(role, "admin") + def test_role_map_or_matching(self): + config = self.proxy_config + config.header_map.role_map = { + "admin": ["group_admin", "group_privileged"], + } + + # OR semantics: a single matching group should map to the role + headers = {"x-remote-role": "group_admin"} + role = resolve_role(headers, config, self.config_roles) + self.assertEqual(role, "admin") + + headers = {"x-remote-role": "group_admin|group_privileged"} + role = resolve_role(headers, config, self.config_roles) + self.assertEqual(role, "admin") + def test_direct_role_header_with_separator(self): config = self.proxy_config config.header_map.role_map = None # disable role_map diff --git a/frigate/video.py b/frigate/video.py index 615c61d61..112844543 100755 --- a/frigate/video.py +++ b/frigate/video.py @@ -214,6 +214,7 @@ class CameraWatchdog(threading.Thread): self.latest_valid_segment_time: float = 0 self.latest_invalid_segment_time: float = 0 self.latest_cache_segment_time: float = 0 + self.record_enable_time: datetime | None = None def _update_enabled_state(self) -> bool: """Fetch the latest config and update enabled state.""" @@ -261,6 +262,9 @@ class CameraWatchdog(threading.Thread): def run(self) -> None: if self._update_enabled_state(): self.start_all_ffmpeg() + # If recording is enabled at startup, set the grace period timer + if self.config.record.enabled: + self.record_enable_time = datetime.now().astimezone(timezone.utc) time.sleep(self.sleeptime) while not self.stop_event.wait(self.sleeptime): @@ -270,13 +274,15 @@ class CameraWatchdog(threading.Thread): self.logger.debug(f"Enabling camera {self.config.name}") self.start_all_ffmpeg() - # reset all timestamps + # reset all timestamps and record the enable time for grace period self.latest_valid_segment_time = 0 self.latest_invalid_segment_time = 0 self.latest_cache_segment_time = 0 + self.record_enable_time = datetime.now().astimezone(timezone.utc) else: self.logger.debug(f"Disabling camera {self.config.name}") self.stop_all_ffmpeg() + self.record_enable_time = None # update camera status self.requestor.send_data( @@ -361,6 +367,12 @@ class CameraWatchdog(threading.Thread): if self.config.record.enabled and "record" in p["roles"]: now_utc = datetime.now().astimezone(timezone.utc) + # Check if we're within the grace period after enabling recording + # Grace period: 90 seconds allows time for ffmpeg to start and create first segment + in_grace_period = self.record_enable_time is not None and ( + now_utc - self.record_enable_time + ) < timedelta(seconds=90) + latest_cache_dt = ( datetime.fromtimestamp( self.latest_cache_segment_time, tz=timezone.utc @@ -386,10 +398,16 @@ class CameraWatchdog(threading.Thread): ) # ensure segments are still being created and that they have valid video data - cache_stale = now_utc > (latest_cache_dt + timedelta(seconds=120)) - valid_stale = now_utc > (latest_valid_dt + timedelta(seconds=120)) + # Skip checks during grace period to allow segments to start being created + cache_stale = not in_grace_period and now_utc > ( + latest_cache_dt + timedelta(seconds=120) + ) + valid_stale = not in_grace_period and now_utc > ( + latest_valid_dt + timedelta(seconds=120) + ) invalid_stale_condition = ( self.latest_invalid_segment_time > 0 + and not in_grace_period and now_utc > (latest_invalid_dt + timedelta(seconds=120)) and self.latest_valid_segment_time <= self.latest_invalid_segment_time diff --git a/web/public/locales/en/components/dialog.json b/web/public/locales/en/components/dialog.json index a56c2b1da..91ff38d82 100644 --- a/web/public/locales/en/components/dialog.json +++ b/web/public/locales/en/components/dialog.json @@ -1,6 +1,7 @@ { "restart": { "title": "Are you sure you want to restart Frigate?", + "description": "This will briefly stop Frigate while it restarts.", "button": "Restart", "restarting": { "title": "Frigate is Restarting", diff --git a/web/src/components/card/AnimatedEventCard.tsx b/web/src/components/card/AnimatedEventCard.tsx index c5da99aa2..b6e9ddf7c 100644 --- a/web/src/components/card/AnimatedEventCard.tsx +++ b/web/src/components/card/AnimatedEventCard.tsx @@ -19,6 +19,8 @@ import { Button } from "../ui/button"; import { FaCircleCheck } from "react-icons/fa6"; import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; +import { getTranslatedLabel } from "@/utils/i18n"; +import { formatList } from "@/utils/stringUtil"; type AnimatedEventCardProps = { event: ReviewSegment; @@ -50,26 +52,37 @@ export function AnimatedEventCard({ fetchPreviews: !currentHour, }); + const getEventType = useCallback( + (text: string) => { + if (event.data.sub_labels?.includes(text)) return "manual"; + if (event.data.audio.includes(text)) return "audio"; + return "object"; + }, + [event], + ); + const tooltipText = useMemo(() => { if (event?.data?.metadata?.title) { return event.data.metadata.title; } return ( - `${[ - ...new Set([ - ...(event.data.objects || []), - ...(event.data.sub_labels || []), - ...(event.data.audio || []), - ]), - ] - .filter((item) => item !== undefined && !item.includes("-verified")) - .map((text) => text.charAt(0).toUpperCase() + text.substring(1)) - .sort() - .join(", ") - .replaceAll("-verified", "")} ` + t("detected") + `${formatList( + [ + ...new Set([ + ...(event.data.objects || []).map((text) => + text.replace("-verified", ""), + ), + ...(event.data.sub_labels || []), + ...(event.data.audio || []), + ]), + ] + .filter((item) => item !== undefined) + .map((text) => getTranslatedLabel(text, getEventType(text))) + .sort(), + )} ` + t("detected") ); - }, [event, t]); + }, [event, getEventType, t]); // visibility diff --git a/web/src/components/card/ReviewCard.tsx b/web/src/components/card/ReviewCard.tsx index b5ba5cfea..89d79dee7 100644 --- a/web/src/components/card/ReviewCard.tsx +++ b/web/src/components/card/ReviewCard.tsx @@ -33,13 +33,14 @@ import axios from "axios"; import { toast } from "sonner"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; -import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { Button, buttonVariants } from "../ui/button"; import { Trans, useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; import { LuCircle } from "react-icons/lu"; import { MdAutoAwesome } from "react-icons/md"; import { GenAISummaryDialog } from "../overlay/chip/GenAISummaryChip"; +import { getTranslatedLabel } from "@/utils/i18n"; +import { formatList } from "@/utils/stringUtil"; type ReviewCardProps = { event: ReviewSegment; @@ -123,6 +124,12 @@ export default function ReviewCard({ } }, [bypassDialogRef, onDelete]); + const getEventType = (text: string) => { + if (event.data.sub_labels?.includes(text)) return "manual"; + if (event.data.audio.includes(text)) return "audio"; + return "object"; + }; + const content = (