mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-03 22:32:42 +00:00
feat: theme-adaptive SVG support for custom MCP and group icons
Custom icons (MCP server iconPath, model spec groupIcon) were rendered as plain <img>, so monochrome SVGs kept fixed dark colors and were nearly invisible in dark theme. Introduce a shared CustomIcon component that detects monochrome SVG glyphs and tints them with currentColor so they follow the active theme, while multi-color SVG logos and raster images keep their original colors. The monochrome decision parses the SVG's color tokens; content is fetched once, cached, and any failure falls back to the original image. Monochrome SVGs render via CSS mask, never inlined, so no SVG markup reaches the DOM. Apply across all custom-icon surfaces: MCP settings cards, the chat MCP dropdown, stacked MCP icons, tool-call headers, and model group icons. Also support SVG in the MCP avatar uploader: add SVG to the accepted file types and sanitize uploaded SVGs with DOMPurify before storing them, and make the dialog preview theme-adaptive via the same component. Add unit tests for SVG detection, monochrome analysis, sanitization, and CustomIcon rendering.
This commit is contained in:
parent
a6b5343220
commit
40d2f682a4
17 changed files with 494 additions and 44 deletions
|
|
@ -2,6 +2,7 @@ import React, { memo, useState } from 'react';
|
|||
import { AlertCircle } from 'lucide-react';
|
||||
import type { IconMapProps } from '~/common';
|
||||
import { getKnownEndpointAsset, hasKnownEndpointIcon } from '~/hooks/Endpoint/UnknownIcon';
|
||||
import CustomIcon from '~/components/ui/CustomIcon';
|
||||
import { icons } from '~/hooks/Endpoint/Icons';
|
||||
|
||||
interface GroupIconProps {
|
||||
|
|
@ -59,10 +60,10 @@ const GroupIcon: React.FC<GroupIconProps> = ({ iconURL, groupName }) => {
|
|||
|
||||
return (
|
||||
<div
|
||||
className="icon-md shrink-0 overflow-hidden rounded-full"
|
||||
className="icon-md shrink-0 overflow-hidden rounded-full text-text-primary"
|
||||
style={{ width: 20, height: 20 }}
|
||||
>
|
||||
<img
|
||||
<CustomIcon
|
||||
src={resolvedIconURL || iconURL}
|
||||
alt={groupName}
|
||||
className="h-full w-full object-cover"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import LangIcon from '~/components/Messages/Content/LangIcon';
|
||||
import CustomIcon from '~/components/ui/CustomIcon';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
function BashIcon({ className }: { className?: string }) {
|
||||
|
|
@ -104,15 +105,14 @@ interface ToolIconProps {
|
|||
export default function ToolIcon({ type, iconUrl, isAnimating = false, className }: ToolIconProps) {
|
||||
if (iconUrl) {
|
||||
return (
|
||||
<img
|
||||
<CustomIcon
|
||||
src={iconUrl}
|
||||
alt=""
|
||||
className={cn(
|
||||
'size-4 shrink-0 rounded-full object-cover',
|
||||
'size-4 shrink-0 rounded-full object-cover text-text-secondary',
|
||||
isAnimating && 'animate-pulse',
|
||||
className,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import * as Ariakit from '@ariakit/react';
|
||||
import { Check } from 'lucide-react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { MCPIcon } from '@librechat/client';
|
||||
import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager';
|
||||
import type { MCPServerStatusIconProps } from './MCPServerStatusIcon';
|
||||
import MCPServerStatusIcon from './MCPServerStatusIcon';
|
||||
import {
|
||||
getStatusColor,
|
||||
getStatusTextKey,
|
||||
shouldShowActionButton,
|
||||
type ConnectionStatusMap,
|
||||
} from './mcpServerUtils';
|
||||
import MCPServerStatusIcon from './MCPServerStatusIcon';
|
||||
import CustomIcon from '~/components/ui/CustomIcon';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
|
|
@ -58,9 +59,9 @@ export default function MCPServerMenuItem({
|
|||
{/* Server Icon with Status Dot */}
|
||||
<div className="relative flex-shrink-0">
|
||||
{server.config?.iconPath ? (
|
||||
<img
|
||||
<CustomIcon
|
||||
src={server.config.iconPath}
|
||||
className="h-8 w-8 rounded-lg object-cover"
|
||||
className="h-8 w-8 rounded-lg object-cover text-text-primary"
|
||||
alt={displayName}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
|||
import { MCPIcon } from '@librechat/client';
|
||||
import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager';
|
||||
import { getSelectedServerIcons } from './mcpServerUtils';
|
||||
import CustomIcon from '~/components/ui/CustomIcon';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface StackedMCPIconsProps {
|
||||
|
|
@ -74,10 +75,10 @@ export default function StackedMCPIcons({
|
|||
style={{ zIndex: icons.length - index }}
|
||||
>
|
||||
{icon.iconPath ? (
|
||||
<img
|
||||
<CustomIcon
|
||||
src={icon.iconPath}
|
||||
alt={icon.displayName}
|
||||
className={cn('rounded-full object-cover', sizes.icon)}
|
||||
className={cn('rounded-full object-cover text-text-primary', sizes.icon)}
|
||||
/>
|
||||
) : (
|
||||
<MCPIcon className={cn('text-text-primary', sizes.icon)} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { SquirclePlusIcon } from '@librechat/client';
|
||||
import CustomIcon from '~/components/ui/CustomIcon';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface MCPIconProps {
|
||||
|
|
@ -8,18 +9,9 @@ interface MCPIconProps {
|
|||
}
|
||||
|
||||
export default function MCPIcon({ icon, onIconChange }: MCPIconProps) {
|
||||
const [previewUrl, setPreviewUrl] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const localize = useLocalize();
|
||||
|
||||
useEffect(() => {
|
||||
if (icon) {
|
||||
setPreviewUrl(icon);
|
||||
} else {
|
||||
setPreviewUrl('');
|
||||
}
|
||||
}, [icon]);
|
||||
|
||||
const handleClick = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
|
|
@ -44,13 +36,11 @@ export default function MCPIcon({ icon, onIconChange }: MCPIconProps) {
|
|||
aria-label={localize('com_ui_upload_icon')}
|
||||
className="bg-token-surface-secondary dark:bg-token-surface-tertiary border-token-border-medium flex h-16 w-16 shrink-0 cursor-pointer items-center justify-center rounded-xl border-2 border-dashed focus:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
|
||||
>
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
className="h-full w-full rounded-xl object-cover"
|
||||
alt="MCP Icon"
|
||||
width="64"
|
||||
height="64"
|
||||
{icon ? (
|
||||
<CustomIcon
|
||||
src={icon}
|
||||
alt={localize('com_ui_icon')}
|
||||
className="h-full w-full rounded-xl object-cover text-text-primary"
|
||||
/>
|
||||
) : (
|
||||
<SquirclePlusIcon />
|
||||
|
|
@ -63,7 +53,7 @@ export default function MCPIcon({ icon, onIconChange }: MCPIconProps) {
|
|||
<span className="text-xs text-text-secondary">{localize('com_agents_mcp_icon_size')}</span>
|
||||
</div>
|
||||
<input
|
||||
accept="image/png,.png,image/jpeg,.jpg,.jpeg,image/gif,.gif,image/webp,.webp"
|
||||
accept="image/png,.png,image/jpeg,.jpg,.jpeg,image/gif,.gif,image/webp,.webp,image/svg+xml,.svg"
|
||||
multiple={false}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ import { MCPIcon } from '@librechat/client';
|
|||
import { PermissionBits, hasPermissions } from 'librechat-data-provider';
|
||||
import type { MCPServerStatusIconProps } from '~/components/MCP/MCPServerStatusIcon';
|
||||
import type { MCPServerDefinition } from '~/hooks';
|
||||
import MCPServerDialog from './MCPServerDialog';
|
||||
import { getStatusDotColor } from './MCPStatusBadge';
|
||||
import MCPCardActions from './MCPCardActions';
|
||||
import { useMCPServerManager, useLocalize } from '~/hooks';
|
||||
import { getStatusDotColor } from './MCPStatusBadge';
|
||||
import CustomIcon from '~/components/ui/CustomIcon';
|
||||
import MCPServerDialog from './MCPServerDialog';
|
||||
import MCPCardActions from './MCPCardActions';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface MCPServerCardProps {
|
||||
|
|
@ -98,11 +99,10 @@ export default function MCPServerCard({
|
|||
{/* Server Icon with Status Dot */}
|
||||
<div className="relative flex-shrink-0">
|
||||
{server.config?.iconPath ? (
|
||||
<img
|
||||
<CustomIcon
|
||||
src={server.config.iconPath}
|
||||
className="size-8 rounded-lg object-cover"
|
||||
className="size-8 rounded-lg object-cover text-text-primary"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-surface-tertiary">
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { useFormContext } from 'react-hook-form';
|
|||
import { Input, Label, Textarea } from '@librechat/client';
|
||||
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
|
||||
import MCPIcon from '~/components/SidePanel/Agents/MCPIcon';
|
||||
import { cn, sanitizeSvg, svgToDataUri } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function BasicInfoSection() {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -18,14 +18,25 @@ export default function BasicInfoSection() {
|
|||
|
||||
const handleIconChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64String = reader.result as string;
|
||||
setValue('icon', base64String);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
const isSvg = file.type === 'image/svg+xml' || /\.svg$/i.test(file.name);
|
||||
if (isSvg) {
|
||||
reader.onloadend = () => {
|
||||
const sanitized = sanitizeSvg(reader.result as string);
|
||||
setValue('icon', svgToDataUri(sanitized));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
return;
|
||||
}
|
||||
|
||||
reader.onloadend = () => {
|
||||
setValue('icon', reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
55
client/src/components/ui/CustomIcon.tsx
Normal file
55
client/src/components/ui/CustomIcon.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type { ReactEventHandler } from 'react';
|
||||
import useAdaptiveIcon from '~/hooks/useAdaptiveIcon';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface CustomIconProps {
|
||||
src: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
onError?: ReactEventHandler<HTMLImageElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a user-provided custom icon (e.g. an MCP server `iconPath` or a model
|
||||
* group `groupIcon`). Monochrome SVG glyphs are masked with `currentColor` so
|
||||
* they follow the active theme, while raster images and multi-color SVG logos
|
||||
* keep their original colors. The tint color is inherited from the element's
|
||||
* text color, so set a `text-*` class on `className`.
|
||||
*/
|
||||
export default function CustomIcon({ src, alt = '', className, onError }: CustomIconProps) {
|
||||
const { shouldTint } = useAdaptiveIcon(src);
|
||||
const decorative = alt === '';
|
||||
|
||||
if (shouldTint) {
|
||||
const maskUrl = `url("${src.replace(/"/g, '%22')}")`;
|
||||
return (
|
||||
<span
|
||||
role={decorative ? undefined : 'img'}
|
||||
aria-label={decorative ? undefined : alt}
|
||||
aria-hidden={decorative ? true : undefined}
|
||||
className={cn('inline-block', className)}
|
||||
style={{
|
||||
backgroundColor: 'currentColor',
|
||||
maskImage: maskUrl,
|
||||
WebkitMaskImage: maskUrl,
|
||||
maskRepeat: 'no-repeat',
|
||||
WebkitMaskRepeat: 'no-repeat',
|
||||
maskPosition: 'center',
|
||||
WebkitMaskPosition: 'center',
|
||||
maskSize: 'contain',
|
||||
WebkitMaskSize: 'contain',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
aria-hidden={decorative ? true : undefined}
|
||||
className={className}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
client/src/components/ui/__tests__/CustomIcon.test.tsx
Normal file
21
client/src/components/ui/__tests__/CustomIcon.test.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import CustomIcon from '../CustomIcon';
|
||||
|
||||
describe('CustomIcon', () => {
|
||||
it('renders a raster image with its source and accessible label', () => {
|
||||
render(<CustomIcon src="/assets/logo.png" alt="My Server" className="size-8" />);
|
||||
|
||||
const img = screen.getByRole('img', { name: 'My Server' });
|
||||
expect(img.tagName).toBe('IMG');
|
||||
expect(img).toHaveAttribute('src', '/assets/logo.png');
|
||||
});
|
||||
|
||||
it('hides decorative raster images from assistive technology', () => {
|
||||
const { container } = render(<CustomIcon src="/assets/logo.png" alt="" />);
|
||||
|
||||
const img = container.querySelector('img');
|
||||
expect(img).not.toBeNull();
|
||||
expect(img).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(img).toHaveAttribute('src', '/assets/logo.png');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export { Button } from '@librechat/client';
|
||||
export { default as CustomIcon } from './CustomIcon';
|
||||
export { default as TermsAndConditionsModal } from './TermsAndConditionsModal';
|
||||
export { default as AdminSettingsDialog } from './AdminSettingsDialog';
|
||||
export type { PermissionConfig, AdminSettingsDialogProps } from './AdminSettingsDialog';
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export { default as useFocusTrap } from './useFocusTrap';
|
|||
export { default as useFavorites } from './useFavorites';
|
||||
export { default as useSkillFavorites } from './useSkillFavorites';
|
||||
export { default as useChatBadges } from './useChatBadges';
|
||||
export { default as useAdaptiveIcon } from './useAdaptiveIcon';
|
||||
export { default as useScrollToRef } from './useScrollToRef';
|
||||
export { default as useIsActiveItem } from './useIsActiveItem';
|
||||
export { default as useLocalStorage } from './useLocalStorage';
|
||||
|
|
|
|||
84
client/src/hooks/useAdaptiveIcon.ts
Normal file
84
client/src/hooks/useAdaptiveIcon.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { isSvgIcon, isMonochromeSvg } from '~/utils';
|
||||
|
||||
/** Resolved monochrome verdicts, keyed by icon source, shared across instances. */
|
||||
const monochromeCache = new Map<string, boolean>();
|
||||
/** In-flight resolutions, so concurrent instances of the same icon fetch once. */
|
||||
const inFlight = new Map<string, Promise<boolean>>();
|
||||
|
||||
function decodeDataUri(uri: string): string {
|
||||
const comma = uri.indexOf(',');
|
||||
if (comma === -1) {
|
||||
return '';
|
||||
}
|
||||
const meta = uri.slice(0, comma);
|
||||
const content = uri.slice(comma + 1);
|
||||
if (/;base64/i.test(meta)) {
|
||||
return atob(content);
|
||||
}
|
||||
return decodeURIComponent(content);
|
||||
}
|
||||
|
||||
async function loadSvgContent(src: string): Promise<string> {
|
||||
if (src.startsWith('data:')) {
|
||||
return decodeDataUri(src);
|
||||
}
|
||||
const response = await fetch(src);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load SVG icon (${response.status})`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function resolveMonochrome(src: string): Promise<boolean> {
|
||||
const cached = monochromeCache.get(src);
|
||||
if (cached !== undefined) {
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
const existing = inFlight.get(src);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const promise = loadSvgContent(src)
|
||||
.then(isMonochromeSvg)
|
||||
.catch(() => false)
|
||||
.then((monochrome) => {
|
||||
monochromeCache.set(src, monochrome);
|
||||
inFlight.delete(src);
|
||||
return monochrome;
|
||||
});
|
||||
inFlight.set(src, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a custom icon should be tinted to `currentColor` so it
|
||||
* adapts to the active theme. Only monochrome SVG glyphs are tinted; raster
|
||||
* images and multi-color SVG logos keep their original colors. SVG content is
|
||||
* fetched once and cached; any fetch failure (e.g. CORS) leaves the icon
|
||||
* untinted.
|
||||
*/
|
||||
export default function useAdaptiveIcon(src?: string | null): { shouldTint: boolean } {
|
||||
const key = isSvgIcon(src) ? src : null;
|
||||
const [isMonochrome, setIsMonochrome] = useState<boolean>(() =>
|
||||
key != null ? (monochromeCache.get(key) ?? false) : false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (key == null) {
|
||||
setIsMonochrome(false);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
resolveMonochrome(key).then((monochrome) => {
|
||||
if (active) {
|
||||
setIsMonochrome(monochrome);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [key]);
|
||||
|
||||
return { shouldTint: isMonochrome };
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { isImageURL } from '../icons';
|
||||
import { isImageURL, isSvgIcon } from '../icons';
|
||||
|
||||
describe('isImageURL', () => {
|
||||
it.each(['https://example.com/icon.png', 'http://example.com/icon.png', '/assets/icon.svg'])(
|
||||
|
|
@ -15,3 +15,29 @@ describe('isImageURL', () => {
|
|||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('isSvgIcon', () => {
|
||||
it.each([
|
||||
'https://example.com/icon.svg',
|
||||
'/assets/icon.svg',
|
||||
'/assets/icon.SVG',
|
||||
'https://example.com/icon.svg?v=2',
|
||||
'https://example.com/icon.svg#hash',
|
||||
'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=',
|
||||
'data:image/svg+xml,%3Csvg%3E%3C/svg%3E',
|
||||
])('accepts SVG icon %s', (iconURL) => {
|
||||
expect(isSvgIcon(iconURL)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://example.com/icon.png',
|
||||
'/assets/icon.jpg',
|
||||
'data:image/png;base64,abc',
|
||||
'https://example.com/svg-logo.png',
|
||||
'',
|
||||
null,
|
||||
undefined,
|
||||
])('rejects non-SVG icon %s', (iconURL) => {
|
||||
expect(isSvgIcon(iconURL)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
110
client/src/utils/__tests__/svg.test.ts
Normal file
110
client/src/utils/__tests__/svg.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { isMonochromeSvg, sanitizeSvg } from '../svg';
|
||||
|
||||
describe('isMonochromeSvg', () => {
|
||||
describe('monochrome icons (tinted to currentColor)', () => {
|
||||
it('treats an SVG with no explicit colors as monochrome (default black fill)', () => {
|
||||
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
|
||||
expect(isMonochromeSvg(svg)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a black fill as monochrome', () => {
|
||||
const svg = '<svg><path fill="#000000" d="M0 0h10v10H0z" /></svg>';
|
||||
expect(isMonochromeSvg(svg)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats shorthand black hex as monochrome', () => {
|
||||
const svg = '<svg><path fill="#000" /></svg>';
|
||||
expect(isMonochromeSvg(svg)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats grayscale shades as monochrome', () => {
|
||||
const svg = '<svg><path fill="#333" /><path stroke="#666666" /><rect fill="#ccc" /></svg>';
|
||||
expect(isMonochromeSvg(svg)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats named grayscale colors as monochrome', () => {
|
||||
const svg = '<svg><path fill="black" /><path stroke="gray" /></svg>';
|
||||
expect(isMonochromeSvg(svg)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats grayscale rgb() as monochrome', () => {
|
||||
const svg = '<svg><path style="fill: rgb(50, 50, 50)" /></svg>';
|
||||
expect(isMonochromeSvg(svg)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats grayscale hsl() (zero saturation) as monochrome', () => {
|
||||
const svg = '<svg><path style="fill: hsl(0, 0%, 20%)" /></svg>';
|
||||
expect(isMonochromeSvg(svg)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores none/transparent and currentColor', () => {
|
||||
const svg =
|
||||
'<svg><path fill="none" stroke="currentColor" /><rect fill="transparent" /></svg>';
|
||||
expect(isMonochromeSvg(svg)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles colors defined inside a style block', () => {
|
||||
const svg = '<svg><style>.a{fill:#222}.b{stroke:#888}</style><path class="a" /></svg>';
|
||||
expect(isMonochromeSvg(svg)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-color icons (colors preserved)', () => {
|
||||
it('treats a saturated hex color as multi-color', () => {
|
||||
const svg = '<svg><path fill="#ff0000" /></svg>';
|
||||
expect(isMonochromeSvg(svg)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a mix of saturated colors as multi-color', () => {
|
||||
const svg =
|
||||
'<svg><path fill="#4285F4" /><path fill="#34A853" /><path fill="#EA4335" /></svg>';
|
||||
expect(isMonochromeSvg(svg)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats named chromatic colors as multi-color', () => {
|
||||
const svg = '<svg><path fill="rebeccapurple" /></svg>';
|
||||
expect(isMonochromeSvg(svg)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats saturated rgb() as multi-color', () => {
|
||||
const svg = '<svg><path style="fill: rgb(255, 0, 0)" /></svg>';
|
||||
expect(isMonochromeSvg(svg)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats saturated hsl() as multi-color', () => {
|
||||
const svg = '<svg><path style="fill: hsl(210, 80%, 50%)" /></svg>';
|
||||
expect(isMonochromeSvg(svg)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats gradient (url reference) fills as multi-color', () => {
|
||||
const svg = '<svg><path fill="url(#grad)" /></svg>';
|
||||
expect(isMonochromeSvg(svg)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeSvg', () => {
|
||||
it('strips script tags but keeps drawing elements', () => {
|
||||
const dirty = '<svg><script>alert(1)</script><path d="M0 0h10v10H0z" /></svg>';
|
||||
const clean = sanitizeSvg(dirty);
|
||||
expect(clean).not.toContain('<script');
|
||||
expect(clean).not.toContain('alert(1)');
|
||||
expect(clean).toContain('path');
|
||||
});
|
||||
|
||||
it('strips inline event handler attributes', () => {
|
||||
const dirty = '<svg onload="alert(1)"><circle cx="5" cy="5" r="5" onclick="alert(2)" /></svg>';
|
||||
const clean = sanitizeSvg(dirty);
|
||||
expect(clean).not.toContain('onload');
|
||||
expect(clean).not.toContain('onclick');
|
||||
expect(clean).toContain('circle');
|
||||
});
|
||||
|
||||
it('removes foreignObject and embedded HTML', () => {
|
||||
const dirty =
|
||||
'<svg><foreignObject><iframe src="javascript:alert(1)"></iframe></foreignObject></svg>';
|
||||
const clean = sanitizeSvg(dirty);
|
||||
expect(clean.toLowerCase()).not.toContain('foreignobject');
|
||||
expect(clean.toLowerCase()).not.toContain('iframe');
|
||||
});
|
||||
});
|
||||
|
|
@ -5,3 +5,16 @@ export function isImageURL(iconURL?: string | null): iconURL is string {
|
|||
|
||||
return /^https?:\/\//i.test(iconURL) || (iconURL.startsWith('/') && !iconURL.startsWith('//'));
|
||||
}
|
||||
|
||||
export function isSvgIcon(iconURL?: string | null): iconURL is string {
|
||||
if (!iconURL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/^data:image\/svg\+xml/i.test(iconURL)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const path = iconURL.split(/[?#]/)[0];
|
||||
return /\.svg$/i.test(path);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import logger from './logger';
|
|||
export * from './map';
|
||||
export * from './json';
|
||||
export * from './icons';
|
||||
export * from './svg';
|
||||
export * from './email';
|
||||
export * from './share';
|
||||
export * from './files';
|
||||
|
|
|
|||
134
client/src/utils/svg.ts
Normal file
134
client/src/utils/svg.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import DOMPurify from 'dompurify';
|
||||
|
||||
/**
|
||||
* Heuristics for deciding whether a custom SVG icon is a monochrome glyph that
|
||||
* should be tinted to `currentColor` (so it follows the active theme) or a
|
||||
* multi-color logo that must keep its original colors.
|
||||
*/
|
||||
|
||||
const COLOR_REGEX =
|
||||
/(?:fill|stroke|stop-color|flood-color|lighting-color|color)\s*[:=]\s*["']?\s*(#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)|hsla?\([^)]*\)|[a-zA-Z]+)/gi;
|
||||
|
||||
/** Color keywords that carry no chromatic information and are ignored. */
|
||||
const IGNORABLE_COLORS = new Set(['none', 'transparent', 'inherit', 'currentcolor']);
|
||||
|
||||
/** Named CSS colors that are pure grayscale. Unknown names are treated as chromatic. */
|
||||
const GRAY_NAMES = new Set([
|
||||
'black',
|
||||
'white',
|
||||
'gray',
|
||||
'grey',
|
||||
'silver',
|
||||
'gainsboro',
|
||||
'whitesmoke',
|
||||
'lightgray',
|
||||
'lightgrey',
|
||||
'darkgray',
|
||||
'darkgrey',
|
||||
'dimgray',
|
||||
'dimgrey',
|
||||
]);
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] | null {
|
||||
let value = hex.slice(1);
|
||||
if (value.length === 3 || value.length === 4) {
|
||||
value = value
|
||||
.split('')
|
||||
.map((char) => char + char)
|
||||
.join('');
|
||||
}
|
||||
if (value.length !== 6 && value.length !== 8) {
|
||||
return null;
|
||||
}
|
||||
const r = parseInt(value.slice(0, 2), 16);
|
||||
const g = parseInt(value.slice(2, 4), 16);
|
||||
const b = parseInt(value.slice(4, 6), 16);
|
||||
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) {
|
||||
return null;
|
||||
}
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
function functionalToValues(color: string): number[] | null {
|
||||
const open = color.indexOf('(');
|
||||
const close = color.indexOf(')');
|
||||
if (open === -1 || close === -1) {
|
||||
return null;
|
||||
}
|
||||
const parts = color
|
||||
.slice(open + 1, close)
|
||||
.split(/[,/\s]+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length < 3) {
|
||||
return null;
|
||||
}
|
||||
const toNumber = (part: string) =>
|
||||
part.endsWith('%') ? (parseFloat(part) / 100) * 255 : parseFloat(part);
|
||||
const values = parts.slice(0, 3).map(toNumber);
|
||||
return values.some(Number.isNaN) ? null : values;
|
||||
}
|
||||
|
||||
function isGrayscaleColor(color: string): boolean {
|
||||
if (color.startsWith('#')) {
|
||||
const rgb = hexToRgb(color);
|
||||
return rgb ? rgb[0] === rgb[1] && rgb[1] === rgb[2] : false;
|
||||
}
|
||||
if (color.startsWith('rgb')) {
|
||||
const rgb = functionalToValues(color);
|
||||
if (!rgb) {
|
||||
return false;
|
||||
}
|
||||
const [r, g, b] = rgb.map(Math.round);
|
||||
return r === g && g === b;
|
||||
}
|
||||
if (color.startsWith('hsl')) {
|
||||
const hsl = functionalToValues(color);
|
||||
return hsl ? hsl[1] === 0 : false;
|
||||
}
|
||||
return GRAY_NAMES.has(color);
|
||||
}
|
||||
|
||||
function extractColors(svg: string): string[] {
|
||||
const colors: string[] = [];
|
||||
COLOR_REGEX.lastIndex = 0;
|
||||
let match: RegExpExecArray | null = COLOR_REGEX.exec(svg);
|
||||
while (match !== null) {
|
||||
const token = match[1].trim().toLowerCase();
|
||||
if (token && !IGNORABLE_COLORS.has(token)) {
|
||||
colors.push(token);
|
||||
}
|
||||
match = COLOR_REGEX.exec(svg);
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when an SVG only contains grayscale colors (or relies on the
|
||||
* default black fill / `currentColor`), meaning it can be safely tinted to match
|
||||
* the theme. Multi-color logos return false so their colors are preserved.
|
||||
*/
|
||||
export function isMonochromeSvg(svg: string): boolean {
|
||||
const colors = extractColors(svg);
|
||||
if (colors.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return colors.every(isGrayscaleColor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips scripts, event handlers, and other active content from user-provided
|
||||
* SVG markup using an allowlist sanitizer, leaving only safe drawing elements.
|
||||
*/
|
||||
export function sanitizeSvg(svg: string): string {
|
||||
return DOMPurify.sanitize(svg, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
FORBID_TAGS: ['script', 'foreignObject'],
|
||||
FORBID_ATTR: ['onload', 'onerror', 'onclick', 'onmouseover', 'onmouseenter', 'onfocus'],
|
||||
});
|
||||
}
|
||||
|
||||
/** Encodes SVG markup as a URL-encoded `image/svg+xml` data URI. */
|
||||
export function svgToDataUri(svg: string): string {
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue