From 5d1a2158f5ab8d2f87e70361d5a633fbb7f34abb Mon Sep 17 00:00:00 2001 From: JosephTian876 Date: Tue, 1 Sep 2026 02:17:29 +0800 Subject: [PATCH] fix(dashboard): reject stale WebUI assets in desktop-managed mode --- astrbot/core/dashboard_assets.py | 168 ++++++++++++++-- astrbot/core/updater.py | 17 +- astrbot/dashboard/api/static_files.py | 33 +++- main.py | 29 ++- tests/test_dashboard.py | 193 ++++++++++++++++++- tests/test_fastapi_v1_dashboard.py | 84 ++++++++ tests/test_main.py | 145 ++++++++++++-- tests/test_updater_socks.py | 151 +++++++++++++-- tests/unit/test_dashboard_dist_resolution.py | 103 +++++++++- 9 files changed, 848 insertions(+), 75 deletions(-) diff --git a/astrbot/core/dashboard_assets.py b/astrbot/core/dashboard_assets.py index 36163a2318..9c45ba56da 100644 --- a/astrbot/core/dashboard_assets.py +++ b/astrbot/core/dashboard_assets.py @@ -1,11 +1,15 @@ """Dashboard asset discovery, compatibility, and package handling.""" import re +import shutil +import tempfile import zipfile from pathlib import Path +from urllib.parse import unquote, urlsplit from astrbot.core import logger from astrbot.core.config.default import VERSION +from astrbot.core.desktop_runtime import is_desktop_managed_backend from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_path from astrbot.core.utils.io import download_file, ensure_dir from astrbot.core.utils.version_comparator import VersionComparator @@ -102,12 +106,56 @@ def _is_dist_compatible(dist_dir: str | Path, current_version: str) -> bool: Whether the dist contains an index and a matching version. """ dist_path = Path(dist_dir) - return (dist_path / "index.html").is_file() and _is_version_compatible( - _read_dashboard_version(dist_path), - current_version, + return _is_dist_complete(dist_path) and _is_version_compatible( + _read_dashboard_version(dist_path), current_version ) +def _is_dist_complete(dist_dir: str | Path) -> bool: + """Check whether a Dashboard dist has a usable local entry bundle. + + Args: + dist_dir: Dashboard dist directory path. + + Returns: + Whether the index references a local JavaScript entry and every local + JavaScript or stylesheet entry exists inside the dist directory. + """ + dist_path = Path(dist_dir) + index_path = dist_path / "index.html" + try: + index_html = index_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return False + + entry_paths: list[Path] = [] + for match in re.finditer( + r"\b(?:src|href)\s*=\s*[\"']([^\"']+)[\"']", + index_html, + flags=re.IGNORECASE, + ): + reference = match.group(1).strip() + if not reference or reference.startswith(("#", "//")): + continue + try: + parsed = urlsplit(reference) + if parsed.scheme or parsed.netloc: + continue + decoded_path = unquote(parsed.path).replace("\\", "/").lstrip("/") + except (UnicodeError, ValueError): + return False + if not decoded_path.lower().endswith((".js", ".css")): + continue + entry_path = Path(decoded_path) + if entry_path.is_absolute() or ".." in entry_path.parts: + return False + entry_paths.append(entry_path) + + if not any(path.suffix.lower() == ".js" for path in entry_paths): + return False + return all((dist_path / path).is_file() for path in entry_paths) + + def _should_use_bundled_dist(user_dist: str | Path, current_version: str) -> bool: """Check whether bundled assets should replace a managed user dist. @@ -119,16 +167,13 @@ def _should_use_bundled_dist(user_dist: str | Path, current_version: str) -> boo Whether the user dist is stale or incomplete and bundled assets match. """ user_dist = Path(user_dist) - user_version = _read_dashboard_version(user_dist) bundled_dist = _get_bundled_dist_path() if not user_dist.exists() or not _is_dist_compatible( bundled_dist, current_version, ): return False - if user_version is None or not (user_dist / "index.html").is_file(): - return True - return not _is_version_compatible(user_version, current_version) + return not _is_dist_compatible(user_dist, current_version) def resolve_dashboard_dist(webui_dir: str | Path | None = None) -> Path | None: @@ -138,23 +183,36 @@ def resolve_dashboard_dist(webui_dir: str | Path | None = None) -> Path | None: webui_dir: Optional explicitly configured Dashboard directory. Returns: - Explicit, managed, bundled, or stale fallback dist in priority order; - None when an existing managed dist is incomplete. + Explicit, managed, bundled, or stale fallback dist in priority order. + A managed desktop backend never receives assets with a known version + mismatch, and None is returned when no compatible fallback exists. """ explicit_dist = Path(webui_dir).absolute() if webui_dir else None if explicit_dist is not None and explicit_dist.exists(): - if not _is_dist_compatible(explicit_dist, VERSION): - explicit_version = _read_dashboard_version(explicit_dist) or "unknown" + if _is_dist_compatible(explicit_dist, VERSION): + return explicit_dist + + explicit_version = _read_dashboard_version(explicit_dist) + if is_desktop_managed_backend(): + logger.warning( + "Refusing the explicitly configured WebUI directory in " + "desktop-managed mode because it is incomplete or its version " + "does not match core: %s, expected v%s (%s).", + explicit_version or "unknown", + VERSION, + explicit_dist, + ) + else: logger.warning( "Serving the explicitly configured WebUI directory even though it " "does not declare a version matching core: %s, expected v%s (%s). " "Some dashboard features may not work until matching assets are " "available.", - explicit_version, + explicit_version or "unknown", VERSION, explicit_dist, ) - return explicit_dist + return explicit_dist user_dist = Path(get_astrbot_data_path()) / "dist" bundled_dist = _get_bundled_dist_path() @@ -167,6 +225,15 @@ def resolve_dashboard_dist(webui_dir: str | Path | None = None) -> Path | None: ): logger.info("Using bundled dashboard dist: %s", bundled_dist) return bundled_dist + if is_desktop_managed_backend(): + if user_dist.exists(): + logger.warning( + "Refusing data/dist in desktop-managed mode because WebUI version " + "does not match core: %s, expected v%s.", + user_version or "unknown", + VERSION, + ) + return None if user_dist.exists() and (user_dist / "index.html").is_file(): logger.warning( "Using existing data/dist as a fallback even though WebUI version " @@ -303,27 +370,86 @@ async def _download_package( _extract_package( zip_path, extract_path or Path(get_astrbot_data_path()), + expected_version=None if len(version) == 40 else version, ) def _extract_package( zip_path: str | Path, extract_path: str | Path, + expected_version: str | None = None, ) -> None: - """Safely extract a Dashboard package. + """Safely stage, validate, and replace a Dashboard package. Args: zip_path: Dashboard ZIP archive path. extract_path: Directory where package contents should be extracted. + expected_version: Optional Core version the Dashboard must match. Raises: - ValueError: If an archive member escapes the extraction root. + ValueError: If an archive member escapes the staging root. + RuntimeError: If the staged Dashboard is incomplete or mismatched. """ extract_root = Path(extract_path).resolve() ensure_dir(extract_root) - with zipfile.ZipFile(zip_path, "r") as archive: - for member in archive.infolist(): - target_path = (extract_root / member.filename).resolve() - if not target_path.is_relative_to(extract_root): - raise ValueError(f"Unsafe dashboard archive path: {member.filename}") - archive.extract(member, extract_root) + staging_root = Path( + tempfile.mkdtemp(prefix=".dashboard-stage-", dir=extract_root) + ).resolve() + backup_dist = staging_root / "previous-dist" + target_dist = extract_root / "dist" + moved_existing = False + cleanup_staging = True + try: + with zipfile.ZipFile(zip_path, "r") as archive: + for member in archive.infolist(): + target_path = (staging_root / member.filename).resolve() + if not target_path.is_relative_to(staging_root): + raise ValueError( + f"Unsafe dashboard archive path: {member.filename}" + ) + archive.extract(member, staging_root) + + staged_dist = staging_root / "dist" + if not _is_dist_complete(staged_dist): + raise RuntimeError("Downloaded Dashboard package is incomplete") + if expected_version is not None and not _is_version_compatible( + _read_dashboard_version(staged_dist), expected_version + ): + raise RuntimeError( + "Downloaded Dashboard version does not match " + f"AstrBot {expected_version}" + ) + + if target_dist.exists() or target_dist.is_symlink(): + target_dist.replace(backup_dist) + moved_existing = True + try: + staged_dist.replace(target_dist) + except BaseException as apply_error: + if moved_existing: + cleanup_staging = False + rollback_error: BaseException | str | None = None + if target_dist.exists() or target_dist.is_symlink(): + rollback_error = "the target path reappeared before rollback" + else: + try: + backup_dist.replace(target_dist) + moved_existing = False + cleanup_staging = True + except BaseException as exc: + rollback_error = exc + if moved_existing: + logger.critical( + "Dashboard replacement and rollback both failed. The " + "previous Dashboard remains at %s: %s", + backup_dist, + rollback_error, + ) + raise RuntimeError( + "Dashboard replacement failed and the previous Dashboard " + f"must be restored from {backup_dist}" + ) from apply_error + raise + finally: + if cleanup_staging: + shutil.rmtree(staging_root, ignore_errors=True) diff --git a/astrbot/core/updater.py b/astrbot/core/updater.py index 89c727d4df..8d56ca98a4 100644 --- a/astrbot/core/updater.py +++ b/astrbot/core/updater.py @@ -15,9 +15,11 @@ _extract_package, _get_bundled_dist_path, _is_dist_compatible, + _is_dist_complete, _read_dashboard_version, _should_use_bundled_dist, ) +from astrbot.core.desktop_runtime import is_desktop_managed_backend from astrbot.core.repository import GitHubRepository from astrbot.core.utils.astrbot_path import ( get_astrbot_data_path, @@ -313,6 +315,7 @@ def verify_packages() -> None: _extract_package, dashboard_zip_path, Path(get_astrbot_data_path()), + target_version if len(target_version) != 40 else None, ) await emit_progress( "apply", @@ -322,13 +325,17 @@ def verify_packages() -> None: ) async def ensure_dashboard(self) -> Path: - """Ensure a complete Dashboard matching the running Core version exists. + """Ensure acceptable Dashboard assets exist for the active runtime. + + Desktop-managed runtimes only receive assets matching the running Core. + Standalone runtimes retain the legacy offline fallback to a structurally + complete ``data/dist`` when a matching package cannot be prepared. Returns: Directory containing the Dashboard assets to serve. Raises: - Exception: If no compatible Dashboard can be prepared. + Exception: If no acceptable Dashboard can be prepared. """ data_dist_path = Path(get_astrbot_data_path()) / "dist" bundled_dist = _get_bundled_dist_path() @@ -359,10 +366,10 @@ async def ensure_dashboard(self) -> Path: allow_insecure_ssl_fallback=False, ) except Exception: - if (data_dist_path / "index.html").is_file(): + if not is_desktop_managed_backend() and _is_dist_complete(data_dist_path): logger.warning( - "Using existing Dashboard %s because a compatible package " - "could not be prepared for v%s.", + "Using existing standalone Dashboard %s because a compatible " + "package could not be prepared for v%s.", existing_version or "unknown", VERSION, ) diff --git a/astrbot/dashboard/api/static_files.py b/astrbot/dashboard/api/static_files.py index 8f873359d2..2a7e5d84d6 100644 --- a/astrbot/dashboard/api/static_files.py +++ b/astrbot/dashboard/api/static_files.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, HTTPException, Request from fastapi.responses import FileResponse, HTMLResponse +from astrbot.core.desktop_runtime import is_desktop_managed_backend from astrbot.dashboard.services.static_file_service import StaticFileService router = APIRouter(include_in_schema=False) @@ -14,14 +15,24 @@ def _static_folder(request: Request) -> str | None: def _not_found_response() -> HTMLResponse: - return HTMLResponse(service.get_not_found_message(), status_code=404) + return HTMLResponse( + service.get_not_found_message(), + status_code=404, + headers={"Cache-Control": "no-store"}, + ) async def serve_index(request: Request): index_file = service.resolve_index_file(_static_folder(request)) if index_file is None: return _not_found_response() - return FileResponse(index_file) + headers = {"Cache-Control": "no-store"} + if is_desktop_managed_backend() and request.query_params.get("astrbot_bundle"): + # The desktop app adds a bundle-identity query whenever its packaged + # resources change. That makes this request bypass an old URL cache + # entry and evicts legacy subresources without clearing cookies or storage. + headers["Clear-Site-Data"] = '"cache"' + return FileResponse(index_file, headers=headers) async def serve_static_file(request: Request, static_path: str): @@ -31,7 +42,23 @@ async def serve_static_file(request: Request, static_path: str): file_path = service.resolve_static_file(_static_folder(request), static_path) if file_path is None: return _not_found_response() - return FileResponse(file_path) + + normalized_path = static_path.replace("\\", "/").strip("/") + is_entry_document = file_path.suffix.lower() == ".html" + headers = { + "Cache-Control": ( + "no-store" + if is_entry_document or normalized_path == "assets/version" + else "no-cache" + ) + } + if ( + is_entry_document + and is_desktop_managed_backend() + and request.query_params.get("astrbot_bundle") + ): + headers["Clear-Site-Data"] = '"cache"' + return FileResponse(file_path, headers=headers) for index_route in service.list_index_routes(): diff --git a/main.py b/main.py index 07e6d3fda4..80f1f53f49 100644 --- a/main.py +++ b/main.py @@ -32,6 +32,7 @@ def _apply_startup_env_flags(argv: list[str]) -> None: _apply_startup_env_flags(sys.argv[1:]) from astrbot.core import LogBroker, LogManager, db_helper, logger # noqa: E402 +from astrbot.core.dashboard_assets import resolve_dashboard_dist # noqa: E402 from astrbot.core.initial_loader import InitialLoader # noqa: E402 from astrbot.core.updater import AstrBotUpdater # noqa: E402 from astrbot.core.utils.astrbot_path import ( # noqa: E402 @@ -96,16 +97,36 @@ async def check_dashboard_files(webui_dir: str | None = None): # 指定webui目录 if webui_dir: if os.path.exists(webui_dir): - logger.info("Using WebUI directory: %s", webui_dir) - return webui_dir - logger.warning("WebUI directory not found: %s. Using default.", webui_dir) + resolved_dist = resolve_dashboard_dist(webui_dir) + if resolved_dist is not None: + logger.info("Using WebUI directory: %s", resolved_dist) + return str(resolved_dist) + logger.warning( + "WebUI directory is incompatible with this desktop-managed core: " + "%s. Attempting repair.", + webui_dir, + ) + else: + logger.warning("WebUI directory not found: %s. Using default.", webui_dir) try: - return str(await AstrBotUpdater().ensure_dashboard()) + prepared_dist = await AstrBotUpdater().ensure_dashboard() except Exception as e: logger.critical(f"Failed to download dashboard files: {e}.") return None + # The updater only prepares managed or bundled assets. Resolve without an + # explicit path so the legacy Desktop package exception cannot admit an + # unversioned data/dist returned after a failed repair. + resolved_dist = resolve_dashboard_dist() + if resolved_dist is None: + logger.critical( + "Prepared dashboard files are not compatible with the running core: %s.", + prepared_dist, + ) + return None + return str(resolved_dist) + async def main_async(webui_dir_arg: str | None) -> None: """主异步入口""" diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 24ebac1d8d..c2acbb3e3d 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -338,8 +338,15 @@ def test_dashboard_uses_bundled_dist_when_data_dist_is_stale( bundled_dist = tmp_path / "bundled-dist" user_dist.mkdir(parents=True) bundled_dist.mkdir() - (bundled_dist / "index.html").write_text("bundled", encoding="utf-8") + (bundled_dist / "index.html").write_text( + '', + encoding="utf-8", + ) (bundled_dist / "assets").mkdir() + (bundled_dist / "assets" / "app.js").write_text( + "export {};", + encoding="utf-8", + ) (bundled_dist / "assets" / "version").write_text( f"v{VERSION}", encoding="utf-8", @@ -517,9 +524,10 @@ async def test_desktop_session_issues_jwt_without_password( assert response.status_code == 200 assert data["status"] == "ok" - assert data["data"]["username"] == core_lifecycle_td.astrbot_config[ - "dashboard" - ]["username"] + assert ( + data["data"]["username"] + == core_lifecycle_td.astrbot_config["dashboard"]["username"] + ) token = data["data"]["token"] payload = jwt.decode( token, @@ -3215,6 +3223,183 @@ def test_extract_dashboard_rejects_zip_path_traversal(tmp_path: Path): assert not (tmp_path / "evil.txt").exists() +def test_extract_dashboard_replaces_dist_only_after_validation(tmp_path: Path): + from astrbot.core.dashboard_assets import _extract_package + + archive_path = tmp_path / "dashboard.zip" + extract_path = tmp_path / "data" + old_dist = extract_path / "dist" + (old_dist / "assets").mkdir(parents=True) + (old_dist / "index.html").write_text("old", encoding="utf-8") + (old_dist / "assets" / "old.js").write_text("old", encoding="utf-8") + with zipfile.ZipFile(archive_path, "w") as archive: + archive.writestr( + "dist/index.html", + '', + ) + archive.writestr("dist/assets/new.js", "export {};") + archive.writestr("dist/assets/version", "v9.9.9") + + _extract_package(archive_path, extract_path, expected_version="v9.9.9") + + assert "new.js" in (old_dist / "index.html").read_text(encoding="utf-8") + assert (old_dist / "assets" / "new.js").is_file() + assert not (old_dist / "assets" / "old.js").exists() + + +@pytest.mark.parametrize( + ("index_html", "version", "error"), + [ + ( + '', + "v9.9.9", + "incomplete", + ), + ( + '', + "v9.9.8", + "does not match", + ), + ], +) +def test_extract_dashboard_keeps_existing_dist_when_validation_fails( + tmp_path: Path, + index_html: str, + version: str, + error: str, +): + from astrbot.core.dashboard_assets import _extract_package + + archive_path = tmp_path / "dashboard.zip" + extract_path = tmp_path / "data" + old_dist = extract_path / "dist" + old_dist.mkdir(parents=True) + (old_dist / "index.html").write_text("old", encoding="utf-8") + with zipfile.ZipFile(archive_path, "w") as archive: + archive.writestr("dist/index.html", index_html) + if "new.js" in index_html: + archive.writestr("dist/assets/new.js", "export {};") + archive.writestr("dist/assets/version", version) + + with pytest.raises(RuntimeError, match=error): + _extract_package(archive_path, extract_path, expected_version="v9.9.9") + + assert (old_dist / "index.html").read_text(encoding="utf-8") == "old" + + +def test_extract_dashboard_rolls_back_when_replacement_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + from astrbot.core.dashboard_assets import _extract_package + + archive_path = tmp_path / "dashboard.zip" + extract_path = tmp_path / "data" + old_dist = extract_path / "dist" + old_dist.mkdir(parents=True) + (old_dist / "index.html").write_text("old", encoding="utf-8") + with zipfile.ZipFile(archive_path, "w") as archive: + archive.writestr( + "dist/index.html", + '', + ) + archive.writestr("dist/assets/new.js", "export {};") + archive.writestr("dist/assets/version", "v9.9.9") + + original_replace = Path.replace + + def fail_staged_replace(path: Path, target: Path): + if path.name == "dist" and path.parent.name.startswith(".dashboard-stage-"): + raise OSError("simulated replacement failure") + return original_replace(path, target) + + monkeypatch.setattr(Path, "replace", fail_staged_replace) + + with pytest.raises(OSError, match="simulated replacement failure"): + _extract_package(archive_path, extract_path, expected_version="v9.9.9") + + assert (old_dist / "index.html").read_text(encoding="utf-8") == "old" + + +def test_extract_dashboard_preserves_backup_when_rollback_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + from astrbot.core.dashboard_assets import _extract_package + + archive_path = tmp_path / "dashboard.zip" + extract_path = tmp_path / "data" + old_dist = extract_path / "dist" + old_dist.mkdir(parents=True) + (old_dist / "index.html").write_text("old", encoding="utf-8") + with zipfile.ZipFile(archive_path, "w") as archive: + archive.writestr( + "dist/index.html", + '', + ) + archive.writestr("dist/assets/new.js", "export {};") + archive.writestr("dist/assets/version", "v9.9.9") + + original_replace = Path.replace + + def fail_replacement_and_rollback(path: Path, target: Path): + if path.name == "dist" and path.parent.name.startswith(".dashboard-stage-"): + raise OSError("simulated replacement failure") + if path.name == "previous-dist": + raise OSError("simulated rollback failure") + return original_replace(path, target) + + monkeypatch.setattr(Path, "replace", fail_replacement_and_rollback) + + with pytest.raises(RuntimeError, match="must be restored from"): + _extract_package(archive_path, extract_path, expected_version="v9.9.9") + + staging_dirs = list(extract_path.glob(".dashboard-stage-*")) + assert len(staging_dirs) == 1 + preserved_backup = staging_dirs[0] / "previous-dist" / "index.html" + assert preserved_backup.read_text(encoding="utf-8") == "old" + + +def test_extract_dashboard_preserves_backup_when_target_reappears( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + from astrbot.core.dashboard_assets import _extract_package + + archive_path = tmp_path / "dashboard.zip" + extract_path = tmp_path / "data" + target_dist = extract_path / "dist" + target_dist.mkdir(parents=True) + (target_dist / "index.html").write_text("old", encoding="utf-8") + with zipfile.ZipFile(archive_path, "w") as archive: + archive.writestr( + "dist/index.html", + '', + ) + archive.writestr("dist/assets/new.js", "export {};") + archive.writestr("dist/assets/version", "v9.9.9") + + original_replace = Path.replace + + def recreate_target_before_failure(path: Path, target: Path): + if path.name == "dist" and path.parent.name.startswith(".dashboard-stage-"): + target.mkdir(parents=True) + (target / "index.html").write_text("concurrent", encoding="utf-8") + raise OSError("simulated replacement failure") + return original_replace(path, target) + + monkeypatch.setattr(Path, "replace", recreate_target_before_failure) + + with pytest.raises(RuntimeError, match="must be restored from"): + _extract_package(archive_path, extract_path, expected_version="v9.9.9") + + staging_dirs = list(extract_path.glob(".dashboard-stage-*")) + assert len(staging_dirs) == 1 + preserved_backup = staging_dirs[0] / "previous-dist" / "index.html" + assert preserved_backup.read_text(encoding="utf-8") == "old" + assert (target_dist / "index.html").read_text(encoding="utf-8") == "concurrent" + + @pytest.mark.asyncio async def test_do_update_hides_internal_error_message_in_response_and_progress( app: FastAPIAppAdapter, diff --git a/tests/test_fastapi_v1_dashboard.py b/tests/test_fastapi_v1_dashboard.py index fb0b9087ae..6199f37486 100644 --- a/tests/test_fastapi_v1_dashboard.py +++ b/tests/test_fastapi_v1_dashboard.py @@ -1190,6 +1190,18 @@ async def test_dashboard_static_dist_files_are_served( "window.__astrbotStaticTest = true;", encoding="utf-8", ) + (assets_folder / "index-AbCd1234.js").write_text( + "window.__astrbotHashedStaticTest = true;", + encoding="utf-8", + ) + (assets_folder / "config-metadata.json").write_text("{}", encoding="utf-8") + (assets_folder / "version").write_text("v4.27.4", encoding="utf-8") + t2i_folder = static_folder / "t2i" + t2i_folder.mkdir() + (t2i_folder / "shiki_runtime.iife.js").write_text( + "window.__astrbotShikiRuntimeTest = true;", + encoding="utf-8", + ) (tmp_path / "secret.txt").write_text("outside static root", encoding="utf-8") app = create_dashboard_asgi_app( @@ -1204,7 +1216,13 @@ async def test_dashboard_static_dist_files_are_served( base_url="http://testserver", ) as client: asset_response = await client.get("/assets/index-demo.js") + hashed_asset_response = await client.get("/assets/index-AbCd1234.js") + word_suffix_asset_response = await client.get("/assets/config-metadata.json") + version_response = await client.get("/assets/version") + unversioned_asset_response = await client.get("/t2i/shiki_runtime.iife.js") favicon_response = await client.get("/favicon.svg") + root_response = await client.get("/") + index_response = await client.get("/index.html") page_response = await client.get("/config") missing_response = await client.get("/assets/missing.js") traversal_response = await client.get("/assets/%2E%2E/%2E%2E/secret.txt") @@ -1212,9 +1230,21 @@ async def test_dashboard_static_dist_files_are_served( assert asset_response.status_code == 200 assert "window.__astrbotStaticTest" in asset_response.text + assert asset_response.headers["cache-control"] == "no-cache" + assert hashed_asset_response.status_code == 200 + assert hashed_asset_response.headers["cache-control"] == "no-cache" + assert word_suffix_asset_response.status_code == 200 + assert word_suffix_asset_response.headers["cache-control"] == "no-cache" + assert version_response.status_code == 200 + assert version_response.headers["cache-control"] == "no-store" + assert unversioned_asset_response.status_code == 200 + assert unversioned_asset_response.headers["cache-control"] == "no-cache" assert favicon_response.status_code == 200 assert favicon_response.text == "" + assert root_response.headers["cache-control"] == "no-store" + assert index_response.headers["cache-control"] == "no-store" assert page_response.status_code == 200 + assert page_response.headers["cache-control"] == "no-store" assert "/assets/index-demo.js" in page_response.text assert missing_response.status_code == 404 assert missing_response.headers["content-type"].startswith("text/html") @@ -1228,6 +1258,60 @@ async def test_dashboard_static_dist_files_are_served( assert api_response.status_code == 404 +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("desktop_managed", "query", "should_clear_cache"), + [ + (True, "?astrbot_bundle=desktop-4.27.5-core-4.27.5-webui-deadbeef", True), + (True, "", False), + (False, "?astrbot_bundle=desktop-4.27.5-core-4.27.5-webui-deadbeef", False), + ], +) +async def test_dashboard_index_clears_legacy_cache_only_for_desktop_bundle( + fake_core_lifecycle, + fake_db: FakeDb, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + desktop_managed: bool, + query: str, + should_clear_cache: bool, +): + static_folder = tmp_path / "dist" + static_folder.mkdir() + (static_folder / "index.html").write_text( + "", + encoding="utf-8", + ) + if desktop_managed: + monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1") + else: + monkeypatch.delenv("ASTRBOT_DESKTOP_MANAGED", raising=False) + + app = create_dashboard_asgi_app( + core_lifecycle=fake_core_lifecycle, + db=fake_db, + jwt_secret=JWT_SECRET, + static_folder=str(static_folder), + ) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + ) as client: + responses = [ + await client.get(f"/{query}"), + await client.get(f"/index.html{query}"), + ] + + for response in responses: + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + if should_clear_cache: + assert response.headers["clear-site-data"] == '"cache"' + else: + assert "clear-site-data" not in response.headers + + @pytest.mark.asyncio async def test_v1_backup_download_accepts_bearer_token( tmp_path: Path, diff --git a/tests/test_main.py b/tests/test_main.py index a9b96a43ee..371527aa1e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -11,6 +11,7 @@ from astrbot.core.dashboard_assets import ( _should_use_bundled_dist, get_dashboard_version, + resolve_dashboard_dist, ) from main import ( DASHBOARD_RESET_PASSWORD_ENV, @@ -51,6 +52,18 @@ def __lt__(self, other): return (self.major, self.minor) < (other.major, other.minor) +def _write_dashboard_dist(dist_path, version: str | None = None) -> None: + assets = dist_path / "assets" + assets.mkdir(parents=True, exist_ok=True) + (dist_path / "index.html").write_text( + '', + encoding="utf-8", + ) + (assets / "app.js").write_text("export {};", encoding="utf-8") + if version is not None: + (assets / "version").write_text(version, encoding="utf-8") + + def test_check_env(monkeypatch): version_info_correct = _version_info(3, 10) version_info_wrong = _version_info(3, 9) @@ -178,6 +191,13 @@ def test_version_info_comparisons(): async def test_check_dashboard_files_delegates_to_updater(monkeypatch, tmp_path): """Startup should depend only on the updater's Dashboard contract.""" dashboard_path = tmp_path / "dist" + from astrbot.core.config.default import VERSION + + _write_dashboard_dist(dashboard_path, f"v{VERSION}") + monkeypatch.setattr( + "astrbot.core.dashboard_assets.get_astrbot_data_path", + lambda: str(tmp_path), + ) ensure_dashboard = mock.AsyncMock(return_value=dashboard_path) monkeypatch.setattr( "main.AstrBotUpdater", @@ -208,8 +228,7 @@ def test_should_use_bundled_dashboard_dist_when_data_dist_is_stale(tmp_path): (user_dist / "assets").mkdir(parents=True) (bundled_dist / "assets").mkdir(parents=True) (user_dist / "assets" / "version").write_text("v4.24.2", encoding="utf-8") - (bundled_dist / "assets" / "version").write_text("v4.24.4", encoding="utf-8") - (bundled_dist / "index.html").write_text("bundled", encoding="utf-8") + _write_dashboard_dist(bundled_dist, "v4.24.4") with mock.patch( "astrbot.core.dashboard_assets._get_bundled_dist_path", @@ -224,8 +243,7 @@ def test_should_use_bundled_dashboard_dist_when_version_file_is_malformed(tmp_pa (user_dist / "assets").mkdir(parents=True) (bundled_dist / "assets").mkdir(parents=True) (user_dist / "assets" / "version").write_text("not-a-version", encoding="utf-8") - (bundled_dist / "assets" / "version").write_text("v4.24.4", encoding="utf-8") - (bundled_dist / "index.html").write_text("bundled", encoding="utf-8") + _write_dashboard_dist(bundled_dist, "v4.24.4") with mock.patch( "astrbot.core.dashboard_assets._get_bundled_dist_path", @@ -238,9 +256,21 @@ def test_should_use_bundled_dashboard_dist_when_data_version_file_is_missing(tmp user_dist = tmp_path / "user-dist" bundled_dist = tmp_path / "bundled-dist" (user_dist / "assets").mkdir(parents=True) - (bundled_dist / "assets").mkdir(parents=True) - (bundled_dist / "assets" / "version").write_text("v4.24.4", encoding="utf-8") - (bundled_dist / "index.html").write_text("bundled", encoding="utf-8") + _write_dashboard_dist(bundled_dist, "v4.24.4") + + with mock.patch( + "astrbot.core.dashboard_assets._get_bundled_dist_path", + return_value=bundled_dist, + ): + assert _should_use_bundled_dist(user_dist, "4.24.4") is True + + +def test_should_use_bundled_dashboard_dist_when_data_entries_are_incomplete(tmp_path): + user_dist = tmp_path / "user-dist" + bundled_dist = tmp_path / "bundled-dist" + _write_dashboard_dist(user_dist, "v4.24.4") + (user_dist / "assets" / "app.js").unlink() + _write_dashboard_dist(bundled_dist, "v4.24.4") with mock.patch( "astrbot.core.dashboard_assets._get_bundled_dist_path", @@ -258,9 +288,7 @@ async def test_get_dashboard_version_uses_bundled_dist_when_data_dist_is_missing data_dir = tmp_path / "data" bundled_dist = tmp_path / "bundled-dist" - (bundled_dist / "assets").mkdir(parents=True) - (bundled_dist / "assets" / "version").write_text(f"v{VERSION}", encoding="utf-8") - (bundled_dist / "index.html").write_text("bundled", encoding="utf-8") + _write_dashboard_dist(bundled_dist, f"v{VERSION}") with mock.patch( "astrbot.core.dashboard_assets.get_astrbot_data_path", @@ -274,14 +302,99 @@ async def test_get_dashboard_version_uses_bundled_dist_when_data_dist_is_missing @pytest.mark.asyncio -async def test_check_dashboard_files_with_webui_dir_arg(monkeypatch): - """Tests that providing a valid webui_dir skips all checks.""" - valid_dir = "/tmp/my-custom-webui" - monkeypatch.setattr(os.path, "exists", lambda path: path == valid_dir) +async def test_check_dashboard_files_with_non_desktop_custom_webui_dir( + monkeypatch, tmp_path +): + """A custom explicit WebUI remains unchanged outside Desktop.""" + monkeypatch.delenv("ASTRBOT_DESKTOP_MANAGED", raising=False) + valid_dir = tmp_path / "my-custom-webui" + (valid_dir / "assets").mkdir(parents=True) + (valid_dir / "index.html").write_text("custom", encoding="utf-8") updater = mock.Mock() monkeypatch.setattr("main.AstrBotUpdater", updater) - result = await check_dashboard_files(webui_dir=valid_dir) + result = await check_dashboard_files(webui_dir=str(valid_dir)) - assert result == valid_dir + assert result == str(valid_dir.absolute()) updater.assert_not_called() + + +@pytest.mark.asyncio +async def test_check_dashboard_files_repairs_stale_desktop_webui(monkeypatch, tmp_path): + """Desktop startup verifies the repaired dist before serving it.""" + monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1") + explicit_dist = tmp_path / "webui" + (explicit_dist / "assets").mkdir(parents=True) + (explicit_dist / "index.html").write_text("stale", encoding="utf-8") + (explicit_dist / "assets" / "version").write_text("v0.0.1", encoding="utf-8") + data_dir = tmp_path / "data" + repaired_dist = data_dir / "dist" + monkeypatch.setattr( + "astrbot.core.dashboard_assets.get_astrbot_data_path", + lambda: str(data_dir), + ) + monkeypatch.setattr( + "astrbot.core.dashboard_assets._get_bundled_dist_path", + lambda: tmp_path / "missing-bundled-dist", + ) + + async def repair_dashboard(): + from astrbot.core.config.default import VERSION + + _write_dashboard_dist(repaired_dist, f"v{VERSION}") + return repaired_dist + + ensure_dashboard = mock.AsyncMock(side_effect=repair_dashboard) + monkeypatch.setattr( + "main.AstrBotUpdater", + lambda: mock.Mock(ensure_dashboard=ensure_dashboard), + ) + + result = await check_dashboard_files(str(explicit_dist)) + + assert result == str(repaired_dist.absolute()) + ensure_dashboard.assert_awaited_once_with() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failed_version", + ["v0.0.2", None], + ids=["known-stale", "unversioned"], +) +async def test_check_dashboard_files_does_not_serve_stale_desktop_webui_when_repair_fails( + monkeypatch, tmp_path, failed_version +): + """A failed desktop repair cannot fall back to unverified managed assets.""" + monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1") + explicit_dist = tmp_path / "webui" + (explicit_dist / "assets").mkdir(parents=True) + (explicit_dist / "index.html").write_text("stale", encoding="utf-8") + (explicit_dist / "assets" / "version").write_text("v0.0.1", encoding="utf-8") + data_dir = tmp_path / "data" + failed_repair_dist = data_dir / "dist" + (failed_repair_dist / "assets").mkdir(parents=True) + (failed_repair_dist / "index.html").write_text("also stale", encoding="utf-8") + if failed_version is not None: + (failed_repair_dist / "assets" / "version").write_text( + failed_version, encoding="utf-8" + ) + monkeypatch.setattr( + "astrbot.core.dashboard_assets.get_astrbot_data_path", + lambda: str(data_dir), + ) + monkeypatch.setattr( + "astrbot.core.dashboard_assets._get_bundled_dist_path", + lambda: tmp_path / "missing-bundled-dist", + ) + # ensure_dashboard historically returns a usable stale dist when its download + # fails. Startup must validate that fallback rather than trusting the path. + ensure_dashboard = mock.AsyncMock(return_value=failed_repair_dist) + monkeypatch.setattr( + "main.AstrBotUpdater", + lambda: mock.Mock(ensure_dashboard=ensure_dashboard), + ) + + assert await check_dashboard_files(str(explicit_dist)) is None + assert resolve_dashboard_dist(str(explicit_dist)) is None + ensure_dashboard.assert_awaited_once_with() diff --git a/tests/test_updater_socks.py b/tests/test_updater_socks.py index 8dd3bf6daa..15bc1f168c 100644 --- a/tests/test_updater_socks.py +++ b/tests/test_updater_socks.py @@ -559,7 +559,12 @@ async def fake_download_core(*, path: Path, **kwargs) -> Path: def fake_apply_core(_path: Path) -> None: calls.append("apply-core") - def fake_extract_dashboard(_path: Path, _data_path: Path) -> None: + def fake_extract_dashboard( + _path: Path, + _data_path: Path, + _expected_version: str | None = None, + ) -> None: + assert _expected_version == "v99.0.0" calls.append("apply-dashboard") async def record_progress(event: UpdateProgress) -> None: @@ -770,19 +775,34 @@ async def fake_download_dashboard(**kwargs) -> None: @pytest.mark.asyncio -async def test_astrbot_updater_ensure_dashboard_keeps_usable_stale_assets_on_failure( +@pytest.mark.parametrize( + "desktop_managed", + [False, True], + ids=["standalone", "desktop-managed"], +) +async def test_astrbot_updater_stale_fallback_is_standalone_only( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + desktop_managed: bool, ) -> None: updater = AstrBotUpdater() data_path = tmp_path / "data" data_dist = data_path / "dist" - data_dist.mkdir(parents=True) - (data_dist / "index.html").write_text("stale", encoding="utf-8") + (data_dist / "assets").mkdir(parents=True) + (data_dist / "index.html").write_text( + '', + encoding="utf-8", + ) + (data_dist / "assets" / "old.js").write_text("old", encoding="utf-8") + (data_dist / "assets" / "version").write_text("v0.0.1", encoding="utf-8") async def fail_download(**_kwargs) -> None: raise RuntimeError("unavailable") + if desktop_managed: + monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1") + else: + monkeypatch.delenv("ASTRBOT_DESKTOP_MANAGED", raising=False) monkeypatch.setattr(core_updater, "get_astrbot_data_path", lambda: str(data_path)) monkeypatch.setattr( core_updater, @@ -791,26 +811,74 @@ async def fail_download(**_kwargs) -> None: ) monkeypatch.setattr( core_updater, - "_is_dist_compatible", - lambda *_args: False, + "_download_package", + fail_download, ) + + if desktop_managed: + with pytest.raises(RuntimeError, match="unavailable"): + await updater.ensure_dashboard() + else: + assert await updater.ensure_dashboard() == data_dist + + +@pytest.mark.asyncio +@pytest.mark.parametrize("desktop_managed", [False, True]) +async def test_astrbot_updater_first_install_fails_when_offline_without_assets( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + desktop_managed: bool, +) -> None: + updater = AstrBotUpdater() + data_path = tmp_path / "data" + + async def fail_download(**_kwargs) -> None: + raise RuntimeError("offline") + + if desktop_managed: + monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1") + else: + monkeypatch.delenv("ASTRBOT_DESKTOP_MANAGED", raising=False) + monkeypatch.setattr(core_updater, "get_astrbot_data_path", lambda: str(data_path)) monkeypatch.setattr( core_updater, - "_should_use_bundled_dist", - lambda *_args: False, + "_get_bundled_dist_path", + lambda: tmp_path / "missing-bundled-dist", ) - monkeypatch.setattr( - core_updater, - "_read_dashboard_version", - lambda _path: "v0.0.1", + monkeypatch.setattr(core_updater, "_download_package", fail_download) + + with pytest.raises(RuntimeError, match="offline"): + await updater.ensure_dashboard() + + +@pytest.mark.asyncio +async def test_astrbot_updater_does_not_return_incomplete_assets_on_failure( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + updater = AstrBotUpdater() + data_path = tmp_path / "data" + data_dist = data_path / "dist" + (data_dist / "assets").mkdir(parents=True) + (data_dist / "index.html").write_text( + '', + encoding="utf-8", ) + + async def fail_download(**_kwargs) -> None: + raise RuntimeError("unavailable") + + monkeypatch.delenv("ASTRBOT_DESKTOP_MANAGED", raising=False) + monkeypatch.setattr(core_updater, "get_astrbot_data_path", lambda: str(data_path)) monkeypatch.setattr( core_updater, - "_download_package", - fail_download, + "_get_bundled_dist_path", + lambda: tmp_path / "missing-bundled-dist", ) + monkeypatch.setattr(core_updater, "_download_package", fail_download) - assert await updater.ensure_dashboard() == data_dist + with pytest.raises(RuntimeError, match="unavailable"): + await updater.ensure_dashboard() @pytest.mark.asyncio @@ -992,6 +1060,59 @@ async def fake_download_file( ] +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("version", "expected_version"), + [ + ("v99.0.0", "v99.0.0"), + ("a" * 40, None), + ], +) +async def test_download_dashboard_propagates_expected_version_to_extractor( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + version: str, + expected_version: str | None, +) -> None: + captured: dict[str, object] = {} + + async def fake_download_file(_url: str, path: str, **_kwargs) -> None: + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("dist/index.html", "dashboard") + + def fake_extract_package( + zip_path: Path, + extract_path: Path, + expected_version: str | None = None, + ) -> None: + captured.update( + zip_path=zip_path, + extract_path=extract_path, + expected_version=expected_version, + ) + + monkeypatch.setattr(dashboard_assets, "download_file", fake_download_file) + monkeypatch.setattr( + dashboard_assets, + "_extract_package", + fake_extract_package, + ) + + zip_path = tmp_path / "dashboard.zip" + extract_path = tmp_path / "extract" + await dashboard_assets._download_package( + path=zip_path, + extract_path=extract_path, + version=version, + ) + + assert captured == { + "zip_path": zip_path, + "extract_path": extract_path, + "expected_version": expected_version, + } + + @pytest.mark.asyncio async def test_fetch_release_info_uses_httpx_client_with_env_proxy_support( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/unit/test_dashboard_dist_resolution.py b/tests/unit/test_dashboard_dist_resolution.py index 52096d8ed6..94e1b7e606 100644 --- a/tests/unit/test_dashboard_dist_resolution.py +++ b/tests/unit/test_dashboard_dist_resolution.py @@ -13,7 +13,11 @@ def _make_dist(root, version: str | None) -> str: assets = root / "assets" assets.mkdir(parents=True) - (root / "index.html").write_text("", encoding="utf-8") + (root / "index.html").write_text( + '', + encoding="utf-8", + ) + (assets / "app.js").write_text("export {};", encoding="utf-8") if version is not None: (assets / "version").write_text(version, encoding="utf-8") return str(root) @@ -31,8 +35,11 @@ def test_matching_version_is_served_quietly(self, tmp_path, caplog): assert str(resolved) == str(tmp_path / "webui") assert WARNING_FRAGMENT not in caplog.text - def test_mismatched_version_warns_but_is_still_served(self, tmp_path, caplog): - """A stale packaged WebUI must not be swapped in silently.""" + def test_non_desktop_mismatched_version_warns_but_is_still_served( + self, monkeypatch, tmp_path, caplog + ): + """A custom WebUI remains supported outside the managed desktop app.""" + monkeypatch.delenv("ASTRBOT_DESKTOP_MANAGED", raising=False) dist = _make_dist(tmp_path / "webui", "v0.0.1") with caplog.at_level(logging.WARNING): @@ -43,17 +50,99 @@ def test_mismatched_version_warns_but_is_still_served(self, tmp_path, caplog): assert "v0.0.1" in caplog.text assert VERSION in caplog.text - def test_missing_version_marker_warns_as_unknown(self, tmp_path, caplog): - """Assets without a version marker cannot be verified, so say so.""" + def test_desktop_missing_version_marker_is_rejected( + self, monkeypatch, tmp_path, caplog + ): + """Desktop assets without a verifiable version must not be served.""" + monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1") dist = _make_dist(tmp_path / "webui", None) with caplog.at_level(logging.WARNING): resolved = resolve_dashboard_dist(dist) - assert resolved is not None - assert WARNING_FRAGMENT in caplog.text + assert resolved is None + assert "refusing" in caplog.text.lower() assert "unknown" in caplog.text + def test_desktop_missing_entry_asset_is_rejected( + self, monkeypatch, tmp_path, caplog + ): + """A marker cannot make a partially installed bundle compatible.""" + monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1") + dist = _make_dist(tmp_path / "webui", f"v{VERSION}") + (tmp_path / "webui" / "assets" / "app.js").unlink() + + with caplog.at_level(logging.WARNING): + resolved = resolve_dashboard_dist(dist) + + assert resolved is None + assert "incomplete" in caplog.text.lower() + + def test_desktop_malformed_entry_url_is_rejected( + self, monkeypatch, tmp_path, caplog + ): + """A malformed asset URL must not abort backend startup.""" + monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1") + dist = _make_dist(tmp_path / "webui", f"v{VERSION}") + (tmp_path / "webui" / "index.html").write_text( + '', + encoding="utf-8", + ) + + with caplog.at_level(logging.WARNING): + resolved = resolve_dashboard_dist(dist) + + assert resolved is None + assert "incomplete" in caplog.text.lower() + + def test_desktop_mismatched_version_uses_matching_managed_dist( + self, monkeypatch, tmp_path, caplog + ): + """A managed desktop backend must replace a known stale explicit dist.""" + monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1") + explicit_dist = _make_dist(tmp_path / "webui", "v0.0.1") + data_dir = tmp_path / "data" + managed_dist = _make_dist(data_dir / "dist", f"v{VERSION}") + monkeypatch.setattr( + "astrbot.core.dashboard_assets.get_astrbot_data_path", + lambda: str(data_dir), + ) + monkeypatch.setattr( + "astrbot.core.dashboard_assets._get_bundled_dist_path", + lambda: tmp_path / "missing-bundled-dist", + ) + + with caplog.at_level(logging.WARNING): + resolved = resolve_dashboard_dist(explicit_dist) + + assert resolved == (tmp_path / "data" / "dist").absolute() + assert str(managed_dist) == str(resolved) + assert "v0.0.1" in caplog.text + assert "refusing" in caplog.text.lower() + + def test_desktop_mismatched_version_without_fallback_is_not_served( + self, monkeypatch, tmp_path, caplog + ): + """Known stale desktop assets must never become the final fallback.""" + monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1") + explicit_dist = _make_dist(tmp_path / "webui", "v0.0.1") + data_dir = tmp_path / "data" + _make_dist(data_dir / "dist", "v0.0.2") + monkeypatch.setattr( + "astrbot.core.dashboard_assets.get_astrbot_data_path", + lambda: str(data_dir), + ) + monkeypatch.setattr( + "astrbot.core.dashboard_assets._get_bundled_dist_path", + lambda: tmp_path / "missing-bundled-dist", + ) + + with caplog.at_level(logging.WARNING): + resolved = resolve_dashboard_dist(explicit_dist) + + assert resolved is None + assert "refusing" in caplog.text.lower() + def test_nonexistent_dir_falls_through(self, tmp_path, caplog): """A path that does not exist must not be reported as a stale dist.""" with caplog.at_level(logging.WARNING):