mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-01 13:15:59 +08:00
feat(4.0-P6): 前端插件后端与 plugin-ui 工作区包
- 清单 [[plugin.ui]]:五个挂载点、ab-plugin- 元素名与 web/ 入口校验
- GET /plugins/ui 列出已启用插件的挂载点;GET /plugins/{id}/web/{path} 提供静态资源(防路径穿越、JS MIME、需登录)
- SPA 文档加 CSP script-src 'self',内联深色模式脚本移到 theme-init.js
- webui/packages/plugin-ui:AbHost 类型、--ab-* 主题变量、Vite 库模式模板
- 设计文档第 0 节记录第一部分的调整
This commit is contained in:
@@ -93,28 +93,38 @@ def posters(path: str):
|
||||
return FileResponse(str(resolved))
|
||||
|
||||
|
||||
if VERSION != "DEV_VERSION":
|
||||
app.mount("/assets", StaticFiles(directory="dist/assets"), name="assets")
|
||||
app.mount("/images", StaticFiles(directory="dist/images"), name="images")
|
||||
# 前端插件组件与宿主同源(第 3.8 节),只允许加载本站脚本:远程脚本与内联脚本
|
||||
# 都被拒绝。只加在 SPA 文档上,/docs 的 Swagger 页面依赖内联脚本与 CDN。
|
||||
_SPA_HEADERS = {"Content-Security-Policy": "script-src 'self'"}
|
||||
|
||||
|
||||
def mount_webui(app: FastAPI, dist: Path) -> None:
|
||||
app.mount("/assets", StaticFiles(directory=dist / "assets"), name="assets")
|
||||
app.mount("/images", StaticFiles(directory=dist / "images"), name="images")
|
||||
# 自托管 Inter 字体:index.html 以 /fonts/*.woff2 预加载,不挂载则被下面的
|
||||
# SPA 兜底路由回成 index.html,全站字体静默回退。在线更新可能覆盖进来
|
||||
# 更旧的、没有 fonts/ 的 dist,故按存在性挂载。
|
||||
if os.path.isdir("dist/fonts"):
|
||||
app.mount("/fonts", StaticFiles(directory="dist/fonts"), name="fonts")
|
||||
if (dist / "fonts").is_dir():
|
||||
app.mount("/fonts", StaticFiles(directory=dist / "fonts"), name="fonts")
|
||||
# app.mount("/icons", StaticFiles(directory="dist/icons"), name="icons")
|
||||
templates = Jinja2Templates(directory="dist")
|
||||
templates = Jinja2Templates(directory=dist)
|
||||
|
||||
# dist/ is immutable inside the container — snapshot once instead of
|
||||
# hitting the filesystem on every request.
|
||||
_DIST_FILES = frozenset(os.listdir("dist"))
|
||||
dist_files = frozenset(os.listdir(dist))
|
||||
|
||||
@app.get("/{path:path}")
|
||||
def html(request: Request, path: str):
|
||||
if path in _DIST_FILES:
|
||||
return FileResponse(f"dist/{path}")
|
||||
if path in dist_files:
|
||||
return FileResponse(dist / path, headers=_SPA_HEADERS)
|
||||
else:
|
||||
context = {"request": request}
|
||||
return templates.TemplateResponse("index.html", context)
|
||||
return templates.TemplateResponse(
|
||||
request, "index.html", headers=_SPA_HEADERS
|
||||
)
|
||||
|
||||
|
||||
if VERSION != "DEV_VERSION":
|
||||
mount_webui(app, Path("dist"))
|
||||
|
||||
else:
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from ab_sdk import points
|
||||
from module.conf import settings
|
||||
from module.core import AppContext
|
||||
from module.plugin.host import plugin_provider_ids
|
||||
from module.plugin.manifest import UiSlot
|
||||
from module.plugin.secrets import mask_options, restore_options
|
||||
from module.security.api import get_current_user
|
||||
|
||||
@@ -49,6 +51,23 @@ class PluginsSettingsUpdate(BaseModel):
|
||||
allow_unsigned: bool
|
||||
|
||||
|
||||
class PluginUiSlot(BaseModel):
|
||||
plugin_id: str
|
||||
slot: UiSlot
|
||||
element: str
|
||||
# 相对插件根目录,经 GET /plugins/<plugin_id>/<entry> 获取
|
||||
entry: str
|
||||
title: dict[str, str]
|
||||
|
||||
|
||||
# 模块脚本要求 JavaScript MIME 类型;mimetypes 的结果依赖系统配置,这里固定
|
||||
_MEDIA_TYPES = {
|
||||
".js": "text/javascript",
|
||||
".mjs": "text/javascript",
|
||||
".css": "text/css",
|
||||
}
|
||||
|
||||
|
||||
def _overview(ctx: AppContext) -> PluginsOverview:
|
||||
conf = settings.plugins
|
||||
plugins = []
|
||||
@@ -74,6 +93,39 @@ async def list_plugins(ctx: AppContext = Depends(get_context)):
|
||||
return _overview(ctx)
|
||||
|
||||
|
||||
@router.get("/ui", response_model=list[PluginUiSlot])
|
||||
async def list_plugin_ui(ctx: AppContext = Depends(get_context)):
|
||||
"""已启用插件声明的前端挂载点(清单中的 ``[[plugin.ui]]``)。"""
|
||||
return [
|
||||
PluginUiSlot(plugin_id=plugin_id, **ui.model_dump())
|
||||
for plugin_id, ui in ctx.plugins.ui_slots()
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{plugin_id}/web/{path:path}", include_in_schema=False)
|
||||
async def plugin_web_file(
|
||||
plugin_id: str, path: str, ctx: AppContext = Depends(get_context)
|
||||
):
|
||||
"""已启用插件 ``web/`` 目录下的静态文件(前端组件的 ES module 等)。
|
||||
|
||||
与其它 API 一样需要登录;浏览器的 ``<script type="module">`` / ``import()``
|
||||
是同源请求,会带上会话 cookie。
|
||||
"""
|
||||
web_dir = ctx.plugins.web_dir(plugin_id)
|
||||
if web_dir is not None:
|
||||
root = web_dir.resolve()
|
||||
target = (root / path).resolve()
|
||||
# resolve() 之后再比较,``..`` 与指向目录外的符号链接都会被拒绝
|
||||
if target.is_relative_to(root) and target.is_file():
|
||||
return FileResponse(
|
||||
target,
|
||||
media_type=_MEDIA_TYPES.get(target.suffix),
|
||||
# 插件升级后文件名不变,每次都按 ETag 重新验证
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
|
||||
|
||||
@router.put("/settings", response_model=PluginsOverview)
|
||||
async def update_plugins_settings(
|
||||
body: PluginsSettingsUpdate, ctx: AppContext = Depends(get_context)
|
||||
|
||||
@@ -53,6 +53,8 @@ class PluginCandidate:
|
||||
source: PluginSource
|
||||
load: Callable[[], type[Plugin[Any]]]
|
||||
unload: Callable[[], None] = lambda: None
|
||||
# 插件根目录(web/ 静态资源从这里读取);pip 包不在文件系统上时为 None
|
||||
root: Path | None = None
|
||||
|
||||
@property
|
||||
def signed(self) -> bool:
|
||||
@@ -124,6 +126,7 @@ def _directory_candidate(plugin_dir: Path, source: PluginSource) -> PluginCandid
|
||||
source=source,
|
||||
load=lambda: _load_directory(plugin_dir, manifest, package),
|
||||
unload=lambda: _unload_package(package, plugin_dir),
|
||||
root=plugin_dir,
|
||||
)
|
||||
|
||||
|
||||
@@ -186,9 +189,8 @@ def _unload_package(package: str, plugin_dir: Path) -> None:
|
||||
def _entry_point_candidate(ep: importlib.metadata.EntryPoint) -> PluginCandidate:
|
||||
top_package = ep.module.split(".", 1)[0]
|
||||
try:
|
||||
text = (importlib.resources.files(top_package) / MANIFEST_NAME).read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
package_files = importlib.resources.files(top_package)
|
||||
text = (package_files / MANIFEST_NAME).read_text(encoding="utf-8")
|
||||
except (ModuleNotFoundError, FileNotFoundError) as e:
|
||||
raise PluginLoadError(f"{top_package} 包内缺少 {MANIFEST_NAME}") from e
|
||||
manifest = parse_manifest(text, f"{top_package}/{MANIFEST_NAME}")
|
||||
@@ -200,7 +202,13 @@ def _entry_point_candidate(ep: importlib.metadata.EntryPoint) -> PluginCandidate
|
||||
raise PluginLoadError(f"加载 entry point {ep.value} 失败:{e}") from e
|
||||
return _check_class(obj, manifest)
|
||||
|
||||
return PluginCandidate(manifest=manifest, source="pip", load=load)
|
||||
return PluginCandidate(
|
||||
manifest=manifest,
|
||||
source="pip",
|
||||
load=load,
|
||||
# zip 等非文件系统安装没有可直接提供的 web/ 目录
|
||||
root=package_files if isinstance(package_files, Path) else None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- helpers
|
||||
|
||||
@@ -17,6 +17,7 @@ from ab_sdk.hooks import HOOK_ATTR, PROVIDER_ATTR, SUBSCRIBE_ATTR
|
||||
from .bus import EventBus
|
||||
from .context import PLUGIN_DATA_ROOT, HostPluginContext
|
||||
from .loader import DiscoveryError, PluginCandidate, PluginLoadError, discover
|
||||
from .manifest import PluginUi
|
||||
from .registry import ExtensionRegistry, HookEntry, ProviderEntry
|
||||
from .runner import CircuitBreaker, HookRunner
|
||||
|
||||
@@ -161,6 +162,22 @@ class PluginManager:
|
||||
)
|
||||
return result
|
||||
|
||||
def ui_slots(self) -> list[tuple[str, PluginUi]]:
|
||||
"""已启用插件声明的前端挂载点,按插件 id 排序。"""
|
||||
return [
|
||||
(plugin_id, ui)
|
||||
for plugin_id, active in sorted(self._active.items())
|
||||
for ui in active.candidate.manifest.ui
|
||||
]
|
||||
|
||||
def web_dir(self, plugin_id: str) -> Path | None:
|
||||
"""已启用插件的 ``web/`` 目录;未启用或没有该目录时为 None。"""
|
||||
active = self._active.get(plugin_id)
|
||||
if active is None or active.candidate.root is None:
|
||||
return None
|
||||
web = active.candidate.root / "web"
|
||||
return web if web.is_dir() else None
|
||||
|
||||
def validate_options(self, plugin_id: str, options: dict[str, Any]) -> None:
|
||||
"""按插件的 config_model 校验配置(插件代码未加载过时不校验)。
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import re
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Literal
|
||||
|
||||
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||
from packaging.version import InvalidVersion, Version
|
||||
@@ -15,12 +16,52 @@ from .host import CORE
|
||||
MANIFEST_NAME = "plugin.toml"
|
||||
_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
_ENTRY_RE = re.compile(r"^[A-Za-z_][\w.]*:[A-Za-z_]\w*$")
|
||||
# 合法的 custom element 名(小写、含连字符),统一以 ab-plugin- 开头避免与宿主冲突
|
||||
_ELEMENT_RE = re.compile(r"^ab-plugin-[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
|
||||
# 前端挂载点(第 3.8 节)
|
||||
UiSlot = Literal[
|
||||
"settings.section",
|
||||
"bangumi.detail.tab",
|
||||
"bangumi.card.action",
|
||||
"page",
|
||||
"dashboard.widget",
|
||||
]
|
||||
|
||||
|
||||
class ManifestError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class PluginUi(BaseModel):
|
||||
"""``[[plugin.ui]]``:插件 ``web/`` 下的 ES module 定义的 custom element
|
||||
挂到宿主的哪个位置。"""
|
||||
|
||||
slot: UiSlot
|
||||
element: str = Field(description="custom element 名,须以 ab-plugin- 开头")
|
||||
entry: str = Field(
|
||||
description="定义该元素的 ES module,相对插件根目录,位于 web/ 下"
|
||||
)
|
||||
title: dict[str, str] = Field(
|
||||
min_length=1, description="按语言的标题,如 {zh-CN = '…', en-US = '…'}"
|
||||
)
|
||||
|
||||
@field_validator("element")
|
||||
@classmethod
|
||||
def _check_element(cls, value: str) -> str:
|
||||
if not _ELEMENT_RE.match(value):
|
||||
raise ValueError("element 须为以 ab-plugin- 开头的小写 custom element 名")
|
||||
return value
|
||||
|
||||
@field_validator("entry")
|
||||
@classmethod
|
||||
def _check_entry(cls, value: str) -> str:
|
||||
parts = PurePosixPath(value).parts
|
||||
if "\\" in value or len(parts) < 2 or parts[0] != "web" or ".." in parts:
|
||||
raise ValueError("entry 须为 web/ 下的相对路径,如 'web/index.js'")
|
||||
return value
|
||||
|
||||
|
||||
class PluginManifest(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
@@ -33,6 +74,7 @@ class PluginManifest(BaseModel):
|
||||
default_enabled: bool = Field(
|
||||
True, description="未设置启用开关时是否默认启用;只对内置插件生效"
|
||||
)
|
||||
ui: list[PluginUi] = Field(default_factory=list)
|
||||
|
||||
@field_validator("id")
|
||||
@classmethod
|
||||
|
||||
@@ -31,6 +31,14 @@ sdk = "{sdk}"
|
||||
entry = "{entry}"
|
||||
"""
|
||||
|
||||
UI = """
|
||||
[[plugin.ui]]
|
||||
slot = "bangumi.detail.tab"
|
||||
element = "ab-plugin-manual-pick"
|
||||
entry = "web/index.js"
|
||||
title = { zh-CN = "手动选种", en-US = "Manual pick" }
|
||||
"""
|
||||
|
||||
|
||||
def write_plugin(
|
||||
root: Path,
|
||||
@@ -95,6 +103,37 @@ class TestManifest:
|
||||
with pytest.raises(ManifestError, match=message):
|
||||
parse_manifest(text)
|
||||
|
||||
def test_ui_entries_parsed(self):
|
||||
m = parse_manifest(MANIFEST.format(id="ok", sdk=">=0.1", entry="a:B") + UI)
|
||||
[ui] = m.ui
|
||||
assert (ui.slot, ui.element, ui.entry) == (
|
||||
"bangumi.detail.tab",
|
||||
"ab-plugin-manual-pick",
|
||||
"web/index.js",
|
||||
)
|
||||
assert ui.title == {"zh-CN": "手动选种", "en-US": "Manual pick"}
|
||||
assert manifest().ui == []
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"old, new, message",
|
||||
[
|
||||
('"bangumi.detail.tab"', '"sidebar"', "slot"),
|
||||
('"ab-plugin-manual-pick"', '"manual-pick"', "ab-plugin-"),
|
||||
('"ab-plugin-manual-pick"', '"ab-plugin-Pick"', "ab-plugin-"),
|
||||
('"ab-plugin-manual-pick"', '"ab-plugin-"', "ab-plugin-"),
|
||||
('"web/index.js"', '"index.js"', "web/"),
|
||||
('"web/index.js"', '"web/../plugin.toml"', "web/"),
|
||||
('"web/index.js"', '"/web/index.js"', "web/"),
|
||||
('"web/index.js"', '"web/"', "web/"),
|
||||
('"web/index.js"', '"web\\\\index.js"', "web/"),
|
||||
('{ zh-CN = "手动选种", en-US = "Manual pick" }', "{}", "title"),
|
||||
],
|
||||
)
|
||||
def test_invalid_ui_entry(self, old, new, message):
|
||||
text = MANIFEST.format(id="ok", sdk=">=0.1", entry="a:B") + UI.replace(old, new)
|
||||
with pytest.raises(ManifestError, match=message):
|
||||
parse_manifest(text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- registry
|
||||
|
||||
|
||||
153
backend/src/test/test_plugin_web.py
Normal file
153
backend/src/test/test_plugin_web.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""前端插件(第 3.8 节):UI 挂载点列表、web/ 静态资源与 SPA 的 CSP。"""
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
from ab_sdk import Plugin
|
||||
from module.api import v1
|
||||
from module.api.deps import get_context
|
||||
from module.models.config import Plugins
|
||||
from module.plugin.loader import PluginCandidate
|
||||
from module.plugin.manager import PluginManager
|
||||
from module.plugin.manifest import parse_manifest
|
||||
from module.security.api import get_current_user
|
||||
|
||||
MANIFEST = """
|
||||
[plugin]
|
||||
id = "{id}"
|
||||
name = "Demo"
|
||||
version = "1.0.0"
|
||||
sdk = ">=0.1,<1"
|
||||
entry = "x:Y"
|
||||
|
||||
[[plugin.ui]]
|
||||
slot = "bangumi.detail.tab"
|
||||
element = "ab-plugin-{id}"
|
||||
entry = "web/index.js"
|
||||
title = {{ zh-CN = "演示", en-US = "Demo" }}
|
||||
"""
|
||||
|
||||
|
||||
class DemoPlugin(Plugin):
|
||||
pass
|
||||
|
||||
|
||||
def candidate(root: Path, plugin_id: str = "demo") -> PluginCandidate:
|
||||
web = root / plugin_id / "web"
|
||||
web.mkdir(parents=True)
|
||||
(web / "index.js").write_text("export const ok = 1;\n")
|
||||
(web / "style.css").write_text(":host{}\n")
|
||||
(root / plugin_id / "plugin.toml").write_text("secret = 1\n")
|
||||
return PluginCandidate(
|
||||
manifest=parse_manifest(MANIFEST.format(id=plugin_id)),
|
||||
source="builtin",
|
||||
load=lambda: DemoPlugin,
|
||||
root=root / plugin_id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def manager(tmp_path):
|
||||
settings_obj = SimpleNamespace(plugins=Plugins(enabled={"off": False}))
|
||||
candidates = [candidate(tmp_path, "demo"), candidate(tmp_path, "off")]
|
||||
mgr = PluginManager(
|
||||
settings_obj,
|
||||
discover_fn=lambda: (candidates, []),
|
||||
data_root=tmp_path / "data",
|
||||
)
|
||||
await mgr.start()
|
||||
yield mgr
|
||||
await mgr.stop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(manager):
|
||||
app = FastAPI()
|
||||
app.include_router(v1, prefix="/api")
|
||||
app.dependency_overrides[get_context] = lambda: SimpleNamespace(plugins=manager)
|
||||
|
||||
async def user():
|
||||
return "testuser"
|
||||
|
||||
app.dependency_overrides[get_current_user] = user
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestManager:
|
||||
async def test_ui_slots_and_web_dir_only_for_active_plugins(
|
||||
self, manager, tmp_path
|
||||
):
|
||||
assert [(pid, ui.element) for pid, ui in manager.ui_slots()] == [
|
||||
("demo", "ab-plugin-demo")
|
||||
]
|
||||
assert manager.web_dir("demo") == tmp_path / "demo" / "web"
|
||||
assert manager.web_dir("off") is None
|
||||
|
||||
await manager._deactivate("demo")
|
||||
assert manager.ui_slots() == []
|
||||
assert manager.web_dir("demo") is None
|
||||
|
||||
|
||||
class TestApi:
|
||||
def test_list_ui_slots(self, client):
|
||||
resp = client.get("/api/v1/plugins/ui")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == [
|
||||
{
|
||||
"plugin_id": "demo",
|
||||
"slot": "bangumi.detail.tab",
|
||||
"element": "ab-plugin-demo",
|
||||
"entry": "web/index.js",
|
||||
"title": {"zh-CN": "演示", "en-US": "Demo"},
|
||||
}
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path, media_type",
|
||||
[("index.js", "text/javascript"), ("style.css", "text/css")],
|
||||
)
|
||||
def test_serves_web_file_with_mime_type(self, client, path, media_type):
|
||||
resp = client.get(f"/api/v1/plugins/demo/web/{path}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith(media_type)
|
||||
assert resp.headers["cache-control"] == "no-cache"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
# httpx 会把字面的 ../ 规范化掉,用百分号编码让它原样到达路由
|
||||
"/api/v1/plugins/demo/web/%2e%2e%2fplugin.toml",
|
||||
# 绝对路径会让 root / path 直接变成该路径
|
||||
"/api/v1/plugins/demo/web//etc/hosts",
|
||||
"/api/v1/plugins/demo/web/missing.js",
|
||||
"/api/v1/plugins/demo/web/",
|
||||
"/api/v1/plugins/off/web/index.js",
|
||||
"/api/v1/plugins/unknown/web/index.js",
|
||||
],
|
||||
)
|
||||
def test_outside_web_dir_or_inactive_is_404(self, client, url):
|
||||
assert client.get(url).status_code == 404
|
||||
|
||||
def test_requires_auth(self, client):
|
||||
del client.app.dependency_overrides[get_current_user]
|
||||
assert client.get("/api/v1/plugins/demo/web/index.js").status_code == 401
|
||||
assert client.get("/api/v1/plugins/ui").status_code == 401
|
||||
|
||||
|
||||
class TestSpaCsp:
|
||||
@pytest.mark.parametrize("path", ["", "bangumi", "sw.js"])
|
||||
def test_spa_responses_carry_csp(self, tmp_path, path):
|
||||
for name in ("assets", "images"):
|
||||
(tmp_path / name).mkdir()
|
||||
(tmp_path / "index.html").write_text("<html></html>")
|
||||
(tmp_path / "sw.js").write_text("")
|
||||
app = FastAPI()
|
||||
main.mount_webui(app, tmp_path)
|
||||
resp = TestClient(app).get(f"/{path}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-security-policy"] == "script-src 'self'"
|
||||
@@ -161,6 +161,18 @@ slots 解析:
|
||||
- 多实例时修改某个实例的主机地址,该实例上进行中的版本替换事务找不到(`_downloader_type` 键含主机哈希)。可选做法:键改为 `<type>:<instance_id>` 并兼容旧键查询,或按「键不属于任何已配置实例」把孤儿事务交给同类型的唯一实例。
|
||||
- 删除规则(删除文件)时某个实例不可用:该实例上的种子不删,种子行却已删除,之后无法重试。可选做法:保留失败实例的种子行,或先删种子再删行(与「仅删数据库不依赖下载器」冲突)。
|
||||
|
||||
### 实施中的调整(P6 第一部分:后端与 `@autobangumi/plugin-ui` 包)
|
||||
|
||||
- **清单 `[[plugin.ui]]`** 由 `module/plugin/manifest.py` 的 `PluginUi` 校验:`slot` 取五个挂载点之一,`element` 须匹配 `^ab-plugin-[a-z0-9]+(-[a-z0-9]+)*$`,`entry` 须是 `web/` 下的相对路径(不含 `..`、反斜杠),`title` 至少一种语言。校验失败时整个清单被拒绝,与其它清单字段一致。
|
||||
- **挂载点列表用新路由 `GET /api/v1/plugins/ui`**(不改 `GET /plugins` 的形状),只列已启用插件,按插件 id 排序。`PluginCandidate` 新增 `root`(目录插件与 pip 包在文件系统上时有值;zip 安装的 pip 包为 None,没有 `web/` 可提供)。
|
||||
- **静态资源 `GET /api/v1/plugins/{id}/web/{path}`** 用 `FileResponse` 提供,只读已启用插件的 `web/` 目录;`resolve()` 后用 `is_relative_to` 判定,`..`、绝对路径、指向目录外的符号链接和未启用插件都返回 404。`.js` / `.mjs` 固定为 `text/javascript`(不依赖系统 `mimetypes`),带 `Cache-Control: no-cache`(插件升级后文件名不变,按 ETag 重新验证)。
|
||||
- **鉴权沿用路由器级的 `get_current_user`**。WebUI 用 HttpOnly 会话 cookie(`token`,`SameSite=Strict`,路径 `/`)登录,同源的 `<script type="module">` 与 `import()` 会带上它,无需 header。此路由必须注册在 `plugin_routes_router` 的分发路由之前(`api/__init__.py` 已是这个顺序);因此插件自己的 `api_router` 不能使用 `web/` 前缀。
|
||||
- **CSP `script-src 'self'` 只加在 SPA 文档上**(`index.html`、`sw.js` 等 dist 根文件),不加在 API 与 `/docs`:Swagger 页面依赖内联脚本与 CDN。`main.py` 的 SPA 挂载抽成 `mount_webui(app, dist)` 以便测试。
|
||||
- **`index.html` 的内联深色模式脚本移到 `public/theme-init.js`**,否则 CSP 会拦下它。在线更新若换入旧版 dist(仍带内联脚本),只会失去首屏深色模式的预先应用(页面载入后 `useDarkMode` 仍会设置),不影响功能。构建产物 `dist/index.html` 经 headless Chrome 验证:登录页在该 CSP 下正常渲染。
|
||||
- **Vite 只给 `preview` 加 CSP,不给 dev**:dev 服务器与 `vite-plugin-pwa` 的开发态都会注入内联模块脚本,加了会拦下它们。
|
||||
- **`@autobangumi/plugin-ui` 是 pnpm 工作区包**(`webui/pnpm-workspace.yaml`,`private`,不发布),WebUI 以 `workspace:*` 作为 devDependency。内容:`src/index.ts`(`AbHost`、`PluginUiSlot`、各挂载点的 `AbSlotContext`、`AbPluginElement`)、`tokens.css`(`--ab-*` 变量,取值回落到宿主的 `--color-*` 等设计令牌,深浅色随宿主切换)、`template/`(Vite 库模式:单文件自包含 ES module,输出到 `../web/index.js`,把 `tokens.css` 以 `?inline` 放进 Shadow DOM)。宿主侧的挂载、`AbHost` 实现与错误边界在第二部分。
|
||||
- **`AbHost.api` 的范围**:不以 `/` 开头的路径相对 `/api/v1/plugins/<id>/`;以 `/api/v1/` 开头的宿主 API 只允许 GET(第 3.8 节的「公开只读 API」)。接口形状为暂定,4.0 期间可能调整。
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
AB 目前只有 **LLM 提供商** 是真正的运行时插件系统:签名下载、目录加载、懒导入、热重载。
|
||||
|
||||
@@ -45,18 +45,7 @@
|
||||
}
|
||||
</style>
|
||||
<title>AutoBangumi</title>
|
||||
<script>
|
||||
// Apply dark mode before render to prevent flash
|
||||
(function () {
|
||||
const saved = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia(
|
||||
'(prefers-color-scheme: dark)'
|
||||
).matches;
|
||||
if (saved === 'dark' || (!saved && prefersDark)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="/theme-init.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^0.38.6",
|
||||
"@autobangumi/plugin-ui": "workspace:*",
|
||||
"@icon-park/vue-next": "^1.4.2",
|
||||
"@iconify-json/simple-icons": "1.2.89",
|
||||
"@intlify/unplugin-vue-i18n": "^0.11.0",
|
||||
|
||||
12
webui/packages/plugin-ui/package.json
Normal file
12
webui/packages/plugin-ui/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@autobangumi/plugin-ui",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "AutoBangumi 前端插件组件的宿主接口类型与主题变量(工作区包,不发布)",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./tokens.css": "./tokens.css"
|
||||
},
|
||||
"types": "./src/index.ts"
|
||||
}
|
||||
78
webui/packages/plugin-ui/src/index.ts
Normal file
78
webui/packages/plugin-ui/src/index.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* AutoBangumi 前端插件组件的宿主接口(设计文档第 3.8 节)。
|
||||
*
|
||||
* 插件在 `web/` 下提供 ES module,定义清单 `[[plugin.ui]]` 中声明的 custom
|
||||
* element。宿主创建元素后先设置 `host` 与 `context` 两个属性,再插入文档。
|
||||
* 组件只通过 `host` 与宿主交互,不直接使用宿主的 axios、store 或全局变量。
|
||||
*
|
||||
* 4.0 期间(ab_sdk 0.x)这些接口可能调整。
|
||||
*/
|
||||
|
||||
/** 前端挂载点。 */
|
||||
export type UiSlot =
|
||||
| 'settings.section'
|
||||
| 'bangumi.detail.tab'
|
||||
| 'bangumi.card.action'
|
||||
| 'page'
|
||||
| 'dashboard.widget';
|
||||
|
||||
/** `GET /api/v1/plugins/ui` 的一项:已启用插件声明的挂载点。 */
|
||||
export interface PluginUiSlot {
|
||||
plugin_id: string;
|
||||
slot: UiSlot;
|
||||
/** custom element 名,以 `ab-plugin-` 开头 */
|
||||
element: string;
|
||||
/** 相对插件根目录(`web/...`),模块地址为 `/api/v1/plugins/<plugin_id>/<entry>` */
|
||||
entry: string;
|
||||
/** 按语言的标题,如 `{ 'zh-CN': '手动选种', 'en-US': 'Manual pick' }` */
|
||||
title: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 各挂载点传给组件的上下文。 */
|
||||
export interface AbSlotContext {
|
||||
'settings.section': Record<string, never>;
|
||||
'bangumi.detail.tab': { bangumiId: number };
|
||||
'bangumi.card.action': { bangumiId: number };
|
||||
page: Record<string, never>;
|
||||
'dashboard.widget': Record<string, never>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带登录态的请求。不以 `/` 开头的路径相对插件自己的路由前缀
|
||||
* `/api/v1/plugins/<pluginId>/`;以 `/api/v1/` 开头的宿主 API 只允许 GET。
|
||||
* 返回响应体(JSON 已解析),非 2xx 时 reject。
|
||||
*/
|
||||
export interface AbHostApi {
|
||||
get<T = unknown>(path: string, params?: Record<string, unknown>): Promise<T>;
|
||||
post<T = unknown>(path: string, body?: unknown): Promise<T>;
|
||||
put<T = unknown>(path: string, body?: unknown): Promise<T>;
|
||||
delete<T = unknown>(path: string): Promise<T>;
|
||||
}
|
||||
|
||||
export interface AbHost {
|
||||
pluginId: string;
|
||||
api: AbHostApi;
|
||||
i18n: {
|
||||
/** 当前语言,如 `zh-CN`、`en-US` */
|
||||
locale: string;
|
||||
/** 宿主文案;找不到时返回 key 本身 */
|
||||
t(key: string, params?: Record<string, unknown>): string;
|
||||
};
|
||||
theme: {
|
||||
mode: 'light' | 'dark';
|
||||
/** `--ab-*` 变量的当前值(见 tokens.css) */
|
||||
tokens: Record<string, string>;
|
||||
};
|
||||
toast(message: string, kind?: 'info' | 'error'): void;
|
||||
events: {
|
||||
/** 订阅宿主事件流中的 `kind`,返回取消订阅函数 */
|
||||
on(kind: string, callback: (payload: unknown) => void): () => void;
|
||||
};
|
||||
}
|
||||
|
||||
/** 插件元素在插入文档前由宿主设置的属性。 */
|
||||
export interface AbPluginElement<S extends UiSlot = UiSlot>
|
||||
extends HTMLElement {
|
||||
host: AbHost;
|
||||
context: AbSlotContext[S];
|
||||
}
|
||||
14
webui/packages/plugin-ui/template/package.json
Normal file
14
webui/packages/plugin-ui/template/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "ab-plugin-web",
|
||||
"type": "module",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@autobangumi/plugin-ui": "link:../",
|
||||
"typescript": "^4.9.5",
|
||||
"vite": "^4.5.5"
|
||||
}
|
||||
}
|
||||
1
webui/packages/plugin-ui/template/src/env.d.ts
vendored
Normal file
1
webui/packages/plugin-ui/template/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
49
webui/packages/plugin-ui/template/src/index.ts
Normal file
49
webui/packages/plugin-ui/template/src/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { AbPluginElement } from '@autobangumi/plugin-ui';
|
||||
import tokens from '@autobangumi/plugin-ui/tokens.css?inline';
|
||||
|
||||
// 元素名须与清单 [[plugin.ui]] 的 element 一致,并以 ab-plugin- 开头
|
||||
const ELEMENT = 'ab-plugin-example';
|
||||
|
||||
class ExampleElement
|
||||
extends HTMLElement
|
||||
implements AbPluginElement<'settings.section'>
|
||||
{
|
||||
// 宿主在插入文档前设置;用 declare 不生成类字段,否则构造时会被重置为 undefined
|
||||
declare host: AbPluginElement['host'];
|
||||
declare context: AbPluginElement<'settings.section'>['context'];
|
||||
|
||||
connectedCallback() {
|
||||
const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' });
|
||||
root.innerHTML = `
|
||||
<style>
|
||||
${tokens}
|
||||
button {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: var(--ab-radius-sm);
|
||||
background: var(--ab-color-primary);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
<button type="button"></button>
|
||||
`;
|
||||
const button = root.querySelector('button')!;
|
||||
button.textContent = this.host.i18n.locale.startsWith('zh')
|
||||
? '调用插件路由'
|
||||
: 'Call plugin route';
|
||||
button.addEventListener('click', async () => {
|
||||
try {
|
||||
// 相对路径 → /api/v1/plugins/<plugin_id>/ping
|
||||
await this.host.api.get('ping');
|
||||
this.host.toast('OK');
|
||||
} catch (e) {
|
||||
this.host.toast(String(e), 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get(ELEMENT))
|
||||
customElements.define(ELEMENT, ExampleElement);
|
||||
23
webui/packages/plugin-ui/template/vite.config.ts
Normal file
23
webui/packages/plugin-ui/template/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// 前端插件组件的 Vite 库模式模板。
|
||||
//
|
||||
// 把本目录复制到插件根目录下(如 my-plugin/web-src/),把 package.json 中
|
||||
// @autobangumi/plugin-ui 的 link: 路径改为指向 webui/packages/plugin-ui,
|
||||
// 然后 `pnpm install && pnpm build`,产物写到插件的 web/index.js,
|
||||
// 清单中对应写 `entry = "web/index.js"`。
|
||||
//
|
||||
// 产物必须是单个自包含的 ES module:浏览器里没有裸模块名解析,CSP
|
||||
// (script-src 'self')也不允许 import map 等内联脚本与远程脚本,
|
||||
// 所以依赖全部打包进来,不设 external。
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: 'src/index.ts',
|
||||
formats: ['es'],
|
||||
fileName: () => 'index.js',
|
||||
},
|
||||
outDir: '../web',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
67
webui/packages/plugin-ui/tokens.css
Normal file
67
webui/packages/plugin-ui/tokens.css
Normal file
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 插件组件的主题变量:在 Shadow DOM 内引入(见 template/src/index.ts)。
|
||||
* --ab-* 是对插件稳定的名字,取宿主当前的设计令牌(webui/src/style/var.scss),
|
||||
* CSS 变量会穿过 Shadow DOM 继承,深浅色切换自动生效。括号中的浅色值只在
|
||||
* 宿主之外单独预览组件时使用。
|
||||
*/
|
||||
:host {
|
||||
--ab-color-primary: var(--color-primary, #6c4ab6);
|
||||
--ab-color-primary-hover: var(--color-primary-hover, #563a92);
|
||||
--ab-color-primary-light: var(--color-primary-light, #e8def8);
|
||||
--ab-color-primary-alpha: var(--color-primary-alpha, rgba(108, 74, 182, 0.2));
|
||||
--ab-color-accent: var(--color-accent, #f97316);
|
||||
--ab-color-success: var(--color-success, #22c55e);
|
||||
--ab-color-danger: var(--color-danger, #ef4444);
|
||||
--ab-color-warning: var(--color-warning, #f59e0b);
|
||||
|
||||
--ab-color-bg: var(--color-bg, #fafafa);
|
||||
--ab-color-surface: var(--color-surface, #ffffff);
|
||||
--ab-color-surface-hover: var(--color-surface-hover, #f5f5f5);
|
||||
--ab-color-surface-2: var(--color-surface-2, #f1f4f8);
|
||||
|
||||
--ab-color-text: var(--color-text, #1e293b);
|
||||
--ab-color-text-secondary: var(--color-text-secondary, #64748b);
|
||||
--ab-color-text-muted: var(--color-text-muted, #94a3b8);
|
||||
|
||||
--ab-color-border: var(--color-border, #e2e8f0);
|
||||
--ab-color-border-hover: var(--color-border-hover, #cbd5e1);
|
||||
|
||||
--ab-radius-sm: var(--radius-sm, 4px);
|
||||
--ab-radius-md: var(--radius-md, 8px);
|
||||
--ab-radius-lg: var(--radius-lg, 12px);
|
||||
--ab-radius-full: var(--radius-full, 9999px);
|
||||
|
||||
--ab-shadow-sm: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.05));
|
||||
--ab-shadow-md: var(
|
||||
--shadow-md,
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -2px rgba(0, 0, 0, 0.1)
|
||||
);
|
||||
|
||||
--ab-transition-fast: var(--transition-fast, 150ms ease-out);
|
||||
--ab-transition-normal: var(--transition-normal, 200ms ease-out);
|
||||
|
||||
--ab-font-family: var(
|
||||
--font-family,
|
||||
'Inter',
|
||||
-apple-system,
|
||||
'Noto Sans SC',
|
||||
'Microsoft YaHei',
|
||||
system-ui,
|
||||
sans-serif
|
||||
);
|
||||
--ab-font-mono: var(
|
||||
--font-mono,
|
||||
ui-monospace,
|
||||
'SF Mono',
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
Consolas,
|
||||
monospace
|
||||
);
|
||||
|
||||
/* 基础样式:字体与颜色本会从宿主继承,这里写明是为了单独预览时一致 */
|
||||
display: block;
|
||||
font-family: var(--ab-font-family);
|
||||
color: var(--ab-color-text);
|
||||
}
|
||||
12
webui/packages/plugin-ui/tsconfig.json
Normal file
12
webui/packages/plugin-ui/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"isolatedModules": true,
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
5
webui/pnpm-lock.yaml
generated
5
webui/pnpm-lock.yaml
generated
@@ -45,6 +45,9 @@ importers:
|
||||
'@antfu/eslint-config':
|
||||
specifier: ^0.38.6
|
||||
version: 0.38.6(eslint@8.57.1)(typescript@4.9.5)
|
||||
'@autobangumi/plugin-ui':
|
||||
specifier: workspace:*
|
||||
version: link:packages/plugin-ui
|
||||
'@icon-park/vue-next':
|
||||
specifier: ^1.4.2
|
||||
version: 1.4.2(vue@3.5.8(typescript@4.9.5))
|
||||
@@ -157,6 +160,8 @@ importers:
|
||||
specifier: ^1.8.27
|
||||
version: 1.8.27(typescript@4.9.5)
|
||||
|
||||
packages/plugin-ui: {}
|
||||
|
||||
packages:
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
|
||||
2
webui/pnpm-workspace.yaml
Normal file
2
webui/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- packages/*
|
||||
7
webui/public/theme-init.js
Normal file
7
webui/public/theme-init.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// 渲染前应用深色模式,避免闪烁。CSP 为 script-src 'self',不能写成内联脚本。
|
||||
(function () {
|
||||
const saved = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (saved === 'dark' || (!saved && prefersDark))
|
||||
document.documentElement.classList.add('dark');
|
||||
})();
|
||||
1
webui/types/dts/auto-imports.d.ts
vendored
1
webui/types/dts/auto-imports.d.ts
vendored
@@ -120,6 +120,7 @@ declare global {
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useDarkMode: typeof import('../../src/hooks/useDarkMode')['useDarkMode']
|
||||
const useDownloaderInstances: typeof import('../../src/hooks/useDownloaderInstances')['useDownloaderInstances']
|
||||
const useDownloaderStore: typeof import('../../src/store/downloader')['useDownloaderStore']
|
||||
const useEventStream: typeof import('../../src/hooks/useEventStream')['useEventStream']
|
||||
const useI18n: typeof import('vue-i18n')['useI18n']
|
||||
|
||||
@@ -10,6 +10,10 @@ import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import VueJsx from '@vitejs/plugin-vue-jsx';
|
||||
|
||||
// 与后端给 SPA 文档加的响应头一致(backend/src/main.py)。只加在 preview:
|
||||
// dev 服务器与 vite-plugin-pwa 的开发态会注入内联脚本,会被这个策略拦下
|
||||
const headers = { 'Content-Security-Policy': "script-src 'self'" };
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: './',
|
||||
@@ -115,6 +119,7 @@ export default defineConfig(({ mode }) => ({
|
||||
'#': resolve(__dirname, 'types'),
|
||||
},
|
||||
},
|
||||
preview: { headers },
|
||||
server: {
|
||||
proxy: {
|
||||
'^/api/.*': {
|
||||
|
||||
Reference in New Issue
Block a user