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
This commit is contained in:
@@ -166,6 +166,19 @@ export function SidebarDataProvider({
|
||||
|
||||
// Deduplicate plugins by composite key (prefer debug over installed)
|
||||
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) {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(
|
||||
author,
|
||||
name,
|
||||
)}
|
||||
alt=""
|
||||
<AuthenticatedPluginIcon
|
||||
author={author}
|
||||
name={name}
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>
|
||||
@@ -459,12 +457,9 @@ export default function KBForm({
|
||||
value={engine.plugin_id}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(
|
||||
author,
|
||||
name,
|
||||
)}
|
||||
alt=""
|
||||
<AuthenticatedPluginIcon
|
||||
author={author}
|
||||
name={name}
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>{extractI18nObject(engine.name)}</span>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+77
-43
@@ -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<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({
|
||||
pluginAuthor,
|
||||
@@ -71,49 +139,15 @@ export default function PluginReadme({
|
||||
<ol className="list-decimal">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="ml-4">{children}</li>,
|
||||
img: ({ src, alt, ...props }) => {
|
||||
let imageSrc = src || '';
|
||||
|
||||
if (typeof imageSrc !== 'string') {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
img: ({ src, alt, ...props }) => (
|
||||
<PluginReadmeImage
|
||||
author={pluginAuthor}
|
||||
name={pluginName}
|
||||
src={typeof src === 'string' ? src : undefined}
|
||||
alt={alt}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{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(
|
||||
author: 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