fix(plugin): invalidate federation static resource cache (#6748)

This commit is contained in:
moviepilot-codex[bot]
2026-09-21 16:24:27 +08:00
parent 17497eb4ca
commit c0f7141e26
5 changed files with 58 additions and 4 deletions

View File

@@ -1,6 +1,7 @@
import asyncio
import mimetypes
from typing import Annotated, Any, Dict, List, Optional
from urllib.parse import urlsplit
import aiofiles
from anyio import Path as AsyncPath
@@ -155,7 +156,7 @@ def _is_plugin_auth_remote_file(plugin_id: str, filepath: str) -> bool:
remote = provider.get("remote") or {}
if str(remote.get("id") or "").lower() != normalized_plugin_id:
continue
remote_path = str(remote.get("url") or "").lstrip("/")
remote_path = urlsplit(str(remote.get("url") or "")).path.lstrip("/")
remote_path_lower = remote_path.lower()
expected_prefix = f"plugin/file/{normalized_plugin_id}/"
if not remote_path_lower.startswith(expected_prefix):
@@ -756,7 +757,10 @@ async def plugin_static_file(
return StreamingResponse(
file_generator(),
media_type=response_type,
headers={"Content-Disposition": f"inline; filename={plugin_file_path.name}"},
headers={
"Content-Disposition": f"inline; filename={plugin_file_path.name}",
"Cache-Control": "no-cache, must-revalidate",
},
)
except Exception as e:
logger.error(

View File

@@ -2,6 +2,7 @@
import inspect
from typing import Any, Callable, Dict, List, Mapping, Optional
from urllib.parse import urlencode
from app.runtime.extensions.plugin.contracts import (
PluginDashboardError,
@@ -177,13 +178,19 @@ class PluginProjection:
"""构造联邦远程入口描述,分身额外带出其源插件 ID。
分身与本体共享同一份前端产物,只有源插件名下才有产物目录;前端联邦加载器
拿不到源插件 ID 就只能按分身 ID 去取,必然落空。
拿不到源插件 ID 就只能按分身 ID 去取,必然落空。入口 URL 同时带上插件版本,
让插件更新后得到新的浏览器与 Service Worker 缓存键。
"""
if not self._remote_entry_factory:
raise RuntimeError("插件联邦入口生成器尚未配置")
remote_url = self._remote_entry_factory(plugin_id, dist_path)
plugin_version = getattr(plugin, "plugin_version", None)
if plugin_version:
separator = "&" if "?" in remote_url else "?"
remote_url = f"{remote_url}{separator}{urlencode({'v': str(plugin_version)})}"
remote: Dict[str, Any] = {
"id": plugin_id,
"url": self._remote_entry_factory(plugin_id, dist_path),
"url": remote_url,
"name": plugin.plugin_name,
}
source_plugin_id = getattr(plugin, "plugin_source_id", None)

View File

@@ -274,6 +274,34 @@ def test_plugin_static_file_requires_resource_token_by_default(monkeypatch):
assert calls == ["resource-token"]
def test_plugin_auth_remote_accepts_versioned_entry_url(monkeypatch):
"""认证远程入口带缓存版本参数时仍允许匿名读取。"""
calls = []
class FakePluginManager:
"""返回带版本参数的认证远程入口桩。"""
def get_plugin_auth_providers(self):
"""返回认证远程入口列表。"""
return [{
"remote": {
"id": "AuthPlugin",
"url": "/plugin/file/AuthPlugin/dist/remoteEntry.js?v=1.2.3",
}
}]
monkeypatch.setattr(plugin_endpoint, "get_plugin_manager", lambda: FakePluginManager())
monkeypatch.setattr(plugin_endpoint, "verify_resource_token", lambda token: calls.append(token))
plugin_endpoint._verify_plugin_static_file_access(
plugin_id="AuthPlugin",
filepath="dist/remoteEntry.js",
resource_token=None,
)
assert calls == []
def test_plugin_auth_remote_files_allow_anonymous_bootstrap(monkeypatch):
"""插件登录认证远程组件需要允许登录前匿名加载。"""
calls = []

View File

@@ -1229,6 +1229,7 @@ def test_virtual_instance_static_file_reads_from_source_directory(tmp_path, monk
assert asyncio.run(read_body()) == b"export default 'shared'"
assert response.media_type == "application/javascript"
assert response.headers["cache-control"] == "no-cache, must-revalidate"
plugin_manager.get_plugin_source_id.assert_called_once_with("DemoPluginwork")

View File

@@ -217,6 +217,20 @@ def test_projection_builds_federation_and_auth_provider_entries():
}]
def test_projection_versions_federation_remote_entry_after_plugin_update():
"""插件版本变化时,联邦入口 URL 应生成新的缓存键。"""
plugin = _Plugin(
plugin_version="1.2.3+build 4",
get_render_mode=lambda: ("vue", "dist/assets"),
)
projection = PluginProjection(
{"Demo": plugin},
remote_entry_factory=lambda plugin_id, path: f"/{plugin_id}/{path}",
)
assert projection.remotes()[0]["url"] == "/Demo/dist/assets?v=1.2.3%2Bbuild+4"
def test_projection_exposes_source_identity_for_virtual_frontend_instance():
"""虚拟实例的联邦入口保留实例 URL,并补充共享源码身份。"""
plugin = _Plugin(