mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-05-07 01:16:43 +02: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