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

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:
Blake Blackshear
2026-06-28 15:04:01 +02:00
co-authored by Claude Opus 4.8
18 changed files with 470 additions and 34 deletions
+13
View File
@@ -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):
+24
View File
@@ -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
View File
@@ -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(