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