diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index 481955423..814d6ef8d 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -1,8 +1,11 @@ click == 8.1.* -Flask == 3.0.* -Flask_Limiter == 3.8.* +# FastAPI +starlette-context == 0.3.6 +fastapi == 0.115.0 +slowapi == 0.1.9 imutils == 0.5.* joserfc == 1.0.* +pathvalidate == 3.2.* markupsafe == 2.1.* mypy == 1.6.1 numpy == 1.26.* diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index 892650b5e..75527bf53 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf @@ -226,7 +226,7 @@ http { location ~* /api/.*\.(jpg|jpeg|png|webp|gif)$ { include auth_request.conf; - rewrite ^/api/(.*)$ $1 break; + rewrite ^/api/(.*)$ /$1 break; proxy_pass http://frigate_api; include proxy.conf; } diff --git a/docs/docs/configuration/authentication.md b/docs/docs/configuration/authentication.md index 47d7e85a3..a48b03b48 100644 --- a/docs/docs/configuration/authentication.md +++ b/docs/docs/configuration/authentication.md @@ -26,7 +26,7 @@ In the event that you are locked out of your instance, you can tell Frigate to r ## Login failure rate limiting -In order to limit the risk of brute force attacks, rate limiting is available for login failures. This is implemented with Flask-Limiter, and the string notation for valid values is available in [the documentation](https://flask-limiter.readthedocs.io/en/stable/configuration.html#rate-limit-string-notation). +In order to limit the risk of brute force attacks, rate limiting is available for login failures. This is implemented with SlowApi, and the string notation for valid values is available in [the documentation](https://limits.readthedocs.io/en/stable/quickstart.html#examples). For example, `1/second;5/minute;20/hour` will rate limit the login endpoint when failures occur more than: diff --git a/frigate/__main__.py b/frigate/__main__.py index ea888a948..ccd2594e2 100644 --- a/frigate/__main__.py +++ b/frigate/__main__.py @@ -5,7 +5,6 @@ import signal import sys import threading -from flask import cli from pydantic import ValidationError from frigate.app import FrigateApp @@ -24,7 +23,6 @@ def main() -> None: ) threading.current_thread().name = "frigate" - cli.show_server_banner = lambda *x: None # Make sure we exit cleanly on SIGTERM. signal.signal(signal.SIGTERM, lambda sig, frame: sys.exit()) diff --git a/frigate/api/app.py b/frigate/api/app.py index 8f9b5109e..8ffcc789b 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -10,27 +10,19 @@ from functools import reduce from typing import Optional import requests -from flask import Blueprint, Flask, current_app, jsonify, make_response, request +from fastapi import APIRouter, Path, Request, Response +from fastapi.encoders import jsonable_encoder +from fastapi.params import Depends +from fastapi.responses import JSONResponse from markupsafe import escape from peewee import operator -from playhouse.sqliteq import SqliteQueueDatabase -from werkzeug.middleware.proxy_fix import ProxyFix -from frigate.api.auth import AuthBp, get_jwt_secret, limiter -from frigate.api.event import EventBp -from frigate.api.export import ExportBp -from frigate.api.media import MediaBp -from frigate.api.notification import NotificationBp -from frigate.api.preview import PreviewBp -from frigate.api.review import ReviewBp +from frigate.api.defs.app_body import AppConfigSetBody +from frigate.api.defs.app_query_parameters import AppTimelineHourlyQueryParameters +from frigate.api.defs.tags import Tags from frigate.config import FrigateConfig from frigate.const import CONFIG_DIR -from frigate.embeddings import EmbeddingsContext -from frigate.events.external import ExternalEventProcessor from frigate.models import Event, Timeline -from frigate.ptz.onvif import OnvifController -from frigate.stats.emitter import StatsEmitter -from frigate.storage import StorageMaintainer from frigate.util.builtin import ( clean_camera_user_pass, get_tz_modifiers, @@ -42,134 +34,75 @@ from frigate.version import VERSION logger = logging.getLogger(__name__) -bp = Blueprint("frigate", __name__) -bp.register_blueprint(EventBp) -bp.register_blueprint(ExportBp) -bp.register_blueprint(MediaBp) -bp.register_blueprint(PreviewBp) -bp.register_blueprint(ReviewBp) -bp.register_blueprint(AuthBp) -bp.register_blueprint(NotificationBp) +router = APIRouter(tags=[Tags.app]) -def create_app( - frigate_config, - database: SqliteQueueDatabase, - embeddings: Optional[EmbeddingsContext], - detected_frames_processor, - storage_maintainer: StorageMaintainer, - onvif: OnvifController, - external_processor: ExternalEventProcessor, - stats_emitter: StatsEmitter, -): - app = Flask(__name__) - - @app.before_request - def check_csrf(): - if request.method in ["GET", "HEAD", "OPTIONS", "TRACE"]: - pass - if "origin" in request.headers and "x-csrf-token" not in request.headers: - return jsonify({"success": False, "message": "Missing CSRF header"}), 401 - - @app.before_request - def _db_connect(): - if database.is_closed(): - database.connect() - - @app.teardown_request - def _db_close(exc): - if not database.is_closed(): - database.close() - - app.frigate_config = frigate_config - app.embeddings = embeddings - app.detected_frames_processor = detected_frames_processor - app.storage_maintainer = storage_maintainer - app.onvif = onvif - app.external_processor = external_processor - app.camera_error_image = None - app.stats_emitter = stats_emitter - app.jwt_token = get_jwt_secret() if frigate_config.auth.enabled else None - # update the request_address with the x-forwarded-for header from nginx - app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1) - # initialize the rate limiter for the login endpoint - limiter.init_app(app) - if frigate_config.auth.failed_login_rate_limit is None: - limiter.enabled = False - - app.register_blueprint(bp) - - return app - - -@bp.route("/") +@router.get("/") def is_healthy(): return "Frigate is running. Alive and healthy!" -@bp.route("/config/schema.json") -def config_schema(): - return current_app.response_class( - current_app.frigate_config.schema_json(), mimetype="application/json" +@router.get("/config/schema.json") +def config_schema(request: Request): + return Response( + content=request.app.frigate_config.schema_json(), media_type="application/json" ) -@bp.route("/go2rtc/streams") +@router.get("/go2rtc/streams") def go2rtc_streams(): r = requests.get("http://127.0.0.1:1984/api/streams") if not r.ok: logger.error("Failed to fetch streams from go2rtc") - return make_response( - jsonify({"success": False, "message": "Error fetching stream data"}), - 500, + return JSONResponse( + content=({"success": False, "message": "Error fetching stream data"}), + status_code=500, ) stream_data = r.json() for data in stream_data.values(): for producer in data.get("producers", []): producer["url"] = clean_camera_user_pass(producer.get("url", "")) - return jsonify(stream_data) + return JSONResponse(content=stream_data) -@bp.route("/go2rtc/streams/") +@router.get("/go2rtc/streams/{camera_name}") def go2rtc_camera_stream(camera_name: str): r = requests.get( f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=allµphone" ) if not r.ok: logger.error("Failed to fetch streams from go2rtc") - return make_response( - jsonify({"success": False, "message": "Error fetching stream data"}), - 500, + return JSONResponse( + content=({"success": False, "message": "Error fetching stream data"}), + status_code=500, ) stream_data = r.json() for producer in stream_data.get("producers", []): producer["url"] = clean_camera_user_pass(producer.get("url", "")) - return jsonify(stream_data) + return JSONResponse(content=stream_data) -@bp.route("/version") +@router.get("/version") def version(): return VERSION -@bp.route("/stats") -def stats(): - return jsonify(current_app.stats_emitter.get_latest_stats()) +@router.get("/stats") +def stats(request: Request): + return JSONResponse(content=request.app.stats_emitter.get_latest_stats()) -@bp.route("/stats/history") -def stats_history(): - keys = request.args.get("keys", default=None) - +@router.get("/stats/history") +def stats_history(request: Request, keys: str = None): if keys: keys = keys.split(",") - return jsonify(current_app.stats_emitter.get_stats_history(keys)) + return JSONResponse(content=request.app.stats_emitter.get_stats_history(keys)) -@bp.route("/config") -def config(): - config_obj: FrigateConfig = current_app.frigate_config +@router.get("/config") +def config(request: Request): + config_obj: FrigateConfig = request.app.frigate_config config: dict[str, dict[str, any]] = config_obj.model_dump( mode="json", warnings="none", exclude_none=True ) @@ -180,7 +113,7 @@ def config(): # remove the proxy secret config["proxy"].pop("auth_secret", None) - for camera_name, camera in current_app.frigate_config.cameras.items(): + for camera_name, camera in request.app.frigate_config.cameras.items(): camera_dict = config["cameras"][camera_name] # clean paths @@ -196,18 +129,18 @@ def config(): for zone_name, zone in config_obj.cameras[camera_name].zones.items(): camera_dict["zones"][zone_name]["color"] = zone.color - config["plus"] = {"enabled": current_app.frigate_config.plus_api.is_active()} + config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()} config["model"]["colormap"] = config_obj.model.colormap for detector_config in config["detectors"].values(): detector_config["model"]["labelmap"] = ( - current_app.frigate_config.model.merged_labelmap + request.app.frigate_config.model.merged_labelmap ) - return jsonify(config) + return JSONResponse(content=config) -@bp.route("/config/raw") +@router.get("/config/raw") def config_raw(): config_file = os.environ.get("CONFIG_FILE", "/config/config.yml") @@ -218,8 +151,9 @@ def config_raw(): config_file = config_file_yaml if not os.path.isfile(config_file): - return make_response( - jsonify({"success": False, "message": "Could not find file"}), 404 + return JSONResponse( + content=({"success": False, "message": "Could not find file"}), + status_code=404, ) with open(config_file, "r") as f: @@ -229,32 +163,30 @@ def config_raw(): return raw_config, 200 -@bp.route("/config/save", methods=["POST"]) -def config_save(): - save_option = request.args.get("save_option") - - new_config = request.get_data().decode() +@router.post("/config/save") +def config_save(save_option: str, body: dict): + new_config = body if not new_config: - return make_response( - jsonify( + return JSONResponse( + content=( {"success": False, "message": "Config with body param is required"} ), - 400, + status_code=400, ) # Validate the config schema try: FrigateConfig.parse_yaml(new_config) except Exception: - return make_response( - jsonify( + return JSONResponse( + content=( { "success": False, "message": f"\nConfig Error:\n\n{escape(str(traceback.format_exc()))}", } ), - 400, + status_code=400, ) # Save the config to file @@ -271,14 +203,14 @@ def config_save(): f.write(new_config) f.close() except Exception: - return make_response( - jsonify( + return JSONResponse( + content=( { "success": False, "message": "Could not write config file, be sure that Frigate has write permission on the config file.", } ), - 400, + status_code=400, ) if save_option == "restart": @@ -286,34 +218,34 @@ def config_save(): restart_frigate() except Exception as e: logging.error(f"Error restarting Frigate: {e}") - return make_response( - jsonify( + return JSONResponse( + content=( { "success": True, "message": "Config successfully saved, unable to restart Frigate", } ), - 200, + status_code=200, ) - return make_response( - jsonify( + return JSONResponse( + content=( { "success": True, "message": "Config successfully saved, restarting (this can take up to one minute)...", } ), - 200, + status_code=200, ) else: - return make_response( - jsonify({"success": True, "message": "Config successfully saved."}), - 200, + return JSONResponse( + content=({"success": True, "message": "Config successfully saved."}), + status_code=200, ) -@bp.route("/config/set", methods=["PUT"]) -def config_set(): +@router.put("/config/set") +def config_set(request: Request, body: AppConfigSetBody): config_file = os.environ.get("CONFIG_FILE", f"{CONFIG_DIR}/config.yml") # Check if we can use .yaml instead of .yml @@ -339,68 +271,68 @@ def config_set(): f.write(old_raw_config) f.close() logger.error(f"\nConfig Error:\n\n{str(traceback.format_exc())}") - return make_response( - jsonify( + return JSONResponse( + content=( { "success": False, "message": "Error parsing config. Check logs for error message.", } ), - 400, + status_code=400, ) except Exception as e: logging.error(f"Error updating config: {e}") - return make_response( - jsonify({"success": False, "message": "Error updating config"}), - 500, + return JSONResponse( + content=({"success": False, "message": "Error updating config"}), + status_code=500, ) - json = request.get_json(silent=True) or {} - - if json.get("requires_restart", 1) == 0: - current_app.frigate_config = FrigateConfig.parse_object( - config_obj, plus_api=current_app.frigate_config.plus_api + if body.requires_restart == 0: + request.app.frigate_config = FrigateConfig.parse_object( + config_obj, request.app.frigate_config.plus_api ) - return make_response( - jsonify( + return JSONResponse( + content=( { "success": True, "message": "Config successfully updated, restart to apply", } ), - 200, + status_code=200, ) -@bp.route("/ffprobe", methods=["GET"]) -def ffprobe(): - path_param = request.args.get("paths", "") +@router.get("/ffprobe") +def ffprobe(request: Request, paths: str = ""): + path_param = paths if not path_param: - return make_response( - jsonify({"success": False, "message": "Path needs to be provided."}), 404 + return JSONResponse( + content=({"success": False, "message": "Path needs to be provided."}), + status_code=404, ) if path_param.startswith("camera"): camera = path_param[7:] - if camera not in current_app.frigate_config.cameras.keys(): - return make_response( - jsonify( + if camera not in request.app.frigate_config.cameras.keys(): + return JSONResponse( + content=( {"success": False, "message": f"{camera} is not a valid camera."} ), - 404, + status_code=404, ) - if not current_app.frigate_config.cameras[camera].enabled: - return make_response( - jsonify({"success": False, "message": f"{camera} is not enabled."}), 404 + if not request.app.frigate_config.cameras[camera].enabled: + return JSONResponse( + content=({"success": False, "message": f"{camera} is not enabled."}), + status_code=404, ) paths = map( lambda input: input.path, - current_app.frigate_config.cameras[camera].ffmpeg.inputs, + request.app.frigate_config.cameras[camera].ffmpeg.inputs, ) elif "," in clean_camera_user_pass(path_param): paths = path_param.split(",") @@ -411,7 +343,7 @@ def ffprobe(): output = [] for path in paths: - ffprobe = ffprobe_stream(current_app.frigate_config.ffmpeg, path.strip()) + ffprobe = ffprobe_stream(request.app.frigate_config.ffmpeg, path.strip()) output.append( { "return_code": ffprobe.returncode, @@ -428,14 +360,14 @@ def ffprobe(): } ) - return jsonify(output) + return JSONResponse(content=output) -@bp.route("/vainfo", methods=["GET"]) +@router.get("/vainfo") def vainfo(): vainfo = vainfo_hwaccel() - return jsonify( - { + return JSONResponse( + content={ "return_code": vainfo.returncode, "stderr": ( vainfo.stderr.decode("unicode_escape").strip() @@ -451,19 +383,26 @@ def vainfo(): ) -@bp.route("/logs/", methods=["GET"]) -def logs(service: str): +@router.get("/logs/{service}", tags=[Tags.logs]) +def logs( + service: str = Path(enum=["frigate", "nginx", "go2rtc", "chroma"]), + download: Optional[str] = None, + start: Optional[int] = 0, + end: Optional[int] = None, +): + """Get logs for the requested service (frigate/nginx/go2rtc/chroma)""" + def download_logs(service_location: str): try: file = open(service_location, "r") contents = file.read() file.close() - return jsonify(contents) + return JSONResponse(jsonable_encoder(contents)) except FileNotFoundError as e: logger.error(e) - return make_response( - jsonify({"success": False, "message": "Could not find log file"}), - 500, + return JSONResponse( + content={"success": False, "message": "Could not find log file"}, + status_code=500, ) log_locations = { @@ -475,17 +414,14 @@ def logs(service: str): service_location = log_locations.get(service) if not service_location: - return make_response( - jsonify({"success": False, "message": "Not a valid service"}), - 404, + return JSONResponse( + content={"success": False, "message": "Not a valid service"}, + status_code=404, ) - if request.args.get("download", type=bool, default=False): + if download: return download_logs(service_location) - start = request.args.get("start", type=int, default=0) - end = request.args.get("end", type=int) - try: file = open(service_location, "r") contents = file.read() @@ -526,49 +462,47 @@ def logs(service: str): logLines.append(currentLine) - return make_response( - jsonify({"totalLines": len(logLines), "lines": logLines[start:end]}), - 200, + return JSONResponse( + content={"totalLines": len(logLines), "lines": logLines[start:end]}, + status_code=200, ) except FileNotFoundError as e: logger.error(e) - return make_response( - jsonify({"success": False, "message": "Could not find log file"}), - 500, + return JSONResponse( + content={"success": False, "message": "Could not find log file"}, + status_code=500, ) -@bp.route("/restart", methods=["POST"]) +@router.post("/restart") def restart(): try: restart_frigate() except Exception as e: logging.error(f"Error restarting Frigate: {e}") - return make_response( - jsonify( + return JSONResponse( + content=( { "success": False, "message": "Unable to restart Frigate.", } ), - 500, + status_code=500, ) - return make_response( - jsonify( + return JSONResponse( + content=( { "success": True, "message": "Restarting (this can take up to one minute)...", } ), - 200, + status_code=200, ) -@bp.route("/labels") -def get_labels(): - camera = request.args.get("camera", type=str, default="") - +@router.get("/labels") +def get_labels(camera: str = ""): try: if camera: events = Event.select(Event.label).where(Event.camera == camera).distinct() @@ -576,24 +510,23 @@ def get_labels(): events = Event.select(Event.label).distinct() except Exception as e: logger.error(e) - return make_response( - jsonify({"success": False, "message": "Failed to get labels"}), 404 + return JSONResponse( + content=({"success": False, "message": "Failed to get labels"}), + status_code=404, ) labels = sorted([e.label for e in events]) - return jsonify(labels) + return JSONResponse(content=labels) -@bp.route("/sub_labels") -def get_sub_labels(): - split_joined = request.args.get("split_joined", type=int) - +@router.get("/sub_labels") +def get_sub_labels(split_joined: Optional[int] = None): try: events = Event.select(Event.sub_label).distinct() except Exception: - return make_response( - jsonify({"success": False, "message": "Failed to get sub_labels"}), - 404, + return JSONResponse( + content=({"success": False, "message": "Failed to get sub_labels"}), + status_code=404, ) sub_labels = [e.sub_label for e in events] @@ -614,15 +547,11 @@ def get_sub_labels(): sub_labels.append(part.strip()) sub_labels.sort() - return jsonify(sub_labels) + return JSONResponse(content=sub_labels) -@bp.route("/timeline") -def timeline(): - camera = request.args.get("camera", "all") - source_id = request.args.get("source_id", type=str) - limit = request.args.get("limit", 100) - +@router.get("/timeline") +def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = None): clauses = [] selected_columns = [ @@ -651,18 +580,18 @@ def timeline(): .dicts() ) - return jsonify([t for t in timeline]) + return JSONResponse(content=[t for t in timeline]) -@bp.route("/timeline/hourly") -def hourly_timeline(): +@router.get("/timeline/hourly") +def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()): """Get hourly summary for timeline.""" - cameras = request.args.get("cameras", "all") - labels = request.args.get("labels", "all") - before = request.args.get("before", type=float) - after = request.args.get("after", type=float) - limit = request.args.get("limit", 200) - tz_name = request.args.get("timezone", default="utc", type=str) + cameras = params.cameras + labels = params.labels + before = params.before + after = params.after + limit = params.limit + tz_name = params.timezone _, minute_modifier, _ = get_tz_modifiers(tz_name) minute_offset = int(minute_modifier.split(" ")[0]) @@ -728,8 +657,8 @@ def hourly_timeline(): else: hours[hour].insert(0, t) - return jsonify( - { + return JSONResponse( + content={ "start": start, "end": end, "count": count, diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 57064eab6..37f8b5e7b 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -12,25 +12,45 @@ import time from datetime import datetime from pathlib import Path -from flask import Blueprint, current_app, jsonify, make_response, redirect, request -from flask_limiter import Limiter +from fastapi import APIRouter, Request, Response +from fastapi.responses import JSONResponse, RedirectResponse from joserfc import jwt from peewee import DoesNotExist +from slowapi import Limiter +from frigate.api.defs.app_body import ( + AppPostLoginBody, + AppPostUsersBody, + AppPutPasswordBody, +) +from frigate.api.defs.tags import Tags from frigate.config import AuthConfig, ProxyConfig from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM from frigate.models import User logger = logging.getLogger(__name__) -AuthBp = Blueprint("auth", __name__) +router = APIRouter(tags=[Tags.auth]) -def get_remote_addr(): +class RateLimiter: + _limit = "" + + def set_limit(self, limit: str): + self._limit = limit + + def get_limit(self) -> str: + return self._limit + + +rateLimiter = RateLimiter() + + +def get_remote_addr(request: Request): route = list(reversed(request.headers.get("x-forwarded-for").split(","))) logger.debug(f"IP Route: {[r for r in route]}") trusted_proxies = [] - for proxy in current_app.frigate_config.auth.trusted_proxies: + for proxy in request.app.frigate_config.auth.trusted_proxies: try: network = ipaddress.ip_network(proxy) except ValueError: @@ -68,16 +88,6 @@ def get_remote_addr(): return request.remote_addr or "127.0.0.1" -limiter = Limiter( - get_remote_addr, - storage_uri="memory://", -) - - -def get_rate_limit(): - return current_app.frigate_config.auth.failed_login_rate_limit - - def get_jwt_secret() -> str: jwt_secret = None # check env var @@ -132,7 +142,7 @@ def get_jwt_secret() -> str: return jwt_secret -def hash_password(password, salt=None, iterations=600000): +def hash_password(password: str, salt=None, iterations=600000): if salt is None: salt = secrets.token_hex(16) assert salt and isinstance(salt, str) and "$" not in salt @@ -158,33 +168,36 @@ def create_encoded_jwt(user, expiration, secret): return jwt.encode({"alg": "HS256"}, {"sub": user, "exp": expiration}, secret) -def set_jwt_cookie(response, cookie_name, encoded_jwt, expiration, secure): +def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, secure): # TODO: ideally this would set secure as well, but that requires TLS response.set_cookie( - cookie_name, encoded_jwt, httponly=True, expires=expiration, secure=secure + key=cookie_name, + value=encoded_jwt, + httponly=True, + expires=expiration, + secure=secure, ) # Endpoint for use with nginx auth_request -@AuthBp.route("/auth") -def auth(): - auth_config: AuthConfig = current_app.frigate_config.auth - proxy_config: ProxyConfig = current_app.frigate_config.proxy +@router.get("/auth") +def auth(request: Request): + auth_config: AuthConfig = request.app.frigate_config.auth + proxy_config: ProxyConfig = request.app.frigate_config.proxy - success_response = make_response({}, 202) + success_response = Response("", status_code=202) # dont require auth if the request is on the internal port # this header is set by Frigate's nginx proxy, so it cant be spoofed - if request.headers.get("x-server-port", 0, type=int) == 5000: + if int(request.headers.get("x-server-port", default=0)) == 5000: return success_response - fail_response = make_response({}, 401) + fail_response = Response("", status_code=401) # ensure the proxy secret matches if configured if ( proxy_config.auth_secret is not None - and request.headers.get("x-proxy-secret", "", type=str) - != proxy_config.auth_secret + and request.headers.get("x-proxy-secret", "") != proxy_config.auth_secret ): logger.debug("X-Proxy-Secret header does not match configured secret value") return fail_response @@ -196,7 +209,6 @@ def auth(): if proxy_config.header_map.user is not None: upstream_user_header_value = request.headers.get( proxy_config.header_map.user, - type=str, default="anonymous", ) success_response.headers["remote-user"] = upstream_user_header_value @@ -207,10 +219,10 @@ def auth(): # now apply authentication fail_response.headers["location"] = "/login" - JWT_COOKIE_NAME = current_app.frigate_config.auth.cookie_name - JWT_COOKIE_SECURE = current_app.frigate_config.auth.cookie_secure - JWT_REFRESH = current_app.frigate_config.auth.refresh_time - JWT_SESSION_LENGTH = current_app.frigate_config.auth.session_length + JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name + JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure + JWT_REFRESH = request.app.frigate_config.auth.refresh_time + JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length jwt_source = None encoded_token = None @@ -230,7 +242,7 @@ def auth(): return fail_response try: - token = jwt.decode(encoded_token, current_app.jwt_token) + token = jwt.decode(encoded_token, request.app.jwt_token) if "sub" not in token.claims: logger.debug("user not set in jwt token") return fail_response @@ -266,7 +278,7 @@ def auth(): return fail_response new_expiration = current_time + JWT_SESSION_LENGTH new_encoded_jwt = create_encoded_jwt( - user, new_expiration, current_app.jwt_token + user, new_expiration, request.app.jwt_token ) set_jwt_cookie( success_response, @@ -283,86 +295,84 @@ def auth(): return fail_response -@AuthBp.route("/profile") -def profile(): - username = request.headers.get("remote-user", type=str) - return jsonify({"username": username}) +@router.get("/profile") +def profile(request: Request): + username = request.headers.get("remote-user") + return JSONResponse(content={"username": username}) -@AuthBp.route("/logout") -def logout(): - auth_config: AuthConfig = current_app.frigate_config.auth - response = make_response(redirect("/login", code=303)) +@router.get("/logout") +def logout(request: Request): + auth_config: AuthConfig = request.app.frigate_config.auth + response = RedirectResponse("/login", status_code=303) response.delete_cookie(auth_config.cookie_name) return response -@AuthBp.route("/login", methods=["POST"]) -@limiter.limit(get_rate_limit, deduct_when=lambda response: response.status_code == 400) -def login(): - JWT_COOKIE_NAME = current_app.frigate_config.auth.cookie_name - JWT_COOKIE_SECURE = current_app.frigate_config.auth.cookie_secure - JWT_SESSION_LENGTH = current_app.frigate_config.auth.session_length - content = request.get_json() - user = content["user"] - password = content["password"] +limiter = Limiter(key_func=get_remote_addr) + + +@router.post("/login") +@limiter.limit(limit_value=rateLimiter.get_limit) +def login(request: Request, body: AppPostLoginBody): + JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name + JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure + JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length + user = body.user + password = body.password try: db_user: User = User.get_by_id(user) except DoesNotExist: - return make_response({"message": "Login failed"}, 400) + return JSONResponse(content={"message": "Login failed"}, status_code=400) password_hash = db_user.password_hash if verify_password(password, password_hash): expiration = int(time.time()) + JWT_SESSION_LENGTH - encoded_jwt = create_encoded_jwt(user, expiration, current_app.jwt_token) - response = make_response({}, 200) + encoded_jwt = create_encoded_jwt(user, expiration, request.app.jwt_token) + response = Response("", 200) set_jwt_cookie( response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE ) return response - return make_response({"message": "Login failed"}, 400) + return JSONResponse(content={"message": "Login failed"}, status_code=400) -@AuthBp.route("/users") +@router.get("/users") def get_users(): exports = User.select(User.username).order_by(User.username).dicts().iterator() - return jsonify([e for e in exports]) + return JSONResponse([e for e in exports]) -@AuthBp.route("/users", methods=["POST"]) -def create_user(): - HASH_ITERATIONS = current_app.frigate_config.auth.hash_iterations +@router.post("/users") +def create_user(request: Request, body: AppPostUsersBody): + HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations - request_data = request.get_json() + if not re.match("^[A-Za-z0-9._]+$", body.username): + JSONResponse(content={"message": "Invalid username"}, status_code=400) - if not re.match("^[A-Za-z0-9._]+$", request_data.get("username", "")): - make_response({"message": "Invalid username"}, 400) - - password_hash = hash_password(request_data["password"], iterations=HASH_ITERATIONS) + password_hash = hash_password(body.password, iterations=HASH_ITERATIONS) User.insert( { - User.username: request_data["username"], + User.username: body.username, User.password_hash: password_hash, } ).execute() - return jsonify({"username": request_data["username"]}) + return JSONResponse(content={"username": body.username}) -@AuthBp.route("/users/", methods=["DELETE"]) +@router.delete("/users/{username}") def delete_user(username: str): User.delete_by_id(username) - return jsonify({"success": True}) + return JSONResponse(content={"success": True}) -@AuthBp.route("/users//password", methods=["PUT"]) -def update_password(username: str): - HASH_ITERATIONS = current_app.frigate_config.auth.hash_iterations +@router.put("/users/{username}/password") +def update_password(request: Request, username: str, body: AppPutPasswordBody): + HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations - request_data = request.get_json() - - password_hash = hash_password(request_data["password"], iterations=HASH_ITERATIONS) + password_hash = hash_password(body.password, iterations=HASH_ITERATIONS) User.set_by_id( username, @@ -370,4 +380,4 @@ def update_password(username: str): User.password_hash: password_hash, }, ) - return jsonify({"success": True}) + return JSONResponse(content={"success": True}) diff --git a/frigate/api/defs/app_body.py b/frigate/api/defs/app_body.py new file mode 100644 index 000000000..85daa5631 --- /dev/null +++ b/frigate/api/defs/app_body.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel + + +class AppConfigSetBody(BaseModel): + requires_restart: int = 1 + + +class AppPutPasswordBody(BaseModel): + password: str + + +class AppPostUsersBody(BaseModel): + username: str + password: str + + +class AppPostLoginBody(BaseModel): + user: str + password: str diff --git a/frigate/api/defs/app_query_parameters.py b/frigate/api/defs/app_query_parameters.py new file mode 100644 index 000000000..e182a6afd --- /dev/null +++ b/frigate/api/defs/app_query_parameters.py @@ -0,0 +1,12 @@ +from typing import Optional + +from pydantic import BaseModel + + +class AppTimelineHourlyQueryParameters(BaseModel): + cameras: Optional[str] = "all" + labels: Optional[str] = "all" + after: Optional[float] = None + before: Optional[float] = None + limit: Optional[int] = 200 + timezone: Optional[str] = "utc" diff --git a/frigate/api/defs/events_body.py b/frigate/api/defs/events_body.py new file mode 100644 index 000000000..ea5cd8c0d --- /dev/null +++ b/frigate/api/defs/events_body.py @@ -0,0 +1,30 @@ +from datetime import datetime +from typing import Optional, Union + +from pydantic import BaseModel, Field + + +class EventsSubLabelBody(BaseModel): + subLabel: str = Field(title="Sub label", max_length=100) + subLabelScore: Optional[float] = Field( + title="Score for sub label", default=None, gt=0.0, le=1.0 + ) + + +class EventsDescriptionBody(BaseModel): + description: Union[str, None] = Field( + title="The description of the event", min_length=1 + ) + + +class EventsCreateBody(BaseModel): + source_type: Optional[str] = "api" + sub_label: Optional[str] = None + score: Optional[int] = 0 + duration: Optional[int] = 30 + include_recording: Optional[bool] = True + draw: Optional[dict] = {} + + +class EventsEndBody(BaseModel): + end_time: Optional[int] = datetime.now().timestamp() diff --git a/frigate/api/defs/events_query_parameters.py b/frigate/api/defs/events_query_parameters.py new file mode 100644 index 000000000..884cbe9f6 --- /dev/null +++ b/frigate/api/defs/events_query_parameters.py @@ -0,0 +1,52 @@ +from typing import Optional + +from pydantic import BaseModel + +DEFAULT_TIME_RANGE = "00:00,24:00" + + +class EventsQueryParams(BaseModel): + camera: Optional[str] = "all" + cameras: Optional[str] = "all" + label: Optional[str] = "all" + labels: Optional[str] = "all" + sub_label: Optional[str] = "all" + sub_labels: Optional[str] = "all" + zone: Optional[str] = "all" + zones: Optional[str] = "all" + limit: Optional[int] = 100 + after: Optional[float] = None + before: Optional[float] = None + time_range: Optional[str] = DEFAULT_TIME_RANGE + has_clip: Optional[int] = None + has_snapshot: Optional[int] = None + in_progress: Optional[int] = None + include_thumbnails: Optional[int] = 1 + favorites: Optional[int] = None + min_score: Optional[float] = None + max_score: Optional[float] = None + is_submitted: Optional[int] = None + min_length: Optional[float] = None + max_length: Optional[float] = None + sort: Optional[str] = None + timezone: Optional[str] = "utc" + + +class EventsSearchQueryParams(BaseModel): + query: Optional[str] = None + event_id: Optional[str] = None + search_type: Optional[str] = "thumbnail,description" + include_thumbnails: Optional[int] = 1 + limit: Optional[int] = 50 + cameras: Optional[str] = "all" + labels: Optional[str] = "all" + zones: Optional[str] = "all" + after: Optional[float] = None + before: Optional[float] = None + timezone: Optional[str] = "utc" + + +class EventsSummaryQueryParams(BaseModel): + timezone: Optional[str] = "utc" + has_clip: Optional[int] = None + has_snapshot: Optional[int] = None diff --git a/frigate/api/defs/media_query_parameters.py b/frigate/api/defs/media_query_parameters.py new file mode 100644 index 000000000..b7df85d30 --- /dev/null +++ b/frigate/api/defs/media_query_parameters.py @@ -0,0 +1,42 @@ +from enum import Enum +from typing import Optional + +from pydantic import BaseModel + + +class Extension(str, Enum): + webp = "webp" + png = "png" + jpg = "jpg" + jpeg = "jpeg" + + +class MediaLatestFrameQueryParams(BaseModel): + bbox: Optional[int] = None + timestamp: Optional[int] = None + zones: Optional[int] = None + mask: Optional[int] = None + motion: Optional[int] = None + regions: Optional[int] = None + quality: Optional[int] = 70 + height: Optional[int] = None + + +class MediaEventsSnapshotQueryParams(BaseModel): + download: Optional[bool] = False + timestamp: Optional[int] = None + bbox: Optional[int] = None + crop: Optional[int] = None + height: Optional[int] = None + quality: Optional[int] = 70 + + +class MediaMjpegFeedQueryParams(BaseModel): + fps: int = 3 + height: int = 360 + bbox: Optional[int] = None + timestamp: Optional[int] = None + zones: Optional[int] = None + mask: Optional[int] = None + motion: Optional[int] = None + regions: Optional[int] = None diff --git a/frigate/api/defs/review_query_parameters.py b/frigate/api/defs/review_query_parameters.py new file mode 100644 index 000000000..8baf609df --- /dev/null +++ b/frigate/api/defs/review_query_parameters.py @@ -0,0 +1,31 @@ +from datetime import datetime, timedelta +from typing import Optional + +from pydantic import BaseModel + + +class ReviewQueryParams(BaseModel): + cameras: Optional[str] = "all" + labels: Optional[str] = "all" + zones: Optional[str] = "all" + reviewed: Optional[int] = 0 + limit: Optional[int] = None + severity: Optional[str] = None + before: Optional[float] = datetime.now().timestamp() + after: Optional[float] = (datetime.now() - timedelta(hours=24)).timestamp() + + +class ReviewSummaryQueryParams(BaseModel): + cameras: Optional[str] = "all" + labels: Optional[str] = "all" + zones: Optional[str] = "all" + timezone: Optional[str] = "utc" + day_ago: Optional[int] = (datetime.now() - timedelta(hours=24)).timestamp() + month_ago: Optional[int] = (datetime.now() - timedelta(days=30)).timestamp() + + +class ReviewActivityMotionQueryParams(BaseModel): + cameras: Optional[str] = "all" + before: Optional[float] = datetime.now().timestamp() + after: Optional[float] = (datetime.now() - timedelta(hours=1)).timestamp() + scale: Optional[int] = 30 diff --git a/frigate/api/defs/tags.py b/frigate/api/defs/tags.py new file mode 100644 index 000000000..80faf255c --- /dev/null +++ b/frigate/api/defs/tags.py @@ -0,0 +1,13 @@ +from enum import Enum + + +class Tags(Enum): + app = "App" + preview = "Preview" + logs = "Logs" + media = "Media" + notifications = "Notifications" + review = "Review" + export = "Export" + events = "Events" + auth = "Auth" diff --git a/frigate/api/event.py b/frigate/api/event.py index bd6e68157..8c56a465d 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -4,24 +4,32 @@ import base64 import io import logging import os -from datetime import datetime from functools import reduce from pathlib import Path from urllib.parse import unquote import cv2 import numpy as np -from flask import ( - Blueprint, - current_app, - jsonify, - make_response, - request, -) +from fastapi import APIRouter, Request +from fastapi.params import Depends +from fastapi.responses import JSONResponse from peewee import JOIN, DoesNotExist, fn, operator from PIL import Image from playhouse.shortcuts import model_to_dict +from frigate.api.defs.events_body import ( + EventsCreateBody, + EventsDescriptionBody, + EventsEndBody, + EventsSubLabelBody, +) +from frigate.api.defs.events_query_parameters import ( + DEFAULT_TIME_RANGE, + EventsQueryParams, + EventsSearchQueryParams, + EventsSummaryQueryParams, +) +from frigate.api.defs.tags import Tags from frigate.const import ( CLIPS_DIR, ) @@ -33,57 +41,55 @@ from frigate.util.builtin import get_tz_modifiers logger = logging.getLogger(__name__) -EventBp = Blueprint("events", __name__) - -DEFAULT_TIME_RANGE = "00:00,24:00" +router = APIRouter(tags=[Tags.events]) -@EventBp.route("/events") -def events(): - camera = request.args.get("camera", "all") - cameras = request.args.get("cameras", "all") +@router.get("/events") +def events(params: EventsQueryParams = Depends()): + camera = params.camera + cameras = params.cameras # handle old camera arg if cameras == "all" and camera != "all": cameras = camera - label = unquote(request.args.get("label", "all")) - labels = request.args.get("labels", "all") + label = unquote(params.label) + labels = params.labels # handle old label arg if labels == "all" and label != "all": labels = label - sub_label = request.args.get("sub_label", "all") - sub_labels = request.args.get("sub_labels", "all") + sub_label = params.sub_label + sub_labels = params.sub_labels # handle old sub_label arg if sub_labels == "all" and sub_label != "all": sub_labels = sub_label - zone = request.args.get("zone", "all") - zones = request.args.get("zones", "all") + zone = params.zone + zones = params.zones # handle old label arg if zones == "all" and zone != "all": zones = zone - limit = request.args.get("limit", 100) - after = request.args.get("after", type=float) - before = request.args.get("before", type=float) - time_range = request.args.get("time_range", DEFAULT_TIME_RANGE) - has_clip = request.args.get("has_clip", type=int) - has_snapshot = request.args.get("has_snapshot", type=int) - in_progress = request.args.get("in_progress", type=int) - include_thumbnails = request.args.get("include_thumbnails", default=1, type=int) - favorites = request.args.get("favorites", type=int) - min_score = request.args.get("min_score", type=float) - max_score = request.args.get("max_score", type=float) - is_submitted = request.args.get("is_submitted", type=int) - min_length = request.args.get("min_length", type=float) - max_length = request.args.get("max_length", type=float) + limit = params.limit + after = params.after + before = params.before + time_range = params.time_range + has_clip = params.has_clip + has_snapshot = params.has_snapshot + in_progress = params.in_progress + include_thumbnails = params.include_thumbnails + favorites = params.favorites + min_score = params.min_score + max_score = params.max_score + is_submitted = params.is_submitted + min_length = params.min_length + max_length = params.max_length - sort = request.args.get("sort", type=str) + sort = params.sort clauses = [] @@ -163,7 +169,7 @@ def events(): if time_range != DEFAULT_TIME_RANGE: # get timezone arg to ensure browser times are used - tz_name = request.args.get("timezone", default="utc", type=str) + tz_name = params.timezone hour_modifier, minute_modifier, _ = get_tz_modifiers(tz_name) times = time_range.split(",") @@ -248,13 +254,11 @@ def events(): .iterator() ) - return jsonify(list(events)) + return JSONResponse(content=list(events)) -@EventBp.route("/events/explore") -def events_explore(): - limit = request.args.get("limit", 10, type=int) - +@router.get("/events/explore") +def events_explore(limit: int = 10): subquery = Event.select( Event.id, Event.camera, @@ -316,69 +320,68 @@ def events_explore(): for event in events ] - return jsonify(processed_events) + return JSONResponse(content=processed_events) -@EventBp.route("/event_ids") -def event_ids(): - idString = request.args.get("ids") - ids = idString.split(",") +@router.get("/event_ids") +def event_ids(ids: str): + ids = ids.split(",") if not ids: - return make_response( - jsonify({"success": False, "message": "Valid list of ids must be sent"}), - 400, + return JSONResponse( + content=({"success": False, "message": "Valid list of ids must be sent"}), + status_code=400, ) try: events = Event.select().where(Event.id << ids).dicts().iterator() - return jsonify(list(events)) + return JSONResponse(list(events)) except Exception: - return make_response( - jsonify({"success": False, "message": "Events not found"}), 400 + return JSONResponse( + content=({"success": False, "message": "Events not found"}), status_code=400 ) -@EventBp.route("/events/search") -def events_search(): - query = request.args.get("query", type=str) - search_type = request.args.get("search_type", "thumbnail,description", type=str) - include_thumbnails = request.args.get("include_thumbnails", default=1, type=int) - limit = request.args.get("limit", 50, type=int) +@router.get("/events/search") +def events_search(request: Request, params: EventsSearchQueryParams = Depends()): + query = params.query + search_type = params.search_type + include_thumbnails = params.include_thumbnails + limit = params.limit # Filters - cameras = request.args.get("cameras", "all", type=str) - labels = request.args.get("labels", "all", type=str) - zones = request.args.get("zones", "all", type=str) - after = request.args.get("after", type=float) - before = request.args.get("before", type=float) + cameras = params.cameras + labels = params.labels + zones = params.zones + after = params.after + before = params.before # for similarity search - event_id = request.args.get("event_id", type=str) + event_id = params.event_id if not query and not event_id: - return make_response( - jsonify( + return JSONResponse( + content=( { "success": False, "message": "A search query must be supplied", } ), - 400, + status_code=400, ) - if not current_app.frigate_config.semantic_search.enabled: - return make_response( - jsonify( + if not request.app.frigate_config.semantic_search.enabled: + return JSONResponse( + content=( { "success": False, "message": "Semantic search is not enabled", } ), - 400, + status_code=400, ) - context: EmbeddingsContext = current_app.embeddings + context: EmbeddingsContext = request.app.embeddings selected_columns = [ Event.id, @@ -437,14 +440,14 @@ def events_search(): try: search_event: Event = Event.get(Event.id == event_id) except DoesNotExist: - return make_response( - jsonify( + return JSONResponse( + content=( { "success": False, "message": "Event not found", } ), - 404, + status_code=404, ) thumbnail = base64.b64decode(search_event.thumbnail) img = np.array(Image.open(io.BytesIO(thumbnail)).convert("RGB")) @@ -504,7 +507,7 @@ def events_search(): } if not results: - return jsonify([]) + return JSONResponse(content=[]) # Get the event data events = ( @@ -537,15 +540,15 @@ def events_search(): ] events = sorted(events, key=lambda x: x["search_distance"])[:limit] - return jsonify(events) + return JSONResponse(content=events) -@EventBp.route("/events/summary") -def events_summary(): - tz_name = request.args.get("timezone", default="utc", type=str) +@router.get("/events/summary") +def events_summary(params: EventsSummaryQueryParams = Depends()): + tz_name = params.timezone hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name) - has_clip = request.args.get("has_clip", type=int) - has_snapshot = request.args.get("has_snapshot", type=int) + has_clip = params.has_clip + has_snapshot = params.has_snapshot clauses = [] @@ -582,47 +585,49 @@ def events_summary(): ) ) - return jsonify([e for e in groups.dicts()]) + return JSONResponse(content=[e for e in groups.dicts()]) -@EventBp.route("/events/", methods=("GET",)) -def event(id): +@router.get("/events/{event_id}") +def event(event_id: str): try: - return model_to_dict(Event.get(Event.id == id)) + return model_to_dict(Event.get(Event.id == event_id)) except DoesNotExist: - return "Event not found", 404 + return JSONResponse(content="Event not found", status_code=404) -@EventBp.route("/events//retain", methods=("POST",)) -def set_retain(id): +@router.post("/events/{event_id}/retain") +def set_retain(event_id: str): try: - event = Event.get(Event.id == id) + event = Event.get(Event.id == event_id) except DoesNotExist: - return make_response( - jsonify({"success": False, "message": "Event " + id + " not found"}), 404 + return JSONResponse( + content=({"success": False, "message": "Event " + event_id + " not found"}), + status_code=404, ) event.retain_indefinitely = True event.save() - return make_response( - jsonify({"success": True, "message": "Event " + id + " retained"}), 200 + return JSONResponse( + content=({"success": True, "message": "Event " + event_id + " retained"}), + status_code=200, ) -@EventBp.route("/events//plus", methods=("POST",)) -def send_to_plus(id): - if not current_app.frigate_config.plus_api.is_active(): +@router.post("/events/{event_id}/plus") +def send_to_plus(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) - return make_response( - jsonify( + return JSONResponse( + content=( { "success": False, "message": message, } ), - 400, + status_code=400, ) include_annotation = ( @@ -630,11 +635,13 @@ def send_to_plus(id): ) try: - event = Event.get(Event.id == id) + event = Event.get(Event.id == event_id) except DoesNotExist: - message = f"Event {id} not found" + message = f"Event {event_id} not found" logger.error(message) - return make_response(jsonify({"success": False, "message": message}), 404) + return JSONResponse( + content=({"success": False, "message": message}), status_code=404 + ) # events from before the conversion to relative dimensions cant include annotations if event.data.get("box") is None: @@ -642,20 +649,22 @@ def send_to_plus(id): if event.end_time is None: logger.error(f"Unable to load clean png for in-progress event: {event.id}") - return make_response( - jsonify( + return JSONResponse( + content=( { "success": False, "message": "Unable to load clean png for in-progress event", } ), - 400, + status_code=400, ) if event.plus_id: message = "Already submitted to plus" logger.error(message) - return make_response(jsonify({"success": False, "message": message}), 400) + return JSONResponse( + content=({"success": False, "message": message}), status_code=400 + ) # load clean.png try: @@ -663,29 +672,29 @@ def send_to_plus(id): image = cv2.imread(os.path.join(CLIPS_DIR, filename)) except Exception: logger.error(f"Unable to load clean png for event: {event.id}") - return make_response( - jsonify( + return JSONResponse( + content=( {"success": False, "message": "Unable to load clean png for event"} ), - 400, + status_code=400, ) if image is None or image.size == 0: logger.error(f"Unable to load clean png for event: {event.id}") - return make_response( - jsonify( + return JSONResponse( + content=( {"success": False, "message": "Unable to load clean png for event"} ), - 400, + status_code=400, ) try: - plus_id = current_app.frigate_config.plus_api.upload_image(image, event.camera) + plus_id = request.app.frigate_config.plus_api.upload_image(image, event.camera) except Exception as ex: logger.exception(ex) - return make_response( - jsonify({"success": False, "message": "Error uploading image"}), - 400, + return JSONResponse( + content=({"success": False, "message": "Error uploading image"}), + status_code=400, ) # store image id in the database @@ -696,7 +705,7 @@ def send_to_plus(id): box = event.data["box"] try: - current_app.frigate_config.plus_api.add_annotation( + request.app.frigate_config.plus_api.add_annotation( event.plus_id, box, event.label, @@ -704,59 +713,67 @@ def send_to_plus(id): except ValueError: message = "Error uploading annotation, unsupported label provided." logger.error(message) - return make_response( - jsonify({"success": False, "message": message}), - 400, + return JSONResponse( + content=({"success": False, "message": message}), + status_code=400, ) except Exception as ex: logger.exception(ex) - return make_response( - jsonify({"success": False, "message": "Error uploading annotation"}), - 400, + return JSONResponse( + content=({"success": False, "message": "Error uploading annotation"}), + status_code=400, ) - return make_response(jsonify({"success": True, "plus_id": plus_id}), 200) + return JSONResponse( + content=({"success": True, "plus_id": plus_id}), status_code=200 + ) -@EventBp.route("/events//false_positive", methods=("PUT",)) -def false_positive(id): - if not current_app.frigate_config.plus_api.is_active(): +@router.put("/events/{event_id}/false_positive") +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) - return make_response( - jsonify( + return JSONResponse( + content=( { "success": False, "message": message, } ), - 400, + status_code=400, ) try: - event = Event.get(Event.id == id) + event = Event.get(Event.id == event_id) except DoesNotExist: - message = f"Event {id} not found" + message = f"Event {event_id} not found" logger.error(message) - return make_response(jsonify({"success": False, "message": message}), 404) + return JSONResponse( + content=({"success": False, "message": message}), status_code=404 + ) # events from before the conversion to relative dimensions cant include annotations if event.data.get("box") is None: message = "Events prior to 0.13 cannot be submitted as false positives" logger.error(message) - return make_response(jsonify({"success": False, "message": message}), 400) + return JSONResponse( + content=({"success": False, "message": message}), status_code=400 + ) if event.false_positive: message = "False positive already submitted to Frigate+" logger.error(message) - return make_response(jsonify({"success": False, "message": message}), 400) + return JSONResponse( + content=({"success": False, "message": message}), status_code=400 + ) if not event.plus_id: - plus_response = send_to_plus(id) + plus_response = send_to_plus(event_id) if plus_response.status_code != 200: return plus_response # need to refetch the event now that it has a plus_id - event = Event.get(Event.id == id) + event = Event.get(Event.id == event_id) region = event.data["region"] box = event.data["box"] @@ -769,7 +786,7 @@ def false_positive(id): ) try: - current_app.frigate_config.plus_api.add_false_positive( + request.app.frigate_config.plus_api.add_false_positive( event.plus_id, region, box, @@ -782,92 +799,65 @@ def false_positive(id): except ValueError: message = "Error uploading false positive, unsupported label provided." logger.error(message) - return make_response( - jsonify({"success": False, "message": message}), - 400, + return JSONResponse( + content=({"success": False, "message": message}), + status_code=400, ) except Exception as ex: logger.exception(ex) - return make_response( - jsonify({"success": False, "message": "Error uploading false positive"}), - 400, + return JSONResponse( + content=({"success": False, "message": "Error uploading false positive"}), + status_code=400, ) event.false_positive = True event.save() - return make_response(jsonify({"success": True, "plus_id": event.plus_id}), 200) + return JSONResponse( + content=({"success": True, "plus_id": event.plus_id}), status_code=200 + ) -@EventBp.route("/events//retain", methods=("DELETE",)) -def delete_retain(id): +@router.delete("/events/{event_id}/retain") +def delete_retain(event_id: str): try: - event = Event.get(Event.id == id) + event = Event.get(Event.id == event_id) except DoesNotExist: - return make_response( - jsonify({"success": False, "message": "Event " + id + " not found"}), 404 + return JSONResponse( + content=({"success": False, "message": "Event " + event_id + " not found"}), + status_code=404, ) event.retain_indefinitely = False event.save() - return make_response( - jsonify({"success": True, "message": "Event " + id + " un-retained"}), 200 + return JSONResponse( + content=({"success": True, "message": "Event " + event_id + " un-retained"}), + status_code=200, ) -@EventBp.route("/events//sub_label", methods=("POST",)) -def set_sub_label(id): +@router.post("/events/{event_id}/sub_label") +def set_sub_label( + request: Request, + event_id: str, + body: EventsSubLabelBody, +): try: - event: Event = Event.get(Event.id == id) + event: Event = Event.get(Event.id == event_id) except DoesNotExist: - return make_response( - jsonify({"success": False, "message": "Event " + id + " not found"}), 404 + return JSONResponse( + content=({"success": False, "message": "Event " + event_id + " not found"}), + status_code=404, ) - json: dict[str, any] = request.get_json(silent=True) or {} - new_sub_label = json.get("subLabel") - new_score = json.get("subLabelScore") - - if new_sub_label is None: - return make_response( - jsonify( - { - "success": False, - "message": "A sub label must be supplied", - } - ), - 400, - ) - - if new_sub_label and len(new_sub_label) > 100: - return make_response( - jsonify( - { - "success": False, - "message": new_sub_label - + " exceeds the 100 character limit for sub_label", - } - ), - 400, - ) - - if new_score is not None and (new_score > 1.0 or new_score < 0): - return make_response( - jsonify( - { - "success": False, - "message": new_score - + " does not fit within the expected bounds 0 <= score <= 1.0", - } - ), - 400, - ) + new_sub_label = body.subLabel + new_score = body.subLabelScore if not event.end_time: # update tracked object tracked_obj: TrackedObject = ( - current_app.detected_frames_processor.camera_states[ + request.app.detected_frames_processor.camera_states[ event.camera ].tracked_objects.get(event.id) ) @@ -878,7 +868,7 @@ def set_sub_label(id): # update timeline items Timeline.update( data=Timeline.data.update({"sub_label": (new_sub_label, new_score)}) - ).where(Timeline.source_id == id).execute() + ).where(Timeline.source_id == event_id).execute() event.sub_label = new_sub_label @@ -888,70 +878,78 @@ def set_sub_label(id): event.data = data event.save() - return make_response( - jsonify( + return JSONResponse( + content=( { "success": True, - "message": "Event " + id + " sub label set to " + new_sub_label, + "message": "Event " + event_id + " sub label set to " + new_sub_label, } ), - 200, + status_code=200, ) -@EventBp.route("/events//description", methods=("POST",)) -def set_description(id): +@router.post("/events/{event_id}/description") +def set_description( + request: Request, + event_id: str, + body: EventsDescriptionBody, +): try: - event: Event = Event.get(Event.id == id) + event: Event = Event.get(Event.id == event_id) except DoesNotExist: - return make_response( - jsonify({"success": False, "message": "Event " + id + " not found"}), 404 + return JSONResponse( + content=({"success": False, "message": "Event " + event_id + " not found"}), + status_code=404, ) - json: dict[str, any] = request.get_json(silent=True) or {} - new_description = json.get("description") + new_description = body.description if new_description is None or len(new_description) == 0: - return make_response( - jsonify( + return JSONResponse( + content=( { "success": False, "message": "description cannot be empty", } ), - 400, + status_code=400, ) event.data["description"] = new_description event.save() # If semantic search is enabled, update the index - if current_app.frigate_config.semantic_search.enabled: - context: EmbeddingsContext = current_app.embeddings + if request.app.frigate_config.semantic_search.enabled: + context: EmbeddingsContext = request.app.embeddings context.embeddings.description.upsert( documents=[new_description], metadatas=[get_metadata(event)], - ids=[id], + ids=[event_id], ) - return make_response( - jsonify( + return JSONResponse( + content=( { "success": True, - "message": "Event " + id + " description set to " + new_description, + "message": "Event " + + event_id + + " description set to " + + new_description, } ), - 200, + status_code=200, ) -@EventBp.route("/events/", methods=("DELETE",)) -def delete_event(id): +@router.delete("/events/{event_id}") +def delete_event(request: Request, event_id: str): try: - event = Event.get(Event.id == id) + event = Event.get(Event.id == event_id) except DoesNotExist: - return make_response( - jsonify({"success": False, "message": "Event " + id + " not found"}), 404 + return JSONResponse( + content=({"success": False, "message": "Event " + event_id + " not found"}), + status_code=404, ) media_name = f"{event.camera}-{event.id}" @@ -965,82 +963,86 @@ def delete_event(id): media.unlink(missing_ok=True) event.delete_instance() - Timeline.delete().where(Timeline.source_id == id).execute() + Timeline.delete().where(Timeline.source_id == event_id).execute() # If semantic search is enabled, update the index - if current_app.frigate_config.semantic_search.enabled: - context: EmbeddingsContext = current_app.embeddings - context.embeddings.thumbnail.delete(ids=[id]) - context.embeddings.description.delete(ids=[id]) - return make_response( - jsonify({"success": True, "message": "Event " + id + " deleted"}), 200 + if request.app.frigate_config.semantic_search.enabled: + context: EmbeddingsContext = request.app.embeddings + context.embeddings.thumbnail.delete(ids=[event_id]) + context.embeddings.description.delete(ids=[event_id]) + return JSONResponse( + content=({"success": True, "message": "Event " + event_id + " deleted"}), + status_code=200, ) -@EventBp.route("/events//