diff --git a/app/adapters/system/update.py b/app/adapters/system/update.py index cbb7582fa..300866e9f 100644 --- a/app/adapters/system/update.py +++ b/app/adapters/system/update.py @@ -878,6 +878,28 @@ class SystemUpdateManager(metaclass=SingletonClass): resource_dir = legacy_dir return resource_dir + @staticmethod + def _is_site_runtime_resource(path: Path) -> bool: + """判断文件是否属于需要跨 Docker 应用升级继承的站点运行时资源。""" + return path.is_file() and ( + (path.name.startswith("user.sites.") and path.suffix == ".bin") + or ( + path.name.startswith("sites.") + and path.suffix in {".so", ".pyd", ".dylib"} + ) + ) + + def _copy_site_runtime_resources( + self, source_dir: Path, destination_dir: Path + ) -> None: + """只把旧版本的站点索引和原生资源叠加到新版源码目录。""" + if not source_dir.is_dir(): + return + destination_dir.mkdir(parents=True, exist_ok=True) + for path in source_dir.iterdir(): + if self._is_site_runtime_resource(path): + shutil.copy2(path, destination_dir / path.name) + def _copy_prepared_resources( self, prepared: dict[str, Any], resource_dir: Path ) -> None: @@ -896,7 +918,12 @@ class SystemUpdateManager(metaclass=SingletonClass): *, include_resources: bool, ) -> tuple[Path, Path]: - """解压并组装待切换的 Docker 后端、前端和插件资源载荷。""" + """ + 解压并组装待切换的 Docker 后端、前端和插件资源载荷。 + + 新版本归档是 Python 源码的唯一来源,旧版本目录只叠加插件和站点运行时资源, + 避免把新版新增的应用模块覆盖掉。 + """ backend_extract = temporary_root / "backend" frontend_extract = temporary_root / "frontend" backend_extract.mkdir() @@ -926,13 +953,8 @@ class SystemUpdateManager(metaclass=SingletonClass): raise RuntimeError("插件运行目录缺少 app.plugins 兼容入口") stage_resources = stage_app / "app" / "application" / "site" - if stage_resources.exists() or stage_resources.is_symlink(): - self._remove_path(stage_resources) current_resources = self._resource_source_dir(current_app) - if current_resources.is_dir(): - shutil.copytree(current_resources, stage_resources, symlinks=True) - else: - stage_resources.mkdir(parents=True, exist_ok=True) + self._copy_site_runtime_resources(current_resources, stage_resources) if include_resources: self._copy_prepared_resources(prepared, stage_resources) return stage_app, stage_public @@ -1348,20 +1370,27 @@ class SystemUpdateManager(metaclass=SingletonClass): raise RuntimeError(f"站点资源文件校验失败:{item.get('name')}") def _request(self) -> RequestUtils: - """创建访问 GitHub Release 的请求客户端。""" + """创建访问 GitHub Release API 的认证请求客户端。""" return RequestUtils( proxies=get_runtime_setting("PROXY"), headers=get_runtime_setting("GITHUB_HEADERS"), timeout=60, ) + def _download_request(self) -> RequestUtils: + """创建公开 GitHub 归档下载客户端,避免认证头传递到 codeload。""" + return RequestUtils( + proxies=get_runtime_setting("PROXY"), + timeout=60, + ) + def _download_file( self, url: str, destination: Path, downloaded_before: int, total_hint: int ) -> tuple[int, int]: - """流式下载文件并把进度写入当前升级类型。""" + """使用公开下载客户端流式下载文件并把进度写入当前升级类型。""" temporary = destination.with_suffix(".part") temporary.unlink(missing_ok=True) - with self._request().get_stream(url) as response: + with self._download_request().get_stream(url) as response: if response is None or response.status_code != 200: raise RuntimeError( f"下载更新包失败:HTTP {getattr(response, 'status_code', '无响应')}" diff --git a/tests/test_system_update_manager.py b/tests/test_system_update_manager.py index 8d82ff0b0..097e5ee3c 100644 --- a/tests/test_system_update_manager.py +++ b/tests/test_system_update_manager.py @@ -1,11 +1,13 @@ """系统后台更新状态机测试。""" +from contextlib import nullcontext import errno import json import threading import zipfile from pathlib import Path from types import SimpleNamespace +from unittest.mock import Mock, call import pytest @@ -151,6 +153,46 @@ def test_check_logs_when_application_is_current(monkeypatch, tmp_path): assert logs == ["MoviePilot 主程序已是最新版本:v3.0.0"] +def test_public_download_request_omits_github_authorization(monkeypatch, tmp_path): + """公开归档下载不得复用 GitHub API 的认证请求头。""" + manager = _manager(monkeypatch, tmp_path) + settings = { + "TEMP_PATH": tmp_path, + "PROXY": {"https": "http://proxy.example:7890"}, + "GITHUB_HEADERS": {"Authorization": "Bearer github-token"}, + } + monkeypatch.setattr(update_module, "get_runtime_setting", settings.__getitem__) + request_utils = Mock() + request_utils.return_value.get_stream.return_value = nullcontext( + SimpleNamespace( + status_code=200, + headers={"content-length": "7"}, + iter_content=lambda **_kwargs: [b"archive"], + ) + ) + monkeypatch.setattr(update_module, "RequestUtils", request_utils) + + manager._request() + destination = tmp_path / "backend.zip" + downloaded, content_length = manager._download_file( + "https://github.com/jxxghp/MoviePilot/archive/refs/tags/v3.0.2.zip", + destination, + 0, + 0, + ) + + assert request_utils.call_args_list == [ + call( + proxies=settings["PROXY"], + headers=settings["GITHUB_HEADERS"], + timeout=60, + ), + call(proxies=settings["PROXY"], timeout=60), + ] + assert downloaded == content_length == 7 + assert destination.read_bytes() == b"archive" + + def test_scheduled_check_failure_stays_silent(monkeypatch, tmp_path): manager = _manager(monkeypatch, tmp_path) monkeypatch.setattr( @@ -433,6 +475,7 @@ def test_apply_prepared_application_replaces_docker_payload_and_preserves_plugin (plugin_dir / "__init__.py").write_text("# compatibility\n", encoding="utf-8") (plugin_dir / "local_plugin.py").write_text("local\n", encoding="utf-8") (resource_dir / "user.sites.v3.bin").write_text("old-resource\n", encoding="utf-8") + (resource_dir / "legacy.py").write_text("old-source\n", encoding="utf-8") (app_dir / "old.py").write_text("old\n", encoding="utf-8") (app_dir / "pyproject.toml").write_text("old-project\n", encoding="utf-8") (app_dir / "uv.lock").write_text("old-lock\n", encoding="utf-8") @@ -447,6 +490,14 @@ def test_apply_prepared_application_replaces_docker_payload_and_preserves_plugin archive.writestr("MoviePilot-v3.1.0/pyproject.toml", "[project]\n") archive.writestr("MoviePilot-v3.1.0/uv.lock", "version = 1\n") archive.writestr("MoviePilot-v3.1.0/new.py", "new\n") + archive.writestr( + "MoviePilot-v3.1.0/app/application/site/__init__.py", + "", + ) + archive.writestr( + "MoviePilot-v3.1.0/app/application/site/auth.py", + "new-auth\n", + ) with zipfile.ZipFile(manager._frontend_archive, "w") as archive: archive.writestr("dist/index.html", "new-front\n") archive.writestr("dist/version.txt", "v3.1.0\n") @@ -482,6 +533,8 @@ def test_apply_prepared_application_replaces_docker_payload_and_preserves_plugin assert not (app_dir / "old.py").exists() assert (app_dir / "app" / "plugins" / "local_plugin.py").exists() assert (resource_dir / "user.sites.v3.bin").read_text(encoding="utf-8") == "old-resource\n" + assert (resource_dir / "auth.py").read_text(encoding="utf-8") == "new-auth\n" + assert not (resource_dir / "legacy.py").exists() assert (public_dir / "index.html").read_text(encoding="utf-8") == "new-front\n" assert not manager._install_file.exists() assert not (manager._root / "prepared.json").exists()