diff --git a/api/app.py b/api/app.py index 207868fac..fedd07999 100644 --- a/api/app.py +++ b/api/app.py @@ -15,17 +15,98 @@ FastAPI 应用工厂模块 app = create_app() """ +import logging import mimetypes import os +import re from contextlib import asynccontextmanager from datetime import datetime from pathlib import Path -from typing import Optional +from urllib.parse import unquote +from typing import List, Optional from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse, HTMLResponse, JSONResponse + +logger = logging.getLogger(__name__) + +# Match src="/assets/foo.js" / href="/assets/foo.css" produced by the +# vite build. Used by the startup self-check to surface packaging +# mismatches early (see GitHub #1064 / #1065 / #1050). +_INDEX_ASSET_REF_PATTERN = re.compile( + r"""(?:src|href)\s*=\s*["'](/assets/[^"']+)["']""", + re.IGNORECASE, +) +_SAFE_MISSING_ASSET_MEDIA_TYPES = frozenset({"text/css", "text/javascript"}) + + +def _check_frontend_assets_consistency(static_dir: Path) -> List[str]: + """ + Verify that ``index.html`` only references assets that actually exist + under ``static_dir``. Returns the list of missing references; an empty + list means the bundle is consistent. + + Logs an actionable error when a mismatch is detected so the root cause + is visible in ``logs/desktop.log`` instead of surfacing as a silent + blank page. + """ + index_html = static_dir / "index.html" + if not index_html.is_file(): + return [] + try: + html = index_html.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + logger.warning("Failed to read %s for asset check: %s", index_html, exc) + return [] + + missing: List[str] = [] + for match in _INDEX_ASSET_REF_PATTERN.finditer(html): + ref = match.group(1) + candidate = static_dir / ref.lstrip("/") + if not candidate.is_file() and ref not in missing: + missing.append(ref) + + if missing: + logger.error( + "Frontend bundle is inconsistent: index.html references %d asset(s) " + "that are not present on disk under %s. This will surface as a " + "blank page in the desktop app (see GitHub #1064 / #1065). " + "Missing: %s. Re-run the frontend build and make sure the packaging " + "step copies the freshly generated static/ directory.", + len(missing), + static_dir, + ", ".join(missing), + ) + return missing + + +def _resolve_asset_path(assets_dir: Path, asset_path: str) -> Optional[Path]: + """Resolve a requested asset path while keeping it confined to assets_dir.""" + decoded_path = unquote(asset_path) + if not decoded_path or decoded_path.startswith(("/", "\\")): + return None + if "\x00" in decoded_path: + return None + if "\\" in decoded_path: + return None + if ":" in decoded_path.split("/", 1)[0]: + return None + + assets_root = assets_dir.resolve() + candidate = (assets_root / decoded_path).resolve() + if not candidate.is_relative_to(assets_root): + return None + return candidate + + +def _missing_asset_media_type(asset_path: str) -> str: + """Return a safe media type for a missing asset response.""" + content_type, _ = mimetypes.guess_type(asset_path) + if content_type in _SAFE_MISSING_ASSET_MEDIA_TYPES: + return content_type + return "text/plain" from api.v1 import api_v1_router from api.middlewares.auth import add_auth_middleware @@ -121,6 +202,11 @@ def create_app(static_dir: Optional[Path] = None) -> FastAPI: has_frontend = static_dir.exists() and (static_dir / "index.html").exists() if has_frontend: + # Surface bundle inconsistencies as soon as the app starts so that + # blank-page reports (#1064 / #1065 / #1050) can be diagnosed from + # logs/desktop.log instead of via browser devtools. + _check_frontend_assets_consistency(static_dir) + @app.get("/", include_in_schema=False) async def root(): """根路由 - 返回前端页面""" @@ -177,11 +263,38 @@ def create_app(static_dir: Optional[Path] = None) -> FastAPI: # ============================================================ if has_frontend: - # 挂载静态资源目录 + # Serve `/assets/*` explicitly so that misses return a plain-text + # 404 with the correct Content-Type instead of the default JSON + # error response. JSON for a JS/CSS request is what masked the + # blank-page root cause in #1064; here we make it obvious that the + # static file simply does not exist on disk. assets_dir = static_dir / "assets" - if assets_dir.exists(): - app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") - + + assets_static_files = StaticFiles(directory=str(assets_dir), check_dir=False) + assets_root = assets_dir.resolve() + + @app.api_route( + "/assets/{asset_path:path}", + methods=["GET", "HEAD"], + include_in_schema=False, + ) + async def serve_asset(request: Request, asset_path: str): + file_path = _resolve_asset_path(assets_dir, asset_path) + if file_path is None: + return Response( + content="not found", + status_code=404, + media_type="text/plain", + ) + if file_path.is_file(): + relative_path = file_path.relative_to(assets_root).as_posix() + return await assets_static_files.get_response(relative_path, request.scope) + return Response( + content="asset not found", + status_code=404, + media_type=_missing_asset_media_type(asset_path), + ) + # SPA 路由回退 @app.get("/{full_path:path}", include_in_schema=False) async def serve_spa(request: Request, full_path: str): diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0dde55717..1d206946a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - [文档] 优化根 README 结构,保留功能特性、技术栈、快速开始、推送效果、Web、Agent、赞助商和新闻源链接入口,将细配置、交易纪律和基本面语义收口到完整指南,并将 Docker 徽章指向官方镜像页 - [文档] 同步英文与繁中 README 的精简入口结构,并补齐完整指南中的 LLM 用量 API 与持仓管理说明 - [文档] 调整 AI 协作与 PR 模板中的 README 维护规则,明确 README 非必要不更新,细节优先进入专题文档 +- [修复] 桌面端打包链路新增 `scripts/check_static_assets.py` 静态资源一致性检查,并在 `build-backend(.ps1|-macos.sh)` 的源 `static/` 与 PyInstaller 产物里各跑一次;同时在后端启动时校验 `index.html` 引用的 `/assets/*.js`/`*.css` 是否真实存在,发现错配时直接在 `logs/desktop.log` 打印明确错误,避免重现 Release 包打开后白屏(Refs #1064 / #1065 / #1050) +- [改进] 后端 `/assets/*` 由显式路由托管,资源缺失时返回与请求扩展名匹配的 `text/javascript` / `text/css` 404,而不是被默认 JSON 错误响应误导排查(Refs #1064) ## [3.13.0] - 2026-04-21 diff --git a/scripts/build-backend-macos.sh b/scripts/build-backend-macos.sh index 0f15d9389..537bdddfa 100755 --- a/scripts/build-backend-macos.sh +++ b/scripts/build-backend-macos.sh @@ -30,6 +30,9 @@ fi npm run build popd >/dev/null +log "Verifying static asset references (source)..." +"${PYTHON_BIN}" "${SCRIPT_DIR}/check_static_assets.py" "${ROOT_DIR}/static" + log "Building backend executable..." if ! "${PYTHON_BIN}" -m PyInstaller --version >/dev/null 2>&1; then "${PYTHON_BIN}" -m pip install pyinstaller @@ -109,4 +112,15 @@ popd >/dev/null cp -R "${ROOT_DIR}/dist/stock_analysis" "${ROOT_DIR}/dist/backend/stock_analysis" +log "Verifying static asset references (packaged)..." +packaged_static="${ROOT_DIR}/dist/backend/stock_analysis/_internal/static" +if [[ ! -d "${packaged_static}" ]]; then + packaged_static="${ROOT_DIR}/dist/backend/stock_analysis/static" +fi +if [[ -d "${packaged_static}" ]]; then + "${PYTHON_BIN}" "${SCRIPT_DIR}/check_static_assets.py" "${packaged_static}" +else + log "WARNING: could not locate packaged static directory under dist/backend/stock_analysis; skipping post-package check." +fi + log "Backend build completed." diff --git a/scripts/build-backend.ps1 b/scripts/build-backend.ps1 index 967f0b0fe..392929f77 100644 --- a/scripts/build-backend.ps1 +++ b/scripts/build-backend.ps1 @@ -15,6 +15,12 @@ if ([string]::IsNullOrWhiteSpace($pythonBin)) { Write-Host "Using Python: $pythonBin" +Write-Host 'Verifying static asset references (source)...' +& $pythonBin "${PSScriptRoot}\check_static_assets.py" 'static' +if ($LASTEXITCODE -ne 0) { + throw "Static asset sanity check failed for source static/. See GitHub #1064." +} + function Test-PythonCode { param( [string]$Python, @@ -124,4 +130,18 @@ if (!(Test-Path 'dist\stock_analysis')) { Copy-Item -Path 'dist\stock_analysis' -Destination 'dist\backend\stock_analysis' -Recurse -Force +Write-Host 'Verifying static asset references (packaged)...' +$packagedStatic = Join-Path 'dist\backend\stock_analysis' '_internal\static' +if (-not (Test-Path $packagedStatic)) { + $packagedStatic = Join-Path 'dist\backend\stock_analysis' 'static' +} +if (Test-Path $packagedStatic) { + & $pythonBin "${PSScriptRoot}\check_static_assets.py" $packagedStatic + if ($LASTEXITCODE -ne 0) { + throw "Static asset sanity check failed for packaged $packagedStatic. See GitHub #1064." + } +} else { + Write-Warning "Could not locate packaged static directory under dist\backend\stock_analysis; skipping post-package check." +} + Write-Host 'Backend build completed.' diff --git a/scripts/check_static_assets.py b/scripts/check_static_assets.py new file mode 100644 index 000000000..f58fdcf39 --- /dev/null +++ b/scripts/check_static_assets.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Static frontend sanity check for the desktop / server packaging pipeline. + +Validates that ``index.html`` only references ``/assets/*.js`` and +``/assets/*.css`` files that actually exist on disk. A mismatch here is the +most common cause of the "Preparing backend..." / blank-page bug reported in +GitHub issues #1064, #1065, #1050: vite re-builds with a new content hash, +but the packaging step picks up a stale ``static/`` directory or copies the +files out of sync, so the browser receives a 404 (often as JSON) for the +main bundle and refuses to execute it. + +Usage: + python scripts/check_static_assets.py [] + +Exits 0 when consistent, non-zero with a human-readable message otherwise. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path +from typing import List, Tuple + +# Match src="/assets/foo.js" or href="/assets/foo.css", with single or double +# quotes. Vite emits absolute paths by default (``base: '/'``). +_ASSET_PATTERN = re.compile( + r"""(?:src|href)\s*=\s*["'](/assets/[^"']+)["']""", + re.IGNORECASE, +) + + +def _parse_referenced_assets(index_html: str) -> List[str]: + """Return the unique list of ``/assets/...`` paths referenced by ``index.html``.""" + seen: List[str] = [] + for match in _ASSET_PATTERN.finditer(index_html): + ref = match.group(1) + if ref not in seen: + seen.append(ref) + return seen + + +def check_static_dir(static_dir: Path) -> Tuple[List[str], List[str]]: + """ + Inspect ``static_dir`` and return ``(referenced, missing)``. + + ``referenced`` is the list of ``/assets/...`` paths declared in + ``index.html``. ``missing`` is the subset that does not exist on disk. + Raises ``FileNotFoundError`` if ``index.html`` itself is missing. + """ + index_html_path = static_dir / "index.html" + if not index_html_path.is_file(): + raise FileNotFoundError(f"index.html not found under {static_dir}") + + html = index_html_path.read_text(encoding="utf-8", errors="replace") + referenced = _parse_referenced_assets(html) + + missing: List[str] = [] + for ref in referenced: + # ref looks like "/assets/index-xxx.js"; strip the leading slash so + # it resolves relative to ``static_dir``. + candidate = static_dir / ref.lstrip("/") + if not candidate.is_file(): + missing.append(ref) + return referenced, missing + + +def main(argv: List[str]) -> int: + if len(argv) > 1: + static_dir = Path(argv[1]).resolve() + else: + static_dir = (Path(__file__).resolve().parent.parent / "static").resolve() + + print(f"[check_static_assets] inspecting {static_dir}") + + try: + referenced, missing = check_static_dir(static_dir) + except FileNotFoundError as exc: + print(f"[check_static_assets] ERROR: {exc}", file=sys.stderr) + print( + "[check_static_assets] Hint: build the frontend first via " + "`cd apps/dsa-web && npm install && npm run build`.", + file=sys.stderr, + ) + return 2 + + if not referenced: + print( + "[check_static_assets] WARNING: index.html does not reference any " + "/assets/* files; this is unusual for a vite build.", + file=sys.stderr, + ) + return 0 + + if missing: + print( + "[check_static_assets] ERROR: index.html references assets that " + "are not present on disk:", + file=sys.stderr, + ) + for ref in missing: + print(f" - {ref}", file=sys.stderr) + print( + "[check_static_assets] This produces a blank page on first load " + "(see GitHub #1064 / #1065). Re-run the frontend build and make " + "sure the packaging step copies the freshly generated static/ " + "directory.", + file=sys.stderr, + ) + return 1 + + print( + f"[check_static_assets] OK: {len(referenced)} asset reference(s) " + f"resolved successfully." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tests/test_static_assets_consistency.py b/tests/test_static_assets_consistency.py new file mode 100644 index 000000000..c3a667da1 --- /dev/null +++ b/tests/test_static_assets_consistency.py @@ -0,0 +1,268 @@ +# -*- coding: utf-8 -*- +"""Tests for ``scripts/check_static_assets.py`` and the equivalent +backend startup self-check in ``api.app``. + +Both code paths target the blank-page / "Preparing backend..." regression +captured in GitHub issues #1064, #1065 and #1050: vite produces a fresh +``index.html`` that references ``/assets/index-.js``, but the +packaging step copies a stale ``static/assets`` directory, so the bundle +referenced by ``index.html`` does not exist on disk. +""" + +from __future__ import annotations + +import importlib +import logging +import sys +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + + +SCRIPT_DIR = Path(__file__).resolve().parent.parent / "scripts" +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +check_static_assets = importlib.import_module("check_static_assets") + + +def _write_index(static_dir: Path, body: str) -> None: + static_dir.mkdir(parents=True, exist_ok=True) + (static_dir / "index.html").write_text(body, encoding="utf-8") + + +def _vite_index(js_name: str, css_name: str) -> str: + return ( + "" + f'' + f'' + "
" + ) + + +def test_check_static_dir_passes_when_assets_match(tmp_path: Path) -> None: + static_dir = tmp_path / "static" + assets_dir = static_dir / "assets" + assets_dir.mkdir(parents=True) + (assets_dir / "index-abc.js").write_text("// js", encoding="utf-8") + (assets_dir / "index-abc.css").write_text("/* css */", encoding="utf-8") + _write_index(static_dir, _vite_index("index-abc.js", "index-abc.css")) + + referenced, missing = check_static_assets.check_static_dir(static_dir) + + assert sorted(referenced) == ["/assets/index-abc.css", "/assets/index-abc.js"] + assert missing == [] + + +def test_check_static_dir_detects_stale_bundle(tmp_path: Path) -> None: + """index.html references new hash but assets/ holds an old hash.""" + static_dir = tmp_path / "static" + assets_dir = static_dir / "assets" + assets_dir.mkdir(parents=True) + # Stale on-disk bundle. + (assets_dir / "index-OLD.js").write_text("// old", encoding="utf-8") + (assets_dir / "index-OLD.css").write_text("/* old */", encoding="utf-8") + # Fresh index.html points to a different hash. + _write_index(static_dir, _vite_index("index-NEW.js", "index-NEW.css")) + + referenced, missing = check_static_assets.check_static_dir(static_dir) + + assert "/assets/index-NEW.js" in referenced + assert sorted(missing) == ["/assets/index-NEW.css", "/assets/index-NEW.js"] + + +def test_check_static_dir_raises_when_index_missing(tmp_path: Path) -> None: + static_dir = tmp_path / "static" + static_dir.mkdir() + with pytest.raises(FileNotFoundError): + check_static_assets.check_static_dir(static_dir) + + +def test_main_returns_nonzero_when_assets_missing(tmp_path: Path, capsys) -> None: + static_dir = tmp_path / "static" + (static_dir / "assets").mkdir(parents=True) + _write_index(static_dir, _vite_index("index-MISSING.js", "index-MISSING.css")) + + rc = check_static_assets.main(["check_static_assets.py", str(static_dir)]) + + captured = capsys.readouterr() + assert rc == 1 + assert "ERROR" in captured.err + assert "/assets/index-MISSING.js" in captured.err + + +def test_main_returns_zero_when_consistent(tmp_path: Path) -> None: + static_dir = tmp_path / "static" + assets = static_dir / "assets" + assets.mkdir(parents=True) + (assets / "main.js").write_text("// ok", encoding="utf-8") + (assets / "main.css").write_text("/* ok */", encoding="utf-8") + _write_index(static_dir, _vite_index("main.js", "main.css")) + + rc = check_static_assets.main(["check_static_assets.py", str(static_dir)]) + assert rc == 0 + + +def test_backend_startup_check_logs_when_bundle_inconsistent( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + from api import app as app_module + + static_dir = tmp_path / "static" + (static_dir / "assets").mkdir(parents=True) + _write_index(static_dir, _vite_index("index-NEW.js", "index-NEW.css")) + + with caplog.at_level(logging.ERROR, logger="api.app"): + missing = app_module._check_frontend_assets_consistency(static_dir) + + assert sorted(missing) == ["/assets/index-NEW.css", "/assets/index-NEW.js"] + assert any( + "Frontend bundle is inconsistent" in record.getMessage() + for record in caplog.records + ) + + +def test_backend_startup_check_silent_when_bundle_consistent( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + from api import app as app_module + + static_dir = tmp_path / "static" + assets = static_dir / "assets" + assets.mkdir(parents=True) + (assets / "index-abc.js").write_text("// js", encoding="utf-8") + (assets / "index-abc.css").write_text("/* css */", encoding="utf-8") + _write_index(static_dir, _vite_index("index-abc.js", "index-abc.css")) + + with caplog.at_level(logging.ERROR, logger="api.app"): + missing = app_module._check_frontend_assets_consistency(static_dir) + + assert missing == [] + assert not any( + "Frontend bundle is inconsistent" in record.getMessage() + for record in caplog.records + ) + + +def test_missing_asset_returns_safe_404_content_types(tmp_path: Path) -> None: + from api.app import create_app + + static_dir = tmp_path / "static" + assets_dir = static_dir / "assets" + assets_dir.mkdir(parents=True) + (assets_dir / "index-abc.js").write_text("// ok", encoding="utf-8") + (assets_dir / "index-abc.css").write_text("/* ok */", encoding="utf-8") + _write_index(static_dir, _vite_index("index-abc.js", "index-abc.css")) + + client = TestClient(create_app(static_dir=static_dir)) + + js_response = client.get("/assets/index-missing.js") + css_response = client.get("/assets/index-missing.css") + html_response = client.get("/assets/%3Cscript%3Ealert(1)%3C/script%3E.html") + + assert js_response.status_code == 404 + assert js_response.text == "asset not found" + assert js_response.headers["content-type"].startswith("text/javascript") + + assert css_response.status_code == 404 + assert css_response.text == "asset not found" + assert css_response.headers["content-type"].startswith("text/css") + + assert html_response.status_code == 404 + assert html_response.text == "asset not found" + assert html_response.headers["content-type"].startswith("text/plain") + + +def test_existing_asset_is_served_from_explicit_assets_route(tmp_path: Path) -> None: + from api.app import create_app + + static_dir = tmp_path / "static" + assets_dir = static_dir / "assets" + assets_dir.mkdir(parents=True) + js_file = assets_dir / "index-abc.js" + css_file = assets_dir / "index-abc.css" + js_file.write_text("console.log('ok')", encoding="utf-8") + css_file.write_text("body{color:#fff}", encoding="utf-8") + _write_index(static_dir, _vite_index("index-abc.js", "index-abc.css")) + + client = TestClient(create_app(static_dir=static_dir)) + + js_response = client.get("/assets/index-abc.js") + css_response = client.get("/assets/index-abc.css") + + assert js_response.status_code == 200 + assert js_response.text == "console.log('ok')" + assert js_response.headers["content-type"].startswith("text/javascript") + + assert css_response.status_code == 200 + assert css_response.text == "body{color:#fff}" + assert css_response.headers["content-type"].startswith("text/css") + + +def test_existing_asset_supports_head_and_conditional_requests(tmp_path: Path) -> None: + from api.app import create_app + + static_dir = tmp_path / "static" + assets_dir = static_dir / "assets" + assets_dir.mkdir(parents=True) + js_file = assets_dir / "index-abc.js" + js_file.write_text("console.log('ok')", encoding="utf-8") + (assets_dir / "index-abc.css").write_text("body{color:#fff}", encoding="utf-8") + _write_index(static_dir, _vite_index("index-abc.js", "index-abc.css")) + + client = TestClient(create_app(static_dir=static_dir)) + + get_response = client.get("/assets/index-abc.js") + etag = get_response.headers["etag"] + + head_response = client.head("/assets/index-abc.js") + cached_response = client.get( + "/assets/index-abc.js", + headers={"if-none-match": etag}, + ) + + assert get_response.status_code == 200 + assert head_response.status_code == 200 + assert head_response.content == b"" + assert head_response.headers["etag"] == etag + assert head_response.headers["content-type"].startswith("text/javascript") + + assert cached_response.status_code == 304 + assert cached_response.content == b"" + assert cached_response.headers["etag"] == etag + + +@pytest.mark.parametrize( + "request_path", + [ + "/assets/..%5C..%5Csecret.txt", + "/assets/C:%5Csecret.txt", + "/assets/%5Cwindows%5Csystem32%5Cconfig", + "/assets/%2e%2e/%2e%2e/secret.txt", + "/assets/%2500.js", + ], +) +def test_asset_traversal_attempts_are_rejected( + tmp_path: Path, + request_path: str, +) -> None: + from api.app import create_app + + static_dir = tmp_path / "static" + assets_dir = static_dir / "assets" + assets_dir.mkdir(parents=True) + (assets_dir / "index-abc.js").write_text("// ok", encoding="utf-8") + (assets_dir / "index-abc.css").write_text("/* ok */", encoding="utf-8") + _write_index(static_dir, _vite_index("index-abc.js", "index-abc.css")) + outside_secret = tmp_path / "secret.txt" + outside_secret.write_text("top secret", encoding="utf-8") + + client = TestClient(create_app(static_dir=static_dir)) + response = client.get(request_path) + + assert response.status_code == 404 + assert response.text == "not found" + assert "top secret" not in response.text