From 878e81e05b9a8b5be86bc8bf5cd4eff12f109cd9 Mon Sep 17 00:00:00 2001 From: 0x464e <36742501+0x464e@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:09:58 +0200 Subject: [PATCH] Add validations to shared link --- web/public/locales/en/views/events.json | 4 ++- web/src/pages/Events.tsx | 39 ++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/web/public/locales/en/views/events.json b/web/public/locales/en/views/events.json index 2efbd2652..00c27e978 100644 --- a/web/public/locales/en/views/events.json +++ b/web/public/locales/en/views/events.json @@ -43,7 +43,9 @@ }, "documentTitle": "Review - Frigate", "recordings": { - "documentTitle": "Recordings - Frigate" + "documentTitle": "Recordings - Frigate", + "invalidSharedLink": "Unable to open timestamped recording link due to parsing error.", + "invalidSharedCamera": "Unable to open timestamped recording link due to an unknown or unauthorized camera." }, "calendarFilter": { "last24Hours": "Last 24 Hours" diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index b1e829037..7d4ef42cc 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -30,9 +30,10 @@ import EventView from "@/views/events/EventView"; import MotionSearchView from "@/views/motion-search/MotionSearchView"; import { RecordingView } from "@/views/recording/RecordingView"; import axios from "axios"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; +import { toast } from "sonner"; import useSWR from "swr"; export default function Events() { @@ -74,6 +75,7 @@ export default function Events() { const [motionSearchDay, setMotionSearchDay] = useState( undefined, ); + const handledReviewLinkRef = useRef(null); const motionSearchCameras = useMemo(() => { if (!config?.cameras) { @@ -257,16 +259,48 @@ export default function Events() { const timezone = searchParams.get(RECORDING_REVIEW_TIMEZONE_PARAM); if (!timestamp) { + handledReviewLinkRef.current = null; + return; + } + + if (!config) { return; } const camera = location.hash ? decodeURIComponent(location.hash.substring(1)) : null; + const reviewLinkKey = `${camera ?? ""}|${timestamp}|${timezone ?? ""}`; + + if (handledReviewLinkRef.current === reviewLinkKey) { + return; + } + + handledReviewLinkRef.current = reviewLinkKey; const reviewLink = parseRecordingReviewLink(camera, timestamp, timezone); if (!reviewLink) { + toast.error(t("recordings.invalidSharedLink"), { + position: "top-center", + }); + navigate(location.pathname, { + state: location.state, + replace: true, + }); + return; + } + + // reject unknown or unauthorized cameras before switching into + // recording view so bad links cleanly fall back to plain /review + const validCamera = + config.cameras[reviewLink.camera] && + allowedCameras.includes(reviewLink.camera); + + if (!validCamera) { + toast.error(t("recordings.invalidSharedCamera"), { + position: "top-center", + }); navigate(location.pathname, { state: location.state, replace: true, @@ -298,11 +332,14 @@ export default function Events() { location.hash, location.pathname, location.state, + config, navigate, + allowedCameras, getReviewDayBounds, reviewFilter, searchParams, setReviewFilter, + t, ]); // review paging