protect media endpoints

This commit is contained in:
Josh Hawkins 2025-09-09 17:45:29 -05:00
parent a984227c9b
commit 909e8d2825

View File

@ -10,19 +10,19 @@ import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from functools import reduce from functools import reduce
from pathlib import Path as FilePath from pathlib import Path as FilePath
from typing import Any from typing import Any, List
from urllib.parse import unquote from urllib.parse import unquote
import cv2 import cv2
import numpy as np import numpy as np
import pytz import pytz
from fastapi import APIRouter, Path, Query, Request, Response from fastapi import APIRouter, Depends, Path, Query, Request, Response
from fastapi.params import Depends
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from pathvalidate import sanitize_filename from pathvalidate import sanitize_filename
from peewee import DoesNotExist, fn, operator from peewee import DoesNotExist, fn, operator
from tzlocal import get_localzone_name from tzlocal import get_localzone_name
from frigate.api.auth import get_allowed_cameras_for_filter, require_camera_access
from frigate.api.defs.query.media_query_parameters import ( from frigate.api.defs.query.media_query_parameters import (
Extension, Extension,
MediaEventsSnapshotQueryParams, MediaEventsSnapshotQueryParams,
@ -50,12 +50,11 @@ from frigate.util.path import get_event_thumbnail_bytes
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.media]) router = APIRouter(tags=[Tags.media])
@router.get("/{camera_name}") @router.get("/{camera_name}", dependencies=[Depends(require_camera_access)])
def mjpeg_feed( async def mjpeg_feed(
request: Request, request: Request,
camera_name: str, camera_name: str,
params: MediaMjpegFeedQueryParams = Depends(), params: MediaMjpegFeedQueryParams = Depends(),
@ -111,7 +110,7 @@ def imagestream(
) )
@router.get("/{camera_name}/ptz/info") @router.get("/{camera_name}/ptz/info", dependencies=[Depends(require_camera_access)])
async def camera_ptz_info(request: Request, camera_name: str): async def camera_ptz_info(request: Request, camera_name: str):
if camera_name in request.app.frigate_config.cameras: if camera_name in request.app.frigate_config.cameras:
# Schedule get_camera_info in the OnvifController's event loop # Schedule get_camera_info in the OnvifController's event loop
@ -127,8 +126,10 @@ async def camera_ptz_info(request: Request, camera_name: str):
) )
@router.get("/{camera_name}/latest.{extension}") @router.get(
def latest_frame( "/{camera_name}/latest.{extension}", dependencies=[Depends(require_camera_access)]
)
async def latest_frame(
request: Request, request: Request,
camera_name: str, camera_name: str,
extension: Extension, extension: Extension,
@ -236,8 +237,11 @@ def latest_frame(
) )
@router.get("/{camera_name}/recordings/{frame_time}/snapshot.{format}") @router.get(
def get_snapshot_from_recording( "/{camera_name}/recordings/{frame_time}/snapshot.{format}",
dependencies=[Depends(require_camera_access)],
)
async def get_snapshot_from_recording(
request: Request, request: Request,
camera_name: str, camera_name: str,
frame_time: float, frame_time: float,
@ -323,8 +327,10 @@ def get_snapshot_from_recording(
) )
@router.post("/{camera_name}/plus/{frame_time}") @router.post(
def submit_recording_snapshot_to_plus( "/{camera_name}/plus/{frame_time}", dependencies=[Depends(require_camera_access)]
)
async def submit_recording_snapshot_to_plus(
request: Request, camera_name: str, frame_time: str request: Request, camera_name: str, frame_time: str
): ):
if camera_name not in request.app.frigate_config.cameras: if camera_name not in request.app.frigate_config.cameras:
@ -412,11 +418,23 @@ def get_recordings_storage_usage(request: Request):
@router.get("/recordings/summary") @router.get("/recordings/summary")
def all_recordings_summary(params: MediaRecordingsSummaryQueryParams = Depends()): def all_recordings_summary(
request: Request,
params: MediaRecordingsSummaryQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
"""Returns true/false by day indicating if recordings exist""" """Returns true/false by day indicating if recordings exist"""
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone) hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone)
cameras = params.cameras cameras = params.cameras
if cameras != "all":
requested = set(unquote(cameras).split(","))
filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(content={})
cameras = ",".join(filtered)
else:
cameras = allowed_cameras
query = ( query = (
Recordings.select( Recordings.select(
@ -453,8 +471,10 @@ def all_recordings_summary(params: MediaRecordingsSummaryQueryParams = Depends()
return JSONResponse(content=days) return JSONResponse(content=days)
@router.get("/{camera_name}/recordings/summary") @router.get(
def recordings_summary(camera_name: str, timezone: str = "utc"): "/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)]
)
async def recordings_summary(camera_name: str, timezone: str = "utc"):
"""Returns hourly summary for recordings of given camera""" """Returns hourly summary for recordings of given camera"""
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(timezone) hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(timezone)
recording_groups = ( recording_groups = (
@ -515,8 +535,8 @@ def recordings_summary(camera_name: str, timezone: str = "utc"):
return JSONResponse(content=list(days.values())) return JSONResponse(content=list(days.values()))
@router.get("/{camera_name}/recordings") @router.get("/{camera_name}/recordings", dependencies=[Depends(require_camera_access)])
def recordings( async def recordings(
camera_name: str, camera_name: str,
after: float = (datetime.now() - timedelta(hours=1)).timestamp(), after: float = (datetime.now() - timedelta(hours=1)).timestamp(),
before: float = datetime.now().timestamp(), before: float = datetime.now().timestamp(),
@ -546,9 +566,22 @@ def recordings(
@router.get("/recordings/unavailable", response_model=list[dict]) @router.get("/recordings/unavailable", response_model=list[dict])
def no_recordings(params: MediaRecordingsAvailabilityQueryParams = Depends()): async def no_recordings(
request: Request,
params: MediaRecordingsAvailabilityQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
"""Get time ranges with no recordings.""" """Get time ranges with no recordings."""
cameras = params.cameras cameras = params.cameras
if cameras != "all":
requested = set(unquote(cameras).split(","))
filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(content=[])
cameras = ",".join(filtered)
else:
cameras = allowed_cameras
before = params.before or datetime.datetime.now().timestamp() before = params.before or datetime.datetime.now().timestamp()
after = ( after = (
params.after params.after
@ -560,6 +593,8 @@ def no_recordings(params: MediaRecordingsAvailabilityQueryParams = Depends()):
if cameras != "all": if cameras != "all":
camera_list = cameras.split(",") camera_list = cameras.split(",")
clauses.append((Recordings.camera << camera_list)) clauses.append((Recordings.camera << camera_list))
else:
camera_list = allowed_cameras
# Get recording start times # Get recording start times
data: list[Recordings] = ( data: list[Recordings] = (
@ -607,9 +642,10 @@ def no_recordings(params: MediaRecordingsAvailabilityQueryParams = Depends()):
@router.get( @router.get(
"/{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4", "/{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4",
dependencies=[Depends(require_camera_access)],
description="For iOS devices, use the master.m3u8 HLS link instead of clip.mp4. Safari does not reliably process progressive mp4 files.", description="For iOS devices, use the master.m3u8 HLS link instead of clip.mp4. Safari does not reliably process progressive mp4 files.",
) )
def recording_clip( async def recording_clip(
request: Request, request: Request,
camera_name: str, camera_name: str,
start_ts: float, start_ts: float,
@ -705,9 +741,10 @@ def recording_clip(
@router.get( @router.get(
"/vod/{camera_name}/start/{start_ts}/end/{end_ts}", "/vod/{camera_name}/start/{start_ts}/end/{end_ts}",
dependencies=[Depends(require_camera_access)],
description="Returns an HLS playlist for the specified timestamp-range on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.", description="Returns an HLS playlist for the specified timestamp-range on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.",
) )
def vod_ts(camera_name: str, start_ts: float, end_ts: float): async def vod_ts(camera_name: str, start_ts: float, end_ts: float):
recordings = ( recordings = (
Recordings.select( Recordings.select(
Recordings.path, Recordings.path,
@ -782,6 +819,7 @@ def vod_ts(camera_name: str, start_ts: float, end_ts: float):
@router.get( @router.get(
"/vod/{year_month}/{day}/{hour}/{camera_name}", "/vod/{year_month}/{day}/{hour}/{camera_name}",
dependencies=[Depends(require_camera_access)],
description="Returns an HLS playlist for the specified date-time on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.", description="Returns an HLS playlist for the specified date-time on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.",
) )
def vod_hour_no_timezone(year_month: str, day: int, hour: int, camera_name: str): def vod_hour_no_timezone(year_month: str, day: int, hour: int, camera_name: str):
@ -793,6 +831,7 @@ def vod_hour_no_timezone(year_month: str, day: int, hour: int, camera_name: str)
@router.get( @router.get(
"/vod/{year_month}/{day}/{hour}/{camera_name}/{tz_name}", "/vod/{year_month}/{day}/{hour}/{camera_name}/{tz_name}",
dependencies=[Depends(require_camera_access)],
description="Returns an HLS playlist for the specified date-time (with timezone) on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.", description="Returns an HLS playlist for the specified date-time (with timezone) on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.",
) )
def vod_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: str): def vod_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: str):
@ -812,7 +851,7 @@ def vod_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: st
"/vod/event/{event_id}", "/vod/event/{event_id}",
description="Returns an HLS playlist for the specified object. Append /master.m3u8 or /index.m3u8 for HLS playback.", description="Returns an HLS playlist for the specified object. Append /master.m3u8 or /index.m3u8 for HLS playback.",
) )
def vod_event( async def vod_event(
event_id: str, event_id: str,
padding: int = Query(0, description="Padding to apply to the vod."), padding: int = Query(0, description="Padding to apply to the vod."),
): ):
@ -828,15 +867,9 @@ def vod_event(
status_code=404, status_code=404,
) )
if not event.has_clip: require_camera_access(
logger.error(f"Event does not have recordings: {event_id}") event.camera
return JSONResponse( ) # Manual call (sync for non-async route, but since async, await if needed)
content={
"success": False,
"message": "Recordings not available.",
},
status_code=404,
)
end_ts = ( end_ts = (
datetime.now().timestamp() datetime.now().timestamp()
@ -861,7 +894,7 @@ def vod_event(
"/events/{event_id}/snapshot.jpg", "/events/{event_id}/snapshot.jpg",
description="Returns a snapshot image for the specified object id. NOTE: The query params only take affect while the event is in-progress. Once the event has ended the snapshot configuration is used.", description="Returns a snapshot image for the specified object id. NOTE: The query params only take affect while the event is in-progress. Once the event has ended the snapshot configuration is used.",
) )
def event_snapshot( async def event_snapshot(
request: Request, request: Request,
event_id: str, event_id: str,
params: MediaEventsSnapshotQueryParams = Depends(), params: MediaEventsSnapshotQueryParams = Depends(),
@ -871,6 +904,7 @@ def event_snapshot(
try: try:
event = Event.get(Event.id == event_id, Event.end_time != None) event = Event.get(Event.id == event_id, Event.end_time != None)
event_complete = True event_complete = True
require_camera_access(event.camera)
if not event.has_snapshot: if not event.has_snapshot:
return JSONResponse( return JSONResponse(
content={"success": False, "message": "Snapshot not available"}, content={"success": False, "message": "Snapshot not available"},
@ -899,6 +933,7 @@ def event_snapshot(
height=params.height, height=params.height,
quality=params.quality, quality=params.quality,
) )
require_camera_access(camera_state.name)
except Exception: except Exception:
return JSONResponse( return JSONResponse(
content={"success": False, "message": "Ongoing event not found"}, content={"success": False, "message": "Ongoing event not found"},
@ -932,7 +967,7 @@ def event_snapshot(
@router.get("/events/{event_id}/thumbnail.{extension}") @router.get("/events/{event_id}/thumbnail.{extension}")
def event_thumbnail( async def event_thumbnail(
request: Request, request: Request,
event_id: str, event_id: str,
extension: Extension, extension: Extension,
@ -945,6 +980,7 @@ def event_thumbnail(
event_complete = False event_complete = False
try: try:
event: Event = Event.get(Event.id == event_id) event: Event = Event.get(Event.id == event_id)
require_camera_access(event.camera)
if event.end_time is not None: if event.end_time is not None:
event_complete = True event_complete = True
@ -1007,7 +1043,7 @@ def event_thumbnail(
) )
@router.get("/{camera_name}/grid.jpg") @router.get("/{camera_name}/grid.jpg", dependencies=[Depends(require_camera_access)])
def grid_snapshot( def grid_snapshot(
request: Request, camera_name: str, color: str = "green", font_scale: float = 0.5 request: Request, camera_name: str, color: str = "green", font_scale: float = 0.5
): ):
@ -1254,7 +1290,10 @@ def event_preview(request: Request, event_id: str):
return preview_gif(request, event.camera, start_ts, end_ts) return preview_gif(request, event.camera, start_ts, end_ts)
@router.get("/{camera_name}/start/{start_ts}/end/{end_ts}/preview.gif") @router.get(
"/{camera_name}/start/{start_ts}/end/{end_ts}/preview.gif",
dependencies=[Depends(require_camera_access)],
)
def preview_gif( def preview_gif(
request: Request, request: Request,
camera_name: str, camera_name: str,
@ -1410,7 +1449,10 @@ def preview_gif(
) )
@router.get("/{camera_name}/start/{start_ts}/end/{end_ts}/preview.mp4") @router.get(
"/{camera_name}/start/{start_ts}/end/{end_ts}/preview.mp4",
dependencies=[Depends(require_camera_access)],
)
def preview_mp4( def preview_mp4(
request: Request, request: Request,
camera_name: str, camera_name: str,
@ -1650,8 +1692,13 @@ def preview_thumbnail(file_name: str):
####################### dynamic routes ########################### ####################### dynamic routes ###########################
@router.get("/{camera_name}/{label}/best.jpg") @router.get(
@router.get("/{camera_name}/{label}/thumbnail.jpg") "/{camera_name}/{label}/best.jpg", dependencies=[Depends(require_camera_access)]
)
@router.get(
"/{camera_name}/{label}/thumbnail.jpg",
dependencies=[Depends(require_camera_access)],
)
def label_thumbnail(request: Request, camera_name: str, label: str): def label_thumbnail(request: Request, camera_name: str, label: str):
label = unquote(label) label = unquote(label)
event_query = Event.select(fn.MAX(Event.id)).where(Event.camera == camera_name) event_query = Event.select(fn.MAX(Event.id)).where(Event.camera == camera_name)
@ -1673,7 +1720,9 @@ def label_thumbnail(request: Request, camera_name: str, label: str):
) )
@router.get("/{camera_name}/{label}/clip.mp4") @router.get(
"/{camera_name}/{label}/clip.mp4", dependencies=[Depends(require_camera_access)]
)
def label_clip(request: Request, camera_name: str, label: str): def label_clip(request: Request, camera_name: str, label: str):
label = unquote(label) label = unquote(label)
event_query = Event.select(fn.MAX(Event.id)).where( event_query = Event.select(fn.MAX(Event.id)).where(
@ -1692,7 +1741,9 @@ def label_clip(request: Request, camera_name: str, label: str):
) )
@router.get("/{camera_name}/{label}/snapshot.jpg") @router.get(
"/{camera_name}/{label}/snapshot.jpg", dependencies=[Depends(require_camera_access)]
)
def label_snapshot(request: Request, camera_name: str, label: str): def label_snapshot(request: Request, camera_name: str, label: str):
"""Returns the snapshot image from the latest event for the given camera and label combo""" """Returns the snapshot image from the latest event for the given camera and label combo"""
label = unquote(label) label = unquote(label)