Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions motioneye/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1447,7 +1447,10 @@ def motion_camera_ui_to_dict(ui, prev_config=None):
data['on_event_end'] = '; '.join(on_event_end)

# movie end
on_movie_end = [f"{meyectl.find_command('relayevent')} movie_end %t %f"]
# the trailing target_dir lets relayevent.sh send a path relative to it
on_movie_end = [
f"{meyectl.find_command('relayevent')} movie_end %t %f '{data['target_dir']}'"
]

if ui['web_hook_storage_enabled']:
url = sub('\\s', '+', ui['web_hook_storage_url'])
Expand All @@ -1466,7 +1469,10 @@ def motion_camera_ui_to_dict(ui, prev_config=None):
data['on_movie_end'] = '; '.join(on_movie_end)

# picture save
on_picture_save = [f"{meyectl.find_command('relayevent')} picture_save %t %f"]
# the trailing target_dir lets relayevent.sh send a path relative to it
on_picture_save = [
f"{meyectl.find_command('relayevent')} picture_save %t %f '{data['target_dir']}'"
]

if ui['web_hook_storage_enabled']:
url = sub('\\s', '+', ui['web_hook_storage_url'])
Expand Down
16 changes: 9 additions & 7 deletions motioneye/handlers/relay_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import logging
from os import sep
import os
from typing import Optional

from motioneye import config, mediafiles, motionctl, tasks, uploadservices, utils
Expand Down Expand Up @@ -73,10 +73,11 @@ def post(self) -> None:
filename: Optional[str] = self.get_argument('filename')
if filename is not None:
target_dir: str = camera_config['target_dir']
utils.validate_paths(
utils.remove_prefix(filename, target_dir + sep),
target_dir=target_dir,
)
# the relay script sends a path relative to the camera dir;
# remove_prefix also tolerates a legacy absolute path coming from an
# un-regenerated motion config
filename = utils.remove_prefix(filename, target_dir + os.sep)
utils.validate_paths(filename, target_dir=target_dir)

if event == 'start':
if not camera_config['@motion_detection']:
Expand All @@ -98,7 +99,7 @@ def post(self) -> None:
mediafiles.make_movie_preview,
tag='make_movie_preview(%s)' % filename,
camera_config=camera_config,
full_path=filename,
rel_path=filename,
)

# upload to external service
Expand Down Expand Up @@ -127,7 +128,8 @@ def upload_media_file(self, filename, camera_id, camera_config, media_type):
camera_name=camera_config['camera_name'],
target_dir=camera_config['@upload_subfolders']
and camera_config['target_dir'],
filename=filename,
# the upload subsystem still works with the absolute path
filename=os.path.join(camera_config['target_dir'], filename),
media_type=media_type,
clean_uploaded=camera_config['@clean_uploaded'],
)
14 changes: 6 additions & 8 deletions motioneye/mediafiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,11 @@ def get_movie_duration_seconds(path: str) -> int:
return 0


def make_movie_preview(camera_config: dict, full_path: str) -> Optional[str]:
def make_movie_preview(camera_config: dict, rel_path: str) -> Optional[str]:
target_dir: str = camera_config['target_dir']
utils.validate_paths(rel_path, target_dir=target_dir)
full_path = os.path.join(target_dir, rel_path)

framerate = camera_config['framerate']
pre_capture = camera_config['pre_capture']
offs = pre_capture / framerate
Expand All @@ -462,12 +466,6 @@ def make_movie_preview(camera_config: dict, full_path: str) -> Optional[str]:
path = quote(full_path)
thumb_path = full_path + '.thumb'

target_dir: str = camera_config['target_dir']
utils.validate_paths(
utils.remove_prefix(full_path, target_dir + os.sep),
target_dir=target_dir,
)

logging.debug(
f'creating movie preview for {full_path} with an offset of {offs} seconds...'
)
Expand Down Expand Up @@ -912,7 +910,7 @@ def get_media_preview(camera_config, path: str, media_type, width, height):
# have already been created by the thumbnailer task;
# if, for some reason that's not the case,
# we create it right away
if not make_movie_preview(camera_config, full_path):
if not make_movie_preview(camera_config, path):
return None

full_path += '.thumb'
Expand Down
11 changes: 10 additions & 1 deletion motioneye/scripts/relayevent.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env sh

if [ -z "$3" ]; then
echo "Usage: $0 <motioneye.conf> <event> <motion_camera_id> [filename]"
echo "Usage: $0 <motioneye.conf> <event> <motion_camera_id> [filename] [camera_dir]"
exit 1
fi

Expand All @@ -24,6 +24,15 @@ fi
event=$2
motion_camera_id=$3
filename=$4
camera_dir=$5

# send the API a path relative to the camera dir (POSIX prefix strip). Guard
# against an empty camera_dir - e.g. an old motion config calling a newer
# script - so we never strip a bare leading "/". If the prefix does not match,
# the filename is left unchanged and the API still validates it.
if [ -n "$camera_dir" ]; then
filename=${filename#"$camera_dir"/}
fi

uri="/_relay_event/?event=$event&motion_camera_id=$motion_camera_id"
data="{\"filename\": \"$filename\"}"
Expand Down
33 changes: 27 additions & 6 deletions tests/test_mediafiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from shutil import rmtree
from tempfile import mkdtemp
from time import time
from unittest.mock import patch

from motioneye import mediafiles
from motioneye.mediafiles import _list_media_files
Expand Down Expand Up @@ -341,11 +342,11 @@ def _assert_raises_dir_escape(self, fn, *args, **kwargs):
# --- make_movie_preview ---

def test_make_movie_preview_rejects_traversal(self):
# make_movie_preview now takes a path relative to the camera dir
for path in self._FILENAME_TRAVERSALS:
full_path = os.path.join(self._camera_config['target_dir'], path)
with self.subTest(full_path=full_path):
with self.subTest(path=path):
self._assert_raises_traversal(
mediafiles.make_movie_preview, self._camera_config, full_path
mediafiles.make_movie_preview, self._camera_config, path
)

def test_make_movie_preview_rejects_absolute_path(self):
Expand All @@ -355,12 +356,32 @@ def test_make_movie_preview_rejects_absolute_path(self):
mediafiles.make_movie_preview, self._camera_config, path
)

def test_make_movie_preview_reconstructs_absolute_path(self):
# a valid relative path is joined back onto target_dir for ffmpeg
rel_path = 'group/movie.mp4'
expected_full = os.path.join(self._camera_config['target_dir'], rel_path)
captured = {}

def fake_call_subprocess(cmd, **kwargs):
captured['cmd'] = cmd
return ''

with patch(
'motioneye.mediafiles.get_movie_duration_seconds', return_value=10.0
), patch('motioneye.utils.call_subprocess', side_effect=fake_call_subprocess):
# the thumbnail is never actually written, so it returns None, but
# the ffmpeg command must reference the reconstructed absolute path
mediafiles.make_movie_preview(self._camera_config, rel_path)

self.assertIn('cmd', captured)
self.assertIn(expected_full, ' '.join(captured['cmd']))

def test_make_movie_preview_rejects_dir_escape(self):
# make_movie_preview now takes a path relative to the camera dir
for path in self._FILENAME_ESCAPES:
full_path = os.path.join(self._camera_config['target_dir'], path)
with self.subTest(full_path=full_path):
with self.subTest(path=path):
self._assert_raises_dir_escape(
mediafiles.make_movie_preview, self._camera_config, full_path
mediafiles.make_movie_preview, self._camera_config, path
)

# --- list_media ---
Expand Down
Loading