mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
* refactor: migrate to fastapi * structure refactor * fix: pyright fix * refactor: improve error handling and public messages in plugin services * feat(api): refactor API client integration and enhance request handling - Updated API client configuration to use a dedicated HTTP client. - Introduced utility functions for generating options, queries, and form data for API requests. - Refactored multiple API methods to utilize the new utility functions for improved consistency and readability. - Renamed types for clarity and updated import statements accordingly. feat(docs): add script to update OpenAPI JSON from YAML spec - Created a Python script to convert OpenAPI YAML specification to JSON format. - The script supports customizable input and output paths. - Ensured the script handles directory creation for output paths and validates the YAML structure. * fix * feat(auth): implement rate limiting for v1 login endpoint and enhance request handling * Refactor dashboard API routers to use legacy_router for backward compatibility - Changed all instances of dashboard_router to legacy_router across multiple API modules including platform, plugins, providers, sessions, skills, stats, subagents, t2i, tools, updates, and asgi_runtime. - Updated route definitions to ensure existing endpoints remain functional under the new router structure. - Introduced support for Quart request context in asgi_runtime to enhance compatibility with existing Quart-based plugins. - Added a test case to validate the functionality of the new Quart request context handling in plugin extensions. * chore: remove cli test * fix: update dashboard tests for fastapi migration * chore: satisfy ruff checks * fix: update openapi api key scopes * fix: sync config scope chip selection * fix: restore quart dependency * docs: clarify quart plugin api compatibility * docs: update openapi scope documentation * fix: use singular skill openapi scope * fix: hide update service exception details * fix: address fastapi review comments * fix: address dashboard review findings * docs: revert unrelated package deployment changes * docs: update agent api generation guidance * feat: add plugin page web api helpers * docs: add plugin page bridge demo * fix: type plugin upload files * fix: stabilize plugin page uploads * fix: type plugin web request proxy * docs: remove plugin page docs example * fix: authenticate plugin page SSE bridge
932 lines
31 KiB
Python
932 lines
31 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
import posixpath
|
|
import re
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import cast
|
|
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
|
|
|
|
import aiofiles
|
|
import jwt
|
|
from aiofiles import ospath as aio_ospath
|
|
|
|
from astrbot.core.config.astrbot_config import AstrBotConfig
|
|
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
|
from astrbot.core.star.star import StarMetadata
|
|
from astrbot.core.star.star_manager import PluginManager
|
|
|
|
PLUGIN_PAGE_ASSET_TOKEN_TYPE = "plugin_page_asset"
|
|
PLUGIN_PAGE_ASSET_TOKEN_TTL_SECONDS = 60
|
|
PLUGIN_PAGE_ROOT_DIR_NAME = "pages"
|
|
PLUGIN_PAGE_ENTRY_FILE_NAME = "index.html"
|
|
PLUGIN_PAGE_BRIDGE_FILE = (
|
|
Path(__file__).resolve().parent.parent / "plugin_page_bridge.js"
|
|
)
|
|
|
|
_HTML_ASSET_ATTR_RE = re.compile(
|
|
r"(?P<attr>src|href)=(?P<quote>[\"\'])(?P<url>.*?)(?P=quote)",
|
|
re.IGNORECASE,
|
|
)
|
|
_CSS_URL_RE = re.compile(
|
|
r"url\(\s*(?P<quote>[\"\']?)(?P<url>.*?)(?P=quote)\s*\)",
|
|
re.IGNORECASE,
|
|
)
|
|
_JS_DYNAMIC_IMPORT_RE = re.compile(
|
|
r"(?P<prefix>\bimport\s*\(\s*)(?P<quote>[\"\'])(?P<url>.*?)(?P=quote)(?P<suffix>\s*\))",
|
|
re.IGNORECASE,
|
|
)
|
|
_JS_MODULE_FROM_RE = re.compile(
|
|
r"(?P<prefix>\b(?:import|export)\s+(?:[^;]*?\s+from\s+))(?P<quote>[\"\'])(?P<url>.*?)(?P=quote)",
|
|
re.IGNORECASE | re.DOTALL,
|
|
)
|
|
_JS_SIDE_EFFECT_IMPORT_RE = re.compile(
|
|
r"(?P<prefix>\bimport\s+)(?P<quote>[\"\'])(?P<url>[^\"'\r\n]+)(?P=quote)",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class PluginPage:
|
|
name: str
|
|
title: str
|
|
entry_file: str = PLUGIN_PAGE_ENTRY_FILE_NAME
|
|
|
|
|
|
@dataclass
|
|
class PluginPageContentPayload:
|
|
content: str | bytes
|
|
content_type: str
|
|
|
|
|
|
class PluginPageServiceError(Exception):
|
|
def __init__(
|
|
self,
|
|
message: str,
|
|
status_code: int = 400,
|
|
*,
|
|
public_message: str | None = None,
|
|
) -> None:
|
|
super().__init__(message)
|
|
self.status_code = status_code
|
|
self.public_message = public_message or message
|
|
|
|
|
|
class PluginPageService:
|
|
def __init__(
|
|
self,
|
|
plugin_manager: PluginManager,
|
|
core_lifecycle: AstrBotCoreLifecycle | None = None,
|
|
config: AstrBotConfig | None = None,
|
|
) -> None:
|
|
self.plugin_manager = plugin_manager
|
|
self.config = config or (
|
|
core_lifecycle.astrbot_config if core_lifecycle is not None else None
|
|
)
|
|
self.bridge_file = PLUGIN_PAGE_BRIDGE_FILE
|
|
|
|
def _jwt_secret(self) -> str | None:
|
|
if self.config is None:
|
|
return None
|
|
return self.config.get("dashboard", {}).get("jwt_secret")
|
|
|
|
def get_plugin_metadata_by_name(self, plugin_name: str) -> StarMetadata | None:
|
|
for plugin in self.plugin_manager.context.get_all_stars():
|
|
if plugin.name == plugin_name:
|
|
return plugin
|
|
return None
|
|
|
|
@staticmethod
|
|
def get_by_path(source: dict | None, key: str):
|
|
if not isinstance(source, dict) or not key:
|
|
return None
|
|
current = source
|
|
for part in key.split("."):
|
|
if not isinstance(current, dict) or part not in current:
|
|
return None
|
|
current = current[part]
|
|
return current
|
|
|
|
@staticmethod
|
|
def apply_theme_to_html(html: str, theme: str) -> str:
|
|
def _replace_html_tag(m: re.Match) -> str:
|
|
attrs = m.group(1) or ""
|
|
attrs = re.sub(
|
|
r'\s+data-theme\s*=\s*["\'][^"\']*["\']',
|
|
"",
|
|
attrs,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
return f'<html{attrs} data-theme="{theme}">'
|
|
|
|
html = re.sub(
|
|
r"<html(\b[^>]*)>",
|
|
_replace_html_tag,
|
|
html,
|
|
count=1,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
|
|
meta_tag = f'<meta name="color-scheme" content="{theme}">'
|
|
html = re.sub(
|
|
r'<meta\s[^>]*name\s*=\s*["\']color-scheme["\'][^>]*>',
|
|
"",
|
|
html,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
|
|
head_match = re.search(r"<head\b[^>]*>", html, re.IGNORECASE)
|
|
if head_match:
|
|
html = html.replace(
|
|
head_match.group(0), f"{head_match.group(0)}{meta_tag}", 1
|
|
)
|
|
else:
|
|
html = re.sub(
|
|
r"(<html\b[^>]*>)",
|
|
rf"\1<head>{meta_tag}</head>",
|
|
html,
|
|
count=1,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
return html
|
|
|
|
def build_initial_context(
|
|
self,
|
|
*,
|
|
asset_token: str,
|
|
jwt_secret: str | None = None,
|
|
locale: str,
|
|
theme: str | None,
|
|
) -> dict | None:
|
|
if not asset_token:
|
|
return None
|
|
jwt_secret = jwt_secret or self._jwt_secret()
|
|
if not isinstance(jwt_secret, str) or not jwt_secret.strip():
|
|
return None
|
|
|
|
try:
|
|
payload = jwt.decode(asset_token, jwt_secret, algorithms=["HS256"])
|
|
except jwt.InvalidTokenError:
|
|
return None
|
|
if payload.get("token_type") != PLUGIN_PAGE_ASSET_TOKEN_TYPE:
|
|
return None
|
|
|
|
plugin_name = payload.get("plugin_name")
|
|
page_name = payload.get("page_name")
|
|
if not isinstance(plugin_name, str) or not isinstance(page_name, str):
|
|
return None
|
|
|
|
plugin = self.get_plugin_metadata_by_name(plugin_name)
|
|
if not plugin:
|
|
return None
|
|
|
|
resolved_locale = locale
|
|
token_locale = payload.get("locale")
|
|
if isinstance(token_locale, str):
|
|
resolved_locale = token_locale
|
|
plugin_i18n = plugin.i18n or {}
|
|
try:
|
|
plugin_root = self.get_plugin_root_dir(plugin)
|
|
fresh_i18n = PluginManager._load_plugin_i18n(str(plugin_root))
|
|
if fresh_i18n:
|
|
plugin_i18n = fresh_i18n
|
|
except (OSError, ValueError):
|
|
pass
|
|
|
|
locale_data = plugin_i18n.get(resolved_locale)
|
|
display_name = (
|
|
self.get_by_path(locale_data, "metadata.display_name")
|
|
or plugin.display_name
|
|
or plugin.name
|
|
)
|
|
page_title = (
|
|
self.get_by_path(locale_data, f"pages.{page_name}.title") or page_name
|
|
)
|
|
|
|
return {
|
|
"pluginName": plugin.name,
|
|
"displayName": display_name,
|
|
"pageName": page_name,
|
|
"pageTitle": page_title,
|
|
"locale": resolved_locale,
|
|
"i18n": plugin_i18n,
|
|
"isDark": theme == "dark",
|
|
}
|
|
|
|
async def get_plugin_page_entry_config(
|
|
self,
|
|
*,
|
|
plugin_name: str | None,
|
|
page_name: str | None,
|
|
jwt_secret: str | None = None,
|
|
username: str | None,
|
|
locale: str,
|
|
) -> dict:
|
|
if not plugin_name:
|
|
raise PluginPageServiceError("缺少插件名")
|
|
if not page_name:
|
|
raise PluginPageServiceError("缺少 Page 名称")
|
|
|
|
plugin = self.get_plugin_metadata_by_name(plugin_name)
|
|
if not plugin:
|
|
raise PluginPageServiceError("插件不存在")
|
|
if not plugin.activated:
|
|
raise PluginPageServiceError("插件未启用")
|
|
|
|
page = await self.serialize_plugin_page_for_request(
|
|
plugin,
|
|
page_name,
|
|
include_content_path=True,
|
|
jwt_secret=jwt_secret,
|
|
username=username,
|
|
locale=locale,
|
|
)
|
|
if not page:
|
|
raise PluginPageServiceError("插件 Page 不存在")
|
|
return page
|
|
|
|
async def serialize_plugin_page_for_request(
|
|
self,
|
|
plugin: StarMetadata,
|
|
page_name: str,
|
|
*,
|
|
include_content_path: bool = False,
|
|
jwt_secret: str | None = None,
|
|
username: str | None,
|
|
locale: str,
|
|
) -> dict | None:
|
|
asset_token = ""
|
|
if include_content_path:
|
|
plugin_name = plugin.name.strip() if isinstance(plugin.name, str) else ""
|
|
asset_token = (
|
|
self.issue_plugin_page_asset_token(
|
|
plugin_name=plugin_name,
|
|
page_name=page_name,
|
|
jwt_secret=jwt_secret or self._jwt_secret(),
|
|
username=username,
|
|
locale=locale,
|
|
)
|
|
or ""
|
|
)
|
|
return await self.serialize_plugin_page(
|
|
plugin,
|
|
page_name,
|
|
include_content_path=include_content_path,
|
|
asset_token=asset_token,
|
|
)
|
|
|
|
def prepare_plugin_page_query_params(
|
|
self,
|
|
plugin_name: str,
|
|
page_name: str,
|
|
*,
|
|
asset_token: str,
|
|
jwt_secret: str | None = None,
|
|
username: str | None,
|
|
locale: str,
|
|
theme: str | None,
|
|
) -> dict[str, str] | None:
|
|
if not asset_token:
|
|
asset_token = (
|
|
self.issue_plugin_page_asset_token(
|
|
plugin_name=plugin_name,
|
|
page_name=page_name,
|
|
jwt_secret=jwt_secret or self._jwt_secret(),
|
|
username=username,
|
|
locale=locale,
|
|
)
|
|
or ""
|
|
)
|
|
|
|
if not asset_token and not theme:
|
|
return None
|
|
|
|
params: dict[str, str] = {}
|
|
if asset_token:
|
|
params["asset_token"] = asset_token
|
|
if theme:
|
|
params["theme"] = theme
|
|
return params
|
|
|
|
async def serve_bridge_sdk(
|
|
self,
|
|
*,
|
|
asset_token: str,
|
|
jwt_secret: str | None = None,
|
|
locale: str,
|
|
theme: str | None,
|
|
) -> PluginPageContentPayload:
|
|
if not self.bridge_file.is_file():
|
|
raise PluginPageServiceError(
|
|
"Plugin Page bridge SDK not found",
|
|
status_code=404,
|
|
)
|
|
bridge_js = await self.read_plugin_page_text(self.bridge_file)
|
|
initial_context = self.build_initial_context(
|
|
asset_token=asset_token,
|
|
jwt_secret=jwt_secret,
|
|
locale=locale,
|
|
theme=theme,
|
|
)
|
|
if initial_context:
|
|
context_json = json.dumps(initial_context, ensure_ascii=False)
|
|
bridge_js += (
|
|
f"\n;window.AstrBotPluginPage?.__setInitialContext({context_json});\n"
|
|
)
|
|
return PluginPageContentPayload(
|
|
content=bridge_js,
|
|
content_type="application/javascript; charset=utf-8",
|
|
)
|
|
|
|
async def serve_page_content(
|
|
self,
|
|
*,
|
|
plugin_name: str,
|
|
page_name: str,
|
|
asset_path: str,
|
|
asset_token: str,
|
|
jwt_secret: str | None = None,
|
|
username: str | None,
|
|
locale: str,
|
|
theme: str | None,
|
|
) -> PluginPageContentPayload:
|
|
plugin = self.get_plugin_metadata_by_name(plugin_name)
|
|
if not plugin:
|
|
raise PluginPageServiceError("Plugin not found", status_code=404)
|
|
if not plugin.activated:
|
|
raise PluginPageServiceError("Plugin is disabled", status_code=403)
|
|
|
|
try:
|
|
page = await self.get_plugin_page(plugin, page_name)
|
|
file_path = await self.resolve_plugin_page_file(
|
|
plugin,
|
|
page.name,
|
|
asset_path,
|
|
)
|
|
except (FileNotFoundError, ValueError) as exc:
|
|
raise PluginPageServiceError(
|
|
"Plugin Page asset not found",
|
|
status_code=404,
|
|
) from exc
|
|
|
|
extra_query_params = self.prepare_plugin_page_query_params(
|
|
plugin_name,
|
|
page.name,
|
|
asset_token=asset_token,
|
|
jwt_secret=jwt_secret,
|
|
username=username,
|
|
locale=locale,
|
|
theme=theme,
|
|
)
|
|
served_asset_path = asset_path or page.entry_file
|
|
suffix = file_path.suffix.lower()
|
|
if suffix == ".html":
|
|
html_text = await self.read_plugin_page_text(file_path)
|
|
return PluginPageContentPayload(
|
|
content=self.rewrite_plugin_page_html(
|
|
html_text,
|
|
plugin_name,
|
|
page.name,
|
|
served_asset_path,
|
|
theme=theme,
|
|
extra_query_params=extra_query_params,
|
|
),
|
|
content_type="text/html; charset=utf-8",
|
|
)
|
|
if suffix == ".css":
|
|
css_text = await self.read_plugin_page_text(file_path)
|
|
return PluginPageContentPayload(
|
|
content=self.rewrite_plugin_page_css(
|
|
css_text,
|
|
plugin_name,
|
|
page.name,
|
|
served_asset_path,
|
|
extra_query_params=extra_query_params,
|
|
),
|
|
content_type="text/css; charset=utf-8",
|
|
)
|
|
if suffix in {".js", ".mjs"}:
|
|
js_text = await self.read_plugin_page_text(file_path)
|
|
return PluginPageContentPayload(
|
|
content=self.rewrite_plugin_page_js(
|
|
js_text,
|
|
plugin_name,
|
|
page.name,
|
|
served_asset_path,
|
|
extra_query_params=extra_query_params,
|
|
),
|
|
content_type="application/javascript; charset=utf-8",
|
|
)
|
|
return PluginPageContentPayload(
|
|
content=await self.read_plugin_page_binary(file_path),
|
|
content_type=self.guess_plugin_page_mime_type(file_path),
|
|
)
|
|
|
|
@staticmethod
|
|
def build_security_headers() -> dict[str, str]:
|
|
headers = {
|
|
"Cache-Control": "no-store",
|
|
"Referrer-Policy": "no-referrer",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Cross-Origin-Resource-Policy": "cross-origin",
|
|
"Access-Control-Allow-Origin": "*",
|
|
}
|
|
|
|
csp = "object-src 'none'; base-uri 'self'"
|
|
if os.environ.get("ASTRBOT_LAUNCHER") not in ("1", "true"):
|
|
headers["X-Frame-Options"] = "SAMEORIGIN"
|
|
csp = f"frame-ancestors 'self'; {csp}"
|
|
headers["Content-Security-Policy"] = csp
|
|
return headers
|
|
|
|
@staticmethod
|
|
def normalize_plugin_page_path(
|
|
raw_path: str,
|
|
*,
|
|
base_dir: str | None = None,
|
|
allow_empty: bool = False,
|
|
) -> str:
|
|
path = raw_path.replace("\\", "/").strip()
|
|
if base_dir:
|
|
path = posixpath.join(base_dir, path)
|
|
normalized = posixpath.normpath(path)
|
|
if normalized in {"", "."}:
|
|
if allow_empty:
|
|
return ""
|
|
raise ValueError("Invalid plugin Page asset path")
|
|
if (
|
|
normalized.startswith("../")
|
|
or normalized == ".."
|
|
or normalized.startswith("/")
|
|
):
|
|
raise ValueError("Invalid plugin Page asset path")
|
|
return normalized
|
|
|
|
@staticmethod
|
|
def normalize_plugin_page_name(raw_name: str) -> str:
|
|
page_name = raw_name.strip()
|
|
if not page_name:
|
|
raise ValueError("Invalid plugin Page name")
|
|
normalized = posixpath.normpath(page_name.replace("\\", "/"))
|
|
if (
|
|
normalized != page_name
|
|
or normalized in {".", ".."}
|
|
or normalized.startswith(".")
|
|
or "/" in page_name
|
|
or "\\" in page_name
|
|
):
|
|
raise ValueError("Invalid plugin Page name")
|
|
return page_name
|
|
|
|
def get_plugin_root_dir(self, plugin: StarMetadata) -> Path:
|
|
if not plugin.root_dir_name:
|
|
raise FileNotFoundError("Plugin directory metadata is missing")
|
|
|
|
base_dir = Path(
|
|
self.plugin_manager.reserved_plugin_path
|
|
if plugin.reserved
|
|
else self.plugin_manager.plugin_store_path
|
|
).resolve(strict=False)
|
|
plugin_root = (base_dir / plugin.root_dir_name).resolve(strict=False)
|
|
plugin_root.relative_to(base_dir)
|
|
return plugin_root
|
|
|
|
async def resolve_plugin_pages_root(self, plugin: StarMetadata) -> Path:
|
|
plugin_root = self.get_plugin_root_dir(plugin)
|
|
pages_root = (plugin_root / PLUGIN_PAGE_ROOT_DIR_NAME).resolve(strict=False)
|
|
pages_root.relative_to(plugin_root)
|
|
if pages_root == plugin_root:
|
|
raise FileNotFoundError("Plugin Pages root directory is invalid")
|
|
if not await aio_ospath.isdir(str(pages_root)):
|
|
raise FileNotFoundError("Plugin Pages root directory does not exist")
|
|
return pages_root
|
|
|
|
async def discover_plugin_pages(self, plugin: StarMetadata) -> list[PluginPage]:
|
|
try:
|
|
pages_root = await self.resolve_plugin_pages_root(plugin)
|
|
except (FileNotFoundError, ValueError):
|
|
return []
|
|
|
|
pages: list[PluginPage] = []
|
|
try:
|
|
page_dirs = sorted(
|
|
(item for item in pages_root.iterdir() if item.is_dir()),
|
|
key=lambda item: item.name.lower(),
|
|
)
|
|
except OSError:
|
|
return []
|
|
|
|
for page_dir in page_dirs:
|
|
try:
|
|
page_name = self.normalize_plugin_page_name(page_dir.name)
|
|
except ValueError:
|
|
continue
|
|
entry_path = page_dir / PLUGIN_PAGE_ENTRY_FILE_NAME
|
|
if not await aio_ospath.isfile(str(entry_path)):
|
|
continue
|
|
pages.append(
|
|
PluginPage(
|
|
name=page_name,
|
|
title=page_name,
|
|
entry_file=PLUGIN_PAGE_ENTRY_FILE_NAME,
|
|
)
|
|
)
|
|
return pages
|
|
|
|
async def get_plugin_page(
|
|
self,
|
|
plugin: StarMetadata,
|
|
page_name: str,
|
|
) -> PluginPage:
|
|
normalized_name = self.normalize_plugin_page_name(page_name)
|
|
for page in await self.discover_plugin_pages(plugin):
|
|
if page.name == normalized_name:
|
|
return page
|
|
raise FileNotFoundError("Plugin Page entry not found")
|
|
|
|
async def resolve_plugin_page_root(
|
|
self,
|
|
plugin: StarMetadata,
|
|
page_name: str,
|
|
) -> Path:
|
|
normalized_name = self.normalize_plugin_page_name(page_name)
|
|
pages_root = await self.resolve_plugin_pages_root(plugin)
|
|
page_root = (pages_root / normalized_name).resolve(strict=False)
|
|
page_root.relative_to(pages_root)
|
|
if not await aio_ospath.isdir(str(page_root)):
|
|
raise FileNotFoundError("Plugin Page root directory does not exist")
|
|
return page_root
|
|
|
|
async def resolve_plugin_page_file(
|
|
self,
|
|
plugin: StarMetadata,
|
|
page_name: str,
|
|
asset_path: str,
|
|
) -> Path:
|
|
page = await self.get_plugin_page(plugin, page_name)
|
|
page_root = await self.resolve_plugin_page_root(plugin, page.name)
|
|
target_name = (
|
|
self.normalize_plugin_page_path(asset_path, allow_empty=True)
|
|
or page.entry_file
|
|
)
|
|
target_path = (page_root / target_name).resolve(strict=False)
|
|
target_path.relative_to(page_root)
|
|
if not await aio_ospath.isfile(str(target_path)):
|
|
raise FileNotFoundError("Plugin Page asset not found")
|
|
return target_path
|
|
|
|
@staticmethod
|
|
def is_rewritable_asset_url(raw_url: str) -> bool:
|
|
value = raw_url.strip()
|
|
lower = value.lower()
|
|
if not value:
|
|
return False
|
|
if value.startswith(("#", "/#")):
|
|
return False
|
|
if lower.startswith(
|
|
(
|
|
"http://",
|
|
"https://",
|
|
"//",
|
|
"data:",
|
|
"javascript:",
|
|
"mailto:",
|
|
"tel:",
|
|
"blob:",
|
|
)
|
|
):
|
|
return False
|
|
return True
|
|
|
|
@staticmethod
|
|
def resolve_referenced_asset_path(
|
|
base_asset_path: str,
|
|
referenced_url: str,
|
|
) -> str:
|
|
parts = urlsplit(referenced_url)
|
|
referenced_path = parts.path.strip()
|
|
if not referenced_path:
|
|
raise ValueError("Plugin Page referenced asset path is empty")
|
|
base_dir = posixpath.dirname(base_asset_path) if base_asset_path else ""
|
|
normalized = PluginPageService.normalize_plugin_page_path(
|
|
referenced_path,
|
|
base_dir=base_dir,
|
|
)
|
|
if not normalized:
|
|
raise ValueError("Plugin Page referenced asset path is invalid")
|
|
return normalized
|
|
|
|
def build_plugin_page_asset_url(
|
|
self,
|
|
plugin_name: str,
|
|
page_name: str,
|
|
asset_path: str,
|
|
original_query: str = "",
|
|
original_fragment: str = "",
|
|
extra_query_params: dict[str, str] | None = None,
|
|
) -> str:
|
|
path = self.build_plugin_page_content_path(plugin_name, page_name, asset_path)
|
|
query_dict = dict(parse_qsl(original_query, keep_blank_values=True))
|
|
if extra_query_params:
|
|
for key, value in extra_query_params.items():
|
|
if value:
|
|
query_dict[key] = value
|
|
query = urlencode(query_dict)
|
|
return urlunsplit(("", "", path, query, original_fragment))
|
|
|
|
@staticmethod
|
|
def build_plugin_page_content_path(
|
|
plugin_name: str,
|
|
page_name: str,
|
|
asset_path: str = "",
|
|
) -> str:
|
|
encoded_plugin_name = quote(plugin_name, safe="")
|
|
encoded_page_name = quote(
|
|
PluginPageService.normalize_plugin_page_name(page_name),
|
|
safe="",
|
|
)
|
|
if not asset_path:
|
|
return (
|
|
f"/api/plugin/page/content/{encoded_plugin_name}/{encoded_page_name}/"
|
|
)
|
|
safe_asset_path = PluginPageService.normalize_plugin_page_path(
|
|
asset_path,
|
|
allow_empty=True,
|
|
)
|
|
encoded_path = "/".join(
|
|
quote(part, safe="") for part in safe_asset_path.split("/")
|
|
)
|
|
return (
|
|
f"/api/plugin/page/content/{encoded_plugin_name}/"
|
|
f"{encoded_page_name}/{encoded_path}"
|
|
)
|
|
|
|
@staticmethod
|
|
def get_plugin_page_bridge_sdk_url(
|
|
extra_query_params: dict[str, str] | None = None,
|
|
) -> str:
|
|
query = urlencode(extra_query_params or {})
|
|
return urlunsplit(("", "", "/api/plugin/page/bridge-sdk.js", query, ""))
|
|
|
|
@staticmethod
|
|
def is_js_relative_module_specifier(raw_url: str) -> bool:
|
|
value = raw_url.strip()
|
|
return value.startswith(("./", "../", "/"))
|
|
|
|
def rewrite_relative_asset_url(
|
|
self,
|
|
raw_url: str,
|
|
base_asset_path: str,
|
|
plugin_name: str,
|
|
page_name: str,
|
|
extra_query_params: dict[str, str] | None = None,
|
|
) -> str | None:
|
|
candidate = raw_url.strip()
|
|
if not self.is_rewritable_asset_url(candidate):
|
|
return None
|
|
parts = urlsplit(candidate)
|
|
asset_path = self.resolve_referenced_asset_path(base_asset_path, candidate)
|
|
return self.build_plugin_page_asset_url(
|
|
plugin_name,
|
|
page_name,
|
|
asset_path,
|
|
original_query=parts.query,
|
|
original_fragment=parts.fragment,
|
|
extra_query_params=extra_query_params,
|
|
)
|
|
|
|
def rewrite_plugin_page_html(
|
|
self,
|
|
html_text: str,
|
|
plugin_name: str,
|
|
page_name: str,
|
|
entry_asset_path: str,
|
|
*,
|
|
theme: str | None,
|
|
extra_query_params: dict[str, str] | None = None,
|
|
) -> str:
|
|
def replace_attr(match: re.Match[str]) -> str:
|
|
raw_url = match.group("url")
|
|
attr = match.group("attr")
|
|
quote_char = match.group("quote")
|
|
|
|
if raw_url.strip() == "/api/plugin/page/bridge-sdk.js":
|
|
url = self.get_plugin_page_bridge_sdk_url(extra_query_params)
|
|
return f"{attr}={quote_char}{url}{quote_char}"
|
|
|
|
if not self.is_rewritable_asset_url(raw_url):
|
|
return match.group(0)
|
|
|
|
try:
|
|
rewritten_url = self.rewrite_relative_asset_url(
|
|
raw_url,
|
|
entry_asset_path,
|
|
plugin_name,
|
|
page_name,
|
|
extra_query_params=extra_query_params,
|
|
)
|
|
if not rewritten_url:
|
|
return match.group(0)
|
|
return f"{attr}={quote_char}{rewritten_url}{quote_char}"
|
|
except ValueError:
|
|
return match.group(0)
|
|
|
|
rewritten_html = _HTML_ASSET_ATTR_RE.sub(replace_attr, html_text)
|
|
if theme:
|
|
rewritten_html = self.apply_theme_to_html(rewritten_html, theme)
|
|
if "/api/plugin/page/bridge-sdk.js" not in rewritten_html:
|
|
bridge_tag = f'<script src="{self.get_plugin_page_bridge_sdk_url(extra_query_params)}"></script>'
|
|
if "</body>" in rewritten_html:
|
|
rewritten_html = rewritten_html.replace(
|
|
"</body>", f"{bridge_tag}</body>", 1
|
|
)
|
|
else:
|
|
rewritten_html += bridge_tag
|
|
return rewritten_html
|
|
|
|
def rewrite_plugin_page_css(
|
|
self,
|
|
css_text: str,
|
|
plugin_name: str,
|
|
page_name: str,
|
|
css_asset_path: str,
|
|
extra_query_params: dict[str, str] | None = None,
|
|
) -> str:
|
|
def replace_url(match: re.Match[str]) -> str:
|
|
raw_url = match.group("url").strip()
|
|
quote_char = match.group("quote") or ""
|
|
try:
|
|
rewritten_url = self.rewrite_relative_asset_url(
|
|
raw_url,
|
|
css_asset_path,
|
|
plugin_name,
|
|
page_name,
|
|
extra_query_params=extra_query_params,
|
|
)
|
|
if not rewritten_url:
|
|
return match.group(0)
|
|
return f"url({quote_char}{rewritten_url}{quote_char})"
|
|
except ValueError:
|
|
return match.group(0)
|
|
|
|
return _CSS_URL_RE.sub(replace_url, css_text)
|
|
|
|
def rewrite_plugin_page_js(
|
|
self,
|
|
js_text: str,
|
|
plugin_name: str,
|
|
page_name: str,
|
|
js_asset_path: str,
|
|
extra_query_params: dict[str, str] | None = None,
|
|
) -> str:
|
|
def rewrite_specifier(raw_url: str) -> str:
|
|
if not self.is_js_relative_module_specifier(raw_url):
|
|
return raw_url
|
|
if not self.is_rewritable_asset_url(raw_url):
|
|
return raw_url
|
|
rewritten = self.rewrite_relative_asset_url(
|
|
raw_url,
|
|
js_asset_path,
|
|
plugin_name,
|
|
page_name,
|
|
extra_query_params=extra_query_params,
|
|
)
|
|
return rewritten or raw_url
|
|
|
|
def replace_dynamic(match: re.Match[str]) -> str:
|
|
raw_url = match.group("url")
|
|
try:
|
|
rewritten = rewrite_specifier(raw_url)
|
|
except ValueError:
|
|
return match.group(0)
|
|
return (
|
|
f"{match.group('prefix')}{match.group('quote')}{rewritten}"
|
|
f"{match.group('quote')}{match.group('suffix')}"
|
|
)
|
|
|
|
def replace_from(match: re.Match[str]) -> str:
|
|
raw_url = match.group("url")
|
|
try:
|
|
rewritten = rewrite_specifier(raw_url)
|
|
except ValueError:
|
|
return match.group(0)
|
|
return (
|
|
f"{match.group('prefix')}{match.group('quote')}"
|
|
f"{rewritten}{match.group('quote')}"
|
|
)
|
|
|
|
rewritten_js = _JS_DYNAMIC_IMPORT_RE.sub(replace_dynamic, js_text)
|
|
rewritten_js = _JS_MODULE_FROM_RE.sub(replace_from, rewritten_js)
|
|
|
|
def replace_side_effect(match: re.Match[str]) -> str:
|
|
raw_url = match.group("url")
|
|
if raw_url.startswith(("{", "*")):
|
|
return match.group(0)
|
|
try:
|
|
rewritten = rewrite_specifier(raw_url)
|
|
except ValueError:
|
|
return match.group(0)
|
|
return (
|
|
f"{match.group('prefix')}{match.group('quote')}"
|
|
f"{rewritten}{match.group('quote')}"
|
|
)
|
|
|
|
return _JS_SIDE_EFFECT_IMPORT_RE.sub(replace_side_effect, rewritten_js)
|
|
|
|
@staticmethod
|
|
async def read_plugin_page_text(file_path: Path) -> str:
|
|
async with aiofiles.open(file_path, encoding="utf-8") as file:
|
|
return await file.read()
|
|
|
|
@staticmethod
|
|
async def read_plugin_page_binary(file_path: Path) -> bytes:
|
|
async with aiofiles.open(file_path, mode="rb") as file:
|
|
return await file.read()
|
|
|
|
@staticmethod
|
|
def guess_plugin_page_mime_type(file_path: Path) -> str:
|
|
return mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
|
|
|
|
async def serialize_plugin_page(
|
|
self,
|
|
plugin: StarMetadata,
|
|
page_name: str,
|
|
*,
|
|
include_content_path: bool = False,
|
|
asset_token: str = "",
|
|
) -> dict | None:
|
|
plugin_name = plugin.name.strip() if isinstance(plugin.name, str) else ""
|
|
if not plugin_name:
|
|
return None
|
|
try:
|
|
page = await self.get_plugin_page(plugin, page_name)
|
|
await self.resolve_plugin_page_file(plugin, page.name, "")
|
|
except (FileNotFoundError, ValueError):
|
|
return None
|
|
|
|
page_data = {
|
|
"name": page.name,
|
|
"title": page.title,
|
|
"i18n_key": f"pages.{page.name}",
|
|
}
|
|
if include_content_path:
|
|
extra_query_params = {"asset_token": asset_token} if asset_token else None
|
|
page_data["content_path"] = self.build_plugin_page_asset_url(
|
|
plugin_name,
|
|
page.name,
|
|
"",
|
|
extra_query_params=extra_query_params,
|
|
)
|
|
return page_data
|
|
|
|
async def serialize_plugin_pages(self, plugin: StarMetadata) -> list[dict]:
|
|
pages = []
|
|
for page in await self.discover_plugin_pages(plugin):
|
|
page_data = await self.serialize_plugin_page(plugin, page.name)
|
|
if page_data:
|
|
pages.append(page_data)
|
|
return pages
|
|
|
|
def issue_plugin_page_asset_token(
|
|
self,
|
|
*,
|
|
plugin_name: str,
|
|
page_name: str,
|
|
jwt_secret: str | None = None,
|
|
username: str | None,
|
|
locale: str,
|
|
) -> str | None:
|
|
jwt_secret = jwt_secret or self._jwt_secret()
|
|
if not isinstance(jwt_secret, str) or not jwt_secret.strip():
|
|
return None
|
|
if not isinstance(username, str) or not username.strip():
|
|
return None
|
|
|
|
now = datetime.now(timezone.utc)
|
|
payload = {
|
|
"username": username,
|
|
"token_type": PLUGIN_PAGE_ASSET_TOKEN_TYPE,
|
|
"plugin_name": plugin_name,
|
|
"page_name": page_name,
|
|
"locale": locale,
|
|
"iat": now,
|
|
"exp": now + timedelta(seconds=PLUGIN_PAGE_ASSET_TOKEN_TTL_SECONDS),
|
|
}
|
|
return cast(str, jwt.encode(payload, jwt_secret, algorithm="HS256"))
|
|
|
|
|
|
__all__ = [
|
|
"PLUGIN_PAGE_ASSET_TOKEN_TYPE",
|
|
"PLUGIN_PAGE_BRIDGE_FILE",
|
|
"PLUGIN_PAGE_ENTRY_FILE_NAME",
|
|
"PLUGIN_PAGE_ROOT_DIR_NAME",
|
|
"PluginPage",
|
|
"PluginPageContentPayload",
|
|
"PluginPageService",
|
|
"PluginPageServiceError",
|
|
]
|