src/webui/services/hub.py
Rama main · réplica de lectura del repositorio original.
"""DiceFrame Hub 偏好、详情和社区交互。"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any, Awaitable, Callable
import aiohttp
from src.hub_client import HubUnavailable
from src.plugin_host.mirrors import parse_github_repository
# 作者 GitHub 仓库 Raw README 的大小上限(超出视为不可信,直接拒绝)。
_README_RAW_MAX_BYTES = 256 * 1024
# 只允许这些固定候选文件名;禁止请求任意模型提供的 URL(SSRF 防护)。
_README_CANDIDATES = ("README_CN.md", "README.md", "README_EN.md")
@dataclass(frozen=True)
class HubDependencies:
client: Any | None
plugin_host: Any | None
config_state: Callable[[], dict[str, Any]]
save_config: Callable[[], None]
current_legal_documents: Callable[[], Awaitable[dict[str, Any]]]
legal_bundle_version: Callable[[dict[str, Any]], str]
legal_acceptance_payload: Callable[[dict[str, Any], str], dict[str, Any]]
legal_accepted: Callable[[dict[str, Any], dict[str, Any] | None], bool]
record_legal_acceptance: Callable[..., None]
def _client(dependencies: HubDependencies):
client = dependencies.client
if client is None:
raise RuntimeError("DiceFrame Hub 当前未启用")
return client
async def preferences(
dependencies: HubDependencies, language: str = "zh-CN",
) -> dict[str, Any]:
state = dependencies.config_state()
client = dependencies.client
identity_exists = bool(client and client.identity_file.exists())
documents = await dependencies.current_legal_documents()
return {
"ok": True,
"available": client is not None,
"telemetry_enabled": bool(state.get("hub_telemetry_enabled", False)),
"choice_made": bool(state.get("hub_telemetry_choice_made", False)),
"identity_created": identity_exists,
"legal_version": dependencies.legal_bundle_version(documents),
"legal_documents": dependencies.legal_acceptance_payload(documents, language),
"legal_accepted": dependencies.legal_accepted(state, documents),
}
async def update_preferences(
dependencies: HubDependencies,
telemetry_enabled: bool,
legal_acceptance: dict[str, Any] | None = None,
language: str = "zh-CN",
) -> dict[str, Any]:
state = dependencies.config_state()
documents = await dependencies.current_legal_documents()
if telemetry_enabled and not (
dependencies.legal_accepted(state, documents)
or legal_acceptance is not None
):
raise ValueError("启用匿名使用统计前必须先确认当前隐私政策")
previous = {
"hub_telemetry_enabled": state.get("hub_telemetry_enabled", False),
"hub_telemetry_choice_made": state.get("hub_telemetry_choice_made", False),
"legal_terms_accepted_updated_at": state.get("legal_terms_accepted_updated_at"),
"legal_terms_accepted_version": state.get("legal_terms_accepted_version"),
"legal_terms_accepted_hash": state.get("legal_terms_accepted_hash"),
"legal_terms_accepted_language": state.get("legal_terms_accepted_language"),
"legal_privacy_acknowledged_version": state.get("legal_privacy_acknowledged_version"),
"legal_privacy_accepted_updated_at": state.get("legal_privacy_accepted_updated_at"),
"legal_privacy_accepted_version": state.get("legal_privacy_accepted_version"),
"legal_privacy_accepted_hash": state.get("legal_privacy_accepted_hash"),
"legal_privacy_accepted_language": state.get("legal_privacy_accepted_language"),
"legal_accepted_at": state.get("legal_accepted_at"),
}
if legal_acceptance is not None:
dependencies.record_legal_acceptance(
state,
acceptance=legal_acceptance,
documents=documents,
accepted_at=datetime.now(UTC).isoformat(),
)
state["hub_telemetry_enabled"] = bool(telemetry_enabled)
state["hub_telemetry_choice_made"] = True
try:
dependencies.save_config()
except Exception:
for key, value in previous.items():
if value is None:
state.pop(key, None)
else:
state[key] = value
raise
client = dependencies.client
if client is not None:
await client.set_telemetry(bool(telemetry_enabled), choice_made=True)
return await preferences(dependencies, language)
async def delete_identity(dependencies: HubDependencies) -> dict[str, Any]:
client = _client(dependencies)
await client.delete_identity()
return await preferences(dependencies)
async def create_rendezvous_room(
dependencies: HubDependencies, peer_count: int
) -> dict[str, Any]:
return {
"ok": True,
**await _client(dependencies).create_rendezvous_room(peer_count),
}
async def rendezvous_config(dependencies: HubDependencies) -> dict[str, Any]:
return {"ok": True, **await _client(dependencies).rendezvous_config()}
async def plugin_detail(dependencies: HubDependencies, plugin_id: str) -> dict[str, Any]:
return {"ok": True, **await _client(dependencies).plugin_detail(plugin_id)}
async def plugin_readme(dependencies: HubDependencies, plugin_id: str) -> dict[str, Any]:
"""插件详情 README 的三层兜底:Hub 已清洗 HTML → 本地磁盘缓存 → 作者 GitHub Raw。
Hub 成功时把已清洗正文写入磁盘缓存;Hub 失败时先读缓存并标记 stale,
无缓存时才按注册表中已经校验的 GitHub 仓库地址读取固定候选 README,
禁止请求任意 URL。返回 `markdown`(Raw 原文)由前端用公共清洗器渲染。
"""
client = _client(dependencies)
hub_error = ""
try:
payload = await client.plugin_readme(plugin_id)
html = str(payload.get("html") or "")
if html:
meta = await _repository_meta(dependencies, plugin_id)
client.write_readme_cache(
plugin_id,
html=html,
repository_url=meta["repository_url"],
commit_sha=meta["commit_sha"],
)
return {
"ok": True,
"html": html,
"source": {"hub": True, "github": False, "cached": False, "stale": False},
}
hub_error = "Hub 未提供 README"
except (HubUnavailable, aiohttp.ClientError, asyncio.TimeoutError, ValueError) as exc:
hub_error = str(exc)
cached = client.read_readme_cache(plugin_id)
if cached is not None:
return {
"ok": True,
"html": str(cached.get("html") or ""),
"source": {
"hub": True,
"github": False,
"cached": True,
"stale": True,
"error": hub_error,
},
}
markdown = await _fetch_author_readme(dependencies, plugin_id)
if markdown:
return {
"ok": True,
"html": "",
"markdown": markdown,
"source": {"hub": False, "github": True, "cached": False, "stale": False},
}
return {
"ok": False,
"html": "",
"markdown": "",
"error": hub_error or "README 暂不可用",
"source": {"hub": False, "github": False, "cached": False, "stale": False, "error": hub_error},
}
async def _repository_meta(
dependencies: HubDependencies, plugin_id: str,
) -> dict[str, str]:
"""从已校验的商店索引取插件仓库地址与最新提交版本。"""
plugins = dependencies.plugin_host
marketplace = getattr(plugins, "marketplace", None)
if marketplace is None:
return {"repository_url": "", "commit_sha": ""}
try:
listing = await marketplace.list_plugins()
except Exception:
return {"repository_url": "", "commit_sha": ""}
items = listing.get("plugins") if isinstance(listing, dict) else None
if not isinstance(items, list):
return {"repository_url": "", "commit_sha": ""}
item = next((entry for entry in items if isinstance(entry, dict) and entry.get("id") == plugin_id), None)
if item is None:
return {"repository_url": "", "commit_sha": ""}
latest = item.get("latest") if isinstance(item.get("latest"), dict) else {}
commit_sha = str(latest.get("commit_sha") or item.get("commit_sha") or "").strip().lower()
return {
"repository_url": str(item.get("repository_url") or "").strip(),
"commit_sha": commit_sha,
}
async def _fetch_author_readme(
dependencies: HubDependencies, plugin_id: str,
) -> str:
"""只在注册表仓库地址基础上读取固定候选 README;非 GitHub 或私网地址直接跳过。"""
plugins = dependencies.plugin_host
mirrors = getattr(plugins, "mirrors", None)
if mirrors is None:
return ""
meta = await _repository_meta(dependencies, plugin_id)
repository_url = meta["repository_url"]
if not repository_url:
return ""
try:
owner, repo = parse_github_repository(repository_url)
except ValueError:
return ""
commit_sha = meta["commit_sha"]
ref = commit_sha if len(commit_sha) == 40 else "main"
for candidate in _README_CANDIDATES:
url = f"https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{candidate}"
try:
result = await mirrors.fetch_github_url(url, max_bytes=_README_RAW_MAX_BYTES)
except (ValueError, aiohttp.ClientError, asyncio.TimeoutError):
continue
if result.ok and isinstance(result.data, str) and result.data.strip():
return result.data
return ""
async def plugin_ratings(dependencies: HubDependencies, plugin_id: str) -> dict[str, Any]:
return {"ok": True, **await _client(dependencies).plugin_ratings(plugin_id)}
async def set_plugin_like(
dependencies: HubDependencies, plugin_id: str, liked: bool,
) -> dict[str, Any]:
return {"ok": True, **await _client(dependencies).set_like(plugin_id, liked)}
async def set_plugin_rating(
dependencies: HubDependencies,
plugin_id: str,
stars: int | None,
tags: list[str] | None = None,
) -> dict[str, Any]:
return {
"ok": True,
**await _client(dependencies).set_rating(plugin_id, stars, tags),
}
class HubService:
"""Hub preferences, discovery, and community actions with explicit boundaries."""
def __init__(self, dependencies: HubDependencies) -> None:
self._dependencies = dependencies
async def preferences(self, language: str = "zh-CN") -> dict[str, Any]:
return await preferences(self._dependencies, language)
async def update_preferences(
self,
telemetry_enabled: bool,
legal_acceptance: dict[str, Any] | None = None,
language: str = "zh-CN",
) -> dict[str, Any]:
return await update_preferences(
self._dependencies, telemetry_enabled, legal_acceptance, language,
)
async def delete_identity(self) -> dict[str, Any]:
return await delete_identity(self._dependencies)
async def create_rendezvous_room(self, peer_count: int) -> dict[str, Any]:
return await create_rendezvous_room(self._dependencies, peer_count)
async def rendezvous_config(self) -> dict[str, Any]:
return await rendezvous_config(self._dependencies)
async def plugin_detail(self, plugin_id: str) -> dict[str, Any]:
return await plugin_detail(self._dependencies, plugin_id)
async def plugin_readme(self, plugin_id: str) -> dict[str, Any]:
return await plugin_readme(self._dependencies, plugin_id)
async def plugin_ratings(self, plugin_id: str) -> dict[str, Any]:
return await plugin_ratings(self._dependencies, plugin_id)
async def set_plugin_like(self, plugin_id: str, liked: bool) -> dict[str, Any]:
return await set_plugin_like(self._dependencies, plugin_id, liked)
async def set_plugin_rating(
self, plugin_id: str, stars: int | None, tags: list[str] | None = None,
) -> dict[str, Any]:
return await set_plugin_rating(
self._dependencies, plugin_id, stars, tags,
)