diff --git a/motioneye/config.py b/motioneye/config.py index 999e39d0c..155b39100 100644 --- a/motioneye/config.py +++ b/motioneye/config.py @@ -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']) @@ -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']) diff --git a/motioneye/handlers/relay_event.py b/motioneye/handlers/relay_event.py index e21e16843..303aa82ee 100644 --- a/motioneye/handlers/relay_event.py +++ b/motioneye/handlers/relay_event.py @@ -16,7 +16,7 @@ # along with this program. If not, see . import logging -from os import sep +import os from typing import Optional from motioneye import config, mediafiles, motionctl, tasks, uploadservices, utils @@ -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']: @@ -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 @@ -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'], ) diff --git a/motioneye/mediafiles.py b/motioneye/mediafiles.py index 74e0f43d8..296a9a171 100644 --- a/motioneye/mediafiles.py +++ b/motioneye/mediafiles.py @@ -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 @@ -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...' ) @@ -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' diff --git a/motioneye/scripts/relayevent.sh b/motioneye/scripts/relayevent.sh index 0fcde5474..8197f4281 100755 --- a/motioneye/scripts/relayevent.sh +++ b/motioneye/scripts/relayevent.sh @@ -1,7 +1,7 @@ #!/usr/bin/env sh if [ -z "$3" ]; then - echo "Usage: $0 [filename]" + echo "Usage: $0 [filename] [camera_dir]" exit 1 fi @@ -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\"}" diff --git a/tests/test_mediafiles.py b/tests/test_mediafiles.py index be9790d5b..2918472a9 100644 --- a/tests/test_mediafiles.py +++ b/tests/test_mediafiles.py @@ -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 @@ -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): @@ -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 ---