diff --git a/.gitignore b/.gitignore index 202ca04..323fb71 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ .pytest_cache/ .DS_Store .vscode/ -_project_management/ \ No newline at end of file +_project_management/ +.venv/ \ No newline at end of file diff --git a/api/main.py b/api/main.py index 483a8b8..cbcd632 100644 --- a/api/main.py +++ b/api/main.py @@ -3551,13 +3551,9 @@ def _run_direct_url_with_cli( if not url or not isinstance(url, str): raise ValueError("single_url is required") - # Resolve destination (default to configured DOWNLOADS_DIR). - # Relative paths are resolved against DOWNLOADS_DIR, matching the job queue path. - if destination: - raw = destination.strip() - dest_dir = raw if os.path.isabs(raw) else os.path.join(str(DOWNLOADS_DIR), raw) - else: - dest_dir = str(DOWNLOADS_DIR) + # Match the queue path: resolve_dir handles empty/relative/absolute and rejects + # destinations that escape the base via inputs like "../etc". + dest_dir = resolve_dir((destination or "").strip(), paths.single_downloads_dir) ensure_dir(dest_dir) # Direct URL runs are intentionally NOT persisted into the unified download_jobs queue. diff --git a/tests/test_direct_url_contracts.py b/tests/test_direct_url_contracts.py index ee3abc7..71ba8cd 100644 --- a/tests/test_direct_url_contracts.py +++ b/tests/test_direct_url_contracts.py @@ -31,6 +31,7 @@ def api_module(monkeypatch, tmp_path: Path): db_path=str(db_path), temp_downloads_dir=str(temp_dir), thumbs_dir=str(thumbs_dir), + single_downloads_dir=str(tmp_path), ) module.app.state.state = "idle" module.app.state.current_download_proc = None @@ -145,6 +146,89 @@ def _fake_download_with_ytdlp(url, temp_dir, config_arg, **kwargs): assert files[0].endswith(".mkv") +def test_server_direct_url_relative_destination_resolves_under_downloads_dir( + api_module, monkeypatch, tmp_path: Path +) -> None: + module = api_module + module.app.state.paths.single_downloads_dir = str(tmp_path / "downloads_root") + Path(module.app.state.paths.single_downloads_dir).mkdir(parents=True, exist_ok=True) + config = { + "final_format": "mkv", + "filename_template": "VID-%(title)s.%(ext)s", + } + + def _fake_download_with_ytdlp(url, temp_dir, config_arg, **kwargs): + _ = config_arg, kwargs + output = Path(temp_dir) / "payload.mkv" + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(b"video") + return { + "id": "abc123xyz99", + "title": "Video Title", + "uploader": "Channel", + "webpage_url": url, + }, str(output) + + monkeypatch.setattr(module, "get_loaded_config", lambda: config) + monkeypatch.setattr(module, "download_with_ytdlp", _fake_download_with_ytdlp) + monkeypatch.setattr(module, "embed_metadata", lambda *args, **kwargs: None) + monkeypatch.setattr(module, "_record_direct_url_history", lambda *args, **kwargs: None) + monkeypatch.setattr(module, "ensure_mb_bound_music_track", lambda *args, **kwargs: None) + + module._run_direct_url_with_cli( + url="https://youtu.be/-LI8X-GhFA8", + paths=module.app.state.paths, + config=config, + destination="Singles", + final_format_override="mkv", + media_type="video", + media_intent="episode", + music_mode=False, + stop_event=threading.Event(), + status=None, + ) + + singles_dir = Path(module.app.state.paths.single_downloads_dir) / "Singles" + files = [p.name for p in singles_dir.glob("*") if p.is_file()] + assert len(files) == 1 + assert files[0].startswith("VID-Video Title.") + assert files[0].endswith(".mkv") + + +def test_server_direct_url_escape_destination_is_rejected( + api_module, monkeypatch, tmp_path: Path +) -> None: + module = api_module + module.app.state.paths.single_downloads_dir = str(tmp_path / "downloads_root") + Path(module.app.state.paths.single_downloads_dir).mkdir(parents=True, exist_ok=True) + config = {"final_format": "mkv"} + + called = {"download": False} + + def _fake_download_with_ytdlp(*args, **kwargs): + called["download"] = True + raise AssertionError("download_with_ytdlp should not be called for escape destinations") + + monkeypatch.setattr(module, "get_loaded_config", lambda: config) + monkeypatch.setattr(module, "download_with_ytdlp", _fake_download_with_ytdlp) + + with pytest.raises(ValueError, match="within base directory"): + module._run_direct_url_with_cli( + url="https://youtu.be/-LI8X-GhFA8", + paths=module.app.state.paths, + config=config, + destination="../../etc", + final_format_override="mkv", + media_type="video", + media_intent="episode", + music_mode=False, + stop_event=threading.Event(), + status=None, + ) + + assert called["download"] is False + + def test_client_direct_url_music_mode_is_rejected(api_module) -> None: module = api_module config = {"music_final_format": "mp3"}