Skip to content
Merged
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
10 changes: 9 additions & 1 deletion apps/web/app/api/chats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,22 @@ import { getChats } from '@utils/getChats'

import type { Chat } from 'types'

const DEFAULT_PAGE_SIZE = 20
const MAX_PAGE_SIZE = 100

export async function GET(req: Request): Promise<NextResponse<Chat[]>> {
const session = await getCachedSession()

if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const mergedChats = await getChats(session.user)
const { searchParams } = new URL(req.url)
const requestedLimit = parseInt(searchParams.get('limit') || String(DEFAULT_PAGE_SIZE))
const limit = Math.min(Math.max(1, requestedLimit), MAX_PAGE_SIZE)
const cursor = searchParams.get('cursor') || undefined
Comment on lines +18 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Sanitize limit when parsing chat list query

The new GET handler clamps the limit query param using Math.min(Math.max(1, requestedLimit), MAX_PAGE_SIZE), but requestedLimit can be NaN when a client calls /api/chats?limit=abc or /api/chats?limit=. Because Math.min/Math.max propagate NaN, limit ends up as NaN and is forwarded to getChats, ultimately producing a limit=NaN remote request. The backend then attempts to use NaN for the SQL take clause and returns a 500. To keep the “1‑100 items per page” validation promise, the handler should detect Number.isNaN(requestedLimit) and either default to DEFAULT_PAGE_SIZE or return a 400 before calling the service.

Useful? React with 👍 / 👎.

Comment on lines +18 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Sanitize limit when parsing chat list query

The new GET handler clamps the limit query param using Math.min(Math.max(1, requestedLimit), MAX_PAGE_SIZE), but requestedLimit can be NaN when a client calls /api/chats?limit=abc or /api/chats?limit=. Because Math.min/Math.max propagate NaN, limit ends up as NaN and is forwarded to getChats, ultimately producing a limit=NaN remote request. The backend then attempts to use NaN for the SQL take clause and returns a 500. To keep the “1‑100 items per page” validation promise, the handler should detect Number.isNaN(requestedLimit) and either default to DEFAULT_PAGE_SIZE or return a 400 before calling the service.

Useful? React with 👍 / 👎.


const mergedChats = await getChats(session.user, { limit, cursor })
return NextResponse.json(mergedChats)
}

Expand Down
62 changes: 55 additions & 7 deletions packages-answers/ui/src/ChatDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'
import * as React from 'react'
import useSWR from 'swr'
import useSWRInfinite from 'swr/infinite'
import NextLink from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { styled } from '@mui/material/styles'
Expand All @@ -9,6 +9,7 @@ import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
import ListItemButton from '@mui/material/ListItemButton'
import ListItemText from '@mui/material/ListItemText'
import CircularProgress from '@mui/material/CircularProgress'

import closedMixin from './theme/closedMixin'
import openedMixin from './theme/openedMixin'
Expand All @@ -17,6 +18,7 @@ import { Chat, Journey } from 'types'
import { Box } from '@mui/material'

const drawerWidth = 400
const CHATS_PAGE_SIZE = 20

const DrawerHeader = styled('div')(({ theme }) => ({
display: 'flex',
Expand Down Expand Up @@ -57,8 +59,28 @@ export default function ChatDrawer({ journeys, chats, defaultOpen }: ChatDrawerP
const pathname = usePathname()
const [open, setOpen] = React.useState<boolean | undefined>(defaultOpen)
const [opened, setOpened] = React.useState<{ [key: string | number]: boolean }>({ chats: true })
const loadMoreRef = React.useRef<HTMLDivElement>(null)

const { data: fetchedChats } = useSWR<Chat[] | { error: string }>('/api/chats', fetcher, { fallback: chats })
const getKey = (pageIndex: number, previousPageData: Chat[] | null) => {
// Reached the end (no data or less than limit means no more pages)
if (previousPageData && previousPageData.length < CHATS_PAGE_SIZE) return null

// First page
if (pageIndex === 0) return `/api/chats?limit=${CHATS_PAGE_SIZE}`

// Get cursor from last chat of previous page
// Chatflows API uses 'createdDate', but fallback to 'createdAt' for type compatibility
const lastChat = previousPageData?.[previousPageData.length - 1]
const cursor = lastChat?.createdDate || lastChat?.createdAt
return `/api/chats?limit=${CHATS_PAGE_SIZE}&cursor=${cursor}`
}

const { data, size, setSize, isValidating } = useSWRInfinite<Chat[]>(getKey, fetcher, {
fallbackData: chats ? [chats] : undefined,
revalidateFirstPage: false
})

const fetchedChats = React.useMemo(() => data?.flat() || [], [data])
const getDateKey = (chat: Chat) => {
const date = new Date(chat.createdAt ?? chat.createdDate)
const now = new Date()
Expand All @@ -70,17 +92,37 @@ export default function ChatDrawer({ journeys, chats, defaultOpen }: ChatDrawerP
}

const chatsByDate = React.useMemo(() => {
if (!fetchedChats || fetchedChats?.error) return {}
if (!fetchedChats || fetchedChats.length === 0) return {}

const sortedChats = fetchedChats?.sort(
(a, b) => new Date(b.createdAt ?? b.createdDate).getTime() - new Date(a.createdAt ?? a.createdDate).getTime()
)
return sortedChats?.reduce((accum: { [key: string]: Chat[] }, chat: Chat) => {
return fetchedChats.reduce((accum: { [key: string]: Chat[] }, chat: Chat) => {
const dateKey = getDateKey(chat)
return { ...accum, [dateKey]: [...(accum[dateKey] || []), chat] }
}, {})
}, [fetchedChats])

// Check if there's more data to load
const hasMore = data && data[data.length - 1]?.length === CHATS_PAGE_SIZE

// IntersectionObserver for infinite scroll
React.useEffect(() => {
if (!hasMore) return

const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isValidating) {
setSize(size + 1)
}
},
{ threshold: 0.1 }
)

if (loadMoreRef.current) {
observer.observe(loadMoreRef.current)
}

return () => observer.disconnect()
}, [size, setSize, isValidating, hasMore])

return (
<>
<List disablePadding>
Expand Down Expand Up @@ -119,6 +161,12 @@ export default function ChatDrawer({ journeys, chats, defaultOpen }: ChatDrawerP
))}
</Box>
))}
{/* Load more trigger */}
{hasMore && (
<Box ref={loadMoreRef} sx={{ p: 2, display: 'flex', justifyContent: 'center' }}>
{isValidating && <CircularProgress size={24} />}
</Box>
)}
</List>
</>
)
Expand Down
78 changes: 23 additions & 55 deletions packages-answers/utils/src/getChats.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { prisma } from '@db/client'
import auth0 from '@utils/auth/auth0'

interface User {
Expand All @@ -8,7 +7,13 @@ interface User {
chatflowDomain: string
}

export async function getChats(user: User) {
interface PaginationOptions {
limit?: number
cursor?: string
}

export async function getChats(user: User, options: PaginationOptions = {}) {
const { limit = 20, cursor } = options
// Get auth token for chatflow API
let token
try {
Expand All @@ -19,64 +24,27 @@ export async function getChats(user: User) {
token = accessToken
} catch (err) {
console.error('Auth error:', err)
return []
}

// Fetch local chats
const localChatsPromise = prisma.chat
.findMany({
where: {
users: { some: { email: user.email } },
organization: { id: user.organizationId },
chatflowChatId: { not: null },
journeyId: null
},
orderBy: {
createdAt: 'desc'
},
include: {
prompt: true,
messages: { orderBy: { createdAt: 'desc' }, take: 1 }
// Fetch chatflow chats with pagination
try {
const response = await fetch(`${user.chatflowDomain}/api/v1/chats?limit=${limit}${cursor ? `&cursor=${cursor}` : ''}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then((data: any) => JSON.parse(JSON.stringify(data)))

// Fetch chatflow chats
const chatflowChatsPromise = token
? fetch(`${user.chatflowDomain}/api/v1/chats`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then((res) => (res.ok ? res.json() : []))
.catch((err: any) => {
console.error('Error fetching chatflow chats:', err.message)
return []
})
: Promise.resolve([])

// Wait for both promises to resolve
const [localChats, chatflowChats] = await Promise.all([localChatsPromise, chatflowChatsPromise])
if (!response.ok) {
console.error('Error fetching chatflow chats:', response.statusText)
return []
}

// Merge and deduplicate chats
const mergedChats = [
// ...localChats
]
if (chatflowChats.length > 0) {
chatflowChats.forEach((chatflowChat: any) => {
// Only add if not already in local chats
if (!localChats.some((local) => local.chatflowChatId === chatflowChat.id)) {
mergedChats.push({
...chatflowChat,
chatflowChatId: chatflowChat.id
})
}
})
return await response.json()
} catch (err: any) {
console.error('Error fetching chatflow chats:', err.message)
return []
}

// Sort merged chats by date
mergedChats.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())

return mergedChats
}
8 changes: 7 additions & 1 deletion packages/server/src/controllers/chats/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@ import chatsService from '../../services/chats'
import { InternalFlowiseError } from '../../errors/internalFlowiseError'
import { StatusCodes } from 'http-status-codes'

const DEFAULT_PAGE_SIZE = 20
const MAX_PAGE_SIZE = 100

const getAllChats = async (req: Request, res: Response, next: NextFunction) => {
try {
if (!req.user) {
throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, 'Error: chatsController.getAllChats - Unauthorized')
}
const apiResponse = await chatsService.getAllChats(req.user)
const requestedLimit = req.query.limit ? parseInt(req.query.limit as string, 10) : DEFAULT_PAGE_SIZE
const limit = Math.min(Math.max(1, requestedLimit), MAX_PAGE_SIZE)
const cursor = req.query.cursor as string | undefined
const apiResponse = await chatsService.getAllChats(req.user, { limit, cursor })
Comment on lines +14 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Backend pagination accepts non-numeric limit

The controller similarly parses req.query.limit with parseInt and clamps it, but without an isNaN guard a value like limit=foo produces limit = NaN. This NaN is passed to chatsService.getAllChats, which hands it to TypeORM’s take option. TypeORM will generate a malformed LIMIT and throw, turning a bad client parameter into a 500 instead of a controlled 400/default value. Adding an Number.isNaN check before clamping (or defaulting to DEFAULT_PAGE_SIZE) prevents arbitrary errors from the public API.

Useful? React with 👍 / 👎.

Comment on lines +14 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Backend pagination accepts non-numeric limit

The controller similarly parses req.query.limit with parseInt and clamps it, but without an isNaN guard a value like limit=foo produces limit = NaN. This NaN is passed to chatsService.getAllChats, which hands it to TypeORM’s take option. TypeORM will generate a malformed LIMIT and throw, turning a bad client parameter into a 500 instead of a controlled 400/default value. Adding an Number.isNaN check before clamping (or defaulting to DEFAULT_PAGE_SIZE) prevents arbitrary errors from the public API.

Useful? React with 👍 / 👎.

return res.json(apiResponse)
} catch (error) {
next(error)
Expand Down
33 changes: 25 additions & 8 deletions packages/server/src/services/chats/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,37 @@ import { getRunningExpressApp } from '../../utils/getRunningExpressApp'
import { InternalFlowiseError } from '../../errors/internalFlowiseError'
import { getErrorMessage } from '../../errors/utils'
import { Chat } from '../../database/entities/Chat'
import { Not, IsNull } from 'typeorm'
import { Not, IsNull, LessThan } from 'typeorm'

interface PaginationOptions {
limit?: number
cursor?: string
}

const getAllChats = async (user: IUser, options: PaginationOptions = {}) => {
const { limit = 20, cursor } = options

// Validate cursor date if provided
if (cursor) {
const cursorDate = new Date(cursor)
if (isNaN(cursorDate.getTime())) {
throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, 'Invalid cursor date format')
}
}

const getAllChats = async (user: IUser) => {
try {
const appServer = getRunningExpressApp()
const chats = await appServer.AppDataSource.getRepository(Chat).find({
where: {
owner: { id: user.id },
organization: { id: user.organizationId },
chatflowChatId: Not(IsNull())
ownerId: user.id,
organizationId: user.organizationId,
chatflowChatId: Not(IsNull()),
...(cursor ? { createdDate: LessThan(new Date(cursor)) } : {})
},
order: {
createdDate: 'DESC'
}
},
take: limit
})
return JSON.parse(JSON.stringify(chats))
} catch (error) {
Expand All @@ -31,8 +48,8 @@ const getChatById = async (chatId: string, user: IUser) => {
const chat = await appServer.AppDataSource.getRepository(Chat).findOne({
where: {
id: chatId,
owner: { id: user.id },
organization: { id: user.organizationId }
ownerId: user.id,
organizationId: user.organizationId
},
relations: {
chatflow: true
Expand Down
Loading