fix(web): 前端 dist 根级静态文件直出, 修复浏览器页签图标 404

SPA 兜底此前把所有未匹配路径都回退 index.html, favicon.svg /
donate-wechat.jpg 这类 dist 根目录文件也被当成前端路由, 浏览器把 HTML
当图标解析失败, Docker 与桌面安装包的页签因此没有图标。

- 抽出 _register_static_routes(): /assets 继续走 StaticFiles, 单段根级
  路径若命中真实文件则 FileResponse 直出
- index.html 仍走 no-store 分支, 保证 JS/CSS hash 变更后及时生效
- 路径校验排除 "." / ".." 与含 "/" 的多段路径, 不扩大可读取范围

验证: 新增 4 条路由测试 (根级文件直出 / 目录穿越被拒 / 未知路径仍回退
SPA / index.html 带 no-store)。
This commit is contained in:
shy3130
2026-10-20 16:15:06 +08:00
parent 648bab1bfe
commit fc4a4e7a33
2 changed files with 84 additions and 11 deletions
+21 -11
View File
@@ -686,22 +686,32 @@ async def capability_denied_handler(request: Request, exc: CapabilityDenied) ->
)
# 生产期静态文件(前端 dist)
_static = Path(settings.static_dir)
if _static.exists():
if (_static / "assets").exists():
app.mount("/assets", StaticFiles(directory=_static / "assets"), name="assets")
def _register_static_routes(target: FastAPI, static_dir: Path) -> None:
"""挂载前端构建产物: /assets 走静态服务, 根级文件直出, 其余回退 SPA。
@app.get("/{full_path:path}", include_in_schema=False)
def spa_fallback(full_path: str): # noqa: ARG001
"""所有未匹配路径回退到 index.html — React Router 接管。
根级静态文件 (favicon.svg / donate-wechat.jpg 等) 必须原样返回。此前所有
未匹配路径都回退 index.html, 浏览器把 HTML 当图标解析失败, Docker 与桌面
安装包的浏览器页签因此没有图标。
"""
if (static_dir / "assets").exists():
target.mount("/assets", StaticFiles(directory=static_dir / "assets"), name="assets")
index.html 禁止缓存 (Cache-Control: no-store), 确保浏览器每次拿到
最新版本引用的 JS/CSS 文件名 (assets 带 hash, 可长缓存)。
"""
index = _static / "index.html"
@target.get("/{full_path:path}", include_in_schema=False)
def spa_fallback(full_path: str):
# 只直出 dist 根目录文件 (单段路径); index.html 仍走下方 no-store 分支。
if full_path and "/" not in full_path and full_path not in (".", ".."):
asset = static_dir / full_path
if full_path != "index.html" and asset.is_file():
return FileResponse(asset)
index = static_dir / "index.html"
if index.exists():
return FileResponse(
index,
headers={"Cache-Control": "no-store, must-revalidate"},
)
return {"error": "frontend not built"}
_static = Path(settings.static_dir)
if _static.exists():
_register_static_routes(app, _static)
+63
View File
@@ -0,0 +1,63 @@
"""前端构建产物的静态路由 — 根级文件不能被 SPA 兜底成 HTML。
Docker/桌面安装包由 FastAPI 托管 frontend/dist。favicon.svg 等根级资源若被
SPA 兜底返回 index.html, 浏览器会因 MIME 不符丢弃图标, 页签显示默认占位图。
"""
from __future__ import annotations
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.main import _register_static_routes
SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"></svg>'
JPEG = b"\xff\xd8\xff\xe0fake-jpeg"
INDEX_HTML = "<!doctype html><div id=root></div>"
NO_STORE = "no-store, must-revalidate"
def _client(static_dir: Path) -> TestClient:
(static_dir / "assets").mkdir()
(static_dir / "assets" / "index-abc123.js").write_text("console.log(1)")
(static_dir / "favicon.svg").write_text(SVG)
(static_dir / "donate-wechat.jpg").write_bytes(JPEG)
(static_dir / "index.html").write_text(INDEX_HTML)
app = FastAPI()
_register_static_routes(app, static_dir)
return TestClient(app)
def test_favicon_is_served_as_svg_not_spa_html(tmp_path: Path) -> None:
response = _client(tmp_path).get("/favicon.svg")
assert response.status_code == 200
assert response.headers["content-type"].startswith("image/svg+xml")
assert response.text == SVG
def test_root_image_is_served_instead_of_spa_html(tmp_path: Path) -> None:
response = _client(tmp_path).get("/donate-wechat.jpg")
assert response.status_code == 200
assert response.headers["content-type"] == "image/jpeg"
assert response.content == JPEG
def test_unknown_route_still_falls_back_to_index_html(tmp_path: Path) -> None:
response = _client(tmp_path).get("/screener")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/html")
assert response.headers["cache-control"] == NO_STORE
assert response.text == INDEX_HTML
def test_index_html_direct_request_stays_uncached(tmp_path: Path) -> None:
response = _client(tmp_path).get("/index.html")
assert response.status_code == 200
assert response.headers["cache-control"] == NO_STORE
assert response.text == INDEX_HTML