mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	Security fixes (#8081)
* use safeloader * use json responses wherever possible * remove CORS and add CSRF token * formatting fixes * add envjs back * fix baseurl test
This commit is contained in:
		
							parent
							
								
									9a4f970337
								
							
						
					
					
						commit
						14d2b79c72
					
				| @ -93,10 +93,6 @@ http { | |||||||
|             secure_token $args; |             secure_token $args; | ||||||
|             secure_token_types application/vnd.apple.mpegurl; |             secure_token_types application/vnd.apple.mpegurl; | ||||||
| 
 | 
 | ||||||
|             add_header Access-Control-Allow-Headers '*'; |  | ||||||
|             add_header Access-Control-Expose-Headers 'Server,range,Content-Length,Content-Range'; |  | ||||||
|             add_header Access-Control-Allow-Methods 'GET, HEAD, OPTIONS'; |  | ||||||
|             add_header Access-Control-Allow-Origin '*'; |  | ||||||
|             add_header Cache-Control "no-store"; |             add_header Cache-Control "no-store"; | ||||||
|             expires off; |             expires off; | ||||||
|         } |         } | ||||||
| @ -104,16 +100,6 @@ http { | |||||||
|         location /stream/ { |         location /stream/ { | ||||||
|             add_header Cache-Control "no-store"; |             add_header Cache-Control "no-store"; | ||||||
|             expires off; |             expires off; | ||||||
|             add_header 'Access-Control-Allow-Origin' "$http_origin" always; |  | ||||||
|             add_header 'Access-Control-Allow-Credentials' 'true'; |  | ||||||
|             add_header 'Access-Control-Expose-Headers' 'Content-Length'; |  | ||||||
|             if ($request_method = 'OPTIONS') { |  | ||||||
|                 add_header 'Access-Control-Allow-Origin' "$http_origin"; |  | ||||||
|                 add_header 'Access-Control-Max-Age' 1728000; |  | ||||||
|                 add_header 'Content-Type' 'text/plain charset=UTF-8'; |  | ||||||
|                 add_header 'Content-Length' 0; |  | ||||||
|                 return 204; |  | ||||||
|             } |  | ||||||
| 
 | 
 | ||||||
|             types { |             types { | ||||||
|                 application/dash+xml mpd; |                 application/dash+xml mpd; | ||||||
| @ -126,16 +112,6 @@ http { | |||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         location /clips/ { |         location /clips/ { | ||||||
|             add_header 'Access-Control-Allow-Origin' "$http_origin" always; |  | ||||||
|             add_header 'Access-Control-Allow-Credentials' 'true'; |  | ||||||
|             add_header 'Access-Control-Expose-Headers' 'Content-Length'; |  | ||||||
|             if ($request_method = 'OPTIONS') { |  | ||||||
|                 add_header 'Access-Control-Allow-Origin' "$http_origin"; |  | ||||||
|                 add_header 'Access-Control-Max-Age' 1728000; |  | ||||||
|                 add_header 'Content-Type' 'text/plain charset=UTF-8'; |  | ||||||
|                 add_header 'Content-Length' 0; |  | ||||||
|                 return 204; |  | ||||||
|             } |  | ||||||
| 
 | 
 | ||||||
|             types { |             types { | ||||||
|                 video/mp4 mp4; |                 video/mp4 mp4; | ||||||
| @ -152,17 +128,6 @@ http { | |||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         location /recordings/ { |         location /recordings/ { | ||||||
|             add_header 'Access-Control-Allow-Origin' "$http_origin" always; |  | ||||||
|             add_header 'Access-Control-Allow-Credentials' 'true'; |  | ||||||
|             add_header 'Access-Control-Expose-Headers' 'Content-Length'; |  | ||||||
|             if ($request_method = 'OPTIONS') { |  | ||||||
|                 add_header 'Access-Control-Allow-Origin' "$http_origin"; |  | ||||||
|                 add_header 'Access-Control-Max-Age' 1728000; |  | ||||||
|                 add_header 'Content-Type' 'text/plain charset=UTF-8'; |  | ||||||
|                 add_header 'Content-Length' 0; |  | ||||||
|                 return 204; |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             types { |             types { | ||||||
|                 video/mp4 mp4; |                 video/mp4 mp4; | ||||||
|             } |             } | ||||||
| @ -173,17 +138,6 @@ http { | |||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         location /exports/ { |         location /exports/ { | ||||||
|             add_header 'Access-Control-Allow-Origin' "$http_origin" always; |  | ||||||
|             add_header 'Access-Control-Allow-Credentials' 'true'; |  | ||||||
|             add_header 'Access-Control-Expose-Headers' 'Content-Length'; |  | ||||||
|             if ($request_method = 'OPTIONS') { |  | ||||||
|                 add_header 'Access-Control-Allow-Origin' "$http_origin"; |  | ||||||
|                 add_header 'Access-Control-Max-Age' 1728000; |  | ||||||
|                 add_header 'Content-Type' 'text/plain charset=UTF-8'; |  | ||||||
|                 add_header 'Content-Length' 0; |  | ||||||
|                 return 204; |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             types { |             types { | ||||||
|                 video/mp4 mp4; |                 video/mp4 mp4; | ||||||
|             } |             } | ||||||
| @ -235,8 +189,6 @@ http { | |||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         location ~* /api/.*\.(jpg|jpeg|png)$ { |         location ~* /api/.*\.(jpg|jpeg|png)$ { | ||||||
|             add_header 'Access-Control-Allow-Origin' '*'; |  | ||||||
|             add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS'; |  | ||||||
|             rewrite ^/api/(.*)$ $1 break; |             rewrite ^/api/(.*)$ $1 break; | ||||||
|             proxy_pass http://frigate_api; |             proxy_pass http://frigate_api; | ||||||
|             proxy_pass_request_headers on; |             proxy_pass_request_headers on; | ||||||
| @ -248,10 +200,6 @@ http { | |||||||
|         location /api/ { |         location /api/ { | ||||||
|             add_header Cache-Control "no-store"; |             add_header Cache-Control "no-store"; | ||||||
|             expires off; |             expires off; | ||||||
| 
 |  | ||||||
|             add_header 'Access-Control-Allow-Origin' '*'; |  | ||||||
|             add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS'; |  | ||||||
|             add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range'; |  | ||||||
|             proxy_pass http://frigate_api/; |             proxy_pass http://frigate_api/; | ||||||
|             proxy_pass_request_headers on; |             proxy_pass_request_headers on; | ||||||
|             proxy_set_header Host $host; |             proxy_set_header Host $host; | ||||||
|  | |||||||
| @ -155,10 +155,6 @@ cd web && npm install | |||||||
| cd web && npm run dev | cd web && npm run dev | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
| #### 3a. Run the development server against a non-local instance |  | ||||||
| 
 |  | ||||||
| To run the development server against a non-local instance, you will need to modify the API_HOST default return in `web/src/env.js`. |  | ||||||
| 
 |  | ||||||
| #### 4. Making changes | #### 4. Making changes | ||||||
| 
 | 
 | ||||||
| The Web UI is built using [Vite](https://vitejs.dev/), [Preact](https://preactjs.com), and [Tailwind CSS](https://tailwindcss.com). | The Web UI is built using [Vite](https://vitejs.dev/), [Preact](https://preactjs.com), and [Tailwind CSS](https://tailwindcss.com). | ||||||
|  | |||||||
							
								
								
									
										194
									
								
								frigate/http.py
									
									
									
									
									
								
							
							
						
						
									
										194
									
								
								frigate/http.py
									
									
									
									
									
								
							| @ -20,6 +20,7 @@ from flask import ( | |||||||
|     Flask, |     Flask, | ||||||
|     Response, |     Response, | ||||||
|     current_app, |     current_app, | ||||||
|  |     escape, | ||||||
|     jsonify, |     jsonify, | ||||||
|     make_response, |     make_response, | ||||||
|     request, |     request, | ||||||
| @ -73,6 +74,13 @@ def create_app( | |||||||
| ): | ): | ||||||
|     app = Flask(__name__) |     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 |     @app.before_request | ||||||
|     def _db_connect(): |     def _db_connect(): | ||||||
|         if database.is_closed(): |         if database.is_closed(): | ||||||
| @ -532,10 +540,14 @@ def event_thumbnail(id, max_cache_age=2592000): | |||||||
|                     if tracked_obj is not None: |                     if tracked_obj is not None: | ||||||
|                         thumbnail_bytes = tracked_obj.get_thumbnail() |                         thumbnail_bytes = tracked_obj.get_thumbnail() | ||||||
|         except Exception: |         except Exception: | ||||||
|             return "Event not found", 404 |             return make_response( | ||||||
|  |                 jsonify({"success": False, "message": "Event not found"}), 404 | ||||||
|  |             ) | ||||||
| 
 | 
 | ||||||
|     if thumbnail_bytes is None: |     if thumbnail_bytes is None: | ||||||
|         return "Event not found", 404 |         return make_response( | ||||||
|  |             jsonify({"success": False, "message": "Event not found"}), 404 | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     # android notifications prefer a 2:1 ratio |     # android notifications prefer a 2:1 ratio | ||||||
|     if format == "android": |     if format == "android": | ||||||
| @ -630,7 +642,9 @@ def event_snapshot(id): | |||||||
|         event = Event.get(Event.id == id, Event.end_time != None) |         event = Event.get(Event.id == id, Event.end_time != None) | ||||||
|         event_complete = True |         event_complete = True | ||||||
|         if not event.has_snapshot: |         if not event.has_snapshot: | ||||||
|             return "Snapshot not available", 404 |             return make_response( | ||||||
|  |                 jsonify({"success": False, "message": "Snapshot not available"}), 404 | ||||||
|  |             ) | ||||||
|         # read snapshot from disk |         # read snapshot from disk | ||||||
|         with open( |         with open( | ||||||
|             os.path.join(CLIPS_DIR, f"{event.camera}-{id}.jpg"), "rb" |             os.path.join(CLIPS_DIR, f"{event.camera}-{id}.jpg"), "rb" | ||||||
| @ -652,12 +666,18 @@ def event_snapshot(id): | |||||||
|                             quality=request.args.get("quality", default=70, type=int), |                             quality=request.args.get("quality", default=70, type=int), | ||||||
|                         ) |                         ) | ||||||
|         except Exception: |         except Exception: | ||||||
|             return "Event not found", 404 |             return make_response( | ||||||
|  |                 jsonify({"success": False, "message": "Event not found"}), 404 | ||||||
|  |             ) | ||||||
|     except Exception: |     except Exception: | ||||||
|         return "Event not found", 404 |         return make_response( | ||||||
|  |             jsonify({"success": False, "message": "Event not found"}), 404 | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     if jpg_bytes is None: |     if jpg_bytes is None: | ||||||
|         return "Event not found", 404 |         return make_response( | ||||||
|  |             jsonify({"success": False, "message": "Event not found"}), 404 | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     response = make_response(jpg_bytes) |     response = make_response(jpg_bytes) | ||||||
|     response.headers["Content-Type"] = "image/jpeg" |     response.headers["Content-Type"] = "image/jpeg" | ||||||
| @ -710,10 +730,14 @@ def event_clip(id): | |||||||
|     try: |     try: | ||||||
|         event: Event = Event.get(Event.id == id) |         event: Event = Event.get(Event.id == id) | ||||||
|     except DoesNotExist: |     except DoesNotExist: | ||||||
|         return "Event not found.", 404 |         return make_response( | ||||||
|  |             jsonify({"success": False, "message": "Event not found"}), 404 | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     if not event.has_clip: |     if not event.has_clip: | ||||||
|         return "Clip not available", 404 |         return make_response( | ||||||
|  |             jsonify({"success": False, "message": "Clip not available"}), 404 | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     file_name = f"{event.camera}-{id}.mp4" |     file_name = f"{event.camera}-{id}.mp4" | ||||||
|     clip_path = os.path.join(CLIPS_DIR, file_name) |     clip_path = os.path.join(CLIPS_DIR, file_name) | ||||||
| @ -1019,7 +1043,9 @@ def config_raw(): | |||||||
|         config_file = config_file_yaml |         config_file = config_file_yaml | ||||||
| 
 | 
 | ||||||
|     if not os.path.isfile(config_file): |     if not os.path.isfile(config_file): | ||||||
|         return "Could not find file", 410 |         return make_response( | ||||||
|  |             jsonify({"success": False, "message": "Could not find file"}), 404 | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     with open(config_file, "r") as f: |     with open(config_file, "r") as f: | ||||||
|         raw_config = f.read() |         raw_config = f.read() | ||||||
| @ -1035,7 +1061,12 @@ def config_save(): | |||||||
|     new_config = request.get_data().decode() |     new_config = request.get_data().decode() | ||||||
| 
 | 
 | ||||||
|     if not new_config: |     if not new_config: | ||||||
|         return "Config with body param is required", 400 |         return make_response( | ||||||
|  |             jsonify( | ||||||
|  |                 {"success": False, "message": "Config with body param is required"} | ||||||
|  |             ), | ||||||
|  |             400, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     # Validate the config schema |     # Validate the config schema | ||||||
|     try: |     try: | ||||||
| @ -1045,7 +1076,7 @@ def config_save(): | |||||||
|             jsonify( |             jsonify( | ||||||
|                 { |                 { | ||||||
|                     "success": False, |                     "success": False, | ||||||
|                     "message": f"\nConfig Error:\n\n{str(traceback.format_exc())}", |                     "message": f"\nConfig Error:\n\n{escape(str(traceback.format_exc()))}", | ||||||
|                 } |                 } | ||||||
|             ), |             ), | ||||||
|             400, |             400, | ||||||
| @ -1080,14 +1111,30 @@ def config_save(): | |||||||
|             restart_frigate() |             restart_frigate() | ||||||
|         except Exception as e: |         except Exception as e: | ||||||
|             logging.error(f"Error restarting Frigate: {e}") |             logging.error(f"Error restarting Frigate: {e}") | ||||||
|             return "Config successfully saved, unable to restart Frigate", 200 |             return make_response( | ||||||
|  |                 jsonify( | ||||||
|  |                     { | ||||||
|  |                         "success": True, | ||||||
|  |                         "message": "Config successfully saved, unable to restart Frigate", | ||||||
|  |                     } | ||||||
|  |                 ), | ||||||
|  |                 200, | ||||||
|  |             ) | ||||||
| 
 | 
 | ||||||
|         return ( |         return make_response( | ||||||
|             "Config successfully saved, restarting (this can take up to one minute)...", |             jsonify( | ||||||
|  |                 { | ||||||
|  |                     "success": True, | ||||||
|  |                     "message": "Config successfully saved, restarting (this can take up to one minute)...", | ||||||
|  |                 } | ||||||
|  |             ), | ||||||
|             200, |             200, | ||||||
|         ) |         ) | ||||||
|     else: |     else: | ||||||
|         return "Config successfully saved.", 200 |         return make_response( | ||||||
|  |             jsonify({"success": True, "message": "Config successfully saved."}), | ||||||
|  |             200, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @bp.route("/config/set", methods=["PUT"]) | @bp.route("/config/set", methods=["PUT"]) | ||||||
| @ -1127,9 +1174,20 @@ def config_set(): | |||||||
|             ) |             ) | ||||||
|     except Exception as e: |     except Exception as e: | ||||||
|         logging.error(f"Error updating config: {e}") |         logging.error(f"Error updating config: {e}") | ||||||
|         return "Error updating config", 500 |         return make_response( | ||||||
|  |             jsonify({"success": False, "message": "Error updating config"}), | ||||||
|  |             500, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     return "Config successfully updated, restart to apply", 200 |     return make_response( | ||||||
|  |         jsonify( | ||||||
|  |             { | ||||||
|  |                 "success": True, | ||||||
|  |                 "message": "Config successfully updated, restart to apply", | ||||||
|  |             } | ||||||
|  |         ), | ||||||
|  |         200, | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @bp.route("/config/schema.json") | @bp.route("/config/schema.json") | ||||||
| @ -1179,7 +1237,10 @@ def mjpeg_feed(camera_name): | |||||||
|             mimetype="multipart/x-mixed-replace; boundary=frame", |             mimetype="multipart/x-mixed-replace; boundary=frame", | ||||||
|         ) |         ) | ||||||
|     else: |     else: | ||||||
|         return "Camera named {} not found".format(camera_name), 404 |         return make_response( | ||||||
|  |             jsonify({"success": False, "message": "Camera not found"}), | ||||||
|  |             404, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @bp.route("/<camera_name>/ptz/info") | @bp.route("/<camera_name>/ptz/info") | ||||||
| @ -1187,7 +1248,10 @@ def camera_ptz_info(camera_name): | |||||||
|     if camera_name in current_app.frigate_config.cameras: |     if camera_name in current_app.frigate_config.cameras: | ||||||
|         return jsonify(current_app.onvif.get_camera_info(camera_name)) |         return jsonify(current_app.onvif.get_camera_info(camera_name)) | ||||||
|     else: |     else: | ||||||
|         return "Camera named {} not found".format(camera_name), 404 |         return make_response( | ||||||
|  |             jsonify({"success": False, "message": "Camera not found"}), | ||||||
|  |             404, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @bp.route("/<camera_name>/latest.jpg") | @bp.route("/<camera_name>/latest.jpg") | ||||||
| @ -1229,7 +1293,10 @@ def latest_frame(camera_name): | |||||||
|         width = int(height * frame.shape[1] / frame.shape[0]) |         width = int(height * frame.shape[1] / frame.shape[0]) | ||||||
| 
 | 
 | ||||||
|         if frame is None: |         if frame is None: | ||||||
|             return "Unable to get valid frame from {}".format(camera_name), 500 |             return make_response( | ||||||
|  |                 jsonify({"success": False, "message": "Unable to get valid frame"}), | ||||||
|  |                 500, | ||||||
|  |             ) | ||||||
| 
 | 
 | ||||||
|         if height < 1 or width < 1: |         if height < 1 or width < 1: | ||||||
|             return ( |             return ( | ||||||
| @ -1265,7 +1332,10 @@ def latest_frame(camera_name): | |||||||
|         response.headers["Cache-Control"] = "no-store" |         response.headers["Cache-Control"] = "no-store" | ||||||
|         return response |         return response | ||||||
|     else: |     else: | ||||||
|         return "Camera named {} not found".format(camera_name), 404 |         return make_response( | ||||||
|  |             jsonify({"success": False, "message": "Camera not found"}), | ||||||
|  |             404, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @bp.route("/<camera_name>/recordings/<frame_time>/snapshot.png") | @bp.route("/<camera_name>/recordings/<frame_time>/snapshot.png") | ||||||
| @ -1315,7 +1385,15 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str): | |||||||
|         response.headers["Content-Type"] = "image/png" |         response.headers["Content-Type"] = "image/png" | ||||||
|         return response |         return response | ||||||
|     except DoesNotExist: |     except DoesNotExist: | ||||||
|         return "Recording not found for {} at {}".format(camera_name, frame_time), 404 |         return make_response( | ||||||
|  |             jsonify( | ||||||
|  |                 { | ||||||
|  |                     "success": False, | ||||||
|  |                     "message": "Recording not found at {}".format(frame_time), | ||||||
|  |                 } | ||||||
|  |             ), | ||||||
|  |             404, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @bp.route("/recordings/storage", methods=["GET"]) | @bp.route("/recordings/storage", methods=["GET"]) | ||||||
| @ -1517,7 +1595,15 @@ def recording_clip(camera_name, start_ts, end_ts): | |||||||
| 
 | 
 | ||||||
|         if p.returncode != 0: |         if p.returncode != 0: | ||||||
|             logger.error(p.stderr) |             logger.error(p.stderr) | ||||||
|             return f"Could not create clip from recordings for {camera_name}.", 500 |             return make_response( | ||||||
|  |                 jsonify( | ||||||
|  |                     { | ||||||
|  |                         "success": False, | ||||||
|  |                         "message": "Could not create clip from recordings", | ||||||
|  |                     } | ||||||
|  |                 ), | ||||||
|  |                 500, | ||||||
|  |             ) | ||||||
|     else: |     else: | ||||||
|         logger.debug( |         logger.debug( | ||||||
|             f"Ignoring subsequent request for {path} as it already exists in the cache." |             f"Ignoring subsequent request for {path} as it already exists in the cache." | ||||||
| @ -1573,7 +1659,15 @@ def vod_ts(camera_name, start_ts, end_ts): | |||||||
| 
 | 
 | ||||||
|     if not clips: |     if not clips: | ||||||
|         logger.error("No recordings found for the requested time range") |         logger.error("No recordings found for the requested time range") | ||||||
|         return "No recordings found.", 404 |         return make_response( | ||||||
|  |             jsonify( | ||||||
|  |                 { | ||||||
|  |                     "success": False, | ||||||
|  |                     "message": "No recordings found.", | ||||||
|  |                 } | ||||||
|  |             ), | ||||||
|  |             404, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     hour_ago = datetime.now() - timedelta(hours=1) |     hour_ago = datetime.now() - timedelta(hours=1) | ||||||
|     return jsonify( |     return jsonify( | ||||||
| @ -1616,11 +1710,27 @@ def vod_event(id): | |||||||
|         event: Event = Event.get(Event.id == id) |         event: Event = Event.get(Event.id == id) | ||||||
|     except DoesNotExist: |     except DoesNotExist: | ||||||
|         logger.error(f"Event not found: {id}") |         logger.error(f"Event not found: {id}") | ||||||
|         return "Event not found.", 404 |         return make_response( | ||||||
|  |             jsonify( | ||||||
|  |                 { | ||||||
|  |                     "success": False, | ||||||
|  |                     "message": "Event not found.", | ||||||
|  |                 } | ||||||
|  |             ), | ||||||
|  |             404, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     if not event.has_clip: |     if not event.has_clip: | ||||||
|         logger.error(f"Event does not have recordings: {id}") |         logger.error(f"Event does not have recordings: {id}") | ||||||
|         return "Recordings not available", 404 |         return make_response( | ||||||
|  |             jsonify( | ||||||
|  |                 { | ||||||
|  |                     "success": False, | ||||||
|  |                     "message": "Recordings not available.", | ||||||
|  |                 } | ||||||
|  |             ), | ||||||
|  |             404, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     clip_path = os.path.join(CLIPS_DIR, f"{event.camera}-{id}.mp4") |     clip_path = os.path.join(CLIPS_DIR, f"{event.camera}-{id}.mp4") | ||||||
| 
 | 
 | ||||||
| @ -1697,7 +1807,15 @@ def export_recording(camera_name: str, start_time, end_time): | |||||||
|         else PlaybackFactorEnum.realtime, |         else PlaybackFactorEnum.realtime, | ||||||
|     ) |     ) | ||||||
|     exporter.start() |     exporter.start() | ||||||
|     return "Starting export of recording", 200 |     return make_response( | ||||||
|  |         jsonify( | ||||||
|  |             { | ||||||
|  |                 "success": True, | ||||||
|  |                 "message": "Starting export of recording.", | ||||||
|  |             } | ||||||
|  |         ), | ||||||
|  |         200, | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @bp.route("/export/<file_name>", methods=["DELETE"]) | @bp.route("/export/<file_name>", methods=["DELETE"]) | ||||||
| @ -1711,7 +1829,15 @@ def export_delete(file_name: str): | |||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|     os.unlink(file) |     os.unlink(file) | ||||||
|     return "Successfully deleted file", 200 |     return make_response( | ||||||
|  |         jsonify( | ||||||
|  |             { | ||||||
|  |                 "success": True, | ||||||
|  |                 "message": "Successfully deleted file.", | ||||||
|  |             } | ||||||
|  |         ), | ||||||
|  |         200, | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def imagestream(detected_frames_processor, camera_name, fps, height, draw_options): | def imagestream(detected_frames_processor, camera_name, fps, height, draw_options): | ||||||
| @ -1811,8 +1937,11 @@ def logs(service: str): | |||||||
|     } |     } | ||||||
|     service_location = log_locations.get(service) |     service_location = log_locations.get(service) | ||||||
| 
 | 
 | ||||||
|     if not service: |     if not service_location: | ||||||
|         return f"{service} is not a valid service", 404 |         return make_response( | ||||||
|  |             jsonify({"success": False, "message": "Not a valid service"}), | ||||||
|  |             404, | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|     try: |     try: | ||||||
|         file = open(service_location, "r") |         file = open(service_location, "r") | ||||||
| @ -1820,4 +1949,7 @@ def logs(service: str): | |||||||
|         file.close() |         file.close() | ||||||
|         return contents, 200 |         return contents, 200 | ||||||
|     except FileNotFoundError as e: |     except FileNotFoundError as e: | ||||||
|         return f"Could not find log file: {e}", 500 |         return make_response( | ||||||
|  |             jsonify({"success": False, "message": f"Could not find log file: {e}"}), | ||||||
|  |             500, | ||||||
|  |         ) | ||||||
|  | |||||||
| @ -87,7 +87,8 @@ def load_config_with_no_duplicates(raw_config) -> dict: | |||||||
|     """Get config ensuring duplicate keys are not allowed.""" |     """Get config ensuring duplicate keys are not allowed.""" | ||||||
| 
 | 
 | ||||||
|     # https://stackoverflow.com/a/71751051 |     # https://stackoverflow.com/a/71751051 | ||||||
|     class PreserveDuplicatesLoader(yaml.loader.Loader): |     # important to use SafeLoader here to avoid RCE | ||||||
|  |     class PreserveDuplicatesLoader(yaml.loader.SafeLoader): | ||||||
|         pass |         pass | ||||||
| 
 | 
 | ||||||
|     def map_constructor(loader, node, deep=False): |     def map_constructor(loader, node, deep=False): | ||||||
|  | |||||||
| @ -20,7 +20,6 @@ | |||||||
|   }, |   }, | ||||||
|   "ignorePatterns": ["*.d.ts"], |   "ignorePatterns": ["*.d.ts"], | ||||||
|   "rules": { |   "rules": { | ||||||
|     "indent": ["error", 2, { "SwitchCase": 1 }], |  | ||||||
|     "comma-dangle": [ |     "comma-dangle": [ | ||||||
|       "error", |       "error", | ||||||
|       { |       { | ||||||
|  | |||||||
| @ -1,4 +1,5 @@ | |||||||
| { | { | ||||||
|   "printWidth": 120, |   "printWidth": 120, | ||||||
|  |   "trailingComma": "es5", | ||||||
|   "singleQuote": true |   "singleQuote": true | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,8 +1,8 @@ | |||||||
| import { rest } from 'msw'; | import { rest } from 'msw'; | ||||||
| import { API_HOST } from '../src/env'; | // import { API_HOST } from '../src/env';
 | ||||||
| 
 | 
 | ||||||
| export const handlers = [ | export const handlers = [ | ||||||
|   rest.get(`${API_HOST}api/config`, (req, res, ctx) => { |   rest.get(`api/config`, (req, res, ctx) => { | ||||||
|     return res( |     return res( | ||||||
|       ctx.status(200), |       ctx.status(200), | ||||||
|       ctx.json({ |       ctx.json({ | ||||||
| @ -37,7 +37,7 @@ export const handlers = [ | |||||||
|       }) |       }) | ||||||
|     ); |     ); | ||||||
|   }), |   }), | ||||||
|   rest.get(`${API_HOST}api/stats`, (req, res, ctx) => { |   rest.get(`api/stats`, (req, res, ctx) => { | ||||||
|     return res( |     return res( | ||||||
|       ctx.status(200), |       ctx.status(200), | ||||||
|       ctx.json({ |       ctx.json({ | ||||||
| @ -58,7 +58,7 @@ export const handlers = [ | |||||||
|       }) |       }) | ||||||
|     ); |     ); | ||||||
|   }), |   }), | ||||||
|   rest.get(`${API_HOST}api/events`, (req, res, ctx) => { |   rest.get(`api/events`, (req, res, ctx) => { | ||||||
|     return res( |     return res( | ||||||
|       ctx.status(200), |       ctx.status(200), | ||||||
|       ctx.json( |       ctx.json( | ||||||
| @ -77,7 +77,7 @@ export const handlers = [ | |||||||
|       ) |       ) | ||||||
|     ); |     ); | ||||||
|   }), |   }), | ||||||
|   rest.get(`${API_HOST}api/sub_labels`, (req, res, ctx) => { |   rest.get(`api/sub_labels`, (req, res, ctx) => { | ||||||
|     return res( |     return res( | ||||||
|       ctx.status(200), |       ctx.status(200), | ||||||
|       ctx.json([ |       ctx.json([ | ||||||
|  | |||||||
							
								
								
									
										1290
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1290
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -45,6 +45,7 @@ | |||||||
|     "eslint-config-preact": "^1.3.0", |     "eslint-config-preact": "^1.3.0", | ||||||
|     "eslint-config-prettier": "^9.0.0", |     "eslint-config-prettier": "^9.0.0", | ||||||
|     "eslint-plugin-jest": "^27.2.3", |     "eslint-plugin-jest": "^27.2.3", | ||||||
|  |     "eslint-plugin-prettier": "^5.0.0", | ||||||
|     "eslint-plugin-vitest-globals": "^1.4.0", |     "eslint-plugin-vitest-globals": "^1.4.0", | ||||||
|     "fake-indexeddb": "^4.0.1", |     "fake-indexeddb": "^4.0.1", | ||||||
|     "jsdom": "^22.0.0", |     "jsdom": "^22.0.0", | ||||||
|  | |||||||
| @ -1,2 +1 @@ | |||||||
| export const ENV = 'test'; | export const ENV = 'test'; | ||||||
| export const API_HOST = 'http://base-url.local:5000/'; |  | ||||||
|  | |||||||
| @ -18,6 +18,6 @@ describe('useApiHost', () => { | |||||||
|         <Test /> |         <Test /> | ||||||
|       </ApiProvider> |       </ApiProvider> | ||||||
|     ); |     ); | ||||||
|     expect(screen.queryByText('http://base-url.local:5000/')).toBeInTheDocument(); |     expect(screen.queryByText('http://localhost:3000/')).toBeInTheDocument(); | ||||||
|   }); |   }); | ||||||
| }); | }); | ||||||
|  | |||||||
| @ -1,2 +1 @@ | |||||||
| import { API_HOST } from '../env'; | export const baseUrl = `${window.location.protocol}//${window.location.host}${window.baseUrl || '/'}`; | ||||||
| export const baseUrl = API_HOST || `${window.location.protocol}//${window.location.host}${window.baseUrl || '/'}`; |  | ||||||
|  | |||||||
| @ -5,6 +5,9 @@ import { WsProvider } from './ws'; | |||||||
| import axios from 'axios'; | import axios from 'axios'; | ||||||
| 
 | 
 | ||||||
| axios.defaults.baseURL = `${baseUrl}api/`; | axios.defaults.baseURL = `${baseUrl}api/`; | ||||||
|  | axios.defaults.headers.common = { | ||||||
|  |   'X-CSRF-TOKEN': 1, | ||||||
|  | }; | ||||||
| 
 | 
 | ||||||
| export function ApiProvider({ children, options }) { | export function ApiProvider({ children, options }) { | ||||||
|   return ( |   return ( | ||||||
|  | |||||||
| @ -58,7 +58,7 @@ export default function CameraImage({ camera, onload, searchParams = '', stretch | |||||||
|     if (!config || scaledHeight === 0 || !canvasRef.current) { |     if (!config || scaledHeight === 0 || !canvasRef.current) { | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|     img.src = `${apiHost}/api/${name}/latest.jpg?h=${scaledHeight}${searchParams ? `&${searchParams}` : ''}`; |     img.src = `${apiHost}api/${name}/latest.jpg?h=${scaledHeight}${searchParams ? `&${searchParams}` : ''}`; | ||||||
|   }, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]); |   }, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]); | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|  | |||||||
| @ -56,10 +56,10 @@ export const HistoryVideo = ({ | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     video.src({ |     video.src({ | ||||||
|       src: `${apiHost}/vod/event/${id}/master.m3u8`, |       src: `${apiHost}vod/event/${id}/master.m3u8`, | ||||||
|       type: 'application/vnd.apple.mpegurl', |       type: 'application/vnd.apple.mpegurl', | ||||||
|     }); |     }); | ||||||
|     video.poster(`${apiHost}/api/events/${id}/snapshot.jpg`); |     video.poster(`${apiHost}api/events/${id}/snapshot.jpg`); | ||||||
|     if (videoIsPlaying) { |     if (videoIsPlaying) { | ||||||
|       video.play(); |       video.play(); | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -61,8 +61,7 @@ export default function MultiSelect({ className, title, options, selection, onTo | |||||||
|                   className="max-h-[35px] mx-2" |                   className="max-h-[35px] mx-2" | ||||||
|                   onClick={() => onSelectSingle(item)} |                   onClick={() => onSelectSingle(item)} | ||||||
|                 > |                 > | ||||||
|                   { (title === "Labels" && config.audio.listen.includes(item)) ? ( <SpeakerIcon /> ) : ( <CameraIcon /> ) } |                   {title === 'Labels' && config.audio.listen.includes(item) ? <SpeakerIcon /> : <CameraIcon />} | ||||||
|                    |  | ||||||
|                 </Button> |                 </Button> | ||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|  | |||||||
| @ -153,7 +153,7 @@ export function EventCard({ camera, event }) { | |||||||
|     <Link className="" href={`/recording/${camera}/${format(start, 'yyyy-MM-dd/HH/mm/ss')}`}> |     <Link className="" href={`/recording/${camera}/${format(start, 'yyyy-MM-dd/HH/mm/ss')}`}> | ||||||
|       <div className="flex flex-row mb-2"> |       <div className="flex flex-row mb-2"> | ||||||
|         <div className="w-28 mr-4"> |         <div className="w-28 mr-4"> | ||||||
|           <img className="antialiased" loading="lazy" src={`${apiHost}/api/events/${event.id}/thumbnail.jpg`} /> |           <img className="antialiased" loading="lazy" src={`${apiHost}api/events/${event.id}/thumbnail.jpg`} /> | ||||||
|         </div> |         </div> | ||||||
|         <div className="flex flex-row w-full border-b"> |         <div className="flex flex-row w-full border-b"> | ||||||
|           <div className="w-full text-gray-700 font-semibold relative pt-0"> |           <div className="w-full text-gray-700 font-semibold relative pt-0"> | ||||||
|  | |||||||
| @ -1,2 +1 @@ | |||||||
| export const ENV = import.meta.env.MODE; | export const ENV = import.meta.env.MODE; | ||||||
| export const API_HOST = ENV === 'production' ? '' : 'http://localhost:5000/'; |  | ||||||
|  | |||||||
| @ -212,7 +212,7 @@ export default function Camera({ camera }) { | |||||||
|               key={objectType} |               key={objectType} | ||||||
|               header={objectType} |               header={objectType} | ||||||
|               href={`/events?cameras=${camera}&labels=${encodeURIComponent(objectType)}`} |               href={`/events?cameras=${camera}&labels=${encodeURIComponent(objectType)}`} | ||||||
|               media={<img src={`${apiHost}/api/${camera}/${encodeURIComponent(objectType)}/thumbnail.jpg`} />} |               media={<img src={`${apiHost}api/${camera}/${encodeURIComponent(objectType)}/thumbnail.jpg`} />} | ||||||
|             /> |             /> | ||||||
|           ))} |           ))} | ||||||
|         </div> |         </div> | ||||||
|  | |||||||
| @ -146,7 +146,6 @@ export default function CameraMasks({ camera }) { | |||||||
|     } |     } | ||||||
|   }, [camera, motionMaskPoints]); |   }, [camera, motionMaskPoints]); | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
|   // Zone methods |   // Zone methods | ||||||
|   const handleEditZone = useCallback( |   const handleEditZone = useCallback( | ||||||
|     (key) => { |     (key) => { | ||||||
| @ -177,7 +176,9 @@ export default function CameraMasks({ camera }) { | |||||||
| ${Object.keys(zonePoints) | ${Object.keys(zonePoints) | ||||||
|   .map( |   .map( | ||||||
|     (zoneName) => `    ${zoneName}: |     (zoneName) => `    ${zoneName}: | ||||||
|       coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}`).join('\n')}`; |       coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}` | ||||||
|  |   ) | ||||||
|  |   .join('\n')}`; | ||||||
| 
 | 
 | ||||||
|     if (window.navigator.clipboard && window.navigator.clipboard.writeText) { |     if (window.navigator.clipboard && window.navigator.clipboard.writeText) { | ||||||
|       // Use Clipboard API if available |       // Use Clipboard API if available | ||||||
| @ -207,7 +208,10 @@ ${Object.keys(zonePoints) | |||||||
|   const handleSaveZones = useCallback(async () => { |   const handleSaveZones = useCallback(async () => { | ||||||
|     try { |     try { | ||||||
|       const queryParameters = Object.keys(zonePoints) |       const queryParameters = Object.keys(zonePoints) | ||||||
|         .map((zoneName) => `cameras.${camera}.zones.${zoneName}.coordinates=${polylinePointsToPolyline(zonePoints[zoneName])}`) |         .map( | ||||||
|  |           (zoneName) => | ||||||
|  |             `cameras.${camera}.zones.${zoneName}.coordinates=${polylinePointsToPolyline(zonePoints[zoneName])}` | ||||||
|  |         ) | ||||||
|         .join('&'); |         .join('&'); | ||||||
|       const endpoint = `config/set?${queryParameters}`; |       const endpoint = `config/set?${queryParameters}`; | ||||||
|       const response = await axios.put(endpoint); |       const response = await axios.put(endpoint); | ||||||
| @ -266,7 +270,12 @@ ${Object.keys(objectMaskPoints) | |||||||
|     try { |     try { | ||||||
|       const queryParameters = Object.keys(objectMaskPoints) |       const queryParameters = Object.keys(objectMaskPoints) | ||||||
|         .filter((objectName) => objectMaskPoints[objectName].length > 0) |         .filter((objectName) => objectMaskPoints[objectName].length > 0) | ||||||
|         .map((objectName, index) => `cameras.${camera}.objects.filters.${objectName}.mask.${index}=${polylinePointsToPolyline(objectMaskPoints[objectName])}`) |         .map( | ||||||
|  |           (objectName, index) => | ||||||
|  |             `cameras.${camera}.objects.filters.${objectName}.mask.${index}=${polylinePointsToPolyline( | ||||||
|  |               objectMaskPoints[objectName] | ||||||
|  |             )}` | ||||||
|  |         ) | ||||||
|         .join('&'); |         .join('&'); | ||||||
|       const endpoint = `config/set?${queryParameters}`; |       const endpoint = `config/set?${queryParameters}`; | ||||||
|       const response = await axios.put(endpoint); |       const response = await axios.put(endpoint); | ||||||
| @ -324,8 +333,8 @@ ${Object.keys(objectMaskPoints) | |||||||
|       <Card |       <Card | ||||||
|         content={ |         content={ | ||||||
|           <p> |           <p> | ||||||
|             When done, copy each mask configuration into your <code className="font-mono">config.yml</code> file |             When done, copy each mask configuration into your <code className="font-mono">config.yml</code> file restart | ||||||
|             restart your Frigate instance to save your changes. |             your Frigate instance to save your changes. | ||||||
|           </p> |           </p> | ||||||
|         } |         } | ||||||
|         header="Warning" |         header="Warning" | ||||||
| @ -336,7 +345,7 @@ ${Object.keys(objectMaskPoints) | |||||||
| 
 | 
 | ||||||
|       <div className="space-y-4"> |       <div className="space-y-4"> | ||||||
|         <div className="relative"> |         <div className="relative"> | ||||||
|           <img ref={imageRef} src={`${apiHost}/api/${camera}/latest.jpg`} /> |           <img ref={imageRef} src={`${apiHost}api/${camera}/latest.jpg`} /> | ||||||
|           <EditableMask |           <EditableMask | ||||||
|             onChange={handleUpdateEditable} |             onChange={handleUpdateEditable} | ||||||
|             points={'subkey' in editing ? editing.set[editing.key][editing.subkey] : editing.set[editing.key]} |             points={'subkey' in editing ? editing.set[editing.key][editing.subkey] : editing.set[editing.key]} | ||||||
| @ -569,8 +578,6 @@ function MaskValues({ | |||||||
|     [onAdd] |     [onAdd] | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|   return ( |   return ( | ||||||
|     <div className="overflow-hidden" onMouseOver={handleMousein} onMouseOut={handleMouseout}> |     <div className="overflow-hidden" onMouseOver={handleMousein} onMouseOut={handleMouseout}> | ||||||
|       <div className="flex space-x-4"> |       <div className="flex space-x-4"> | ||||||
|  | |||||||
| @ -61,9 +61,9 @@ export default function Config() { | |||||||
| 
 | 
 | ||||||
|     let yamlModel; |     let yamlModel; | ||||||
|     if (editor.getModels().length > 0) { |     if (editor.getModels().length > 0) { | ||||||
|       yamlModel = editor.getModel(modelUri) |       yamlModel = editor.getModel(modelUri); | ||||||
|     } else { |     } else { | ||||||
|       yamlModel = editor.createModel(config, 'yaml', modelUri) |       yamlModel = editor.createModel(config, 'yaml', modelUri); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     setDiagnosticsOptions({ |     setDiagnosticsOptions({ | ||||||
| @ -74,7 +74,7 @@ export default function Config() { | |||||||
|       format: true, |       format: true, | ||||||
|       schemas: [ |       schemas: [ | ||||||
|         { |         { | ||||||
|           uri: `${apiHost}/api/config/schema.json`, |           uri: `${apiHost}api/config/schema.json`, | ||||||
|           fileMatch: [String(modelUri)], |           fileMatch: [String(modelUri)], | ||||||
|         }, |         }, | ||||||
|       ], |       ], | ||||||
| @ -100,10 +100,10 @@ export default function Config() { | |||||||
|           <Button className="mx-2" onClick={(e) => handleCopyConfig(e)}> |           <Button className="mx-2" onClick={(e) => handleCopyConfig(e)}> | ||||||
|             Copy Config |             Copy Config | ||||||
|           </Button> |           </Button> | ||||||
|           <Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, "restart")}> |           <Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, 'restart')}> | ||||||
|             Save & Restart |             Save & Restart | ||||||
|           </Button> |           </Button> | ||||||
|           <Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, "saveonly")}> |           <Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, 'saveonly')}> | ||||||
|             Save Only |             Save Only | ||||||
|           </Button> |           </Button> | ||||||
|         </div> |         </div> | ||||||
|  | |||||||
| @ -402,7 +402,7 @@ export default function Events({ path, ...props }) { | |||||||
|               icon={Snapshot} |               icon={Snapshot} | ||||||
|               label="Download Snapshot" |               label="Download Snapshot" | ||||||
|               value="snapshot" |               value="snapshot" | ||||||
|               href={`${apiHost}/api/events/${downloadEvent.id}/snapshot.jpg?download=true`} |               href={`${apiHost}api/events/${downloadEvent.id}/snapshot.jpg?download=true`} | ||||||
|               download |               download | ||||||
|             /> |             /> | ||||||
|           )} |           )} | ||||||
| @ -411,7 +411,7 @@ export default function Events({ path, ...props }) { | |||||||
|               icon={Clip} |               icon={Clip} | ||||||
|               label="Download Clip" |               label="Download Clip" | ||||||
|               value="clip" |               value="clip" | ||||||
|               href={`${apiHost}/api/events/${downloadEvent.id}/clip.mp4?download=true`} |               href={`${apiHost}api/events/${downloadEvent.id}/clip.mp4?download=true`} | ||||||
|               download |               download | ||||||
|             /> |             /> | ||||||
|           )} |           )} | ||||||
| @ -492,7 +492,7 @@ export default function Events({ path, ...props }) { | |||||||
| 
 | 
 | ||||||
|                 <img |                 <img | ||||||
|                   className="flex-grow-0" |                   className="flex-grow-0" | ||||||
|                   src={`${apiHost}/api/events/${plusSubmitEvent.id}/snapshot.jpg`} |                   src={`${apiHost}api/events/${plusSubmitEvent.id}/snapshot.jpg`} | ||||||
|                   alt={`${plusSubmitEvent.label}`} |                   alt={`${plusSubmitEvent.label}`} | ||||||
|                 /> |                 /> | ||||||
| 
 | 
 | ||||||
| @ -619,7 +619,7 @@ export default function Events({ path, ...props }) { | |||||||
|                     <div |                     <div | ||||||
|                       className="relative rounded-l flex-initial min-w-[125px] h-[125px] bg-contain bg-no-repeat bg-center" |                       className="relative rounded-l flex-initial min-w-[125px] h-[125px] bg-contain bg-no-repeat bg-center" | ||||||
|                       style={{ |                       style={{ | ||||||
|                         'background-image': `url(${apiHost}/api/events/${event.id}/thumbnail.jpg)`, |                         'background-image': `url(${apiHost}api/events/${event.id}/thumbnail.jpg)`, | ||||||
|                       }} |                       }} | ||||||
|                     > |                     > | ||||||
|                       <StarRecording |                       <StarRecording | ||||||
| @ -776,8 +776,8 @@ export default function Events({ path, ...props }) { | |||||||
|                                 className="flex-grow-0" |                                 className="flex-grow-0" | ||||||
|                                 src={ |                                 src={ | ||||||
|                                   event.has_snapshot |                                   event.has_snapshot | ||||||
|                                     ? `${apiHost}/api/events/${event.id}/snapshot.jpg` |                                     ? `${apiHost}api/events/${event.id}/snapshot.jpg` | ||||||
|                                     : `${apiHost}/api/events/${event.id}/thumbnail.jpg` |                                     : `${apiHost}api/events/${event.id}/thumbnail.jpg` | ||||||
|                                 } |                                 } | ||||||
|                                 alt={`${event.label} at ${((event?.data?.top_score || event.top_score) * 100).toFixed( |                                 alt={`${event.label} at ${((event?.data?.top_score || event.top_score) * 100).toFixed( | ||||||
|                                   0 |                                   0 | ||||||
|  | |||||||
| @ -334,8 +334,13 @@ export default function System() { | |||||||
|             <ActivityIndicator /> |             <ActivityIndicator /> | ||||||
|           ) : ( |           ) : ( | ||||||
|             <div data-testid="cameras" className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4"> |             <div data-testid="cameras" className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4"> | ||||||
|               {cameraNames.map((camera) => ( config.cameras[camera]["enabled"] && ( |               {cameraNames.map( | ||||||
|                 <div key={camera} className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow"> |                 (camera) => | ||||||
|  |                   config.cameras[camera]['enabled'] && ( | ||||||
|  |                     <div | ||||||
|  |                       key={camera} | ||||||
|  |                       className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow" | ||||||
|  |                     > | ||||||
|                       <div className="capitalize text-lg flex justify-between p-4"> |                       <div className="capitalize text-lg flex justify-between p-4"> | ||||||
|                         <Link href={`/cameras/${camera}`}>{camera.replaceAll('_', ' ')}</Link> |                         <Link href={`/cameras/${camera}`}>{camera.replaceAll('_', ' ')}</Link> | ||||||
|                         <Button onClick={(e) => onHandleFfprobe(camera, e)}>ffprobe</Button> |                         <Button onClick={(e) => onHandleFfprobe(camera, e)}>ffprobe</Button> | ||||||
| @ -406,8 +411,9 @@ export default function System() { | |||||||
|                           </Tbody> |                           </Tbody> | ||||||
|                         </Table> |                         </Table> | ||||||
|                       </div> |                       </div> | ||||||
|                 </div> ) |                     </div> | ||||||
|               ))} |                   ) | ||||||
|  |               )} | ||||||
|             </div> |             </div> | ||||||
|           )} |           )} | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -9,6 +9,13 @@ export default defineConfig({ | |||||||
|   define: { |   define: { | ||||||
|     'import.meta.vitest': 'undefined', |     'import.meta.vitest': 'undefined', | ||||||
|   }, |   }, | ||||||
|  |   server: { | ||||||
|  |     proxy: { | ||||||
|  |       '/api': { | ||||||
|  |         target: 'http://localhost:5000' | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|   plugins: [ |   plugins: [ | ||||||
|     preact(), |     preact(), | ||||||
|     monacoEditorPlugin.default({ |     monacoEditorPlugin.default({ | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user