From 24602abcc85d5e04a5936b6882ff28d1e4461be6 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:35:10 +0000 Subject: [PATCH] fix(cloud): authenticate plugin assets and report workspace resources (cherry picked from commit 61faa6819422efb48f5ab8934b20415a3d9d5ff4) --- .../pkg/api/http/controller/groups/plugins.py | 73 +++++++++-- src/langbot/pkg/telemetry/heartbeat.py | 45 +++++++ .../api/test_plugin_runtime_route_fence.py | 14 ++ tests/unit_tests/telemetry/test_heartbeat.py | 33 ++++- .../home-sidebar/SidebarDataContext.tsx | 17 ++- .../knowledge/components/kb-form/KBForm.tsx | 19 +-- .../ExtensionCardComponent.tsx | 11 +- .../plugin-readme/PluginReadme.tsx | 120 +++++++++++------- web/src/app/infra/http/BackendClient.ts | 26 ++++ .../components/AuthenticatedPluginIcon.tsx | 32 +++++ .../hooks/useAuthenticatedPluginResource.ts | 71 +++++++++++ 11 files changed, 384 insertions(+), 77 deletions(-) create mode 100644 web/src/components/AuthenticatedPluginIcon.tsx create mode 100644 web/src/hooks/useAuthenticatedPluginResource.ts diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index bbcda6c6b..7ad46d999 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -318,6 +318,13 @@ class PluginsRouterGroup(group.RouterGroup): ) 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: """Resolve public assets only for the OSS singleton Workspace. @@ -372,7 +379,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) 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() return self.success(data={'plugins': redact_plugin_secrets(plugins)}) @@ -385,7 +392,7 @@ class PluginsRouterGroup(group.RouterGroup): ) async def _(request_context: RequestContext) -> str: """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() # Get debug URL from config @@ -428,7 +435,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) 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) if plugin is None: return self.http_status(404, -1, 'plugin not found') @@ -469,7 +476,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) 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) if plugin is None: return self.http_status(404, -1, 'plugin not found') @@ -489,7 +496,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_MANAGE, ) 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) if plugin is None: return self.http_status(404, -1, 'plugin not found') @@ -506,7 +513,7 @@ class PluginsRouterGroup(group.RouterGroup): ) except ValueError as 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) return self.success(data={}) @@ -517,7 +524,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_VIEW, ) 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') readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language) return self.success(data={'readme': readme}) @@ -529,7 +536,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.AUDIT_VIEW, ) 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: limit = int(quart.request.args.get('limit', 200)) 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) return self.success(data={'logs': logs}) + @self.route( + '///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( + '///authenticated-assets/', + 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( '///icon', methods=['GET'], @@ -596,7 +641,7 @@ class PluginsRouterGroup(group.RouterGroup): ) async def _(author: str, plugin_name: str, request_context: RequestContext) -> str: """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 if not isinstance(data, dict): return self.http_status(400, -1, 'invalid request body') @@ -625,7 +670,7 @@ class PluginsRouterGroup(group.RouterGroup): ) async def _(request_context: RequestContext) -> str: """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 repo_url = data.get('repo_url', '') @@ -705,7 +750,7 @@ class PluginsRouterGroup(group.RouterGroup): ) async def _(request_context: RequestContext) -> str: """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 owner = data.get('owner', '') repo = data.get('repo', '') @@ -901,7 +946,7 @@ class PluginsRouterGroup(group.RouterGroup): permission=Permission.RESOURCE_MANAGE, ) 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') if file is None: return self.http_status(400, -1, 'file is required') @@ -942,7 +987,7 @@ class PluginsRouterGroup(group.RouterGroup): ) async def _(request_context: RequestContext) -> str: """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') if file is None: 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: """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'): return self.http_status(400, -1, 'invalid file key') diff --git a/src/langbot/pkg/telemetry/heartbeat.py b/src/langbot/pkg/telemetry/heartbeat.py index 3c3efb507..c535120ca 100644 --- a/src/langbot/pkg/telemetry/heartbeat.py +++ b/src/langbot/pkg/telemetry/heartbeat.py @@ -52,6 +52,47 @@ async def _count( 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: """Collect the anonymous instance profile snapshot.""" from ..entity.persistence import bot as persistence_bot @@ -136,6 +177,10 @@ async def build_heartbeat_payload(ap: core_app.Application) -> dict: except Exception: pass + workspace_resources = await _cloud_workspace_resource_counts(ap) + if workspace_resources: + features['workspace_resources'] = workspace_resources + return { 'event_type': 'instance_heartbeat', 'query_id': '', diff --git a/tests/unit_tests/api/test_plugin_runtime_route_fence.py b/tests/unit_tests/api/test_plugin_runtime_route_fence.py index 0bc324e83..3594a7374 100644 --- a/tests/unit_tests/api/test_plugin_runtime_route_fence.py +++ b/tests/unit_tests/api/test_plugin_runtime_route_fence.py @@ -46,6 +46,20 @@ def plugin_router_cls(): 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 async def test_public_plugin_asset_route_is_disabled_for_multi_workspace_policy(plugin_router_cls): connector = SimpleNamespace( diff --git a/tests/unit_tests/telemetry/test_heartbeat.py b/tests/unit_tests/telemetry/test_heartbeat.py index a8dfae936..24c25b4ee 100644 --- a/tests/unit_tests/telemetry/test_heartbeat.py +++ b/tests/unit_tests/telemetry/test_heartbeat.py @@ -106,15 +106,31 @@ class TestBuildHeartbeatPayload: side_effect=AssertionError('Cloud heartbeat must not issue per-tenant COUNTs') ) 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( 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( - 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) @@ -124,6 +140,17 @@ class TestBuildHeartbeatPayload: assert features['mcp_server_count'] == 3 assert features['knowledge_base_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() @pytest.mark.asyncio diff --git a/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx b/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx index 6f2adb3d1..58bd5f6fa 100644 --- a/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx +++ b/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx @@ -166,6 +166,19 @@ export function SidebarDataProvider({ // Deduplicate plugins by composite key (prefer debug over installed) const pluginMap = new Map(); + const pluginIconURLs = new Map( + 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) { const meta = plugin.manifest.manifest.metadata; const author = meta.author ?? ''; @@ -184,7 +197,7 @@ export function SidebarDataProvider({ const item: SidebarEntityItem = { id: compositeKey, name: extractI18nObject(meta.label), - iconURL: httpClient.getPluginIconURL(author, name), + iconURL: pluginIconURLs.get(compositeKey) || '', installSource: plugin.install_source, installInfo: plugin.install_info, hasUpdate, @@ -218,7 +231,7 @@ export function SidebarDataProvider({ pluginAuthor: author, pluginName: name, pluginLabel: label, - pluginIconURL: httpClient.getPluginIconURL(author, name), + pluginIconURL: pluginIconURLs.get(`${author}/${name}`) || '', pageId: page.id, path: page.path, }); diff --git a/web/src/app/home/knowledge/components/kb-form/KBForm.tsx b/web/src/app/home/knowledge/components/kb-form/KBForm.tsx index 398a97f8c..c585e0ff9 100644 --- a/web/src/app/home/knowledge/components/kb-form/KBForm.tsx +++ b/web/src/app/home/knowledge/components/kb-form/KBForm.tsx @@ -4,6 +4,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useTranslation } from 'react-i18next'; +import { AuthenticatedPluginIcon } from '@/components/AuthenticatedPluginIcon'; import { Input } from '@/components/ui/input'; import EmojiPicker from '@/components/ui/emoji-picker'; import { @@ -428,12 +429,9 @@ export default function KBForm({ ); return (
- @@ -459,12 +457,9 @@ export default function KBForm({ value={engine.plugin_id} >
- {extractI18nObject(engine.name)} diff --git a/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx index 781b92b35..e3c030091 100644 --- a/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx @@ -13,7 +13,7 @@ import { Puzzle, } from 'lucide-react'; 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 { Card } from '@/components/ui/card'; import { @@ -39,6 +39,11 @@ export default function ExtensionCardComponent({ const { t } = useTranslation(); const [dropdownOpen, setDropdownOpen] = useState(false); const [iconFailed, setIconFailed] = useState(false); + const authenticatedIcon = useAuthenticatedPluginIcon( + cardVO.author, + cardVO.name, + cardVO.type === 'plugin', + ); const FallbackIcon = cardVO.type === 'mcp' @@ -47,8 +52,8 @@ export default function ExtensionCardComponent({ ? Sparkles : Puzzle; const iconSrc = - cardVO.iconURL || httpClient.getPluginIconURL(cardVO.author, cardVO.name); - const showFallback = iconFailed || !iconSrc; + cardVO.type === 'plugin' ? authenticatedIcon.url : cardVO.iconURL; + const showFallback = iconFailed || authenticatedIcon.error || !iconSrc; const getTypeLabel = (type: ExtensionType) => { switch (type) { diff --git a/web/src/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme.tsx b/web/src/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme.tsx index 4f69c51e7..a8a5dd2ad 100644 --- a/web/src/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme.tsx @@ -10,6 +10,74 @@ import rehypeSlug from 'rehype-slug'; import rehypeAutolinkHeadings from 'rehype-autolink-headings'; import { getAPILanguageCode } from '@/i18n/I18nProvider'; 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) { + const { url, error } = useAuthenticatedPluginAsset(author, name, filepath); + if (error) + return ( + {alt || filepath} + ); + if (!url) + return ( + + ); + return ( + {alt + ); +} + +function PluginReadmeImage({ + author, + name, + src, + alt, + ...props +}: { + author: string; + name: string; + src?: string; + alt?: string; +} & React.ImgHTMLAttributes) { + const imageSrc = typeof src === 'string' ? src : ''; + if (!imageSrc || /^(https?:\/\/|data:)/i.test(imageSrc)) { + return ( + {alt + ); + } + let filepath = imageSrc.replace(/^(\.\/|\/)+/, ''); + filepath = filepath.replace(/^assets\//, ''); + return ( + + ); +} export default function PluginReadme({ pluginAuthor, @@ -71,49 +139,15 @@ export default function PluginReadme({
    {children}
), li: ({ children }) =>
  • {children}
  • , - img: ({ src, alt, ...props }) => { - let imageSrc = src || ''; - - if (typeof imageSrc !== 'string') { - return ( - {alt - ); - } - - 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 ( - {alt - ); - }, + img: ({ src, alt, ...props }) => ( + + ), }} > {readme} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index e1733eaff..c831412c3 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -710,6 +710,32 @@ export class BackendClient extends BaseHttpClient { ); } + private async getAuthenticatedObjectURL(path: string): Promise { + const response = await this.instance.get(path, { + responseType: 'blob', + }); + return URL.createObjectURL(response.data); + } + + public getAuthenticatedPluginAssetURL( + author: string, + name: string, + filepath: string, + ): Promise { + return this.getAuthenticatedObjectURL( + `/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`, + ); + } + + public getAuthenticatedPluginIconURL( + author: string, + name: string, + ): Promise { + return this.getAuthenticatedObjectURL( + `/api/v1/plugins/${author}/${name}/authenticated-icon`, + ); + } + public async pluginPageApi( author: string, name: string, diff --git a/web/src/components/AuthenticatedPluginIcon.tsx b/web/src/components/AuthenticatedPluginIcon.tsx new file mode 100644 index 000000000..fd4055cf4 --- /dev/null +++ b/web/src/components/AuthenticatedPluginIcon.tsx @@ -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 ( + + ); + } + + return {alt}; +} diff --git a/web/src/hooks/useAuthenticatedPluginResource.ts b/web/src/hooks/useAuthenticatedPluginResource.ts new file mode 100644 index 000000000..8ec46df27 --- /dev/null +++ b/web/src/hooks/useAuthenticatedPluginResource.ts @@ -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 }; +}