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:
Blake Blackshear 2023-10-06 22:20:30 -05:00 committed by GitHub
parent 9a4f970337
commit 14d2b79c72
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1357 additions and 488 deletions

View File

@ -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;

View File

@ -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).

View File

@ -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,
)

View File

@ -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):

View File

@ -20,7 +20,6 @@
}, },
"ignorePatterns": ["*.d.ts"], "ignorePatterns": ["*.d.ts"],
"rules": { "rules": {
"indent": ["error", 2, { "SwitchCase": 1 }],
"comma-dangle": [ "comma-dangle": [
"error", "error",
{ {

View File

@ -1,4 +1,5 @@
{ {
"printWidth": 120, "printWidth": 120,
"trailingComma": "es5",
"singleQuote": true "singleQuote": true
} }

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -1,2 +1 @@
export const ENV = 'test'; export const ENV = 'test';
export const API_HOST = 'http://base-url.local:5000/';

View File

@ -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();
}); });
}); });

View File

@ -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 || '/'}`;

View File

@ -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 (

View File

@ -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 (

View File

@ -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();
} }

View File

@ -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>

View File

@ -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">

View File

@ -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/';

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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

View File

@ -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>
)} )}

View File

@ -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({