diff --git a/docker/browser.sh b/docker/browser.sh new file mode 100644 index 000000000..1bfe93ce9 --- /dev/null +++ b/docker/browser.sh @@ -0,0 +1,131 @@ +#!/bin/bash +# shellcheck shell=bash + +# 浏览器缓存可由容器环境显式覆盖;未覆盖时优先复用已存在的持久缓存。 +function is_cloakbrowser_cache_ready() { + local candidate="${1:-}" + [ -d "${candidate}" ] || return 1 + + CLOAKBROWSER_CACHE_DIR="${candidate}" "${VENV_PATH}/bin/python3" -c ' +import os +from cloakbrowser import binary_info + +info = binary_info() +path = info.get("binary_path") +raise SystemExit(0 if info.get("installed") and path and os.access(path, os.X_OK) else 1) +' >/dev/null 2>&1 +} + +function is_path_mountpoint() { + local candidate="${1:-}" + "${VENV_PATH}/bin/python3" -c ' +import os +import sys + +raise SystemExit(0 if os.path.ismount(sys.argv[1]) else 1) +' "${candidate}" >/dev/null 2>&1 +} + +function resolve_browser_cache_dir() { + local explicit_cache="${CLOAKBROWSER_CACHE_DIR:-}" + local browser_cache_root="${CONFIG_DIR}/.browser" + local config_cache="${browser_cache_root}/cloakbrowser" + local legacy_cache="${HOME}/.cloakbrowser" + local selected_cache + local selected_source + + if [ -n "${explicit_cache}" ]; then + if [[ "${explicit_cache}" != /* ]]; then + ERROR "→ CLOAKBROWSER_CACHE_DIR 必须是独立的绝对目录:${explicit_cache}" + return 1 + fi + explicit_cache="$(python3 -c 'import os, sys; print(os.path.normpath(sys.argv[1]))' "${explicit_cache}")" + if [ "${explicit_cache}" = "/" ] \ + || [ "${explicit_cache}" = "${CONFIG_DIR%/}" ] \ + || [ "${explicit_cache}" = "${HOME%/}" ]; then + ERROR "→ CLOAKBROWSER_CACHE_DIR 不能占用受管根目录:${explicit_cache}" + return 1 + fi + selected_cache="${explicit_cache}" + selected_source="容器环境" + elif is_cloakbrowser_cache_ready "${config_cache}"; then + selected_cache="${config_cache}" + selected_source="V3 持久缓存" + elif is_cloakbrowser_cache_ready "${legacy_cache}" || is_path_mountpoint "${legacy_cache}"; then + selected_cache="${legacy_cache}" + selected_source="预发布 V3 兼容缓存" + else + selected_cache="${config_cache}" + selected_source="新安装默认目录" + fi + + CLOAKBROWSER_CACHE_DIR="${selected_cache}" + export CLOAKBROWSER_CACHE_DIR + INFO "→ CloakBrowser 缓存目录:${CLOAKBROWSER_CACHE_DIR}(${selected_source})" +} + +# 权限修复跳过大体积缓存子树,仅处理其根目录和直接子项。 +function chown_path_excluding_browser_cache() { + local target="${1:-}" + local cache_dir="${CLOAKBROWSER_CACHE_DIR:-}" + + [ -e "${target}" ] || return 0 + if [ -n "${cache_dir}" ] && [ "${cache_dir}" = "${target}" ]; then + chown -h moviepilot:moviepilot "${target}" + return 0 + fi + if [ -n "${cache_dir}" ] && [[ "${cache_dir}" == "${target}"/* ]]; then + find "${target}" -path "${cache_dir}" -prune -o -exec chown -h moviepilot:moviepilot {} + + return 0 + fi + chown -R moviepilot:moviepilot "${target}" +} + +function prepare_browser_cache_dir() { + if ! mkdir -p "${CLOAKBROWSER_CACHE_DIR}"; then + ERROR "→ 无法创建 CloakBrowser 缓存目录:${CLOAKBROWSER_CACHE_DIR}" + return 1 + fi + + if [ "${CLOAKBROWSER_CACHE_DIR}" = "${CONFIG_DIR}/.browser/cloakbrowser" ]; then + chown -h moviepilot:moviepilot "${CONFIG_DIR}/.browser" + fi + chown -h moviepilot:moviepilot "${CLOAKBROWSER_CACHE_DIR}" + find "${CLOAKBROWSER_CACHE_DIR}" -mindepth 1 -maxdepth 1 -exec chown -h moviepilot:moviepilot {} + + if is_truthy_value "${MOVIEPILOT_FORCE_CHOWN:-false}"; then + chown -R moviepilot:moviepilot "${CLOAKBROWSER_CACHE_DIR}" + fi + + if ! gosu moviepilot:moviepilot sh -c '[ -r "$1" ] && [ -w "$1" ] && [ -x "$1" ]' sh "${CLOAKBROWSER_CACHE_DIR}"; then + ERROR "→ CloakBrowser 缓存目录不可读写:${CLOAKBROWSER_CACHE_DIR}" + return 1 + fi +} + +function install_browser_kernel() { + local emulation="${BROWSER_EMULATION:-cloakbrowser}" + emulation="$(normalize_env_value "${emulation}")" + local proxy="${HTTPS_PROXY:-${https_proxy:-${PROXY_HOST:-}}}" + + if [ "${emulation}" != "cloakbrowser" ] && [ "${emulation}" != "flaresolverr" ] && [ -n "${emulation}" ]; then + WARN "浏览器仿真类型 ${emulation} 已按 CloakBrowser 处理。" + fi + + INFO "确保 CloakBrowser 浏览器内核可用" + if [[ "$proxy" =~ ^https?:// ]]; then + HTTPS_PROXY="$proxy" gosu moviepilot:moviepilot "${VENV_PATH}/bin/python3" -m cloakbrowser install + else + gosu moviepilot:moviepilot "${VENV_PATH}/bin/python3" -m cloakbrowser install + fi +} + +function ensure_browser_kernel() { + if is_cloakbrowser_cache_ready "${CLOAKBROWSER_CACHE_DIR:-}"; then + INFO "CloakBrowser 浏览器内核已就绪" + return 0 + fi + + if ! install_browser_kernel; then + WARN "CloakBrowser 浏览器内核安装失败,首次使用时将重试" + fi +} diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 8e98420d9..ed7ea86b5 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -385,6 +385,8 @@ function force_chown_image_paths_if_requested() { } function correct_home_permissions() { + local child + [ -e "${HOME}" ] || return 0 chown moviepilot:moviepilot "${HOME}" @@ -396,7 +398,22 @@ function correct_home_permissions() { INFO "→ 默认跳过 ${HOME}/.cloakbrowser 递归权限校正,如遇浏览器缓存权限错误可设置 MOVIEPILOT_FORCE_CHOWN=true 后重启一次。" fi - find "${HOME}" -mindepth 1 -maxdepth 1 ! -name ".cloakbrowser" -exec chown -R moviepilot:moviepilot {} + + while IFS= read -r -d '' child; do + [ "${child}" = "${HOME}/.cloakbrowser" ] && continue + chown_path_excluding_browser_cache "${child}" + done < <(find "${HOME}" -mindepth 1 -maxdepth 1 -print0) +} + +function correct_config_permissions() { + local child + + [ -e "${CONFIG_DIR}" ] || return 0 + + chown moviepilot:moviepilot "${CONFIG_DIR}" + while IFS= read -r -d '' child; do + [ "${child}" = "${CONFIG_DIR}/.browser" ] && continue + chown_path_excluding_browser_cache "${child}" + done < <(find "${CONFIG_DIR}" -mindepth 1 -maxdepth 1 -print0) } function chown_plugin_runtime_path() { @@ -427,8 +444,8 @@ function correct_file_permissions() { correct_site_resource_permissions chown_plugin_runtime_path /app/app/plugins correct_home_permissions + correct_config_permissions chown -R moviepilot:moviepilot \ - "${CONFIG_DIR}" \ /var/lib/nginx \ /var/log/nginx chown moviepilot:moviepilot /etc/hosts /tmp @@ -477,35 +494,27 @@ if [ "${ONE_SHOT_UPDATE_APPLIED}" = "true" ]; then fi cd /app || exit +source "/app/docker/browser.sh" + # 更改 moviepilot userid 和 groupid groupmod -o -g "${PGID}" moviepilot usermod -o -u "${PUID}" moviepilot -# 更改文件权限 -correct_file_permissions - # 启动前优先确认主运行环境仍然健康,避免插件依赖污染导致服务直接起不来。 ensure_backend_runtime_dependencies -# 下载浏览器内核 -function install_browser_kernel() { - local emulation="${BROWSER_EMULATION:-cloakbrowser}" - emulation="$(normalize_env_value "${emulation}")" - local proxy="${HTTPS_PROXY:-${https_proxy:-$PROXY_HOST}}" +# 缓存路径解析必须晚于依赖自愈,确保有效性探针使用当前运行版本。 +if ! resolve_browser_cache_dir; then + exit 1 +fi - if [ "${emulation}" != "cloakbrowser" ] && [ "${emulation}" != "flaresolverr" ] && [ -n "${emulation}" ]; then - WARN "浏览器仿真类型 ${emulation} 已按 CloakBrowser 处理。" - fi +# 权限校正需要避开选中的浏览器缓存子树,避免启动时递归扫描内核文件。 +correct_file_permissions - INFO "下载 CloakBrowser 浏览器内核" - if [[ "$proxy" =~ ^https?:// ]]; then - HTTPS_PROXY="$proxy" gosu moviepilot:moviepilot python -m cloakbrowser install - else - gosu moviepilot:moviepilot python -m cloakbrowser install - fi -} - -install_browser_kernel +if ! prepare_browser_cache_dir; then + exit 1 +fi +ensure_browser_kernel # 证书管理 source /app/docker/cert.sh diff --git a/docker/update.sh b/docker/update.sh index 0a57903b1..5b2d36d8d 100644 --- a/docker/update.sh +++ b/docker/update.sh @@ -106,10 +106,6 @@ function install_backend_and_download_resources() { cp /tmp/requirements.txt.backup /app/requirements.txt return 1 fi - INFO "正在更新 CloakBrowser 浏览器内核" - if ! ${VENV_PATH}/bin/python -m cloakbrowser install; then - WARN "CloakBrowser 浏览器内核更新失败,后续首次使用时可能重新下载" - fi INFO "依赖更新成功" else INFO "依赖无变化,跳过依赖更新" diff --git a/tests/test_docker_entrypoint_permissions.py b/tests/test_docker_entrypoint_permissions.py index 429dcd1d7..120480fc2 100644 --- a/tests/test_docker_entrypoint_permissions.py +++ b/tests/test_docker_entrypoint_permissions.py @@ -9,10 +9,12 @@ ROOT = Path(__file__).resolve().parents[1] def _write_entrypoint_functions(tmp_path: Path) -> Path: content = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8") + browser = (ROOT / "docker" / "browser.sh").read_text(encoding="utf-8") marker = "# 使用env配置" assert marker in content functions = tmp_path / "entrypoint-functions.sh" - functions.write_text(content.split(marker, 1)[0], encoding="utf-8") + entrypoint_functions = content.split(marker, 1)[0] + functions.write_text(f"{entrypoint_functions}\n{browser}", encoding="utf-8") return functions @@ -41,17 +43,24 @@ def _run_permission_case(tmp_path: Path, body: str, env: dict[str, str] | None = resource_dir = app_dir / "app" / "application" / "site" public_dir = tmp_path / "public" home_dir = tmp_path / "home" + config_dir = tmp_path / "config" (app_dir / "app" / "plugins").mkdir(parents=True) resource_dir.mkdir(parents=True) public_dir.mkdir() (home_dir / ".cloakbrowser").mkdir(parents=True) (home_dir / "runtime").mkdir() + (config_dir / ".browser" / "cloakbrowser").mkdir(parents=True) + (config_dir / "runtime").mkdir(exist_ok=True) (app_dir / "app" / "plugins" / "plugin.py").write_text("# plugin\n", encoding="utf-8") (resource_dir / "user.sites.v3.bin").write_text("resources\n", encoding="utf-8") (resource_dir / "sites.cpython-312-x86_64-linux-gnu.so").write_text("plugin\n", encoding="utf-8") (public_dir / "index.html").write_text("\n", encoding="utf-8") (home_dir / ".cloakbrowser" / "chrome").write_text("browser cache\n", encoding="utf-8") (home_dir / "runtime" / "state").write_text("state\n", encoding="utf-8") + (config_dir / ".browser" / "cloakbrowser" / "chrome").write_text( + "browser cache\n", encoding="utf-8" + ) + (config_dir / "runtime" / "state").write_text("state\n", encoding="utf-8") external_target = tmp_path / "external-target" external_target.write_text("external\n", encoding="utf-8") (app_dir / "external-link").symlink_to(external_target) @@ -65,7 +74,7 @@ def _run_permission_case(tmp_path: Path, body: str, env: dict[str, str] | None = "PUBLIC_DIR": str(public_dir), "HOME_DIR": str(home_dir), "IMAGE_RESOURCE_DIR": str(resource_dir), - "CONFIG_DIR": str(tmp_path / "config"), + "CONFIG_DIR": str(config_dir), "PUID": str(os.getuid()), "PGID": str(os.getgid()), } @@ -103,6 +112,205 @@ def _run_entrypoint_case(tmp_path: Path, body: str, env: dict[str, str] | None = return result.stdout +def _resolve_browser_cache_case( + tmp_path: Path, + *, + explicit: Path | None = None, + canonical_ready: bool = False, + legacy_ready: bool = False, + legacy_mounted: bool = False, +) -> str: + """在隔离目录中执行浏览器缓存路径选择合同。""" + config_dir = tmp_path / "config" + home_dir = tmp_path / "moviepilot" + canonical = config_dir / ".browser" / "cloakbrowser" + legacy = home_dir / ".cloakbrowser" + canonical.mkdir(parents=True) + legacy.mkdir(parents=True) + if canonical_ready: + (canonical / ".ready").touch() + if legacy_ready: + (legacy / ".ready").touch() + + return _run_entrypoint_case( + tmp_path, + """ + INFO() { :; } + is_cloakbrowser_cache_ready() { [ -f "$1/.ready" ]; } + is_path_mountpoint() { [ "${LEGACY_MOUNTED}" = "true" ] && [ "$1" = "${HOME}/.cloakbrowser" ]; } + resolve_browser_cache_dir + printf '%s\n' "${CLOAKBROWSER_CACHE_DIR}" + """, + env={ + "CONFIG_DIR": str(config_dir), + "HOME": str(home_dir), + "CLOAKBROWSER_CACHE_DIR": str(explicit) if explicit else "", + "LEGACY_MOUNTED": "true" if legacy_mounted else "false", + }, + ).strip() + + +def test_browser_cache_explicit_directory_has_highest_priority(tmp_path: Path) -> None: + explicit = tmp_path / "custom-cache" + + selected = _resolve_browser_cache_case( + tmp_path, + explicit=explicit, + canonical_ready=True, + legacy_ready=True, + ) + + assert selected == str(explicit) + + +def test_browser_cache_rejects_relative_explicit_directory(tmp_path: Path) -> None: + output = _run_entrypoint_case( + tmp_path, + """ + ERROR() { printf '%s\\n' "$1"; } + CONFIG_DIR=/config HOME=/moviepilot CLOAKBROWSER_CACHE_DIR=relative-cache \ + resolve_browser_cache_dir || printf 'rejected\\n' + """, + ) + + assert "CLOAKBROWSER_CACHE_DIR 必须是独立的绝对目录" in output + assert output.endswith("rejected\n") + + +def test_browser_cache_normalizes_explicit_directory(tmp_path: Path) -> None: + explicit = tmp_path / "cache" / ".." / "browser-cache" + + selected = _resolve_browser_cache_case(tmp_path, explicit=explicit) + + assert selected == str(tmp_path / "browser-cache") + + +def test_browser_cache_rejects_managed_root_directory(tmp_path: Path) -> None: + config_dir = tmp_path / "config" + output = _run_entrypoint_case( + tmp_path, + """ + ERROR() { printf '%s\\n' "$1"; } + CONFIG_DIR="${CASE_CONFIG_DIR}" HOME=/moviepilot \ + CLOAKBROWSER_CACHE_DIR="${CASE_CONFIG_DIR}" \ + resolve_browser_cache_dir || printf 'rejected\\n' + """, + env={"CASE_CONFIG_DIR": str(config_dir)}, + ) + + assert "CLOAKBROWSER_CACHE_DIR 不能占用受管根目录" in output + assert output.endswith("rejected\n") + + +def test_browser_cache_prefers_valid_config_cache(tmp_path: Path) -> None: + selected = _resolve_browser_cache_case( + tmp_path, + canonical_ready=True, + legacy_ready=True, + ) + + assert selected == str(tmp_path / "config" / ".browser" / "cloakbrowser") + + +def test_browser_cache_reuses_valid_prerelease_v3_cache(tmp_path: Path) -> None: + selected = _resolve_browser_cache_case(tmp_path, legacy_ready=True) + + assert selected == str(tmp_path / "moviepilot" / ".cloakbrowser") + + +def test_browser_cache_reuses_empty_prerelease_v3_mount(tmp_path: Path) -> None: + selected = _resolve_browser_cache_case(tmp_path, legacy_mounted=True) + + assert selected == str(tmp_path / "moviepilot" / ".cloakbrowser") + + +def test_browser_cache_new_install_uses_config_cache(tmp_path: Path) -> None: + selected = _resolve_browser_cache_case(tmp_path) + + assert selected == str(tmp_path / "config" / ".browser" / "cloakbrowser") + + +def test_browser_install_preserves_upstream_auto_update_default(tmp_path: Path) -> None: + selected = tmp_path / "cache" + output = _run_entrypoint_case( + tmp_path, + """ + INFO() { :; } + gosu() { + printf '%s|%s|%s\n' "${CLOAKBROWSER_AUTO_UPDATE-unset}" "${CLOAKBROWSER_CACHE_DIR}" "$*" + } + unset CLOAKBROWSER_AUTO_UPDATE + CLOAKBROWSER_CACHE_DIR="${SELECTED_CACHE}" + VENV_PATH=/runtime install_browser_kernel + """, + env={"SELECTED_CACHE": str(selected)}, + ).strip() + + assert output == ( + f"unset|{selected}|moviepilot:moviepilot " + "/runtime/bin/python3 -m cloakbrowser install" + ) + + +def test_browser_install_failure_does_not_block_startup(tmp_path: Path) -> None: + output = _run_entrypoint_case( + tmp_path, + """ + WARN() { printf '%s\n' "$1"; } + is_cloakbrowser_cache_ready() { return 1; } + install_browser_kernel() { return 1; } + ensure_browser_kernel + printf 'continued\n' + """, + ) + + assert "浏览器内核安装失败,首次使用时将重试" in output + assert output.endswith("continued\n") + + +def test_ready_browser_cache_skips_startup_install(tmp_path: Path) -> None: + output = _run_entrypoint_case( + tmp_path, + """ + INFO() { printf '%s\n' "$1"; } + is_cloakbrowser_cache_ready() { return 0; } + install_browser_kernel() { printf 'unexpected-install\n'; } + CLOAKBROWSER_CACHE_DIR=/config/.browser/cloakbrowser + ensure_browser_kernel + """, + ) + + assert output == "CloakBrowser 浏览器内核已就绪\n" + + +def test_missing_browser_cache_runs_startup_install(tmp_path: Path) -> None: + output = _run_entrypoint_case( + tmp_path, + """ + is_cloakbrowser_cache_ready() { return 1; } + install_browser_kernel() { printf 'installed\n'; } + CLOAKBROWSER_CACHE_DIR=/config/.browser/cloakbrowser + ensure_browser_kernel + """, + ) + + assert output == "installed\n" + + +def test_browser_install_is_centralized_in_startup() -> None: + entrypoint = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8") + browser = (ROOT / "docker" / "browser.sh").read_text(encoding="utf-8") + updater = (ROOT / "docker" / "update.sh").read_text(encoding="utf-8") + startup = entrypoint.split("# 使用env配置", 1)[1] + + assert "-m cloakbrowser install" not in entrypoint + assert browser.count("-m cloakbrowser install") == 2 + assert "-m cloakbrowser install" not in updater + assert startup.index("source /usr/local/bin/mp_update.sh") < startup.index( + 'source "/app/docker/browser.sh"' + ) < startup.index("resolve_browser_cache_dir") < startup.index("ensure_browser_kernel") + + def test_image_paths_are_not_chowned_by_default_regardless_of_owner(tmp_path: Path) -> None: log = _run_permission_case( tmp_path, @@ -190,14 +398,54 @@ def test_runtime_writable_paths_are_still_corrected(tmp_path: Path) -> None: assert f"moviepilot:moviepilot {tmp_path}/home" in lines assert f"-h moviepilot:moviepilot {tmp_path}/home/.cloakbrowser" in lines assert f"-R moviepilot:moviepilot {tmp_path}/home/runtime" in lines - assert f"-R moviepilot:moviepilot {tmp_path}/config /var/lib/nginx /var/log/nginx" in lines + assert f"moviepilot:moviepilot {tmp_path}/config" in lines + assert f"-R moviepilot:moviepilot {tmp_path}/config/runtime" in lines + assert "-R moviepilot:moviepilot /var/lib/nginx /var/log/nginx" in lines assert "moviepilot:moviepilot /etc/hosts /tmp" in lines assert f"-R moviepilot:moviepilot {tmp_path}/app/app/application/site" in lines assert not any(line.startswith("-R ") and ".cloakbrowser" in line for line in lines) + assert not any(line.startswith("-R ") and "/.browser" in line for line in lines) assert not any(f"{tmp_path}/app " in line for line in lines) assert not any(f"{tmp_path}/public" in line for line in lines) +def test_explicit_browser_cache_subtree_is_not_scanned_by_permission_repair( + tmp_path: Path, +) -> None: + explicit_cache = tmp_path / "config" / "runtime" / "browser-cache" + explicit_cache.mkdir(parents=True) + (explicit_cache / "chromium").write_text("payload\n", encoding="utf-8") + log = _run_permission_case( + tmp_path, + 'CLOAKBROWSER_CACHE_DIR="${EXPLICIT_CACHE}" HOME="${HOME_DIR}" correct_file_permissions', + env={"EXPLICIT_CACHE": str(explicit_cache)}, + ) + + lines = log.splitlines() + assert f"-R moviepilot:moviepilot {tmp_path}/config/runtime" not in lines + assert any(f"{tmp_path}/config/runtime/state" in line for line in lines) + assert not any("browser-cache" in line for line in lines) + assert f"-R moviepilot:moviepilot {tmp_path}/home/runtime" in lines + + +def test_explicit_top_level_browser_cache_is_not_scanned_by_permission_repair( + tmp_path: Path, +) -> None: + explicit_cache = tmp_path / "config" / "browser-cache" + log = _run_permission_case( + tmp_path, + """ + mkdir -p "${EXPLICIT_CACHE}/chromium" + CLOAKBROWSER_CACHE_DIR="${EXPLICIT_CACHE}" HOME="${HOME_DIR}" correct_file_permissions + """, + env={"EXPLICIT_CACHE": str(explicit_cache)}, + ) + + lines = log.splitlines() + assert f"-h moviepilot:moviepilot {explicit_cache}" in lines + assert not any(line.startswith("-R ") and str(explicit_cache) in line for line in lines) + + def test_site_resource_permissions_are_repaired_even_when_owner_matches(tmp_path: Path) -> None: log = _run_permission_case( tmp_path,