Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/plugins/core/runtime/bundle.py
T
insistence 4bd83bc7cd feat: 支持 v2 插件、独立前端和签名制品发布 (#130)
* feat: 支持 v2 插件、原生扩展与签名发布

* fix: 修复测试

* feat: 增强宿主 SDK、运行观测与制品维护能力

* feat: 增强文件传输、配置观测与本地开发能力

* feat: 支持 SSE 实时事件流并完善开发脚手架

* feat: 完善长连接鉴权复核与关闭回收

* feat: 增强生命周期与任务关闭可靠性

* feat: 收紧健康检查契约并补齐前端与真实网络验收

* feat: 增加SDK溯源与离线更新工具并验证跨版本兼容

* feat: 增加持久化业务示例与受控 Python 交付

* feat: 增加统一运行诊断与多worker状态观测

* feat: 增加固定目标发布等待与自动验收

* fix: 修复多环境插件部署前缀与代理配置不一致

* fix: 修复测试
2026-10-09 23:49:45 +08:00

278 lines
11 KiB
Python

import json
import mimetypes
import re
from html import escape
from pathlib import Path
from urllib.parse import quote
from starlette import status
from starlette._utils import get_route_path
from starlette.concurrency import run_in_threadpool
from starlette.datastructures import Headers
from starlette.responses import FileResponse, RedirectResponse, Response
from starlette.types import ASGIApp, Receive, Scope, Send
from plugins.core.manifest.v2 import FrontendBundleManifest
_MIME_TYPES = {
'.html': 'text/html',
'.htm': 'text/html',
'.js': 'text/javascript',
'.mjs': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.map': 'application/json',
'.svg': 'image/svg+xml',
'.wasm': 'application/wasm',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
}
_RESERVED_FALLBACK_PATHS = frozenset({'api', 'assets', 'ws', 'docs', 'redoc'})
_HEAD_TAG = re.compile(r'<head(?:\s[^>]*)?>', re.IGNORECASE)
_HTML_TAG = re.compile(r'<html(?:\s[^>]*)?>', re.IGNORECASE)
_DOCTYPE_TAG = re.compile(r'<!doctype[^>]*>', re.IGNORECASE)
_FIRST_PRINTABLE_CHARACTER = 32
class PluginBundleASGI:
"""
仅为 /ui 命名空间提供资源;必须包装在 PluginGatewayASGI 内。
构建目录应在部署后保持只读。此类只负责静态交付,不签发会话、不公开资源,
也不改变外层传输加密策略。lifespan 和非 UI 请求始终委派原始子应用。
"""
def __init__(
self,
child_app: ASGIApp,
plugin_id: str,
plugin_root: Path,
bundle_manifest: FrontendBundleManifest,
) -> None:
"""
初始化插件前端资源交付器并校验构建目录和入口。
:param child_app: 接收非 UI 请求的原始插件 ASGI 应用
:param plugin_id: 插件 ID
:param plugin_root: 插件资源根目录
:param bundle_manifest: 插件前端构建产物声明
:return: None
"""
self.child_app = child_app
self.plugin_id = plugin_id
self.plugin_root = Path(plugin_root).resolve(strict=True)
self.bundle_manifest = bundle_manifest
self.bundle_root = self._resolve_declared_path(self.plugin_root, bundle_manifest.directory)
if not self.bundle_root.is_dir():
raise ValueError('插件 bundle 目录不存在')
self.entry_path = self._resolve_declared_path(self.bundle_root, bundle_manifest.entry)
if not self.entry_path.is_file():
raise ValueError('插件 bundle 入口不存在')
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
"""
交付 UI 静态资源,并将其他请求委派给原始子应用。
:param scope: ASGI 请求作用域
:param receive: ASGI 消息接收函数
:param send: ASGI 消息发送函数
:return: None
"""
if scope['type'] != 'http':
await self.child_app(scope, receive, send)
return
route_path = get_route_path(scope)
if route_path != '/ui' and not route_path.startswith('/ui/'):
await self.child_app(scope, receive, send)
return
if scope['method'] not in {'GET', 'HEAD'}:
response = Response(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
headers={**self._headers('no-store'), 'Allow': 'GET, HEAD'},
)
elif route_path == '/ui':
location = self._base_paths(scope)['uiBase']
if scope.get('query_string'):
location += '?' + scope['query_string'].decode('latin-1')
response = RedirectResponse(location, headers=self._headers('no-store'))
else:
response = await run_in_threadpool(self._resource_response, scope, route_path[4:])
await response(scope, receive, send)
def _resource_response(self, scope: Scope, resource_path: str) -> Response:
"""
校验资源路径并生成文件响应或允许的页面回退响应。
:param scope: ASGI 请求作用域
:param resource_path: 相对于 UI 命名空间的请求资源路径
:return: 静态资源、HTML 页面或访问错误响应
"""
accepts_html = self._accepts_html(scope)
relative_path = resource_path.rstrip('/')
if resource_path and not self._safe_parts(relative_path):
return self._not_found()
candidate = self.entry_path if not resource_path else self.bundle_root / relative_path
try:
candidate = candidate.resolve()
if not candidate.is_relative_to(self.bundle_root):
return self._not_found()
if candidate.is_dir():
return self._not_found()
if not candidate.is_file():
if not self._allows_fallback(relative_path, accepts_html):
return self._not_found()
candidate = self._resolve_declared_path(self.bundle_root, self.bundle_manifest.entry)
elif resource_path.endswith('/'):
return self._not_found()
if not candidate.is_file():
return self._not_found()
media_type = _MIME_TYPES.get(candidate.suffix.lower()) or mimetypes.guess_type(candidate.name)[0]
if media_type == 'text/html':
if not accepts_html:
return Response(status_code=status.HTTP_406_NOT_ACCEPTABLE, headers=self._headers('no-store'))
html = candidate.read_text(encoding='utf-8')
if candidate == self.entry_path:
html = self._inject_config(html, scope)
content = html.encode('utf-8')
return Response(
content=content if scope['method'] == 'GET' else b'',
media_type='text/html',
headers={**self._headers('no-store'), 'Content-Length': str(len(content))},
)
return FileResponse(
candidate,
media_type=media_type or 'application/octet-stream',
headers=self._headers('no-cache'),
)
except (OSError, ValueError, RuntimeError):
return self._not_found()
def _allows_fallback(self, path: str, accepts_html: bool) -> bool:
"""
判断缺失资源是否允许回退到单页应用入口。
:param path: 相对于构建目录的请求路径
:param accepts_html: 请求是否接受 HTML 响应
:return: 是否允许执行页面回退
"""
parts = path.split('/')
return (
self.bundle_manifest.spa_fallback
and accepts_html
and parts[0].lower() not in _RESERVED_FALLBACK_PATHS
and all('.' not in part for part in parts)
)
def _inject_config(self, html: str, scope: Scope) -> str:
"""
向入口页面注入资源基址和经过转义的插件配置。
:param html: 插件入口页面的 HTML 内容
:param scope: 包含实际挂载路径的 ASGI 请求作用域
:return: 注入插件配置后的 HTML 内容
"""
paths = self._base_paths(scope)
config = json.dumps({'pluginId': self.plugin_id, **paths}, ensure_ascii=True)
# JSON 位于 HTML raw-text 元素中,转义 < 可阻止 </script> 提前结束数据块。
for character, escaped in (('<', '\\u003c'), ('>', '\\u003e'), ('&', '\\u0026')):
config = config.replace(character, escaped)
injected = (
f'<base href="{escape(paths["uiBase"], quote=True)}">'
f'<script id="ruoyi-plugin-config" type="application/json">{config}</script>'
)
head = _HEAD_TAG.search(html)
if head:
return html[: head.end()] + injected + html[head.end() :]
# 兼容省略 head 的 HTML;保留 doctype,避免把正常页面切换到 quirks mode。
opening = _HTML_TAG.search(html) or _DOCTYPE_TAG.search(html)
position = opening.end() if opening else 0
return html[:position] + f'<head>{injected}</head>' + html[position:]
@staticmethod
def _base_paths(scope: Scope) -> dict[str, str]:
"""
根据实际挂载路径生成插件页面和接口基址。
:param scope: ASGI 请求作用域
:return: 页面和接口基址字典
"""
mount_path = quote(scope.get('root_path', '').rstrip('/'), safe='/')
return {'uiBase': f'{mount_path}/ui/', 'apiBase': f'{mount_path}/api/'}
@staticmethod
def _accepts_html(scope: Scope) -> bool:
"""
检查 Accept 请求头是否明确接受有效权重的 HTML 类型。
:param scope: ASGI 请求作用域
:return: 请求是否接受 HTML 响应
"""
for item in Headers(scope=scope).get('accept', '').split(','):
media_type, *parameters = item.strip().lower().split(';')
if media_type != 'text/html':
continue
quality = next((part.strip()[2:] for part in parameters if part.strip().startswith('q=')), '1')
try:
if 0 < float(quality) <= 1:
return True
except ValueError:
continue
return False
@staticmethod
def _safe_parts(path: str) -> bool:
"""
检查相对资源路径的各级名称是否满足安全限制。
:param path: 待检查的相对资源路径
:return: 路径各级名称是否安全
"""
return bool(path) and all(
part
and not part.startswith('.')
and not part.endswith(('.', ' '))
and not any(character in '<>:"\\|?*' or ord(character) < _FIRST_PRINTABLE_CHARACTER for character in part)
for part in path.split('/')
)
@classmethod
def _resolve_declared_path(cls, root: Path, relative_path: str) -> Path:
"""
解析清单声明的资源路径并校验真实路径边界。
:param root: 允许访问的资源根目录
:param relative_path: 清单声明的相对资源路径
:return: 位于允许目录内的资源绝对路径
"""
if not cls._safe_parts(relative_path):
raise ValueError('插件 bundle 资源路径非法')
path = (root / relative_path).resolve()
if not path.is_relative_to(root):
raise ValueError('插件 bundle 资源路径越界')
return path
@staticmethod
def _headers(cache_control: str) -> dict[str, str]:
"""
生成静态资源的缓存控制和同源安全响应头。
:param cache_control: 当前资源的缓存控制策略
:return: 静态资源响应头字典
"""
return {
'Cache-Control': cache_control,
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'SAMEORIGIN',
'Content-Security-Policy': "frame-ancestors 'self'",
'Cross-Origin-Resource-Policy': 'same-origin',
}
@classmethod
def _not_found(cls) -> Response:
"""
生成禁止缓存的资源不存在响应。
:return: 携带安全响应头的 HTTP 404 响应
"""
return Response(status_code=status.HTTP_404_NOT_FOUND, headers=cls._headers('no-store'))