protect event endpoints

This commit is contained in:
Josh Hawkins 2025-09-09 17:45:05 -05:00
parent 2a9d5f5e51
commit a984227c9b

View File

@ -8,6 +8,7 @@ import random
import string import string
from functools import reduce from functools import reduce
from pathlib import Path from pathlib import Path
from typing import List
from urllib.parse import unquote from urllib.parse import unquote
import cv2 import cv2
@ -19,7 +20,11 @@ from pathvalidate import sanitize_filename
from peewee import JOIN, DoesNotExist, fn, operator from peewee import JOIN, DoesNotExist, fn, operator
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
from frigate.api.auth import require_role from frigate.api.auth import (
get_allowed_cameras_for_filter,
require_camera_access,
require_role,
)
from frigate.api.defs.query.events_query_parameters import ( from frigate.api.defs.query.events_query_parameters import (
DEFAULT_TIME_RANGE, DEFAULT_TIME_RANGE,
EventsQueryParams, EventsQueryParams,
@ -61,7 +66,10 @@ router = APIRouter(tags=[Tags.events])
@router.get("/events", response_model=list[EventResponse]) @router.get("/events", response_model=list[EventResponse])
def events(params: EventsQueryParams = Depends()): def events(
params: EventsQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
camera = params.camera camera = params.camera
cameras = params.cameras cameras = params.cameras
@ -135,8 +143,14 @@ def events(params: EventsQueryParams = Depends()):
clauses.append((Event.camera == camera)) clauses.append((Event.camera == camera))
if cameras != "all": if cameras != "all":
camera_list = cameras.split(",") requested = set(cameras.split(","))
clauses.append((Event.camera << camera_list)) filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(content=[])
camera_list = list(filtered)
else:
camera_list = allowed_cameras
clauses.append((Event.camera << camera_list))
if labels != "all": if labels != "all":
label_list = labels.split(",") label_list = labels.split(",")
@ -321,9 +335,17 @@ def events(params: EventsQueryParams = Depends()):
@router.get("/events/explore", response_model=list[EventResponse]) @router.get("/events/explore", response_model=list[EventResponse])
def events_explore(limit: int = 10): def events_explore(
limit: int = 10,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
# get distinct labels for all events # get distinct labels for all events
distinct_labels = Event.select(Event.label).distinct().order_by(Event.label) distinct_labels = (
Event.select(Event.label)
.where(Event.camera << allowed_cameras)
.distinct()
.order_by(Event.label)
)
label_counts = {} label_counts = {}
@ -334,14 +356,18 @@ def events_explore(limit: int = 10):
# get most recent events for this label # get most recent events for this label
label_events = ( label_events = (
Event.select() Event.select()
.where(Event.label == label) .where((Event.label == label) & (Event.camera << allowed_cameras))
.order_by(Event.start_time.desc()) .order_by(Event.start_time.desc())
.limit(limit) .limit(limit)
.iterator() .iterator()
) )
# count total events for this label # count total events for this label
label_counts[label] = Event.select().where(Event.label == label).count() label_counts[label] = (
Event.select()
.where((Event.label == label) & (Event.camera << allowed_cameras))
.count()
)
yield from label_events yield from label_events
@ -403,6 +429,16 @@ def event_ids(ids: str):
status_code=400, status_code=400,
) )
for event_id in ids:
try:
event = Event.get(Event.id == event_id)
require_camera_access(event.camera)
except DoesNotExist:
return JSONResponse(
content=({"success": False, "message": f"Event {event_id} not found"}),
status_code=404,
)
try: try:
events = Event.select().where(Event.id << ids).dicts().iterator() events = Event.select().where(Event.id << ids).dicts().iterator()
return JSONResponse(list(events)) return JSONResponse(list(events))
@ -413,7 +449,11 @@ def event_ids(ids: str):
@router.get("/events/search") @router.get("/events/search")
def events_search(request: Request, params: EventsSearchQueryParams = Depends()): def events_search(
request: Request,
params: EventsSearchQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
query = params.query query = params.query
search_type = params.search_type search_type = params.search_type
include_thumbnails = params.include_thumbnails include_thumbnails = params.include_thumbnails
@ -486,7 +526,13 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
event_filters = [] event_filters = []
if cameras != "all": if cameras != "all":
event_filters.append((Event.camera << cameras.split(","))) requested = set(cameras.split(","))
filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(content=[])
event_filters.append((Event.camera << list(filtered)))
else:
event_filters.append((Event.camera << allowed_cameras))
if labels != "all": if labels != "all":
event_filters.append((Event.label << labels.split(","))) event_filters.append((Event.label << labels.split(",")))
@ -739,7 +785,10 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
@router.get("/events/summary") @router.get("/events/summary")
def events_summary(params: EventsSummaryQueryParams = Depends()): def events_summary(
params: EventsSummaryQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
tz_name = params.timezone tz_name = params.timezone
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name) hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name)
has_clip = params.has_clip has_clip = params.has_clip
@ -771,7 +820,7 @@ def events_summary(params: EventsSummaryQueryParams = Depends()):
Event.zones, Event.zones,
fn.COUNT(Event.id).alias("count"), fn.COUNT(Event.id).alias("count"),
) )
.where(reduce(operator.and_, clauses)) .where(reduce(operator.and_, clauses) & (Event.camera << allowed_cameras))
.group_by( .group_by(
Event.camera, Event.camera,
Event.label, Event.label,
@ -788,7 +837,9 @@ def events_summary(params: EventsSummaryQueryParams = Depends()):
@router.get("/events/{event_id}", response_model=EventResponse) @router.get("/events/{event_id}", response_model=EventResponse)
def event(event_id: str): def event(event_id: str):
try: try:
return model_to_dict(Event.get(Event.id == event_id)) event = Event.get(Event.id == event_id)
require_camera_access(event.camera)
return model_to_dict(event)
except DoesNotExist: except DoesNotExist:
return JSONResponse(content="Event not found", status_code=404) return JSONResponse(content="Event not found", status_code=404)
@ -801,6 +852,7 @@ def event(event_id: str):
def set_retain(event_id: str): def set_retain(event_id: str):
try: try:
event = Event.get(Event.id == event_id) event = Event.get(Event.id == event_id)
require_camera_access(event.camera)
except DoesNotExist: except DoesNotExist:
return JSONResponse( return JSONResponse(
content=({"success": False, "message": "Event " + event_id + " not found"}), content=({"success": False, "message": "Event " + event_id + " not found"}),
@ -835,6 +887,7 @@ def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = None):
try: try:
event = Event.get(Event.id == event_id) event = Event.get(Event.id == event_id)
require_camera_access(event.camera)
except DoesNotExist: except DoesNotExist:
message = f"Event {event_id} not found" message = f"Event {event_id} not found"
logger.error(message) logger.error(message)
@ -945,6 +998,7 @@ def false_positive(request: Request, event_id: str):
try: try:
event = Event.get(Event.id == event_id) event = Event.get(Event.id == event_id)
require_camera_access(event.camera)
except DoesNotExist: except DoesNotExist:
message = f"Event {event_id} not found" message = f"Event {event_id} not found"
logger.error(message) logger.error(message)
@ -1025,6 +1079,7 @@ def false_positive(request: Request, event_id: str):
def delete_retain(event_id: str): def delete_retain(event_id: str):
try: try:
event = Event.get(Event.id == event_id) event = Event.get(Event.id == event_id)
require_camera_access(event.camera)
except DoesNotExist: except DoesNotExist:
return JSONResponse( return JSONResponse(
content=({"success": False, "message": "Event " + event_id + " not found"}), content=({"success": False, "message": "Event " + event_id + " not found"}),
@ -1052,6 +1107,7 @@ def set_sub_label(
): ):
try: try:
event: Event = Event.get(Event.id == event_id) event: Event = Event.get(Event.id == event_id)
require_camera_access(event.camera)
except DoesNotExist: except DoesNotExist:
event = None event = None
@ -1106,6 +1162,7 @@ def set_plate(
): ):
try: try:
event: Event = Event.get(Event.id == event_id) event: Event = Event.get(Event.id == event_id)
require_camera_access(event.camera)
except DoesNotExist: except DoesNotExist:
event = None event = None
@ -1161,6 +1218,7 @@ def set_description(
): ):
try: try:
event: Event = Event.get(Event.id == event_id) event: Event = Event.get(Event.id == event_id)
require_camera_access(event.camera)
except DoesNotExist: except DoesNotExist:
return JSONResponse( return JSONResponse(
content=({"success": False, "message": "Event " + event_id + " not found"}), content=({"success": False, "message": "Event " + event_id + " not found"}),
@ -1210,6 +1268,7 @@ def regenerate_description(
): ):
try: try:
event: Event = Event.get(Event.id == event_id) event: Event = Event.get(Event.id == event_id)
require_camera_access(event.camera)
except DoesNotExist: except DoesNotExist:
return JSONResponse( return JSONResponse(
content=({"success": False, "message": "Event " + event_id + " not found"}), content=({"success": False, "message": "Event " + event_id + " not found"}),
@ -1283,6 +1342,7 @@ def generate_description_embedding(
def delete_single_event(event_id: str, request: Request) -> dict: def delete_single_event(event_id: str, request: Request) -> dict:
try: try:
event = Event.get(Event.id == event_id) event = Event.get(Event.id == event_id)
require_camera_access(event.camera)
except DoesNotExist: except DoesNotExist:
return {"success": False, "message": f"Event {event_id} not found"} return {"success": False, "message": f"Event {event_id} not found"}
@ -1351,7 +1411,10 @@ def delete_events(request: Request, body: EventsDeleteBody):
@router.post( @router.post(
"/events/{camera_name}/{label}/create", "/events/{camera_name}/{label}/create",
response_model=EventCreateResponse, response_model=EventCreateResponse,
dependencies=[Depends(require_role(["admin"]))], dependencies=[
Depends(lambda: require_role(["admin"])),
Depends(require_camera_access),
],
) )
def create_event( def create_event(
request: Request, request: Request,
@ -1412,6 +1475,8 @@ def create_event(
) )
def end_event(request: Request, event_id: str, body: EventsEndBody): def end_event(request: Request, event_id: str, body: EventsEndBody):
try: try:
event: Event = Event.get(Event.id == event_id)
require_camera_access(event.camera)
end_time = body.end_time or datetime.datetime.now().timestamp() end_time = body.end_time or datetime.datetime.now().timestamp()
request.app.event_metadata_updater.publish( request.app.event_metadata_updater.publish(
(event_id, end_time), EventMetadataTypeEnum.manual_event_end.value (event_id, end_time), EventMetadataTypeEnum.manual_event_end.value
@ -1433,12 +1498,15 @@ def end_event(request: Request, event_id: str, body: EventsEndBody):
@router.post( @router.post(
"/trigger/embedding", "/trigger/embedding",
response_model=dict, response_model=dict,
dependencies=[Depends(require_role(["admin"]))], dependencies=[
Depends(lambda: require_role(["admin"])),
Depends(require_camera_access),
],
) )
def create_trigger_embedding( def create_trigger_embedding(
request: Request, request: Request,
body: TriggerEmbeddingBody, body: TriggerEmbeddingBody,
camera: str, camera_name: str,
name: str, name: str,
): ):
try: try:
@ -1454,13 +1522,13 @@ def create_trigger_embedding(
# Check if trigger already exists # Check if trigger already exists
if ( if (
Trigger.select() Trigger.select()
.where(Trigger.camera == camera, Trigger.name == name) .where(Trigger.camera == camera_name, Trigger.name == name)
.exists() .exists()
): ):
return JSONResponse( return JSONResponse(
content={ content={
"success": False, "success": False,
"message": f"Trigger {camera}:{name} already exists", "message": f"Trigger {camera_name}:{name} already exists",
}, },
status_code=400, status_code=400,
) )
@ -1530,28 +1598,29 @@ def create_trigger_embedding(
# Save image to the triggers directory # Save image to the triggers directory
try: try:
os.makedirs( os.makedirs(
os.path.join(TRIGGER_DIR, sanitize_filename(camera)), exist_ok=True os.path.join(TRIGGER_DIR, sanitize_filename(camera_name)),
exist_ok=True,
) )
with open( with open(
os.path.join( os.path.join(
TRIGGER_DIR, TRIGGER_DIR,
sanitize_filename(camera), sanitize_filename(camera_name),
f"{sanitize_filename(body.data)}.webp", f"{sanitize_filename(body.data)}.webp",
), ),
"wb", "wb",
) as f: ) as f:
f.write(thumbnail) f.write(thumbnail)
logger.debug( logger.debug(
f"Writing thumbnail for trigger with data {body.data} in {camera}." f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
) )
except Exception as e: except Exception as e:
logger.error(e.with_traceback()) logger.error(e.with_traceback())
logger.error( logger.error(
f"Failed to write thumbnail for trigger with data {body.data} in {camera}" f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
) )
Trigger.create( Trigger.create(
camera=camera, camera=camera_name,
name=name, name=name,
type=body.type, type=body.type,
data=body.data, data=body.data,
@ -1565,7 +1634,7 @@ def create_trigger_embedding(
return JSONResponse( return JSONResponse(
content={ content={
"success": True, "success": True,
"message": f"Trigger created successfully for {camera}:{name}", "message": f"Trigger created successfully for {camera_name}:{name}",
}, },
status_code=200, status_code=200,
) )
@ -1584,11 +1653,14 @@ def create_trigger_embedding(
@router.put( @router.put(
"/trigger/embedding/{camera}/{name}", "/trigger/embedding/{camera}/{name}",
response_model=dict, response_model=dict,
dependencies=[Depends(require_role(["admin"]))], dependencies=[
Depends(lambda: require_role(["admin"])),
Depends(require_camera_access),
],
) )
def update_trigger_embedding( def update_trigger_embedding(
request: Request, request: Request,
camera: str, camera_name: str,
name: str, name: str,
body: TriggerEmbeddingBody, body: TriggerEmbeddingBody,
): ):
@ -1609,7 +1681,9 @@ def update_trigger_embedding(
embedding = context.generate_description_embedding(body.data) embedding = context.generate_description_embedding(body.data)
elif body.type == "thumbnail": elif body.type == "thumbnail":
webp_file = sanitize_filename(body.data) + ".webp" webp_file = sanitize_filename(body.data) + ".webp"
webp_path = os.path.join(TRIGGER_DIR, sanitize_filename(camera), webp_file) webp_path = os.path.join(
TRIGGER_DIR, sanitize_filename(camera_name), webp_file
)
try: try:
event: Event = Event.get(Event.id == body.data) event: Event = Event.get(Event.id == body.data)
@ -1656,7 +1730,9 @@ def update_trigger_embedding(
) )
# Check if trigger exists for upsert # Check if trigger exists for upsert
trigger = Trigger.get_or_none(Trigger.camera == camera, Trigger.name == name) trigger = Trigger.get_or_none(
Trigger.camera == camera_name, Trigger.name == name
)
if trigger: if trigger:
# Update existing trigger # Update existing trigger
@ -1665,17 +1741,17 @@ def update_trigger_embedding(
os.remove( os.remove(
os.path.join( os.path.join(
TRIGGER_DIR, TRIGGER_DIR,
sanitize_filename(camera), sanitize_filename(camera_name),
f"{trigger.data}.webp", f"{trigger.data}.webp",
) )
) )
logger.debug( logger.debug(
f"Deleted thumbnail for trigger with data {trigger.data} in {camera}." f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
) )
except Exception as e: except Exception as e:
logger.error(e.with_traceback()) logger.error(e.with_traceback())
logger.error( logger.error(
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera}" f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
) )
Trigger.update( Trigger.update(
@ -1685,11 +1761,11 @@ def update_trigger_embedding(
threshold=body.threshold, threshold=body.threshold,
triggering_event_id="", triggering_event_id="",
last_triggered=None, last_triggered=None,
).where(Trigger.camera == camera, Trigger.name == name).execute() ).where(Trigger.camera == camera_name, Trigger.name == name).execute()
else: else:
# Create new trigger (for rename case) # Create new trigger (for rename case)
Trigger.create( Trigger.create(
camera=camera, camera=camera_name,
name=name, name=name,
type=body.type, type=body.type,
data=body.data, data=body.data,
@ -1703,7 +1779,7 @@ def update_trigger_embedding(
if body.type == "thumbnail": if body.type == "thumbnail":
# Save image to the triggers directory # Save image to the triggers directory
try: try:
camera_path = os.path.join(TRIGGER_DIR, sanitize_filename(camera)) camera_path = os.path.join(TRIGGER_DIR, sanitize_filename(camera_name))
os.makedirs(camera_path, exist_ok=True) os.makedirs(camera_path, exist_ok=True)
with open( with open(
os.path.join(camera_path, f"{sanitize_filename(body.data)}.webp"), os.path.join(camera_path, f"{sanitize_filename(body.data)}.webp"),
@ -1711,18 +1787,18 @@ def update_trigger_embedding(
) as f: ) as f:
f.write(thumbnail) f.write(thumbnail)
logger.debug( logger.debug(
f"Writing thumbnail for trigger with data {body.data} in {camera}." f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
) )
except Exception as e: except Exception as e:
logger.error(e.with_traceback()) logger.error(e.with_traceback())
logger.error( logger.error(
f"Failed to write thumbnail for trigger with data {body.data} in {camera}" f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
) )
return JSONResponse( return JSONResponse(
content={ content={
"success": True, "success": True,
"message": f"Trigger updated successfully for {camera}:{name}", "message": f"Trigger updated successfully for {camera_name}:{name}",
}, },
status_code=200, status_code=200,
) )
@ -1741,34 +1817,39 @@ def update_trigger_embedding(
@router.delete( @router.delete(
"/trigger/embedding/{camera}/{name}", "/trigger/embedding/{camera}/{name}",
response_model=dict, response_model=dict,
dependencies=[Depends(require_role(["admin"]))], dependencies=[
Depends(lambda: require_role(["admin"])),
Depends(require_camera_access),
],
) )
def delete_trigger_embedding( def delete_trigger_embedding(
request: Request, request: Request,
camera: str, camera_name: str,
name: str, name: str,
): ):
try: try:
trigger = Trigger.get_or_none(Trigger.camera == camera, Trigger.name == name) trigger = Trigger.get_or_none(
Trigger.camera == camera_name, Trigger.name == name
)
if trigger is None: if trigger is None:
return JSONResponse( return JSONResponse(
content={ content={
"success": False, "success": False,
"message": f"Trigger {camera}:{name} not found", "message": f"Trigger {camera_name}:{name} not found",
}, },
status_code=500, status_code=500,
) )
deleted = ( deleted = (
Trigger.delete() Trigger.delete()
.where(Trigger.camera == camera, Trigger.name == name) .where(Trigger.camera == camera_name, Trigger.name == name)
.execute() .execute()
) )
if deleted == 0: if deleted == 0:
return JSONResponse( return JSONResponse(
content={ content={
"success": False, "success": False,
"message": f"Error deleting trigger {camera}:{name}", "message": f"Error deleting trigger {camera_name}:{name}",
}, },
status_code=401, status_code=401,
) )
@ -1776,22 +1857,22 @@ def delete_trigger_embedding(
try: try:
os.remove( os.remove(
os.path.join( os.path.join(
TRIGGER_DIR, sanitize_filename(camera), f"{trigger.data}.webp" TRIGGER_DIR, sanitize_filename(camera_name), f"{trigger.data}.webp"
) )
) )
logger.debug( logger.debug(
f"Deleted thumbnail for trigger with data {trigger.data} in {camera}." f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
) )
except Exception as e: except Exception as e:
logger.error(e.with_traceback()) logger.error(e.with_traceback())
logger.error( logger.error(
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera}" f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
) )
return JSONResponse( return JSONResponse(
content={ content={
"success": True, "success": True,
"message": f"Trigger deleted successfully for {camera}:{name}", "message": f"Trigger deleted successfully for {camera_name}:{name}",
}, },
status_code=200, status_code=200,
) )
@ -1810,7 +1891,10 @@ def delete_trigger_embedding(
@router.get( @router.get(
"/triggers/status/{camera_name}", "/triggers/status/{camera_name}",
response_model=dict, response_model=dict,
dependencies=[Depends(require_role(["admin"]))], dependencies=[
Depends(lambda: require_role(["admin"])),
Depends(require_camera_access),
],
) )
def get_triggers_status( def get_triggers_status(
camera_name: str, camera_name: str,