mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-02 09:02:15 +03:00
Enhance user roles to limit camera access (#20024)
* update config for roles and add validator * ensure admin and viewer are never overridden * add class method to user to retrieve all allowed cameras * enforce config roles in auth api endpoints * add camera access api dependency functions * protect review endpoints * protect preview endpoints * rename param name for better fastapi injection matching * remove unneeded * protect export endpoints * protect event endpoints * protect media endpoints * update auth hook for allowed cameras * update default app view * ensure anonymous user always returns all cameras * limit cameras in explore * cameras is already a list * limit cameras in review/history * limit cameras in live view * limit cameras in camera groups * only show face library and classification in sidebar for admin * remove check in delete reviews since admin role is required, no need to check camera access. fixes failing test * pass request with camera access for tests * more async * camera access tests * fix proxy auth tests * allowed cameras for review tests * combine event tests and refactor for camera access * fix post validation for roles * don't limit roles in create user dialog * fix triggers endpoints no need to run require camera access dep since the required role is admin * fix type * create and edit role dialogs * delete role dialog * fix role change dialog * update settings view for roles * i18n changes * minor spacing tweaks * docs * use badges and camera name label component * clarify docs * display all cameras badge for admin and viewer * i18n fix * use validator to prevent reserved and empty roles from being assigned * split users and roles into separate tabs in settings * tweak docs * clarify docs * change icon * don't memoize roles always recalculate on component render
This commit is contained in:
+102
-30
@@ -11,7 +11,7 @@ import secrets
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
@@ -33,7 +33,6 @@ from frigate.models import User
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.auth])
|
||||
VALID_ROLES = ["admin", "viewer"]
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
@@ -204,6 +203,7 @@ async def get_current_user(request: Request):
|
||||
def require_role(required_roles: List[str]):
|
||||
async def role_checker(request: Request):
|
||||
proxy_config: ProxyConfig = request.app.frigate_config.proxy
|
||||
config_roles = list(request.app.frigate_config.auth.roles.keys())
|
||||
|
||||
# Get role from header (could be comma-separated)
|
||||
role_header = request.headers.get("remote-role")
|
||||
@@ -217,12 +217,12 @@ def require_role(required_roles: List[str]):
|
||||
if not roles:
|
||||
raise HTTPException(status_code=403, detail="Role not provided")
|
||||
|
||||
# enforce VALID_ROLES
|
||||
valid_roles = [r for r in roles if r in VALID_ROLES]
|
||||
# enforce config roles
|
||||
valid_roles = [r for r in roles if r in config_roles]
|
||||
if not valid_roles:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"No valid roles found in {roles}. Required: {', '.join(required_roles)}",
|
||||
detail=f"No valid roles found in {roles}. Required: {', '.join(required_roles)}. Available: {', '.join(config_roles)}",
|
||||
)
|
||||
|
||||
if not any(role in required_roles for role in valid_roles):
|
||||
@@ -238,7 +238,9 @@ def require_role(required_roles: List[str]):
|
||||
return role_checker
|
||||
|
||||
|
||||
def resolve_role(headers: dict, proxy_config: ProxyConfig) -> str:
|
||||
def resolve_role(
|
||||
headers: dict, proxy_config: ProxyConfig, config_roles: set[str]
|
||||
) -> str:
|
||||
"""
|
||||
Determine the effective role for a request based on proxy headers and configuration.
|
||||
|
||||
@@ -247,31 +249,40 @@ def resolve_role(headers: dict, proxy_config: ProxyConfig) -> str:
|
||||
- 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.
|
||||
2. If no valid role is found, return proxy_config.default_role.
|
||||
2. If no valid role is found, return proxy_config.default_role if it's valid in config_roles, else 'viewer'.
|
||||
|
||||
Args:
|
||||
headers (dict): Incoming request headers (case-insensitive).
|
||||
proxy_config (ProxyConfig): Proxy configuration.
|
||||
config_roles (set[str]): Set of valid roles from config.
|
||||
|
||||
Returns:
|
||||
str: Resolved role (always one of VALID_ROLES).
|
||||
str: Resolved role (one of config_roles or validated default).
|
||||
"""
|
||||
role = proxy_config.default_role
|
||||
default_role = proxy_config.default_role
|
||||
role_header = proxy_config.header_map.role
|
||||
|
||||
# Validate default_role against config; fallback to 'viewer' if invalid
|
||||
validated_default = default_role if default_role in config_roles else "viewer"
|
||||
if not config_roles:
|
||||
validated_default = "viewer" # Edge case: no roles defined
|
||||
|
||||
if not role_header:
|
||||
logger.debug(
|
||||
"No role header configured in proxy_config.header_map. Returning default role '%s'.",
|
||||
role,
|
||||
"No role header configured in proxy_config.header_map. Returning validated default role '%s'.",
|
||||
validated_default,
|
||||
)
|
||||
return role
|
||||
return validated_default
|
||||
|
||||
raw_value = headers.get(role_header, "")
|
||||
logger.debug("Raw role header value from '%s': %r", role_header, raw_value)
|
||||
|
||||
if not raw_value:
|
||||
logger.debug("Role header missing or empty. Returning default role '%s'.", role)
|
||||
return role
|
||||
logger.debug(
|
||||
"Role header missing or empty. Returning validated default role '%s'.",
|
||||
validated_default,
|
||||
)
|
||||
return validated_default
|
||||
|
||||
# role_map configured, treat header as group claims
|
||||
if proxy_config.header_map.role_map:
|
||||
@@ -288,16 +299,18 @@ def resolve_role(headers: dict, proxy_config: ProxyConfig) -> str:
|
||||
logger.debug("Matched roles from role_map: %s", matched_roles)
|
||||
|
||||
if matched_roles:
|
||||
resolved = next((r for r in VALID_ROLES if r in matched_roles), role)
|
||||
resolved = next(
|
||||
(r for r in config_roles if r in matched_roles), validated_default
|
||||
)
|
||||
logger.debug("Resolved role (with role_map) to '%s'.", resolved)
|
||||
return resolved
|
||||
|
||||
logger.debug(
|
||||
"No role_map match for groups '%s'. Using default role '%s'.",
|
||||
"No role_map match for groups '%s'. Using validated default role '%s'.",
|
||||
raw_value,
|
||||
proxy_config.default_role,
|
||||
validated_default,
|
||||
)
|
||||
return role
|
||||
return validated_default
|
||||
|
||||
# no role_map, treat as role names directly
|
||||
roles_from_header = [
|
||||
@@ -306,14 +319,14 @@ def resolve_role(headers: dict, proxy_config: ProxyConfig) -> str:
|
||||
logger.debug("Parsed roles directly from header: %s", roles_from_header)
|
||||
|
||||
resolved = next(
|
||||
(r for r in VALID_ROLES if r in roles_from_header),
|
||||
proxy_config.default_role,
|
||||
(r for r in config_roles if r in roles_from_header),
|
||||
validated_default,
|
||||
)
|
||||
if resolved == proxy_config.default_role and roles_from_header:
|
||||
if resolved == validated_default and roles_from_header:
|
||||
logger.debug(
|
||||
"Provided proxy role header values '%s' did not contain a valid role. Using default role '%s'.",
|
||||
"Provided proxy role header values '%s' did not contain a valid role. Using validated default role '%s'.",
|
||||
raw_value,
|
||||
proxy_config.default_role,
|
||||
validated_default,
|
||||
)
|
||||
else:
|
||||
logger.debug("Resolved role (direct header) to '%s'.", resolved)
|
||||
@@ -358,7 +371,8 @@ def auth(request: Request):
|
||||
)
|
||||
|
||||
# parse header and resolve a valid role
|
||||
role = resolve_role(request.headers, proxy_config)
|
||||
config_roles_set = set(auth_config.roles.keys())
|
||||
role = resolve_role(request.headers, proxy_config, config_roles_set)
|
||||
|
||||
success_response.headers["remote-role"] = role
|
||||
return success_response
|
||||
@@ -452,7 +466,13 @@ def profile(request: Request):
|
||||
username = request.headers.get("remote-user", "anonymous")
|
||||
role = request.headers.get("remote-role", "viewer")
|
||||
|
||||
return JSONResponse(content={"username": username, "role": role})
|
||||
all_camera_names = set(request.app.frigate_config.cameras.keys())
|
||||
roles_dict = request.app.frigate_config.auth.roles
|
||||
allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names)
|
||||
|
||||
return JSONResponse(
|
||||
content={"username": username, "role": role, "allowed_cameras": allowed_cameras}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
@@ -483,8 +503,12 @@ def login(request: Request, body: AppPostLoginBody):
|
||||
password_hash = db_user.password_hash
|
||||
if verify_password(password, password_hash):
|
||||
role = getattr(db_user, "role", "viewer")
|
||||
if role not in VALID_ROLES:
|
||||
role = "viewer" # Enforce valid roles
|
||||
config_roles_set = set(request.app.frigate_config.auth.roles.keys())
|
||||
if role not in config_roles_set:
|
||||
logger.warning(
|
||||
f"User {db_user.username} has an invalid role {role}, falling back to 'viewer'."
|
||||
)
|
||||
role = "viewer"
|
||||
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
||||
encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token)
|
||||
response = Response("", 200)
|
||||
@@ -509,11 +533,17 @@ def create_user(
|
||||
body: AppPostUsersBody,
|
||||
):
|
||||
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
|
||||
config_roles = list(request.app.frigate_config.auth.roles.keys())
|
||||
|
||||
if not re.match("^[A-Za-z0-9._]+$", body.username):
|
||||
return JSONResponse(content={"message": "Invalid username"}, status_code=400)
|
||||
|
||||
role = body.role if body.role in VALID_ROLES else "viewer"
|
||||
if body.role not in config_roles:
|
||||
return JSONResponse(
|
||||
content={"message": f"Role must be one of: {', '.join(config_roles)}"},
|
||||
status_code=400,
|
||||
)
|
||||
role = body.role or "viewer"
|
||||
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
|
||||
User.insert(
|
||||
{
|
||||
@@ -584,10 +614,52 @@ async def update_role(
|
||||
return JSONResponse(
|
||||
content={"message": "Cannot modify admin user's role"}, status_code=403
|
||||
)
|
||||
if body.role not in VALID_ROLES:
|
||||
config_roles = list(request.app.frigate_config.auth.roles.keys())
|
||||
if body.role not in config_roles:
|
||||
return JSONResponse(
|
||||
content={"message": "Role must be 'admin' or 'viewer'"}, status_code=400
|
||||
content={"message": f"Role must be one of: {', '.join(config_roles)}"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
User.set_by_id(username, {User.role: body.role})
|
||||
return JSONResponse(content={"success": True})
|
||||
|
||||
|
||||
async def require_camera_access(
|
||||
camera_name: Optional[str] = None,
|
||||
request: Request = None,
|
||||
):
|
||||
"""Dependency to enforce camera access based on user role."""
|
||||
if camera_name is None:
|
||||
return # For lists, filter later
|
||||
|
||||
current_user = await get_current_user(request)
|
||||
if isinstance(current_user, JSONResponse):
|
||||
return current_user
|
||||
|
||||
role = current_user["role"]
|
||||
all_camera_names = set(request.app.frigate_config.cameras.keys())
|
||||
roles_dict = request.app.frigate_config.auth.roles
|
||||
allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names)
|
||||
|
||||
# Admin or full access bypasses
|
||||
if role == "admin" or not roles_dict.get(role):
|
||||
return
|
||||
|
||||
if camera_name not in allowed_cameras:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Access denied to camera '{camera_name}'. Allowed: {allowed_cameras}",
|
||||
)
|
||||
|
||||
|
||||
async def get_allowed_cameras_for_filter(request: Request):
|
||||
"""Dependency to get allowed_cameras for filtering lists."""
|
||||
current_user = await get_current_user(request)
|
||||
if isinstance(current_user, JSONResponse):
|
||||
return [] # Unauthorized: no cameras
|
||||
|
||||
role = current_user["role"]
|
||||
all_camera_names = set(request.app.frigate_config.cameras.keys())
|
||||
roles_dict = request.app.frigate_config.auth.roles
|
||||
return User.get_allowed_cameras(role, roles_dict, all_camera_names)
|
||||
|
||||
+128
-60
@@ -8,6 +8,7 @@ import random
|
||||
import string
|
||||
from functools import reduce
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from urllib.parse import unquote
|
||||
|
||||
import cv2
|
||||
@@ -19,7 +20,11 @@ from pathvalidate import sanitize_filename
|
||||
from peewee import JOIN, DoesNotExist, fn, operator
|
||||
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 (
|
||||
DEFAULT_TIME_RANGE,
|
||||
EventsQueryParams,
|
||||
@@ -61,7 +66,10 @@ router = APIRouter(tags=[Tags.events])
|
||||
|
||||
|
||||
@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
|
||||
cameras = params.cameras
|
||||
|
||||
@@ -135,8 +143,14 @@ def events(params: EventsQueryParams = Depends()):
|
||||
clauses.append((Event.camera == camera))
|
||||
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((Event.camera << camera_list))
|
||||
requested = set(cameras.split(","))
|
||||
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":
|
||||
label_list = labels.split(",")
|
||||
@@ -321,9 +335,17 @@ def events(params: EventsQueryParams = Depends()):
|
||||
|
||||
|
||||
@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
|
||||
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 = {}
|
||||
|
||||
@@ -334,14 +356,18 @@ def events_explore(limit: int = 10):
|
||||
# get most recent events for this label
|
||||
label_events = (
|
||||
Event.select()
|
||||
.where(Event.label == label)
|
||||
.where((Event.label == label) & (Event.camera << allowed_cameras))
|
||||
.order_by(Event.start_time.desc())
|
||||
.limit(limit)
|
||||
.iterator()
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -394,7 +420,7 @@ def events_explore(limit: int = 10):
|
||||
|
||||
|
||||
@router.get("/event_ids", response_model=list[EventResponse])
|
||||
def event_ids(ids: str):
|
||||
async def event_ids(ids: str, request: Request):
|
||||
ids = ids.split(",")
|
||||
|
||||
if not ids:
|
||||
@@ -403,6 +429,16 @@ def event_ids(ids: str):
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
for event_id in ids:
|
||||
try:
|
||||
event = Event.get(Event.id == event_id)
|
||||
await require_camera_access(event.camera, request=request)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": f"Event {event_id} not found"}),
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
try:
|
||||
events = Event.select().where(Event.id << ids).dicts().iterator()
|
||||
return JSONResponse(list(events))
|
||||
@@ -413,7 +449,11 @@ def event_ids(ids: str):
|
||||
|
||||
|
||||
@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
|
||||
search_type = params.search_type
|
||||
include_thumbnails = params.include_thumbnails
|
||||
@@ -486,7 +526,13 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
|
||||
event_filters = []
|
||||
|
||||
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":
|
||||
event_filters.append((Event.label << labels.split(",")))
|
||||
@@ -739,7 +785,10 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
|
||||
|
||||
|
||||
@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
|
||||
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name)
|
||||
has_clip = params.has_clip
|
||||
@@ -771,7 +820,7 @@ def events_summary(params: EventsSummaryQueryParams = Depends()):
|
||||
Event.zones,
|
||||
fn.COUNT(Event.id).alias("count"),
|
||||
)
|
||||
.where(reduce(operator.and_, clauses))
|
||||
.where(reduce(operator.and_, clauses) & (Event.camera << allowed_cameras))
|
||||
.group_by(
|
||||
Event.camera,
|
||||
Event.label,
|
||||
@@ -786,9 +835,11 @@ def events_summary(params: EventsSummaryQueryParams = Depends()):
|
||||
|
||||
|
||||
@router.get("/events/{event_id}", response_model=EventResponse)
|
||||
def event(event_id: str):
|
||||
async def event(event_id: str, request: Request):
|
||||
try:
|
||||
return model_to_dict(Event.get(Event.id == event_id))
|
||||
event = Event.get(Event.id == event_id)
|
||||
await require_camera_access(event.camera, request=request)
|
||||
return model_to_dict(event)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(content="Event not found", status_code=404)
|
||||
|
||||
@@ -817,7 +868,7 @@ def set_retain(event_id: str):
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/plus", response_model=EventUploadPlusResponse)
|
||||
def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = None):
|
||||
async def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = None):
|
||||
if not request.app.frigate_config.plus_api.is_active():
|
||||
message = "PLUS_API_KEY environment variable is not set"
|
||||
logger.error(message)
|
||||
@@ -835,6 +886,7 @@ def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = None):
|
||||
|
||||
try:
|
||||
event = Event.get(Event.id == event_id)
|
||||
await require_camera_access(event.camera, request=request)
|
||||
except DoesNotExist:
|
||||
message = f"Event {event_id} not found"
|
||||
logger.error(message)
|
||||
@@ -929,7 +981,7 @@ def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = None):
|
||||
|
||||
|
||||
@router.put("/events/{event_id}/false_positive", response_model=EventUploadPlusResponse)
|
||||
def false_positive(request: Request, event_id: str):
|
||||
async def false_positive(request: Request, event_id: str):
|
||||
if not request.app.frigate_config.plus_api.is_active():
|
||||
message = "PLUS_API_KEY environment variable is not set"
|
||||
logger.error(message)
|
||||
@@ -945,6 +997,7 @@ def false_positive(request: Request, event_id: str):
|
||||
|
||||
try:
|
||||
event = Event.get(Event.id == event_id)
|
||||
await require_camera_access(event.camera, request=request)
|
||||
except DoesNotExist:
|
||||
message = f"Event {event_id} not found"
|
||||
logger.error(message)
|
||||
@@ -1022,9 +1075,10 @@ def false_positive(request: Request, event_id: str):
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def delete_retain(event_id: str):
|
||||
async def delete_retain(event_id: str, request: Request):
|
||||
try:
|
||||
event = Event.get(Event.id == event_id)
|
||||
await require_camera_access(event.camera, request=request)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Event " + event_id + " not found"}),
|
||||
@@ -1045,13 +1099,14 @@ def delete_retain(event_id: str):
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def set_sub_label(
|
||||
async def set_sub_label(
|
||||
request: Request,
|
||||
event_id: str,
|
||||
body: EventsSubLabelBody,
|
||||
):
|
||||
try:
|
||||
event: Event = Event.get(Event.id == event_id)
|
||||
await require_camera_access(event.camera, request=request)
|
||||
except DoesNotExist:
|
||||
event = None
|
||||
|
||||
@@ -1099,13 +1154,14 @@ def set_sub_label(
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def set_plate(
|
||||
async def set_plate(
|
||||
request: Request,
|
||||
event_id: str,
|
||||
body: EventsLPRBody,
|
||||
):
|
||||
try:
|
||||
event: Event = Event.get(Event.id == event_id)
|
||||
await require_camera_access(event.camera, request=request)
|
||||
except DoesNotExist:
|
||||
event = None
|
||||
|
||||
@@ -1154,13 +1210,14 @@ def set_plate(
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def set_description(
|
||||
async def set_description(
|
||||
request: Request,
|
||||
event_id: str,
|
||||
body: EventsDescriptionBody,
|
||||
):
|
||||
try:
|
||||
event: Event = Event.get(Event.id == event_id)
|
||||
await require_camera_access(event.camera, request=request)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Event " + event_id + " not found"}),
|
||||
@@ -1205,11 +1262,12 @@ def set_description(
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def regenerate_description(
|
||||
async def regenerate_description(
|
||||
request: Request, event_id: str, params: RegenerateQueryParameters = Depends()
|
||||
):
|
||||
try:
|
||||
event: Event = Event.get(Event.id == event_id)
|
||||
await require_camera_access(event.camera, request=request)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Event " + event_id + " not found"}),
|
||||
@@ -1280,9 +1338,10 @@ def generate_description_embedding(
|
||||
)
|
||||
|
||||
|
||||
def delete_single_event(event_id: str, request: Request) -> dict:
|
||||
async def delete_single_event(event_id: str, request: Request) -> dict:
|
||||
try:
|
||||
event = Event.get(Event.id == event_id)
|
||||
await require_camera_access(event.camera, request=request)
|
||||
except DoesNotExist:
|
||||
return {"success": False, "message": f"Event {event_id} not found"}
|
||||
|
||||
@@ -1312,8 +1371,8 @@ def delete_single_event(event_id: str, request: Request) -> dict:
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def delete_event(request: Request, event_id: str):
|
||||
result = delete_single_event(event_id, request)
|
||||
async def delete_event(request: Request, event_id: str):
|
||||
result = await delete_single_event(event_id, request)
|
||||
status_code = 200 if result["success"] else 404
|
||||
return JSONResponse(content=result, status_code=status_code)
|
||||
|
||||
@@ -1323,7 +1382,7 @@ def delete_event(request: Request, event_id: str):
|
||||
response_model=EventMultiDeleteResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def delete_events(request: Request, body: EventsDeleteBody):
|
||||
async def delete_events(request: Request, body: EventsDeleteBody):
|
||||
if not body.event_ids:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "No event IDs provided."}),
|
||||
@@ -1334,7 +1393,7 @@ def delete_events(request: Request, body: EventsDeleteBody):
|
||||
not_found_events = []
|
||||
|
||||
for event_id in body.event_ids:
|
||||
result = delete_single_event(event_id, request)
|
||||
result = await delete_single_event(event_id, request)
|
||||
if result["success"]:
|
||||
deleted_events.append(event_id)
|
||||
else:
|
||||
@@ -1410,8 +1469,10 @@ def create_event(
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def end_event(request: Request, event_id: str, body: EventsEndBody):
|
||||
async def end_event(request: Request, event_id: str, body: EventsEndBody):
|
||||
try:
|
||||
event: Event = Event.get(Event.id == event_id)
|
||||
await require_camera_access(event.camera, request=request)
|
||||
end_time = body.end_time or datetime.datetime.now().timestamp()
|
||||
request.app.event_metadata_updater.publish(
|
||||
(event_id, end_time), EventMetadataTypeEnum.manual_event_end.value
|
||||
@@ -1438,7 +1499,7 @@ def end_event(request: Request, event_id: str, body: EventsEndBody):
|
||||
def create_trigger_embedding(
|
||||
request: Request,
|
||||
body: TriggerEmbeddingBody,
|
||||
camera: str,
|
||||
camera_name: str,
|
||||
name: str,
|
||||
):
|
||||
try:
|
||||
@@ -1454,13 +1515,13 @@ def create_trigger_embedding(
|
||||
# Check if trigger already exists
|
||||
if (
|
||||
Trigger.select()
|
||||
.where(Trigger.camera == camera, Trigger.name == name)
|
||||
.where(Trigger.camera == camera_name, Trigger.name == name)
|
||||
.exists()
|
||||
):
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Trigger {camera}:{name} already exists",
|
||||
"message": f"Trigger {camera_name}:{name} already exists",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
@@ -1530,28 +1591,29 @@ def create_trigger_embedding(
|
||||
# Save image to the triggers directory
|
||||
try:
|
||||
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(
|
||||
os.path.join(
|
||||
TRIGGER_DIR,
|
||||
sanitize_filename(camera),
|
||||
sanitize_filename(camera_name),
|
||||
f"{sanitize_filename(body.data)}.webp",
|
||||
),
|
||||
"wb",
|
||||
) as f:
|
||||
f.write(thumbnail)
|
||||
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:
|
||||
logger.error(e.with_traceback())
|
||||
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(
|
||||
camera=camera,
|
||||
camera=camera_name,
|
||||
name=name,
|
||||
type=body.type,
|
||||
data=body.data,
|
||||
@@ -1565,7 +1627,7 @@ def create_trigger_embedding(
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"message": f"Trigger created successfully for {camera}:{name}",
|
||||
"message": f"Trigger created successfully for {camera_name}:{name}",
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
@@ -1582,13 +1644,13 @@ def create_trigger_embedding(
|
||||
|
||||
|
||||
@router.put(
|
||||
"/trigger/embedding/{camera}/{name}",
|
||||
"/trigger/embedding/{camera_name}/{name}",
|
||||
response_model=dict,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def update_trigger_embedding(
|
||||
request: Request,
|
||||
camera: str,
|
||||
camera_name: str,
|
||||
name: str,
|
||||
body: TriggerEmbeddingBody,
|
||||
):
|
||||
@@ -1609,7 +1671,9 @@ def update_trigger_embedding(
|
||||
embedding = context.generate_description_embedding(body.data)
|
||||
elif body.type == "thumbnail":
|
||||
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:
|
||||
event: Event = Event.get(Event.id == body.data)
|
||||
@@ -1656,7 +1720,9 @@ def update_trigger_embedding(
|
||||
)
|
||||
|
||||
# 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:
|
||||
# Update existing trigger
|
||||
@@ -1665,17 +1731,17 @@ def update_trigger_embedding(
|
||||
os.remove(
|
||||
os.path.join(
|
||||
TRIGGER_DIR,
|
||||
sanitize_filename(camera),
|
||||
sanitize_filename(camera_name),
|
||||
f"{trigger.data}.webp",
|
||||
)
|
||||
)
|
||||
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:
|
||||
logger.error(e.with_traceback())
|
||||
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(
|
||||
@@ -1685,11 +1751,11 @@ def update_trigger_embedding(
|
||||
threshold=body.threshold,
|
||||
triggering_event_id="",
|
||||
last_triggered=None,
|
||||
).where(Trigger.camera == camera, Trigger.name == name).execute()
|
||||
).where(Trigger.camera == camera_name, Trigger.name == name).execute()
|
||||
else:
|
||||
# Create new trigger (for rename case)
|
||||
Trigger.create(
|
||||
camera=camera,
|
||||
camera=camera_name,
|
||||
name=name,
|
||||
type=body.type,
|
||||
data=body.data,
|
||||
@@ -1703,7 +1769,7 @@ def update_trigger_embedding(
|
||||
if body.type == "thumbnail":
|
||||
# Save image to the triggers directory
|
||||
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)
|
||||
with open(
|
||||
os.path.join(camera_path, f"{sanitize_filename(body.data)}.webp"),
|
||||
@@ -1711,18 +1777,18 @@ def update_trigger_embedding(
|
||||
) as f:
|
||||
f.write(thumbnail)
|
||||
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:
|
||||
logger.error(e.with_traceback())
|
||||
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(
|
||||
content={
|
||||
"success": True,
|
||||
"message": f"Trigger updated successfully for {camera}:{name}",
|
||||
"message": f"Trigger updated successfully for {camera_name}:{name}",
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
@@ -1739,36 +1805,38 @@ def update_trigger_embedding(
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/trigger/embedding/{camera}/{name}",
|
||||
"/trigger/embedding/{camera_name}/{name}",
|
||||
response_model=dict,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def delete_trigger_embedding(
|
||||
request: Request,
|
||||
camera: str,
|
||||
camera_name: str,
|
||||
name: str,
|
||||
):
|
||||
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:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Trigger {camera}:{name} not found",
|
||||
"message": f"Trigger {camera_name}:{name} not found",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
deleted = (
|
||||
Trigger.delete()
|
||||
.where(Trigger.camera == camera, Trigger.name == name)
|
||||
.where(Trigger.camera == camera_name, Trigger.name == name)
|
||||
.execute()
|
||||
)
|
||||
if deleted == 0:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Error deleting trigger {camera}:{name}",
|
||||
"message": f"Error deleting trigger {camera_name}:{name}",
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
@@ -1776,22 +1844,22 @@ def delete_trigger_embedding(
|
||||
try:
|
||||
os.remove(
|
||||
os.path.join(
|
||||
TRIGGER_DIR, sanitize_filename(camera), f"{trigger.data}.webp"
|
||||
TRIGGER_DIR, sanitize_filename(camera_name), f"{trigger.data}.webp"
|
||||
)
|
||||
)
|
||||
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:
|
||||
logger.error(e.with_traceback())
|
||||
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(
|
||||
content={
|
||||
"success": True,
|
||||
"message": f"Trigger deleted successfully for {camera}:{name}",
|
||||
"message": f"Trigger deleted successfully for {camera_name}:{name}",
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
+28
-8
@@ -4,6 +4,7 @@ import logging
|
||||
import random
|
||||
import string
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import psutil
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
@@ -11,7 +12,11 @@ from fastapi.responses import JSONResponse
|
||||
from peewee import DoesNotExist
|
||||
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.request.export_recordings_body import ExportRecordingsBody
|
||||
from frigate.api.defs.request.export_rename_body import ExportRenameBody
|
||||
from frigate.api.defs.tags import Tags
|
||||
@@ -30,12 +35,23 @@ router = APIRouter(tags=[Tags.export])
|
||||
|
||||
|
||||
@router.get("/exports")
|
||||
def get_exports():
|
||||
exports = Export.select().order_by(Export.date.desc()).dicts().iterator()
|
||||
def get_exports(
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
exports = (
|
||||
Export.select()
|
||||
.where(Export.camera << allowed_cameras)
|
||||
.order_by(Export.date.desc())
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
return JSONResponse(content=[e for e in exports])
|
||||
|
||||
|
||||
@router.post("/export/{camera_name}/start/{start_time}/end/{end_time}")
|
||||
@router.post(
|
||||
"/export/{camera_name}/start/{start_time}/end/{end_time}",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
)
|
||||
def export_recording(
|
||||
request: Request,
|
||||
camera_name: str,
|
||||
@@ -134,9 +150,10 @@ def export_recording(
|
||||
@router.patch(
|
||||
"/export/{event_id}/rename", dependencies=[Depends(require_role(["admin"]))]
|
||||
)
|
||||
def export_rename(event_id: str, body: ExportRenameBody):
|
||||
async def export_rename(event_id: str, body: ExportRenameBody, request: Request):
|
||||
try:
|
||||
export: Export = Export.get(Export.id == event_id)
|
||||
await require_camera_access(export.camera, request=request)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
@@ -162,9 +179,10 @@ def export_rename(event_id: str, body: ExportRenameBody):
|
||||
|
||||
|
||||
@router.delete("/export/{event_id}", dependencies=[Depends(require_role(["admin"]))])
|
||||
def export_delete(event_id: str):
|
||||
async def export_delete(event_id: str, request: Request):
|
||||
try:
|
||||
export: Export = Export.get(Export.id == event_id)
|
||||
await require_camera_access(export.camera, request=request)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
@@ -215,9 +233,11 @@ def export_delete(event_id: str):
|
||||
|
||||
|
||||
@router.get("/exports/{export_id}")
|
||||
def get_export(export_id: str):
|
||||
async def get_export(export_id: str, request: Request):
|
||||
try:
|
||||
return JSONResponse(content=model_to_dict(Export.get(Export.id == export_id)))
|
||||
export = Export.get(Export.id == export_id)
|
||||
await require_camera_access(export.camera, request=request)
|
||||
return JSONResponse(content=model_to_dict(export))
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Export not found"},
|
||||
|
||||
+91
-41
@@ -10,19 +10,19 @@ import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import reduce
|
||||
from pathlib import Path as FilePath
|
||||
from typing import Any
|
||||
from typing import Any, List
|
||||
from urllib.parse import unquote
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytz
|
||||
from fastapi import APIRouter, Path, Query, Request, Response
|
||||
from fastapi.params import Depends
|
||||
from fastapi import APIRouter, Depends, Path, Query, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from pathvalidate import sanitize_filename
|
||||
from peewee import DoesNotExist, fn, operator
|
||||
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 (
|
||||
Extension,
|
||||
MediaEventsSnapshotQueryParams,
|
||||
@@ -50,12 +50,11 @@ from frigate.util.path import get_event_thumbnail_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
router = APIRouter(tags=[Tags.media])
|
||||
|
||||
|
||||
@router.get("/{camera_name}")
|
||||
def mjpeg_feed(
|
||||
@router.get("/{camera_name}", dependencies=[Depends(require_camera_access)])
|
||||
async def mjpeg_feed(
|
||||
request: Request,
|
||||
camera_name: str,
|
||||
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):
|
||||
if camera_name in request.app.frigate_config.cameras:
|
||||
# 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}")
|
||||
def latest_frame(
|
||||
@router.get(
|
||||
"/{camera_name}/latest.{extension}", dependencies=[Depends(require_camera_access)]
|
||||
)
|
||||
async def latest_frame(
|
||||
request: Request,
|
||||
camera_name: str,
|
||||
extension: Extension,
|
||||
@@ -236,8 +237,11 @@ def latest_frame(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{camera_name}/recordings/{frame_time}/snapshot.{format}")
|
||||
def get_snapshot_from_recording(
|
||||
@router.get(
|
||||
"/{camera_name}/recordings/{frame_time}/snapshot.{format}",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
)
|
||||
async def get_snapshot_from_recording(
|
||||
request: Request,
|
||||
camera_name: str,
|
||||
frame_time: float,
|
||||
@@ -323,8 +327,10 @@ def get_snapshot_from_recording(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{camera_name}/plus/{frame_time}")
|
||||
def submit_recording_snapshot_to_plus(
|
||||
@router.post(
|
||||
"/{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
|
||||
):
|
||||
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")
|
||||
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"""
|
||||
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone)
|
||||
|
||||
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 = (
|
||||
Recordings.select(
|
||||
@@ -445,7 +463,7 @@ def all_recordings_summary(params: MediaRecordingsSummaryQueryParams = Depends()
|
||||
)
|
||||
|
||||
if cameras != "all":
|
||||
query = query.where(Recordings.camera << cameras.split(","))
|
||||
query = query.where(Recordings.camera << cameras)
|
||||
|
||||
recording_days = query.namedtuples()
|
||||
days = {day.day: True for day in recording_days}
|
||||
@@ -453,8 +471,10 @@ def all_recordings_summary(params: MediaRecordingsSummaryQueryParams = Depends()
|
||||
return JSONResponse(content=days)
|
||||
|
||||
|
||||
@router.get("/{camera_name}/recordings/summary")
|
||||
def recordings_summary(camera_name: str, timezone: str = "utc"):
|
||||
@router.get(
|
||||
"/{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"""
|
||||
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(timezone)
|
||||
recording_groups = (
|
||||
@@ -515,8 +535,8 @@ def recordings_summary(camera_name: str, timezone: str = "utc"):
|
||||
return JSONResponse(content=list(days.values()))
|
||||
|
||||
|
||||
@router.get("/{camera_name}/recordings")
|
||||
def recordings(
|
||||
@router.get("/{camera_name}/recordings", dependencies=[Depends(require_camera_access)])
|
||||
async def recordings(
|
||||
camera_name: str,
|
||||
after: float = (datetime.now() - timedelta(hours=1)).timestamp(),
|
||||
before: float = datetime.now().timestamp(),
|
||||
@@ -546,9 +566,22 @@ def recordings(
|
||||
|
||||
|
||||
@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."""
|
||||
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()
|
||||
after = (
|
||||
params.after
|
||||
@@ -560,6 +593,8 @@ def no_recordings(params: MediaRecordingsAvailabilityQueryParams = Depends()):
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((Recordings.camera << camera_list))
|
||||
else:
|
||||
camera_list = allowed_cameras
|
||||
|
||||
# Get recording start times
|
||||
data: list[Recordings] = (
|
||||
@@ -607,9 +642,10 @@ def no_recordings(params: MediaRecordingsAvailabilityQueryParams = Depends()):
|
||||
|
||||
@router.get(
|
||||
"/{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.",
|
||||
)
|
||||
def recording_clip(
|
||||
async def recording_clip(
|
||||
request: Request,
|
||||
camera_name: str,
|
||||
start_ts: float,
|
||||
@@ -705,9 +741,10 @@ def recording_clip(
|
||||
|
||||
@router.get(
|
||||
"/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.",
|
||||
)
|
||||
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.select(
|
||||
Recordings.path,
|
||||
@@ -782,6 +819,7 @@ def vod_ts(camera_name: str, start_ts: float, end_ts: float):
|
||||
|
||||
@router.get(
|
||||
"/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.",
|
||||
)
|
||||
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(
|
||||
"/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.",
|
||||
)
|
||||
def vod_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: str):
|
||||
@@ -812,7 +851,8 @@ def vod_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: st
|
||||
"/vod/event/{event_id}",
|
||||
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(
|
||||
request: Request,
|
||||
event_id: str,
|
||||
padding: int = Query(0, description="Padding to apply to the vod."),
|
||||
):
|
||||
@@ -828,15 +868,7 @@ def vod_event(
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
if not event.has_clip:
|
||||
logger.error(f"Event does not have recordings: {event_id}")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Recordings not available.",
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
await require_camera_access(event.camera, request=request)
|
||||
|
||||
end_ts = (
|
||||
datetime.now().timestamp()
|
||||
@@ -861,7 +893,7 @@ def vod_event(
|
||||
"/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.",
|
||||
)
|
||||
def event_snapshot(
|
||||
async def event_snapshot(
|
||||
request: Request,
|
||||
event_id: str,
|
||||
params: MediaEventsSnapshotQueryParams = Depends(),
|
||||
@@ -871,6 +903,7 @@ def event_snapshot(
|
||||
try:
|
||||
event = Event.get(Event.id == event_id, Event.end_time != None)
|
||||
event_complete = True
|
||||
await require_camera_access(event.camera, request=request)
|
||||
if not event.has_snapshot:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Snapshot not available"},
|
||||
@@ -899,6 +932,7 @@ def event_snapshot(
|
||||
height=params.height,
|
||||
quality=params.quality,
|
||||
)
|
||||
await require_camera_access(camera_state.name, request=request)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Ongoing event not found"},
|
||||
@@ -932,7 +966,7 @@ def event_snapshot(
|
||||
|
||||
|
||||
@router.get("/events/{event_id}/thumbnail.{extension}")
|
||||
def event_thumbnail(
|
||||
async def event_thumbnail(
|
||||
request: Request,
|
||||
event_id: str,
|
||||
extension: Extension,
|
||||
@@ -945,6 +979,7 @@ def event_thumbnail(
|
||||
event_complete = False
|
||||
try:
|
||||
event: Event = Event.get(Event.id == event_id)
|
||||
await require_camera_access(event.camera, request=request)
|
||||
if event.end_time is not None:
|
||||
event_complete = True
|
||||
|
||||
@@ -1007,7 +1042,7 @@ def event_thumbnail(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{camera_name}/grid.jpg")
|
||||
@router.get("/{camera_name}/grid.jpg", dependencies=[Depends(require_camera_access)])
|
||||
def grid_snapshot(
|
||||
request: Request, camera_name: str, color: str = "green", font_scale: float = 0.5
|
||||
):
|
||||
@@ -1254,7 +1289,10 @@ def event_preview(request: Request, event_id: str):
|
||||
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(
|
||||
request: Request,
|
||||
camera_name: str,
|
||||
@@ -1410,7 +1448,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(
|
||||
request: Request,
|
||||
camera_name: str,
|
||||
@@ -1650,8 +1691,13 @@ def preview_thumbnail(file_name: str):
|
||||
####################### dynamic routes ###########################
|
||||
|
||||
|
||||
@router.get("/{camera_name}/{label}/best.jpg")
|
||||
@router.get("/{camera_name}/{label}/thumbnail.jpg")
|
||||
@router.get(
|
||||
"/{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):
|
||||
label = unquote(label)
|
||||
event_query = Event.select(fn.MAX(Event.id)).where(Event.camera == camera_name)
|
||||
@@ -1673,7 +1719,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):
|
||||
label = unquote(label)
|
||||
event_query = Event.select(fn.MAX(Event.id)).where(
|
||||
@@ -1692,7 +1740,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):
|
||||
"""Returns the snapshot image from the latest event for the given camera and label combo"""
|
||||
label = unquote(label)
|
||||
|
||||
+14
-4
@@ -5,9 +5,10 @@ import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytz
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from frigate.api.auth import require_camera_access
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE
|
||||
from frigate.models import Previews
|
||||
@@ -18,7 +19,10 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=[Tags.preview])
|
||||
|
||||
|
||||
@router.get("/preview/{camera_name}/start/{start_ts}/end/{end_ts}")
|
||||
@router.get(
|
||||
"/preview/{camera_name}/start/{start_ts}/end/{end_ts}",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
)
|
||||
def preview_ts(camera_name: str, start_ts: float, end_ts: float):
|
||||
"""Get all mp4 previews relevant for time period."""
|
||||
if camera_name != "all":
|
||||
@@ -71,7 +75,10 @@ def preview_ts(camera_name: str, start_ts: float, end_ts: float):
|
||||
return JSONResponse(content=clips, status_code=200)
|
||||
|
||||
|
||||
@router.get("/preview/{year_month}/{day}/{hour}/{camera_name}/{tz_name}")
|
||||
@router.get(
|
||||
"/preview/{year_month}/{day}/{hour}/{camera_name}/{tz_name}",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
)
|
||||
def preview_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: str):
|
||||
"""Get all mp4 previews relevant for time period given the timezone"""
|
||||
parts = year_month.split("-")
|
||||
@@ -86,7 +93,10 @@ def preview_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name
|
||||
return preview_ts(camera_name, start_ts, end_ts)
|
||||
|
||||
|
||||
@router.get("/preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames")
|
||||
@router.get(
|
||||
"/preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
)
|
||||
def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: float):
|
||||
"""Get list of cached preview frames"""
|
||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||
|
||||
+69
-21
@@ -4,6 +4,7 @@ import datetime
|
||||
import logging
|
||||
from functools import reduce
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import pandas as pd
|
||||
from fastapi import APIRouter, Request
|
||||
@@ -12,7 +13,12 @@ from fastapi.responses import JSONResponse
|
||||
from peewee import Case, DoesNotExist, IntegrityError, fn, operator
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
from frigate.api.auth import get_current_user, require_role
|
||||
from frigate.api.auth import (
|
||||
get_allowed_cameras_for_filter,
|
||||
get_current_user,
|
||||
require_camera_access,
|
||||
require_role,
|
||||
)
|
||||
from frigate.api.defs.query.review_query_parameters import (
|
||||
ReviewActivityMotionQueryParams,
|
||||
ReviewQueryParams,
|
||||
@@ -41,6 +47,7 @@ router = APIRouter(tags=[Tags.review])
|
||||
async def review(
|
||||
params: ReviewQueryParams = Depends(),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
if isinstance(current_user, JSONResponse):
|
||||
return current_user
|
||||
@@ -65,8 +72,14 @@ async def review(
|
||||
]
|
||||
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((ReviewSegment.camera << camera_list))
|
||||
requested = set(cameras.split(","))
|
||||
filtered = requested.intersection(allowed_cameras)
|
||||
if not filtered:
|
||||
return JSONResponse(content=[])
|
||||
camera_list = list(filtered)
|
||||
else:
|
||||
camera_list = allowed_cameras
|
||||
clauses.append((ReviewSegment.camera << camera_list))
|
||||
|
||||
if labels != "all":
|
||||
# use matching so segments with multiple labels
|
||||
@@ -140,7 +153,7 @@ async def review(
|
||||
|
||||
|
||||
@router.get("/review_ids", response_model=list[ReviewSegmentResponse])
|
||||
def review_ids(ids: str):
|
||||
async def review_ids(request: Request, ids: str):
|
||||
ids = ids.split(",")
|
||||
|
||||
if not ids:
|
||||
@@ -149,6 +162,18 @@ def review_ids(ids: str):
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
for review_id in ids:
|
||||
try:
|
||||
review = ReviewSegment.get(ReviewSegment.id == review_id)
|
||||
await require_camera_access(review.camera, request=request)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{"success": False, "message": f"Review {review_id} not found"}
|
||||
),
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
try:
|
||||
reviews = (
|
||||
ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator()
|
||||
@@ -165,6 +190,7 @@ def review_ids(ids: str):
|
||||
async def review_summary(
|
||||
params: ReviewSummaryQueryParams = Depends(),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
if isinstance(current_user, JSONResponse):
|
||||
return current_user
|
||||
@@ -181,8 +207,14 @@ async def review_summary(
|
||||
clauses = [(ReviewSegment.start_time > day_ago)]
|
||||
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((ReviewSegment.camera << camera_list))
|
||||
requested = set(cameras.split(","))
|
||||
filtered = requested.intersection(allowed_cameras)
|
||||
if not filtered:
|
||||
return JSONResponse(content={})
|
||||
camera_list = list(filtered)
|
||||
else:
|
||||
camera_list = allowed_cameras
|
||||
clauses.append((ReviewSegment.camera << camera_list))
|
||||
|
||||
if labels != "all":
|
||||
# use matching so segments with multiple labels
|
||||
@@ -276,8 +308,14 @@ async def review_summary(
|
||||
clauses = []
|
||||
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((ReviewSegment.camera << camera_list))
|
||||
requested = set(cameras.split(","))
|
||||
filtered = requested.intersection(allowed_cameras)
|
||||
if not filtered:
|
||||
return JSONResponse(content={})
|
||||
camera_list = list(filtered)
|
||||
else:
|
||||
camera_list = allowed_cameras
|
||||
clauses.append((ReviewSegment.camera << camera_list))
|
||||
|
||||
if labels != "all":
|
||||
# use matching so segments with multiple labels
|
||||
@@ -380,6 +418,7 @@ async def review_summary(
|
||||
|
||||
@router.post("/reviews/viewed", response_model=GenericResponse)
|
||||
async def set_multiple_reviewed(
|
||||
request: Request,
|
||||
body: ReviewModifyMultipleBody,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
@@ -390,6 +429,8 @@ async def set_multiple_reviewed(
|
||||
|
||||
for review_id in body.ids:
|
||||
try:
|
||||
review = ReviewSegment.get(ReviewSegment.id == review_id)
|
||||
await require_camera_access(review.camera, request=request)
|
||||
review_status = UserReviewStatus.get(
|
||||
UserReviewStatus.user_id == user_id,
|
||||
UserReviewStatus.review_segment == review_id,
|
||||
@@ -471,7 +512,10 @@ def delete_reviews(body: ReviewModifyMultipleBody):
|
||||
@router.get(
|
||||
"/review/activity/motion", response_model=list[ReviewActivityMotionResponse]
|
||||
)
|
||||
def motion_activity(params: ReviewActivityMotionQueryParams = Depends()):
|
||||
def motion_activity(
|
||||
params: ReviewActivityMotionQueryParams = Depends(),
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
"""Get motion and audio activity."""
|
||||
cameras = params.cameras
|
||||
before = params.before or datetime.datetime.now().timestamp()
|
||||
@@ -486,8 +530,14 @@ def motion_activity(params: ReviewActivityMotionQueryParams = Depends()):
|
||||
clauses.append((Recordings.motion > 0))
|
||||
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
requested = set(cameras.split(","))
|
||||
filtered = requested.intersection(allowed_cameras)
|
||||
if not filtered:
|
||||
return JSONResponse(content=[])
|
||||
camera_list = list(filtered)
|
||||
clauses.append((Recordings.camera << camera_list))
|
||||
else:
|
||||
clauses.append((Recordings.camera << allowed_cameras))
|
||||
|
||||
data: list[Recordings] = (
|
||||
Recordings.select(
|
||||
@@ -545,15 +595,13 @@ def motion_activity(params: ReviewActivityMotionQueryParams = Depends()):
|
||||
|
||||
|
||||
@router.get("/review/event/{event_id}", response_model=ReviewSegmentResponse)
|
||||
def get_review_from_event(event_id: str):
|
||||
async def get_review_from_event(request: Request, event_id: str):
|
||||
try:
|
||||
return JSONResponse(
|
||||
model_to_dict(
|
||||
ReviewSegment.get(
|
||||
ReviewSegment.data["detections"].cast("text") % f'*"{event_id}"*'
|
||||
)
|
||||
)
|
||||
review = ReviewSegment.get(
|
||||
ReviewSegment.data["detections"].cast("text") % f'*"{event_id}"*'
|
||||
)
|
||||
await require_camera_access(review.camera, request=request)
|
||||
return JSONResponse(model_to_dict(review))
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Review item not found"},
|
||||
@@ -562,11 +610,11 @@ def get_review_from_event(event_id: str):
|
||||
|
||||
|
||||
@router.get("/review/{review_id}", response_model=ReviewSegmentResponse)
|
||||
def get_review(review_id: str):
|
||||
async def get_review(request: Request, review_id: str):
|
||||
try:
|
||||
return JSONResponse(
|
||||
content=model_to_dict(ReviewSegment.get(ReviewSegment.id == review_id))
|
||||
)
|
||||
review = ReviewSegment.get(ReviewSegment.id == review_id)
|
||||
await require_camera_access(review.camera, request=request)
|
||||
return JSONResponse(content=model_to_dict(review))
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Review item not found"},
|
||||
|
||||
Reference in New Issue
Block a user