mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
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:
+21
-11
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user