mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-11 21:31:12 +03:00
Merge remote-tracking branch 'origin/master' into dev
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
Resolve conflicts in the export pipeline where dev's job-queue refactor met master's chapter-metadata and security work. - Unify chapter support under ChaptersEnum (none / recording_segments / review_items); the realtime stream-copy export selects the per-segment or per-review-item builder by the camera's configured mode. Thread chapters through ExportRecordingsBody -> _build_export_job -> ExportJob -> RecordingExporter. - Keep master's creation_time/comment export metadata and fix a video_path duplication the textual merge introduced in the preview command. - Move the chapters request field to ExportRecordingsBody (the single export endpoint) where it is actually honored. Restore security fixes the automatic merge would have reverted: - frigate/util/services.py: restore the #23493 rename to the public is_go2rtc_arbitrary_exec_allowed so create_config.py's dynamic-source exec guard imports and runs (the merge otherwise left a broken import). - Preserve the export image-path ".." traversal check inside _sanitize_existing_image, applied to single/custom/batch exports. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -147,6 +147,19 @@ def go2rtc_camera_stream(request: Request, stream_name: str):
|
||||
)
|
||||
def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
|
||||
"""Add or update a go2rtc stream configuration."""
|
||||
if src and is_restricted_go2rtc_source(src):
|
||||
logger.warning(
|
||||
"Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)",
|
||||
stream_name,
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Restricted stream source type",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
params = {"name": stream_name}
|
||||
if src:
|
||||
|
||||
@@ -3,7 +3,10 @@ from typing import Optional, Union
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic.json_schema import SkipJsonSchema
|
||||
|
||||
from frigate.record.export import PlaybackSourceEnum
|
||||
from frigate.record.export import (
|
||||
ChaptersEnum,
|
||||
PlaybackSourceEnum,
|
||||
)
|
||||
|
||||
|
||||
class ExportRecordingsBody(BaseModel):
|
||||
@@ -18,6 +21,14 @@ class ExportRecordingsBody(BaseModel):
|
||||
max_length=30,
|
||||
description="ID of the export case to assign this export to",
|
||||
)
|
||||
chapters: Optional[ChaptersEnum] = Field(
|
||||
default=None,
|
||||
title="Chapter mode",
|
||||
description=(
|
||||
"Optional chapter metadata to embed in the export. When omitted, "
|
||||
"the camera's configured export chapter mode is used."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ExportRecordingsCustomBody(BaseModel):
|
||||
|
||||
@@ -68,6 +68,7 @@ from frigate.jobs.export import (
|
||||
from frigate.models import Export, ExportCase, Previews, Recordings
|
||||
from frigate.record.export import (
|
||||
DEFAULT_TIME_LAPSE_FFMPEG_ARGS,
|
||||
ChaptersEnum,
|
||||
PlaybackSourceEnum,
|
||||
validate_ffmpeg_args,
|
||||
)
|
||||
@@ -128,6 +129,15 @@ def _validate_export_case(export_case_id: Optional[str]) -> Optional[JSONRespons
|
||||
def _sanitize_existing_image(
|
||||
image_path: Optional[str],
|
||||
) -> tuple[Optional[str], Optional[JSONResponse]]:
|
||||
# sanitize_filepath normalizes "\" to "/" but leaves ".." intact, so a path
|
||||
# like "clips\..\..\etc/passwd" passes the CLIPS_DIR prefix check yet still
|
||||
# escapes the directory once resolved. A valid snapshot path never uses "..".
|
||||
if image_path and ".." in image_path:
|
||||
return None, JSONResponse(
|
||||
content={"success": False, "message": "Invalid image path"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
existing_image = sanitize_filepath(image_path) if image_path else None
|
||||
|
||||
if existing_image and not existing_image.startswith(CLIPS_DIR):
|
||||
@@ -254,6 +264,7 @@ def _build_export_job(
|
||||
ffmpeg_input_args: Optional[str] = None,
|
||||
ffmpeg_output_args: Optional[str] = None,
|
||||
cpu_fallback: bool = False,
|
||||
chapters: Optional[ChaptersEnum] = None,
|
||||
) -> ExportJob:
|
||||
return ExportJob(
|
||||
id=_generate_export_id(camera_name),
|
||||
@@ -267,6 +278,7 @@ def _build_export_job(
|
||||
ffmpeg_input_args=ffmpeg_input_args,
|
||||
ffmpeg_output_args=ffmpeg_output_args,
|
||||
cpu_fallback=cpu_fallback,
|
||||
chapters=chapters,
|
||||
)
|
||||
|
||||
|
||||
@@ -725,6 +737,9 @@ def export_recordings_batch(
|
||||
sanitized_images[index],
|
||||
PlaybackSourceEnum.recordings,
|
||||
export_case_id,
|
||||
chapters=request.app.frigate_config.cameras[
|
||||
item.camera
|
||||
].record.export.chapters,
|
||||
)
|
||||
try:
|
||||
start_export_job(request.app.frigate_config, export_job)
|
||||
@@ -803,6 +818,14 @@ def export_recording(
|
||||
|
||||
export_case_id = body.export_case_id
|
||||
|
||||
# a chapters value in the request body overrides the camera's export config
|
||||
camera_config = request.app.frigate_config.cameras[camera_name]
|
||||
chapters = (
|
||||
body.chapters
|
||||
if body.chapters is not None
|
||||
else camera_config.record.export.chapters
|
||||
)
|
||||
|
||||
# Attaching to an existing case requires admin. Single-export for
|
||||
# cameras the user can access is otherwise non-admin; we only gate
|
||||
# the case-attachment side effect.
|
||||
@@ -839,6 +862,7 @@ def export_recording(
|
||||
existing_image,
|
||||
playback_source,
|
||||
export_case_id,
|
||||
chapters=chapters,
|
||||
)
|
||||
try:
|
||||
start_export_job(request.app.frigate_config, export_job)
|
||||
|
||||
+32
-13
@@ -60,6 +60,19 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=[Tags.media])
|
||||
|
||||
|
||||
def _resolve_cache_age(max_cache_age: int) -> int:
|
||||
"""Return max_cache_age as an int.
|
||||
|
||||
When a media handler is invoked directly by another handler instead of
|
||||
through its route, FastAPI doesn't resolve the Query() default and
|
||||
max_cache_age arrives as the Query object; fall back to its int default.
|
||||
"""
|
||||
if isinstance(max_cache_age, int):
|
||||
return max_cache_age
|
||||
|
||||
return max_cache_age.default
|
||||
|
||||
|
||||
@router.get("/{camera_name}", dependencies=[Depends(require_camera_access)])
|
||||
async def mjpeg_feed(
|
||||
request: Request,
|
||||
@@ -413,7 +426,9 @@ async def submit_recording_snapshot_to_plus(
|
||||
)
|
||||
|
||||
nd = cv2.imdecode(np.frombuffer(image_data, dtype=np.int8), cv2.IMREAD_COLOR)
|
||||
request.app.frigate_config.plus_api.upload_image(nd, camera_name)
|
||||
await asyncio.to_thread(
|
||||
request.app.frigate_config.plus_api.upload_image, nd, camera_name
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
@@ -936,7 +951,7 @@ async def event_thumbnail(
|
||||
thumbnail_bytes,
|
||||
media_type=extension.get_mime_type(),
|
||||
headers={
|
||||
"Cache-Control": f"private, max-age={max_cache_age}"
|
||||
"Cache-Control": f"private, max-age={_resolve_cache_age(max_cache_age)}"
|
||||
if event_complete
|
||||
else "no-store",
|
||||
},
|
||||
@@ -1270,14 +1285,14 @@ async def event_preview(request: Request, event_id: str):
|
||||
end_ts = start_ts + (
|
||||
min(event.end_time - event.start_time, 20) if event.end_time else 20
|
||||
)
|
||||
return preview_gif(request, event.camera, start_ts, end_ts)
|
||||
return await preview_gif(request, event.camera, start_ts, end_ts)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{camera_name}/start/{start_ts}/end/{end_ts}/preview.gif",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
)
|
||||
def preview_gif(
|
||||
async def preview_gif(
|
||||
request: Request,
|
||||
camera_name: str,
|
||||
start_ts: float,
|
||||
@@ -1340,7 +1355,8 @@ def preview_gif(
|
||||
"-",
|
||||
]
|
||||
|
||||
process = sp.run(
|
||||
process = await asyncio.to_thread(
|
||||
sp.run,
|
||||
ffmpeg_cmd,
|
||||
capture_output=True,
|
||||
)
|
||||
@@ -1419,7 +1435,8 @@ def preview_gif(
|
||||
"-",
|
||||
]
|
||||
|
||||
process = sp.run(
|
||||
process = await asyncio.to_thread(
|
||||
sp.run,
|
||||
ffmpeg_cmd,
|
||||
input=str.encode("\n".join(selected_previews)),
|
||||
capture_output=True,
|
||||
@@ -1438,7 +1455,7 @@ def preview_gif(
|
||||
gif_bytes,
|
||||
media_type="image/gif",
|
||||
headers={
|
||||
"Cache-Control": f"private, max-age={max_cache_age}",
|
||||
"Cache-Control": f"private, max-age={_resolve_cache_age(max_cache_age)}",
|
||||
"Content-Type": "image/gif",
|
||||
},
|
||||
)
|
||||
@@ -1448,7 +1465,7 @@ def preview_gif(
|
||||
"/{camera_name}/start/{start_ts}/end/{end_ts}/preview.mp4",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
)
|
||||
def preview_mp4(
|
||||
async def preview_mp4(
|
||||
request: Request,
|
||||
camera_name: str,
|
||||
start_ts: float,
|
||||
@@ -1528,7 +1545,8 @@ def preview_mp4(
|
||||
path,
|
||||
]
|
||||
|
||||
process = sp.run(
|
||||
process = await asyncio.to_thread(
|
||||
sp.run,
|
||||
ffmpeg_cmd,
|
||||
capture_output=True,
|
||||
)
|
||||
@@ -1604,7 +1622,8 @@ def preview_mp4(
|
||||
path,
|
||||
]
|
||||
|
||||
process = sp.run(
|
||||
process = await asyncio.to_thread(
|
||||
sp.run,
|
||||
ffmpeg_cmd,
|
||||
input=str.encode("\n".join(selected_previews)),
|
||||
capture_output=True,
|
||||
@@ -1619,7 +1638,7 @@ def preview_mp4(
|
||||
|
||||
headers = {
|
||||
"Content-Description": "File Transfer",
|
||||
"Cache-Control": f"private, max-age={max_cache_age}",
|
||||
"Cache-Control": f"private, max-age={_resolve_cache_age(max_cache_age)}",
|
||||
"Content-Type": "video/mp4",
|
||||
"Content-Length": str(os.path.getsize(path)),
|
||||
# nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers
|
||||
@@ -1657,9 +1676,9 @@ async def review_preview(
|
||||
)
|
||||
|
||||
if format == "gif":
|
||||
return preview_gif(request, review.camera, start_ts, end_ts)
|
||||
return await preview_gif(request, review.camera, start_ts, end_ts)
|
||||
else:
|
||||
return preview_mp4(request, review.camera, start_ts, end_ts)
|
||||
return await preview_mp4(request, review.camera, start_ts, end_ts)
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
Reference in New Issue
Block a user