Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/server/src/controllers/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { StatusCodes } from 'http-status-codes'
import { InternalFlowiseError } from '../../errors/internalFlowiseError'
import toolsService from '../../services/tools'
import { getPageAndLimitParams } from '../../utils/pagination'
import { normalizeOptionalToolIconSrc } from '../../utils/toolIconSrc'

const createTool = async (req: Request, res: Response, next: NextFunction) => {
try {
Expand All @@ -23,7 +24,7 @@ const createTool = async (req: Request, res: Response, next: NextFunction) => {
if (body.name !== undefined) toolBody.name = body.name
if (body.description !== undefined) toolBody.description = body.description
if (body.color !== undefined) toolBody.color = body.color
if (body.iconSrc !== undefined) toolBody.iconSrc = body.iconSrc
if (body.iconSrc !== undefined) toolBody.iconSrc = normalizeOptionalToolIconSrc(body.iconSrc)
if (body.schema !== undefined) toolBody.schema = body.schema
if (body.func !== undefined) toolBody.func = body.func
toolBody.workspaceId = workspaceId
Expand Down Expand Up @@ -98,7 +99,7 @@ const updateTool = async (req: Request, res: Response, next: NextFunction) => {
if (body.name !== undefined) toolBody.name = body.name
if (body.description !== undefined) toolBody.description = body.description
if (body.color !== undefined) toolBody.color = body.color
if (body.iconSrc !== undefined) toolBody.iconSrc = body.iconSrc
if (body.iconSrc !== undefined) toolBody.iconSrc = normalizeOptionalToolIconSrc(body.iconSrc)
if (body.schema !== undefined) toolBody.schema = body.schema
if (body.func !== undefined) toolBody.func = body.func
const apiResponse = await toolsService.updateTool(req.params.id, toolBody, workspaceId)
Expand Down
26 changes: 26 additions & 0 deletions packages/server/src/utils/toolIconSrc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { StatusCodes } from 'http-status-codes'
import { InternalFlowiseError } from '../errors/internalFlowiseError'

/**
* Normalize optional tool iconSrc from the API: empty clears (null); non-empty must be http(s).
* Returns undefined when the client omitted the field (partial updates).
*/
export function normalizeOptionalToolIconSrc(iconSrc: unknown): string | null | undefined {
if (iconSrc === undefined) return undefined
if (iconSrc === null) return null
if (typeof iconSrc !== 'string') {
throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Error: tool icon source must be a string, http(s) URL, or empty.`)
}
const trimmed = iconSrc.trim()
if (!trimmed) return null
try {
const u = new URL(trimmed)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In Node.js, new URL(trimmed) will throw a TypeError if the input is not an absolute URL (e.g., just a domain like example.com). While the catch block correctly handles this by throwing a BAD_REQUEST error, it's worth noting that this implementation strictly enforces absolute URLs. This is consistent with the goal of validating the icon source and promotes fail-fast behavior for invalid external data types.

References
  1. When handling potentially invalid data from external sources (like an API response), prefer throwing an error for invalid input types rather than silently returning a default or empty value. This promotes fail-fast behavior.

if (u.protocol !== 'http:' && u.protocol !== 'https:') {
throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Error: tool icon source must be an http or https URL.`)
}
return u.href
} catch (e) {
if (e instanceof InternalFlowiseError) throw e
throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Error: tool icon source must be a valid http or https URL.`)
}
}
8 changes: 5 additions & 3 deletions packages/ui/src/ui-component/cards/ItemCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Box, Grid, Tooltip, Typography, useTheme } from '@mui/material'
import MainCard from '@/ui-component/cards/MainCard'
import MoreItemsTooltip from '../tooltip/MoreItemsTooltip'
import ScheduleStatusBadge from '@/ui-component/extended/ScheduleStatusBadge'
import { getItemCardIconBackgroundUrl } from '@/utils/toolIconUrl'

const CardWrapper = styled(MainCard)(({ theme }) => ({
background: theme.palette.card.main,
Expand All @@ -34,6 +35,7 @@ const CardWrapper = styled(MainCard)(({ theme }) => ({
const ItemCard = ({ data, images, icons, scheduleStatus, onClick }) => {
const theme = useTheme()
const customization = useSelector((state) => state.customization)
const headerIconBgUrl = getItemCardIconBackgroundUrl(data.iconSrc)

return (
<CardWrapper content={false} onClick={onClick} sx={{ border: 1, borderColor: theme.palette.grey[900] + 25, borderRadius: 2 }}>
Expand All @@ -49,7 +51,7 @@ const ItemCard = ({ data, images, icons, scheduleStatus, onClick }) => {
overflow: 'hidden'
}}
>
{data.iconSrc && (
{headerIconBgUrl && (
<div
style={{
width: 35,
Expand All @@ -58,14 +60,14 @@ const ItemCard = ({ data, images, icons, scheduleStatus, onClick }) => {
flexShrink: 0,
marginRight: 10,
borderRadius: '50%',
backgroundImage: `url(${data.iconSrc})`,
backgroundImage: `url(${headerIconBgUrl})`,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center center'
}}
></div>
)}
{!data.iconSrc && data.color && (
{!headerIconBgUrl && data.color && (
<div
style={{
width: 35,
Expand Down
125 changes: 84 additions & 41 deletions packages/ui/src/ui-component/table/ToolsListTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import {
useTheme
} from '@mui/material'

import { getItemCardIconBackgroundUrl } from '@/utils/toolIconUrl'
import defaultToolIcon from '@/assets/images/tool.svg'

const StyledTableCell = styled(TableCell)(({ theme }) => ({
borderColor: theme.palette.grey[900] + 25,

Expand Down Expand Up @@ -87,47 +90,87 @@ export const ToolsTable = ({ data, isLoading, onSelect }) => {
</>
) : (
<>
{data?.map((row, index) => (
<StyledTableRow key={index}>
<StyledTableCell sx={{ display: 'flex', alignItems: 'center', gap: 1 }} key='0'>
<div
style={{
width: 35,
height: 35,
display: 'flex',
flexShrink: 0,
marginRight: 10,
borderRadius: '50%',
backgroundImage: `url(${row.iconSrc})`,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center center'
}}
></div>
<Typography
sx={{
display: '-webkit-box',
fontSize: 14,
fontWeight: 500,
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
textOverflow: 'ellipsis',
overflow: 'hidden'
}}
>
<Button onClick={() => onSelect(row)} sx={{ textAlign: 'left' }}>
{row.templateName || row.name}
</Button>
</Typography>
</StyledTableCell>
<StyledTableCell key='1'>
<Typography sx={{ overflowWrap: 'break-word', whiteSpace: 'pre-line' }}>
{row.description || ''}
</Typography>
</StyledTableCell>
<StyledTableCell key='3'></StyledTableCell>
</StyledTableRow>
))}
{data?.map((row, index) => {
const headerIconBgUrl = getItemCardIconBackgroundUrl(row.iconSrc)
return (
<StyledTableRow key={index}>
<StyledTableCell sx={{ display: 'flex', alignItems: 'center', gap: 1 }} key='0'>
{headerIconBgUrl ? (
<div
style={{
width: 35,
height: 35,
display: 'flex',
flexShrink: 0,
marginRight: 10,
borderRadius: '50%',
backgroundImage: `url(${headerIconBgUrl})`,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center center'
}}
></div>
) : row.color ? (
<div
style={{
width: 35,
height: 35,
display: 'flex',
flexShrink: 0,
marginRight: 10,
borderRadius: '50%',
background: row.color
}}
></div>
) : (
<div
style={{
width: 35,
height: 35,
display: 'flex',
flexShrink: 0,
marginRight: 10,
borderRadius: '50%',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
backgroundColor: customization.isDarkMode
? theme.palette.grey[800]
: theme.palette.grey[200]
}}
>
<img
src={defaultToolIcon}
alt=''
style={{ width: 22, height: 22, objectFit: 'contain' }}
/>
</div>
)}
<Typography
sx={{
display: '-webkit-box',
fontSize: 14,
fontWeight: 500,
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
textOverflow: 'ellipsis',
overflow: 'hidden'
}}
>
<Button onClick={() => onSelect(row)} sx={{ textAlign: 'left' }}>
{row.templateName || row.name}
</Button>
</Typography>
</StyledTableCell>
<StyledTableCell key='1'>
<Typography sx={{ overflowWrap: 'break-word', whiteSpace: 'pre-line' }}>
{row.description || ''}
</Typography>
</StyledTableCell>
<StyledTableCell key='3'></StyledTableCell>
</StyledTableRow>
)
})}
</>
)}
</TableBody>
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/src/utils/genericHelper.js
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,9 @@ export const isValidURL = (url) => {
export const formatDataGridRows = (rows) => {
try {
const parsedRows = typeof rows === 'string' ? JSON.parse(rows) : rows
if (!Array.isArray(parsedRows)) {
return []
}
return parsedRows.map((sch, index) => {
return {
...sch,
Expand Down
50 changes: 50 additions & 0 deletions packages/ui/src/utils/toolIconUrl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Custom Tool icon source: optional field; when set, must be an absolute http(s) URL.
* @param {string|undefined|null} value
* @returns {boolean}
*/
export function isOptionalHttpOrHttpsToolIconUrl(value) {
if (value == null) return true
const v = String(value).trim()
if (!v) return true
try {
const u = new URL(v)
return u.protocol === 'http:' || u.protocol === 'https:'
} catch {
return false
}
}

/**
* @param {string|undefined|null} value
* @returns {string|null}
*/
export function getValidHttpOrHttpsToolIconUrl(value) {
if (value == null) return null
const v = String(value).trim()
if (!v) return null
try {
const u = new URL(v)
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null
return u.href
} catch {
return null
}
}

/**
* Resolves a value suitable for CSS `background-image: url(...)`, or null to use color/default fallback.
* Accepts http(s) URLs, root-relative paths, and data URLs; rejects plain invalid strings like "abc".
* @param {string|undefined|null} iconSrc
* @returns {string|null}
*/
export function getItemCardIconBackgroundUrl(iconSrc) {
if (iconSrc == null) return null
const t = String(iconSrc).trim()
if (!t) return null
const http = getValidHttpOrHttpsToolIconUrl(t)
if (http) return http
if (t.startsWith('/') || t.startsWith('./')) return t
if (t.toLowerCase().startsWith('data:')) return t
return null
}
Comment on lines +41 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is a slight discrepancy between the UI rendering logic and the validation logic. getItemCardIconBackgroundUrl allows relative paths (/, ./) and data: URLs for display, but the validation in ToolDialog.jsx strictly requires http or https. To ensure a safe fallback mechanism, the validation logic should be updated to match the supported rendering formats, ensuring that validly rendered icons can also be saved.

References
  1. When using a heuristic for detection (e.g., for content type), ensure a safe fallback mechanism is in place to correctly handle cases where the heuristic fails.

Loading