Skip to content

Commit 1d03337

Browse files
committed
fix: #476 serve assets from selected theme
1 parent 8d6eaac commit 1d03337

2 files changed

Lines changed: 78 additions & 14 deletions

File tree

main.py

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@
66
import time
77
from contextlib import asynccontextmanager
88

9-
from fastapi import FastAPI
9+
from fastapi import FastAPI, HTTPException
1010
from fastapi.middleware.cors import CORSMiddleware
11-
from fastapi.responses import HTMLResponse
12-
from fastapi.staticfiles import StaticFiles
11+
from fastapi.responses import FileResponse, HTMLResponse
1312
from tortoise import Tortoise
1413
from tortoise.contrib.fastapi import register_tortoise
1514

@@ -77,12 +76,6 @@ async def lifespan(app: FastAPI):
7776
# 加载配置(多进程下串行化启动写操作)
7877
async with db_startup_lock():
7978
await load_config()
80-
app.mount(
81-
"/assets",
82-
StaticFiles(directory=f"./{settings.themesSelect}/assets"),
83-
name="assets",
84-
)
85-
8679
# 启动后台任务
8780
task = asyncio.create_task(delete_expire_files())
8881
chunk_cleanup_task = asyncio.create_task(clean_incomplete_uploads())
@@ -156,19 +149,46 @@ async def refresh_settings_middleware(request, call_next):
156149
app.include_router(admin_api)
157150

158151

152+
def resolve_theme_root():
153+
themes_root = (BASE_DIR / "themes").resolve()
154+
theme_root = (BASE_DIR / str(settings.themesSelect)).resolve()
155+
try:
156+
theme_root.relative_to(themes_root)
157+
except ValueError:
158+
theme_root = (BASE_DIR / DEFAULT_CONFIG["themesSelect"]).resolve()
159+
if not theme_root.exists():
160+
theme_root = (BASE_DIR / DEFAULT_CONFIG["themesSelect"]).resolve()
161+
return theme_root
162+
163+
164+
def resolve_theme_file(*parts: str):
165+
theme_root = resolve_theme_root()
166+
file_path = theme_root.joinpath(*parts).resolve()
167+
# 防止通过 /assets/../ 读取主题目录外的文件。
168+
try:
169+
file_path.relative_to(theme_root)
170+
except ValueError:
171+
raise HTTPException(status_code=404, detail="资源不存在")
172+
if not file_path.is_file():
173+
raise HTTPException(status_code=404, detail="资源不存在")
174+
return file_path
175+
176+
177+
@app.get("/assets/{asset_path:path}", include_in_schema=False)
178+
async def theme_asset(asset_path: str):
179+
return FileResponse(resolve_theme_file("assets", asset_path))
180+
181+
159182
@app.exception_handler(404)
160183
@app.get("/")
161184
async def index(request=None, exc=None):
162185
return HTMLResponse(
163-
content=open(
164-
BASE_DIR / f"{settings.themesSelect}/index.html", "r", encoding="utf-8"
165-
)
166-
.read()
186+
content=resolve_theme_file("index.html")
187+
.read_text(encoding="utf-8")
167188
.replace("{{title}}", str(settings.name))
168189
.replace("{{description}}", str(settings.description))
169190
.replace("{{keywords}}", str(settings.keywords))
170191
.replace("{{opacity}}", str(settings.opacity))
171-
.replace('"/assets/', '"assets/')
172192
.replace("{{background}}", str(settings.background)),
173193
media_type="text/html",
174194
headers={"Cache-Control": "no-cache"},
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import asyncio
2+
import unittest
3+
4+
from fastapi import HTTPException
5+
6+
from core.settings import settings
7+
from main import index, resolve_theme_file
8+
9+
10+
class SettingsOverrideMixin:
11+
def setUp(self):
12+
self._original_user_config = dict(settings.user_config)
13+
14+
def tearDown(self):
15+
settings.user_config = self._original_user_config
16+
17+
18+
class ThemeAssetTests(SettingsOverrideMixin, unittest.TestCase):
19+
def test_resolves_assets_from_current_theme(self):
20+
settings.themesSelect = "themes/2023"
21+
theme_2023_asset = resolve_theme_file("assets", "index-CxMsK_Ni.js")
22+
23+
settings.themesSelect = "themes/2024"
24+
theme_2024_asset = resolve_theme_file("assets", "index-DjzJA_Oj.js")
25+
26+
self.assertIn("themes/2023/assets", str(theme_2023_asset))
27+
self.assertIn("themes/2024/assets", str(theme_2024_asset))
28+
29+
def test_rejects_theme_asset_path_traversal(self):
30+
settings.themesSelect = "themes/2024"
31+
32+
with self.assertRaises(HTTPException) as error:
33+
resolve_theme_file("assets", "..", "..", "core", "settings.py")
34+
35+
self.assertEqual(error.exception.status_code, 404)
36+
37+
def test_index_keeps_absolute_asset_urls(self):
38+
settings.themesSelect = "themes/2023"
39+
40+
response = asyncio.run(index())
41+
html = response.body.decode("utf-8")
42+
43+
self.assertIn('src="/assets/', html)
44+
self.assertIn('href="/assets/', html)

0 commit comments

Comments
 (0)