diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 165bcb8d8f..bbb30a6911 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -55,6 +55,9 @@ updates: # Needs Python 3.12 and numpy 2 - dependency-name: "librosa" versions: [">=1.0"] + # pandas 3.1 requires numpy 2 + - dependency-name: "pandas" + versions: [">=3.1"] # Needs Python 3.12 - dependency-name: "scipy" versions: [">=1.18"] diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index 4d61778cf8..ca73bf24f3 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -6371,6 +6371,7 @@ paths: required: false schema: type: integer + minimum: 0 description: Padding to apply to the vod. default: 0 title: Padding @@ -7576,6 +7577,7 @@ paths: required: false schema: type: integer + minimum: 1 default: 30 title: Scale responses: diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 49fd641c33..c712ec925e 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -966,6 +966,10 @@ def login(request: Request, body: AppPostLoginBody): try: db_user: User = User.get_by_id(user) except DoesNotExist: + # Hash anyway so response timing doesn't reveal which usernames exist + hash_password( + password, iterations=request.app.frigate_config.auth.hash_iterations + ) logger.warning(f"Login failed for unknown user '{user}' from {remote_addr}") failed_logins.record(user, time.time()) return JSONResponse(content={"message": "Login failed"}, status_code=401) diff --git a/frigate/api/defs/query/recordings_query_parameters.py b/frigate/api/defs/query/recordings_query_parameters.py index cd7b4221c2..d10db010fb 100644 --- a/frigate/api/defs/query/recordings_query_parameters.py +++ b/frigate/api/defs/query/recordings_query_parameters.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field from pydantic.json_schema import SkipJsonSchema @@ -11,7 +11,7 @@ class MediaRecordingsAvailabilityQueryParams(BaseModel): cameras: str = "all" before: float | SkipJsonSchema[None] = None after: float | SkipJsonSchema[None] = None - scale: int = 30 + scale: int = Field(default=30, ge=1) class RecordingsDeleteQueryParams(BaseModel): diff --git a/frigate/api/event.py b/frigate/api/event.py index 3137b124e6..7640e979c9 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -291,12 +291,12 @@ def events( clauses.append( reduce( operator.or_, - [(start_hour_fun > time_after), (start_hour_fun < time_before)], + [(start_hour_fun >= time_after), (start_hour_fun < time_before)], ) ) # all other cases should be and operator else: - clauses.append(start_hour_fun > time_after) + clauses.append(start_hour_fun >= time_after) clauses.append(start_hour_fun < time_before) if has_clip is not None: @@ -749,12 +749,12 @@ def events_search( event_filters.append( reduce( operator.or_, - [(start_hour_fun > time_after), (start_hour_fun < time_before)], + [(start_hour_fun >= time_after), (start_hour_fun < time_before)], ) ) # all other cases should be and operator else: - event_filters.append(start_hour_fun > time_after) + event_filters.append(start_hour_fun >= time_after) event_filters.append(start_hour_fun < time_before) # Perform semantic search diff --git a/frigate/api/export.py b/frigate/api/export.py index 5873a565c8..4d1017fc88 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -1227,7 +1227,7 @@ def bulk_delete_exports(body: ExportBulkDeleteBody): files_in_use = _get_files_in_use() for export in exports: - if export.video_path.split("/")[-1] in files_in_use: + if export.in_progress or export.video_path.split("/")[-1] in files_in_use: return JSONResponse( content={ "success": False, diff --git a/frigate/api/media.py b/frigate/api/media.py index 34448bddc0..2af79cb816 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -862,7 +862,7 @@ async def vod_hour( async def vod_event( request: Request, event_id: str, - padding: int = Query(0, description="Padding to apply to the vod."), + padding: int = Query(0, ge=0, description="Padding to apply to the vod."), ): try: event: Event = Event.get(Event.id == event_id) @@ -1521,6 +1521,7 @@ async def preview_gif( | ((start_ts > Previews.start_time) & (end_ts < Previews.end_time)) ) .where(Previews.camera == camera_name) + .order_by(Previews.start_time.asc()) .limit(1) .get() ) @@ -1530,7 +1531,8 @@ async def preview_gif( status_code=404, ) - diff = start_ts - preview.start_time + clip_start = max(start_ts, preview.start_time) + diff = clip_start - preview.start_time minutes = int(diff / 60) seconds = int(diff % 60) config: FrigateConfig = request.app.frigate_config @@ -1542,7 +1544,7 @@ async def preview_gif( "-ss", f"00:{minutes}:{seconds}", "-t", - f"{end_ts - start_ts}", + f"{end_ts - clip_start}", "-i", preview.path, "-r", @@ -1709,6 +1711,7 @@ async def preview_mp4( | ((start_ts > Previews.start_time) & (end_ts < Previews.end_time)) ) .where(Previews.camera == camera_name) + .order_by(Previews.start_time.asc()) .limit(1) .get() ) @@ -1721,7 +1724,8 @@ async def preview_mp4( status_code=404, ) - diff = start_ts - preview.start_time + clip_start = max(start_ts, preview.start_time) + diff = clip_start - preview.start_time minutes = int(diff / 60) seconds = int(diff % 60) config: FrigateConfig = request.app.frigate_config @@ -1734,7 +1738,7 @@ async def preview_mp4( "-ss", f"00:{minutes}:{seconds}", "-t", - f"{end_ts - start_ts}", + f"{end_ts - clip_start}", "-i", preview.path, "-r", diff --git a/frigate/api/review.py b/frigate/api/review.py index 14f9bb169c..0a0c758436 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -163,8 +163,7 @@ async def review( # Apply ordering and limit review_query = ( - review_query.order_by(ReviewSegment.severity.asc()) - .order_by(ReviewSegment.start_time.desc()) + review_query.order_by(ReviewSegment.start_time.desc()) .limit(limit) .dicts() .iterator() diff --git a/frigate/record/export.py b/frigate/record/export.py index 5efa4914fa..9bfe64e4ac 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -1470,6 +1470,9 @@ class RecordingExporter(threading.Thread): try: self._run_export(video_path, thumb_path) + except Exception: + self._discard_failed_export(video_path, thumb_path) + raise finally: # staged runs hold a full copy of the export, so they must not # survive a failure the way the small chapter file could @@ -1479,7 +1482,8 @@ class RecordingExporter(threading.Thread): """Drop the partial output and the row that promised it.""" Path(video_path).unlink(missing_ok=True) Export.delete().where(Export.id == self.export_id).execute() - Path(thumb_path).unlink(missing_ok=True) + if thumb_path: + Path(thumb_path).unlink(missing_ok=True) def _run_export(self, video_path: str, thumb_path: str) -> None: try: @@ -1498,6 +1502,7 @@ class RecordingExporter(threading.Thread): else: ffmpeg_cmd, playlist_lines = self.get_preview_export_command(video_path) except DoesNotExist: + self._discard_failed_export(video_path, thumb_path) return # When neither custom ffmpeg arg is set the default path uses diff --git a/frigate/test/http_api/test_http_event.py b/frigate/test/http_api/test_http_event.py index 8de5eb9dbc..f6b128b5fa 100644 --- a/frigate/test/http_api/test_http_event.py +++ b/frigate/test/http_api/test_http_event.py @@ -393,8 +393,8 @@ class TestHttpApp(BaseTestHttp): def test_event_time_filtering(self): morning_id = "123456.random" evening_id = "654321.random" - morning = 1656590400 # 06/30/2022 6 am (GMT) - evening = 1656633600 # 06/30/2022 6 pm (GMT) + morning = 1656568800 # 06/30/2022 6 am (GMT) + evening = 1656612000 # 06/30/2022 6 pm (GMT) with AuthTestClient(self.app) as client: super().insert_mock_event(morning_id, morning) @@ -417,6 +417,12 @@ class TestHttpApp(BaseTestHttp): ).json() assert events assert len(events) == 1 + # an event in the first minute of the range is included + events = client.get( + "/events", + params={"time_range": "06:00,24:00"}, + ).json() + assert len(events) == 2 def test_set_delete_sub_label(self): mock_event_updater = Mock(spec=EventMetadataPublisher) diff --git a/frigate/test/http_api/test_http_export.py b/frigate/test/http_api/test_http_export.py index 13f9422610..ae46ce52be 100644 --- a/frigate/test/http_api/test_http_export.py +++ b/frigate/test/http_api/test_http_export.py @@ -427,6 +427,30 @@ class TestHttpExport(BaseTestHttp): assert response.status_code == 400 assert Export.get(Export.id == "front_door_running").video_path == video + def test_delete_export_rejected_while_in_progress(self): + with tempfile.TemporaryDirectory() as tmpdir: + video = os.path.join(tmpdir, "front_door_abc123.mp4") + with open(video, "wb") as handle: + handle.write(b"video") + + Export.create( + id="front_door_running", + camera="front_door", + name="front door export", + date=100, + video_path=video, + thumb_path=os.path.join(tmpdir, "t.webp"), + in_progress=True, + ) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/delete", json={"ids": ["front_door_running"]} + ) + + assert response.status_code == 400 + assert os.path.exists(video) + def test_rename_export_missing_file_leaves_the_row_alone(self): with tempfile.TemporaryDirectory() as tmpdir: video = os.path.join(tmpdir, "front_door_gone_abc123.mp4") diff --git a/frigate/test/http_api/test_http_media.py b/frigate/test/http_api/test_http_media.py index 047d7de01a..ba7c7babae 100644 --- a/frigate/test/http_api/test_http_media.py +++ b/frigate/test/http_api/test_http_media.py @@ -584,6 +584,10 @@ class TestHttpMedia(BaseTestHttp): {"start_time": 1160, "end_time": 1300}, ] + # a non-positive scale would never advance the gap scan + response = client.get("/recordings/unavailable", params={"scale": 0}) + assert response.status_code == 422 + def test_recordings_summary_day_attribution_by_start_time(self): """ A recording spanning midnight marks only its start day. diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index c6e483f4ee..a759f394c5 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1086,13 +1086,6 @@ "title": "Confirm Password", "placeholder": "Confirm Password" }, - "strength": { - "title": "Password strength: ", - "weak": "Weak", - "medium": "Medium", - "strong": "Strong", - "veryStrong": "Very Strong" - }, "requirements": { "title": "Password requirements:", "length": "At least 12 characters" diff --git a/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx index 3db2a8203b..6f38611742 100644 --- a/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx +++ b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx @@ -415,10 +415,19 @@ export default function NotificationsSettingsExtras({ pushSubscription.unsubscribe(); workerRegistration.unregister(); setRegistration(null); + removeMessage("notification_settings", "registration"); }); + }) + .catch(() => { + toast.error(t("notification.toast.error.registerFailed"), { + position: "top-center", + }); + workerRegistration.unregister(); + setRegistration(null); + removeMessage("notification_settings", "registration"); }); }, - [addMessage, publicKey, t], + [addMessage, removeMessage, publicKey, t], ); useEffect(() => { diff --git a/web/src/components/config-form/theme/fields/KnownPlatesField.tsx b/web/src/components/config-form/theme/fields/KnownPlatesField.tsx index 7b5d81cc8a..b0d753b462 100644 --- a/web/src/components/config-form/theme/fields/KnownPlatesField.tsx +++ b/web/src/components/config-form/theme/fields/KnownPlatesField.tsx @@ -307,6 +307,9 @@ export function KnownPlatesField(props: FieldProps) { ); const handleAddEntry = useCallback(() => { + if ("" in data) { + return; + } const next = { ...data, "": [""] }; onChange(next, fieldPath); }, [data, fieldPath, onChange]); diff --git a/web/src/components/overlay/CreateUserDialog.tsx b/web/src/components/overlay/CreateUserDialog.tsx index 2953a29cb9..d33b2117aa 100644 --- a/web/src/components/overlay/CreateUserDialog.tsx +++ b/web/src/components/overlay/CreateUserDialog.tsx @@ -37,12 +37,7 @@ import { useTranslation } from "react-i18next"; import { isDesktop, isMobile } from "react-device-detect"; import { cn } from "@/lib/utils"; import { FrigateConfig } from "@/types/frigateConfig"; -import { - calculatePasswordStrength, - getPasswordRequirements, - getPasswordStrengthLabel, - getPasswordStrengthColor, -} from "@/utils/passwordUtil"; +import { getPasswordRequirements } from "@/utils/passwordUtil"; import { MobilePage, MobilePageContent, @@ -123,12 +118,6 @@ export default function CreateUserDialog({ const passwordsMatch = password === confirmPassword; const showMatchIndicator = password && confirmPassword; - // Password strength calculation - const passwordStrength = useMemo( - () => calculatePasswordStrength(password), - [password], - ); - const requirements = useMemo( () => getPasswordRequirements(password), [password], @@ -224,6 +213,12 @@ export default function CreateUserDialog({ type={showPasswordVisible ? "text" : "password"} className="h-10 pr-10" {...field} + onChange={(e) => { + field.onChange(e); + if (form.getValues("confirmPassword")) { + form.trigger("confirmPassword"); + } + }} />