mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 12:40:59 +00:00
fix(cloud): authenticate plugin assets and report workspace resources
(cherry picked from commit 61faa68194)
This commit is contained in:
@@ -318,6 +318,13 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
return await operation()
|
return await operation()
|
||||||
|
|
||||||
|
async def _require_authenticated_plugin_runtime_context(
|
||||||
|
self,
|
||||||
|
request_context: RequestContext,
|
||||||
|
) -> ExecutionContext:
|
||||||
|
"""Fence an authenticated resource request to its injected Workspace."""
|
||||||
|
return await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||||
|
|
||||||
async def _require_public_plugin_runtime_context(self) -> ExecutionContext:
|
async def _require_public_plugin_runtime_context(self) -> ExecutionContext:
|
||||||
"""Resolve public assets only for the OSS singleton Workspace.
|
"""Resolve public assets only for the OSS singleton Workspace.
|
||||||
|
|
||||||
@@ -372,7 +379,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_VIEW,
|
permission=Permission.RESOURCE_VIEW,
|
||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
async def _(request_context: RequestContext) -> str:
|
||||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||||
plugins = await self.ap.plugin_connector.list_plugins()
|
plugins = await self.ap.plugin_connector.list_plugins()
|
||||||
|
|
||||||
return self.success(data={'plugins': redact_plugin_secrets(plugins)})
|
return self.success(data={'plugins': redact_plugin_secrets(plugins)})
|
||||||
@@ -385,7 +392,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
async def _(request_context: RequestContext) -> str:
|
||||||
"""Get plugin debug information including debug URL and key"""
|
"""Get plugin debug information including debug URL and key"""
|
||||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||||
debug_info = await self.ap.plugin_connector.get_debug_info()
|
debug_info = await self.ap.plugin_connector.get_debug_info()
|
||||||
|
|
||||||
# Get debug URL from config
|
# Get debug URL from config
|
||||||
@@ -428,7 +435,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_VIEW,
|
permission=Permission.RESOURCE_VIEW,
|
||||||
)
|
)
|
||||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
|
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
|
||||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||||
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
|
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
|
||||||
if plugin is None:
|
if plugin is None:
|
||||||
return self.http_status(404, -1, 'plugin not found')
|
return self.http_status(404, -1, 'plugin not found')
|
||||||
@@ -469,7 +476,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_VIEW,
|
permission=Permission.RESOURCE_VIEW,
|
||||||
)
|
)
|
||||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
||||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||||
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
|
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
|
||||||
if plugin is None:
|
if plugin is None:
|
||||||
return self.http_status(404, -1, 'plugin not found')
|
return self.http_status(404, -1, 'plugin not found')
|
||||||
@@ -489,7 +496,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_MANAGE,
|
permission=Permission.RESOURCE_MANAGE,
|
||||||
)
|
)
|
||||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
||||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||||
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
|
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
|
||||||
if plugin is None:
|
if plugin is None:
|
||||||
return self.http_status(404, -1, 'plugin not found')
|
return self.http_status(404, -1, 'plugin not found')
|
||||||
@@ -506,7 +513,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return self.http_status(400, -1, str(exc))
|
return self.http_status(400, -1, str(exc))
|
||||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||||
await self.ap.plugin_connector.set_plugin_config(author, plugin_name, config)
|
await self.ap.plugin_connector.set_plugin_config(author, plugin_name, config)
|
||||||
return self.success(data={})
|
return self.success(data={})
|
||||||
|
|
||||||
@@ -517,7 +524,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_VIEW,
|
permission=Permission.RESOURCE_VIEW,
|
||||||
)
|
)
|
||||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
||||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||||
language = quart.request.args.get('language', 'en')
|
language = quart.request.args.get('language', 'en')
|
||||||
readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language)
|
readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language)
|
||||||
return self.success(data={'readme': readme})
|
return self.success(data={'readme': readme})
|
||||||
@@ -529,7 +536,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.AUDIT_VIEW,
|
permission=Permission.AUDIT_VIEW,
|
||||||
)
|
)
|
||||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
||||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||||
try:
|
try:
|
||||||
limit = int(quart.request.args.get('limit', 200))
|
limit = int(quart.request.args.get('limit', 200))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -538,6 +545,44 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
logs = await self.ap.plugin_connector.get_plugin_logs(author, plugin_name, limit=limit, level=level)
|
logs = await self.ap.plugin_connector.get_plugin_logs(author, plugin_name, limit=limit, level=level)
|
||||||
return self.success(data={'logs': logs})
|
return self.success(data={'logs': logs})
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/<author>/<plugin_name>/authenticated-icon',
|
||||||
|
methods=['GET'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
|
permission=Permission.RESOURCE_VIEW,
|
||||||
|
)
|
||||||
|
async def _(
|
||||||
|
author: str,
|
||||||
|
plugin_name: str,
|
||||||
|
request_context: RequestContext,
|
||||||
|
) -> quart.Response:
|
||||||
|
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||||
|
icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name)
|
||||||
|
icon_bytes = await asyncio.to_thread(base64.b64decode, icon_data['plugin_icon_base64'])
|
||||||
|
return quart.Response(icon_bytes, mimetype=icon_data['mime_type'])
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/<author>/<plugin_name>/authenticated-assets/<path:filepath>',
|
||||||
|
methods=['GET'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
|
permission=Permission.RESOURCE_VIEW,
|
||||||
|
)
|
||||||
|
async def _(
|
||||||
|
author: str,
|
||||||
|
plugin_name: str,
|
||||||
|
filepath: str,
|
||||||
|
request_context: RequestContext,
|
||||||
|
) -> quart.Response:
|
||||||
|
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||||
|
asset_path = _normalize_plugin_asset_path(filepath)
|
||||||
|
if asset_path is None:
|
||||||
|
return quart.Response('Asset not found', status=404)
|
||||||
|
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path)
|
||||||
|
if not asset_data.get('asset_base64'):
|
||||||
|
return quart.Response('Asset not found', status=404)
|
||||||
|
asset_bytes = await asyncio.to_thread(base64.b64decode, asset_data['asset_base64'])
|
||||||
|
return quart.Response(asset_bytes, mimetype=asset_data['mime_type'])
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/<author>/<plugin_name>/icon',
|
'/<author>/<plugin_name>/icon',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
@@ -596,7 +641,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
|
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
|
||||||
"""Forward a page API request to the plugin."""
|
"""Forward a page API request to the plugin."""
|
||||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||||
data = await quart.request.json
|
data = await quart.request.json
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return self.http_status(400, -1, 'invalid request body')
|
return self.http_status(400, -1, 'invalid request body')
|
||||||
@@ -625,7 +670,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
async def _(request_context: RequestContext) -> str:
|
||||||
"""Get releases from a GitHub repository URL"""
|
"""Get releases from a GitHub repository URL"""
|
||||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||||
data = await quart.request.json
|
data = await quart.request.json
|
||||||
repo_url = data.get('repo_url', '')
|
repo_url = data.get('repo_url', '')
|
||||||
|
|
||||||
@@ -705,7 +750,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
async def _(request_context: RequestContext) -> str:
|
||||||
"""Get assets from a specific GitHub release"""
|
"""Get assets from a specific GitHub release"""
|
||||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||||
data = await quart.request.json
|
data = await quart.request.json
|
||||||
owner = data.get('owner', '')
|
owner = data.get('owner', '')
|
||||||
repo = data.get('repo', '')
|
repo = data.get('repo', '')
|
||||||
@@ -901,7 +946,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_MANAGE,
|
permission=Permission.RESOURCE_MANAGE,
|
||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
async def _(request_context: RequestContext) -> str:
|
||||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||||
file = (await quart.request.files).get('file')
|
file = (await quart.request.files).get('file')
|
||||||
if file is None:
|
if file is None:
|
||||||
return self.http_status(400, -1, 'file is required')
|
return self.http_status(400, -1, 'file is required')
|
||||||
@@ -942,7 +987,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
async def _(request_context: RequestContext) -> str:
|
||||||
"""Upload a file for plugin configuration"""
|
"""Upload a file for plugin configuration"""
|
||||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||||
file = (await quart.request.files).get('file')
|
file = (await quart.request.files).get('file')
|
||||||
if file is None:
|
if file is None:
|
||||||
return self.http_status(400, -1, 'file is required')
|
return self.http_status(400, -1, 'file is required')
|
||||||
@@ -974,7 +1019,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
async def _(file_key: str, request_context: RequestContext) -> str:
|
async def _(file_key: str, request_context: RequestContext) -> str:
|
||||||
"""Delete a plugin configuration file"""
|
"""Delete a plugin configuration file"""
|
||||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||||
if not self.ap.storage_mgr.is_scoped_object_key(file_key, expected_owner_type='plugin_config'):
|
if not self.ap.storage_mgr.is_scoped_object_key(file_key, expected_owner_type='plugin_config'):
|
||||||
return self.http_status(400, -1, 'invalid file key')
|
return self.http_status(400, -1, 'invalid file key')
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,47 @@ async def _count(
|
|||||||
return -1
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
async def _cloud_workspace_resource_counts(ap: core_app.Application) -> list[dict]:
|
||||||
|
"""Summarize already-loaded Cloud registries without per-tenant SQL."""
|
||||||
|
persistence_mgr = ap.persistence_mgr
|
||||||
|
if getattr(getattr(persistence_mgr, 'mode', None), 'value', None) != 'cloud_runtime':
|
||||||
|
return []
|
||||||
|
|
||||||
|
bindings = await ap.workspace_service.list_active_execution_bindings()
|
||||||
|
counts = {
|
||||||
|
binding.workspace_uuid: {
|
||||||
|
'workspace_uuid': binding.workspace_uuid,
|
||||||
|
'bot_count': 0,
|
||||||
|
'pipeline_count': 0,
|
||||||
|
'knowledge_base_count': 0,
|
||||||
|
'plugin_count': 0,
|
||||||
|
'mcp_server_count': 0,
|
||||||
|
'extension_count': 0,
|
||||||
|
}
|
||||||
|
for binding in bindings
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in getattr(ap.platform_mgr, '_bots_by_key', {}):
|
||||||
|
if len(key) >= 2 and key[1] in counts:
|
||||||
|
counts[key[1]]['bot_count'] += 1
|
||||||
|
for key in getattr(ap.pipeline_mgr, '_pipelines_by_key', {}):
|
||||||
|
if len(key) >= 2 and key[1] in counts:
|
||||||
|
counts[key[1]]['pipeline_count'] += 1
|
||||||
|
for key in getattr(ap.rag_mgr, 'knowledge_bases', {}):
|
||||||
|
if len(key) >= 1 and key[0] in counts:
|
||||||
|
counts[key[0]]['knowledge_base_count'] += 1
|
||||||
|
for key in getattr(ap.tool_mgr.mcp_tool_loader, '_sessions', {}):
|
||||||
|
if len(key) >= 2 and key[1] in counts:
|
||||||
|
counts[key[1]]['mcp_server_count'] += 1
|
||||||
|
for workspace_uuid, installations in getattr(ap.plugin_connector, '_workspace_installations', {}).items():
|
||||||
|
if workspace_uuid in counts:
|
||||||
|
counts[workspace_uuid]['plugin_count'] = len(installations)
|
||||||
|
|
||||||
|
for resource in counts.values():
|
||||||
|
resource['extension_count'] = resource['plugin_count'] + resource['mcp_server_count']
|
||||||
|
return list(counts.values())
|
||||||
|
|
||||||
|
|
||||||
async def build_heartbeat_payload(ap: core_app.Application) -> dict:
|
async def build_heartbeat_payload(ap: core_app.Application) -> dict:
|
||||||
"""Collect the anonymous instance profile snapshot."""
|
"""Collect the anonymous instance profile snapshot."""
|
||||||
from ..entity.persistence import bot as persistence_bot
|
from ..entity.persistence import bot as persistence_bot
|
||||||
@@ -136,6 +177,10 @@ async def build_heartbeat_payload(ap: core_app.Application) -> dict:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
workspace_resources = await _cloud_workspace_resource_counts(ap)
|
||||||
|
if workspace_resources:
|
||||||
|
features['workspace_resources'] = workspace_resources
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'event_type': 'instance_heartbeat',
|
'event_type': 'instance_heartbeat',
|
||||||
'query_id': '',
|
'query_id': '',
|
||||||
|
|||||||
@@ -46,6 +46,20 @@ def plugin_router_cls():
|
|||||||
yield PluginsRouterGroup
|
yield PluginsRouterGroup
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_authenticated_plugin_resource_fences_injected_workspace_context(plugin_router_cls):
|
||||||
|
connector = SimpleNamespace(
|
||||||
|
require_workspace_context=AsyncMock(return_value=CONTEXT),
|
||||||
|
)
|
||||||
|
router = object.__new__(plugin_router_cls)
|
||||||
|
router.ap = SimpleNamespace(plugin_connector=connector)
|
||||||
|
|
||||||
|
result = await router._require_authenticated_plugin_runtime_context(CONTEXT)
|
||||||
|
|
||||||
|
assert result == CONTEXT
|
||||||
|
connector.require_workspace_context.assert_awaited_once_with(CONTEXT)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_public_plugin_asset_route_is_disabled_for_multi_workspace_policy(plugin_router_cls):
|
async def test_public_plugin_asset_route_is_disabled_for_multi_workspace_policy(plugin_router_cls):
|
||||||
connector = SimpleNamespace(
|
connector = SimpleNamespace(
|
||||||
|
|||||||
@@ -106,15 +106,31 @@ class TestBuildHeartbeatPayload:
|
|||||||
side_effect=AssertionError('Cloud heartbeat must not issue per-tenant COUNTs')
|
side_effect=AssertionError('Cloud heartbeat must not issue per-tenant COUNTs')
|
||||||
)
|
)
|
||||||
ap.pipeline_mgr = SimpleNamespace(
|
ap.pipeline_mgr = SimpleNamespace(
|
||||||
_pipelines_by_key={'pipeline-a': object(), 'pipeline-b': object()},
|
_pipelines_by_key={
|
||||||
|
('instance-a', 'workspace-a', 'pipeline-a'): object(),
|
||||||
|
('instance-a', 'workspace-a', 'pipeline-b'): object(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
ap.platform_mgr._bots_by_key = {
|
||||||
|
('instance-a', 'workspace-a', 'bot-a'): object(),
|
||||||
|
}
|
||||||
ap.tool_mgr = SimpleNamespace(
|
ap.tool_mgr = SimpleNamespace(
|
||||||
mcp_tool_loader=SimpleNamespace(
|
mcp_tool_loader=SimpleNamespace(
|
||||||
_sessions={'mcp-a': object(), 'mcp-b': object(), 'mcp-c': object()},
|
_sessions={
|
||||||
|
('instance-a', 'workspace-a', 1, 'mcp-a'): object(),
|
||||||
|
('instance-a', 'workspace-a', 1, 'mcp-b'): object(),
|
||||||
|
('instance-a', 'workspace-a', 1, 'mcp-c'): object(),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
ap.rag_mgr = SimpleNamespace(
|
ap.rag_mgr = SimpleNamespace(
|
||||||
knowledge_bases={'kb-a': object()},
|
knowledge_bases={('workspace-a', 'kb-a'): object()},
|
||||||
|
)
|
||||||
|
ap.plugin_connector._workspace_installations = {
|
||||||
|
'workspace-a': {'plugin-a', 'plugin-b'},
|
||||||
|
}
|
||||||
|
ap.workspace_service.list_active_execution_bindings = AsyncMock(
|
||||||
|
return_value=[SimpleNamespace(workspace_uuid='workspace-a')],
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = await heartbeat.build_heartbeat_payload(ap)
|
payload = await heartbeat.build_heartbeat_payload(ap)
|
||||||
@@ -124,6 +140,17 @@ class TestBuildHeartbeatPayload:
|
|||||||
assert features['mcp_server_count'] == 3
|
assert features['mcp_server_count'] == 3
|
||||||
assert features['knowledge_base_count'] == 1
|
assert features['knowledge_base_count'] == 1
|
||||||
assert features['bot_count'] == 1
|
assert features['bot_count'] == 1
|
||||||
|
assert features['workspace_resources'] == [
|
||||||
|
{
|
||||||
|
'workspace_uuid': 'workspace-a',
|
||||||
|
'bot_count': 1,
|
||||||
|
'pipeline_count': 2,
|
||||||
|
'knowledge_base_count': 1,
|
||||||
|
'plugin_count': 2,
|
||||||
|
'mcp_server_count': 3,
|
||||||
|
'extension_count': 5,
|
||||||
|
}
|
||||||
|
]
|
||||||
ap.persistence_mgr.execute_async.assert_not_awaited()
|
ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -166,6 +166,19 @@ export function SidebarDataProvider({
|
|||||||
|
|
||||||
// Deduplicate plugins by composite key (prefer debug over installed)
|
// Deduplicate plugins by composite key (prefer debug over installed)
|
||||||
const pluginMap = new Map<string, SidebarEntityItem>();
|
const pluginMap = new Map<string, SidebarEntityItem>();
|
||||||
|
const pluginIconURLs = new Map<string, string>(
|
||||||
|
await Promise.all(
|
||||||
|
pluginsResp.plugins.map(async (plugin) => {
|
||||||
|
const meta = plugin.manifest.manifest.metadata;
|
||||||
|
const author = meta.author ?? '';
|
||||||
|
const name = meta.name;
|
||||||
|
const url = await httpClient
|
||||||
|
.getAuthenticatedPluginIconURL(author, name)
|
||||||
|
.catch(() => '');
|
||||||
|
return [`${author}/${name}`, url] as const;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
for (const plugin of pluginsResp.plugins) {
|
for (const plugin of pluginsResp.plugins) {
|
||||||
const meta = plugin.manifest.manifest.metadata;
|
const meta = plugin.manifest.manifest.metadata;
|
||||||
const author = meta.author ?? '';
|
const author = meta.author ?? '';
|
||||||
@@ -184,7 +197,7 @@ export function SidebarDataProvider({
|
|||||||
const item: SidebarEntityItem = {
|
const item: SidebarEntityItem = {
|
||||||
id: compositeKey,
|
id: compositeKey,
|
||||||
name: extractI18nObject(meta.label),
|
name: extractI18nObject(meta.label),
|
||||||
iconURL: httpClient.getPluginIconURL(author, name),
|
iconURL: pluginIconURLs.get(compositeKey) || '',
|
||||||
installSource: plugin.install_source,
|
installSource: plugin.install_source,
|
||||||
installInfo: plugin.install_info,
|
installInfo: plugin.install_info,
|
||||||
hasUpdate,
|
hasUpdate,
|
||||||
@@ -218,7 +231,7 @@ export function SidebarDataProvider({
|
|||||||
pluginAuthor: author,
|
pluginAuthor: author,
|
||||||
pluginName: name,
|
pluginName: name,
|
||||||
pluginLabel: label,
|
pluginLabel: label,
|
||||||
pluginIconURL: httpClient.getPluginIconURL(author, name),
|
pluginIconURL: pluginIconURLs.get(`${author}/${name}`) || '',
|
||||||
pageId: page.id,
|
pageId: page.id,
|
||||||
path: page.path,
|
path: page.path,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { AuthenticatedPluginIcon } from '@/components/AuthenticatedPluginIcon';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import EmojiPicker from '@/components/ui/emoji-picker';
|
import EmojiPicker from '@/components/ui/emoji-picker';
|
||||||
import {
|
import {
|
||||||
@@ -428,12 +429,9 @@ export default function KBForm({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img
|
<AuthenticatedPluginIcon
|
||||||
src={httpClient.getPluginIconURL(
|
author={author}
|
||||||
author,
|
name={name}
|
||||||
name,
|
|
||||||
)}
|
|
||||||
alt=""
|
|
||||||
className="h-5 w-5 rounded"
|
className="h-5 w-5 rounded"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
@@ -459,12 +457,9 @@ export default function KBForm({
|
|||||||
value={engine.plugin_id}
|
value={engine.plugin_id}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img
|
<AuthenticatedPluginIcon
|
||||||
src={httpClient.getPluginIconURL(
|
author={author}
|
||||||
author,
|
name={name}
|
||||||
name,
|
|
||||||
)}
|
|
||||||
alt=""
|
|
||||||
className="h-5 w-5 rounded"
|
className="h-5 w-5 rounded"
|
||||||
/>
|
/>
|
||||||
<span>{extractI18nObject(engine.name)}</span>
|
<span>{extractI18nObject(engine.name)}</span>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
Puzzle,
|
Puzzle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
|
import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { useAuthenticatedPluginIcon } from '@/hooks/useAuthenticatedPluginResource';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +39,11 @@ export default function ExtensionCardComponent({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
const [iconFailed, setIconFailed] = useState(false);
|
const [iconFailed, setIconFailed] = useState(false);
|
||||||
|
const authenticatedIcon = useAuthenticatedPluginIcon(
|
||||||
|
cardVO.author,
|
||||||
|
cardVO.name,
|
||||||
|
cardVO.type === 'plugin',
|
||||||
|
);
|
||||||
|
|
||||||
const FallbackIcon =
|
const FallbackIcon =
|
||||||
cardVO.type === 'mcp'
|
cardVO.type === 'mcp'
|
||||||
@@ -47,8 +52,8 @@ export default function ExtensionCardComponent({
|
|||||||
? Sparkles
|
? Sparkles
|
||||||
: Puzzle;
|
: Puzzle;
|
||||||
const iconSrc =
|
const iconSrc =
|
||||||
cardVO.iconURL || httpClient.getPluginIconURL(cardVO.author, cardVO.name);
|
cardVO.type === 'plugin' ? authenticatedIcon.url : cardVO.iconURL;
|
||||||
const showFallback = iconFailed || !iconSrc;
|
const showFallback = iconFailed || authenticatedIcon.error || !iconSrc;
|
||||||
|
|
||||||
const getTypeLabel = (type: ExtensionType) => {
|
const getTypeLabel = (type: ExtensionType) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
|||||||
+77
-43
@@ -10,6 +10,74 @@ import rehypeSlug from 'rehype-slug';
|
|||||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
||||||
import { getAPILanguageCode } from '@/i18n/I18nProvider';
|
import { getAPILanguageCode } from '@/i18n/I18nProvider';
|
||||||
import '@/styles/github-markdown.css';
|
import '@/styles/github-markdown.css';
|
||||||
|
import { useAuthenticatedPluginAsset } from '@/hooks/useAuthenticatedPluginResource';
|
||||||
|
|
||||||
|
function AuthenticatedReadmeImage({
|
||||||
|
author,
|
||||||
|
name,
|
||||||
|
filepath,
|
||||||
|
alt,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
author: string;
|
||||||
|
name: string;
|
||||||
|
filepath: string;
|
||||||
|
alt?: string;
|
||||||
|
} & React.ImgHTMLAttributes<HTMLImageElement>) {
|
||||||
|
const { url, error } = useAuthenticatedPluginAsset(author, name, filepath);
|
||||||
|
if (error)
|
||||||
|
return (
|
||||||
|
<span className="text-sm text-muted-foreground">{alt || filepath}</span>
|
||||||
|
);
|
||||||
|
if (!url)
|
||||||
|
return (
|
||||||
|
<span className="inline-block h-6 w-24 animate-pulse rounded bg-muted" />
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={alt || ''}
|
||||||
|
className="max-w-lg h-auto my-4"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PluginReadmeImage({
|
||||||
|
author,
|
||||||
|
name,
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
author: string;
|
||||||
|
name: string;
|
||||||
|
src?: string;
|
||||||
|
alt?: string;
|
||||||
|
} & React.ImgHTMLAttributes<HTMLImageElement>) {
|
||||||
|
const imageSrc = typeof src === 'string' ? src : '';
|
||||||
|
if (!imageSrc || /^(https?:\/\/|data:)/i.test(imageSrc)) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
alt={alt || ''}
|
||||||
|
className="max-w-lg h-auto my-4"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let filepath = imageSrc.replace(/^(\.\/|\/)+/, '');
|
||||||
|
filepath = filepath.replace(/^assets\//, '');
|
||||||
|
return (
|
||||||
|
<AuthenticatedReadmeImage
|
||||||
|
author={author}
|
||||||
|
name={name}
|
||||||
|
filepath={filepath}
|
||||||
|
alt={alt}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function PluginReadme({
|
export default function PluginReadme({
|
||||||
pluginAuthor,
|
pluginAuthor,
|
||||||
@@ -71,49 +139,15 @@ export default function PluginReadme({
|
|||||||
<ol className="list-decimal">{children}</ol>
|
<ol className="list-decimal">{children}</ol>
|
||||||
),
|
),
|
||||||
li: ({ children }) => <li className="ml-4">{children}</li>,
|
li: ({ children }) => <li className="ml-4">{children}</li>,
|
||||||
img: ({ src, alt, ...props }) => {
|
img: ({ src, alt, ...props }) => (
|
||||||
let imageSrc = src || '';
|
<PluginReadmeImage
|
||||||
|
author={pluginAuthor}
|
||||||
if (typeof imageSrc !== 'string') {
|
name={pluginName}
|
||||||
return (
|
src={typeof src === 'string' ? src : undefined}
|
||||||
<img
|
alt={alt}
|
||||||
src={src}
|
{...props}
|
||||||
alt={alt || ''}
|
/>
|
||||||
className="max-w-full h-auto rounded-lg my-4"
|
),
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
imageSrc &&
|
|
||||||
!imageSrc.startsWith('http://') &&
|
|
||||||
!imageSrc.startsWith('https://') &&
|
|
||||||
!imageSrc.startsWith('data:')
|
|
||||||
) {
|
|
||||||
imageSrc = imageSrc.replace(/^(\.\/|\/)+/, '');
|
|
||||||
|
|
||||||
if (!imageSrc.startsWith('assets/')) {
|
|
||||||
imageSrc = `assets/${imageSrc}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const assetPath = imageSrc.replace(/^assets\//, '');
|
|
||||||
imageSrc = httpClient.getPluginAssetURL(
|
|
||||||
pluginAuthor,
|
|
||||||
pluginName,
|
|
||||||
assetPath,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={imageSrc}
|
|
||||||
alt={alt || ''}
|
|
||||||
className="max-w-lg h-auto my-4"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{readme}
|
{readme}
|
||||||
|
|||||||
@@ -710,6 +710,32 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getAuthenticatedObjectURL(path: string): Promise<string> {
|
||||||
|
const response = await this.instance.get<Blob>(path, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
return URL.createObjectURL(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAuthenticatedPluginAssetURL(
|
||||||
|
author: string,
|
||||||
|
name: string,
|
||||||
|
filepath: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return this.getAuthenticatedObjectURL(
|
||||||
|
`/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAuthenticatedPluginIconURL(
|
||||||
|
author: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return this.getAuthenticatedObjectURL(
|
||||||
|
`/api/v1/plugins/${author}/${name}/authenticated-icon`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public async pluginPageApi(
|
public async pluginPageApi(
|
||||||
author: string,
|
author: string,
|
||||||
name: string,
|
name: string,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useAuthenticatedPluginIcon } from '@/hooks/useAuthenticatedPluginResource';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function AuthenticatedPluginIcon({
|
||||||
|
author,
|
||||||
|
name,
|
||||||
|
alt = '',
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
author: string;
|
||||||
|
name: string;
|
||||||
|
alt?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const icon = useAuthenticatedPluginIcon(
|
||||||
|
author,
|
||||||
|
name,
|
||||||
|
Boolean(author && name),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!icon.url || icon.error) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden={alt ? undefined : true}
|
||||||
|
aria-label={alt || undefined}
|
||||||
|
className={cn('inline-block bg-muted', className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <img src={icon.url} alt={alt} className={className} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
|
||||||
|
export function useAuthenticatedPluginIcon(
|
||||||
|
author: string,
|
||||||
|
name: string,
|
||||||
|
enabled = true,
|
||||||
|
): { url: string; error: boolean } {
|
||||||
|
const [url, setURL] = useState('');
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setURL('');
|
||||||
|
setError(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let active = true;
|
||||||
|
let objectURL = '';
|
||||||
|
setURL('');
|
||||||
|
setError(false);
|
||||||
|
httpClient
|
||||||
|
.getAuthenticatedPluginIconURL(author, name)
|
||||||
|
.then((nextURL) => {
|
||||||
|
objectURL = nextURL;
|
||||||
|
if (active) setURL(nextURL);
|
||||||
|
else URL.revokeObjectURL(nextURL);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (active) setError(true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
if (objectURL) URL.revokeObjectURL(objectURL);
|
||||||
|
};
|
||||||
|
}, [author, enabled, name]);
|
||||||
|
|
||||||
|
return { url, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthenticatedPluginAsset(
|
||||||
|
author: string,
|
||||||
|
name: string,
|
||||||
|
filepath: string,
|
||||||
|
): { url: string; error: boolean } {
|
||||||
|
const [url, setURL] = useState('');
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
let objectURL = '';
|
||||||
|
setURL('');
|
||||||
|
setError(false);
|
||||||
|
httpClient
|
||||||
|
.getAuthenticatedPluginAssetURL(author, name, filepath)
|
||||||
|
.then((nextURL) => {
|
||||||
|
objectURL = nextURL;
|
||||||
|
if (active) setURL(nextURL);
|
||||||
|
else URL.revokeObjectURL(nextURL);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (active) setError(true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
if (objectURL) URL.revokeObjectURL(objectURL);
|
||||||
|
};
|
||||||
|
}, [author, name, filepath]);
|
||||||
|
|
||||||
|
return { url, error };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user