Skip to content
Closed
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
64 changes: 63 additions & 1 deletion frontend/src/components/ChatBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
const scrollPositionRef = useRef<number>(0)
const navigate = useNavigate()
const fileInputRef = useRef<HTMLInputElement | null>(null)
const uploadControllersRef = useRef<Map<string, AbortController>>(new Map())

const [showReferenceBox, setShowReferenceBox] = useState(false)
const [searchMode, setSearchMode] = useState<"citations" | "global">(
Expand Down Expand Up @@ -842,6 +843,10 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
)

const uploadPromises = files.map(async (selectedFile) => {
// Create AbortController for this file upload
const controller = new AbortController()
uploadControllersRef.current.set(selectedFile.id, controller)

try {
const formData = new FormData()
formData.append("attachment", selectedFile.file)
Expand All @@ -850,6 +855,7 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
{
method: "POST",
body: formData,
signal: controller.signal,
},
)

Expand All @@ -874,6 +880,12 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
throw new Error("No document ID returned from upload")
}
} catch (error) {
// Check if the error is due to abort
if (error instanceof Error && error.name === 'AbortError') {
// Upload was cancelled, don't show error message
return null
}

const errorMessage =
error instanceof Error ? error.message : "Upload failed"
setSelectedFiles((prev) =>
Expand All @@ -889,6 +901,8 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
})
return null
} finally {
// Clean up the controller reference
uploadControllersRef.current.delete(selectedFile.id)
setUploadingFilesCount((prev) => prev - 1)
}
})
Expand Down Expand Up @@ -980,6 +994,48 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
const removeFile = useCallback(async (id: string) => {
const fileToRemove = selectedFiles.find((f) => f.id === id)

// If the file is currently uploading, abort the upload
if (fileToRemove?.uploading) {
const controller = uploadControllersRef.current.get(id)
if (controller) {
controller.abort()
uploadControllersRef.current.delete(id)

// Update the uploading count immediately
setUploadingFilesCount((prev) => Math.max(0, prev - 1))

// Update file state to show it was cancelled
Comment on lines +1004 to +1007
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Avoid double-decrementing uploadingFilesCount on abort

You decrement here and again in the upload promise’s finally, which can drive the counter negative and break “wait for uploads” logic.

Apply this diff:

-          // Update the uploading count immediately
-          setUploadingFilesCount((prev) => Math.max(0, prev - 1))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Update the uploading count immediately
setUploadingFilesCount((prev) => Math.max(0, prev - 1))
// Update file state to show it was cancelled
// Update file state to show it was cancelled
🤖 Prompt for AI Agents
In frontend/src/components/ChatBox.tsx around lines 1004 to 1007, the code
immediately decrements uploadingFilesCount when an upload is aborted while the
upload promise’s finally block also decrements it, causing double-decrement and
possibly negative counts; remove the immediate setUploadingFilesCount((prev) =>
Math.max(0, prev - 1)) here and rely on the upload promise’s finally to
decrement once (or alternatively add a per-upload "didDecrement" flag so the
count is decremented exactly once), ensuring the counter cannot be decremented
twice.

setSelectedFiles((prev) =>
prev.map((f) =>
f.id === id
? { ...f, uploading: false, uploadError: "Upload cancelled" }
: f,
),
)

// Call cleanup endpoint to remove any partial uploads
try {
await authFetch("/api/v1/files/upload-attachment/cleanup", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
fileId: id,
}),
})
} catch (cleanupError) {
console.warn("Failed to cleanup aborted upload:", cleanupError)
// Don't show error to user since the main goal is to cancel the upload
}

toast({
title: "Upload cancelled",
description: `Upload for ${fileToRemove.file.name} was cancelled`,
})
}
}

// If the file has metadata with fileId (meaning it's already uploaded), delete it from the server
if (fileToRemove?.metadata?.fileId) {
try {
Expand Down Expand Up @@ -1007,7 +1063,7 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
}
return prev.filter((f) => f.id !== id)
})
}, [selectedFiles])
}, [selectedFiles, toast])

const { handleFileSelect, handleFileChange } = createFileSelectionHandlers(
fileInputRef,
Expand Down Expand Up @@ -2213,6 +2269,12 @@ export const ChatBox = React.forwardRef<ChatBoxRef, ChatBoxProps>(
.map((f) => f.preview)
.filter(Boolean) as string[]
cleanupPreviewUrls(previewUrls)

// Abort all ongoing uploads
uploadControllersRef.current.forEach((controller) => {
controller.abort()
})
uploadControllersRef.current.clear()
}
}, [])

Expand Down
87 changes: 87 additions & 0 deletions server/api/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,93 @@ export const handleAttachmentDeleteApi = async (c: Context) => {
}
}

export const handleAttachmentUploadCleanup = async (c: Context) => {
const { sub } = c.get(JwtPayloadKey)
const email = sub

try {
const { fileId } = await c.req.json()
if (!fileId) {
throw new HTTPException(400, { message: "File ID is required for cleanup" })
}

loggerWithChild({ email }).info(
`Cleaning up aborted upload for file ID: ${fileId}`
)

// Validate fileId to prevent path traversal
if (
fileId.includes("..") ||
fileId.includes("/") ||
fileId.includes("\\")
) {
throw new HTTPException(400, { message: "Invalid file ID" })
}

// Check if this is an image attachment that might have been partially written to disk
const imageBaseDir = path.resolve(
process.env.IMAGE_DIR || "downloads/xyne_images_db",
)
const imageDir = path.join(imageBaseDir, fileId)

try {
// Try to remove the directory if it exists (for image uploads)
await fs.access(imageDir)
await fs.rm(imageDir, { recursive: true, force: true })
loggerWithChild({ email }).info(
`Cleaned up image directory for aborted upload: ${imageDir}`
)
} catch (error) {
// Directory might not exist, which is fine for aborted uploads
loggerWithChild({ email }).debug(
`No image directory to cleanup for file ID: ${fileId}`
)
}

// Try to delete from Vespa if the document was partially created
try {
const vespaIds = expandSheetIds(fileId)
for (const vespaId of vespaIds) {
// Check if document exists before trying to delete
const doc = await GetDocument(fileSchema, vespaId)
if (doc && doc.fields) {
const fields = doc.fields as any
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

Using as any here bypasses TypeScript's type checking, which can make the code less safe and harder to maintain. Since you are working with fileSchema from @xyne/vespa-ts/types, you could likely infer the type from the schema (e.g., type VespaFile = z.infer<typeof fileSchema>) and use that for doc.fields. This would provide better type safety and autocompletion.

const permissions = Array.isArray(fields.permissions) ? fields.permissions as string[] : []

// Only delete if user has permissions
if (permissions.includes(email)) {
await DeleteDocument(vespaId, fileSchema)
// Also try to delete any associated images
await DeleteImages(vespaId)
loggerWithChild({ email }).info(
`Cleaned up Vespa document for aborted upload: ${vespaId}`
)
}
}
}
} catch (error) {
// Document might not exist in Vespa, which is fine for aborted uploads
loggerWithChild({ email }).debug(
`No Vespa document to cleanup for file ID: ${fileId}`
)
}

return c.json({
success: true,
message: "Cleanup completed for aborted upload"
})
} catch (error) {
if (error instanceof HTTPException) {
throw error
}
loggerWithChild({ email }).error(
error,
"Error during attachment upload cleanup"
)
throw new HTTPException(500, { message: "Internal server error during cleanup" })
}
}

Comment on lines +551 to +637
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Cleanup can’t find aborted uploads: wrong fileId is sent from the client

The cleanup endpoint expects the fileId used on server (attf_${uuid}), but the client calls it with selectedFile.id (client-only). On aborted uploads, the server never returned its fileId, so this endpoint will often no-op and leave partial files/docs behind.

Fix by aligning IDs:

  • Frontend: include a stable client fileId in the upload form.
  • Server: accept and use that client-provided fileId for the upload (docId, image dir). Then the cleanup can actually remove partial artifacts using the same id.

Additionally:

  • Consider zod-validating { fileId: string }.
  • For parity with deletion code, optionally delete from KbItemsSchema when id starts with "att_".

To confirm the mismatch, run:


🏁 Script executed:

#!/bin/bash
# 1) Locate generateFileId and see its format
rg -nP --type=ts '(export\s+)?function\s+generateFileId|\bgenerateFileId\s*\(' -C3

# 2) Check if frontend already appends any client id to the upload form
rg -nP --type=tsx -C3 'formData\.append\([^)]*attachment|upload-attachment'

# 3) Confirm server constructs its own attf_ fileId
rg -nP --type=ts -C3 'upload-attachment|attf_' server

Length of output: 3353


🏁 Script executed:

#!/bin/bash
# 1. Show handleAttachmentUpload definition and context
rg -nP 'export const handleAttachmentUpload' -C50 server/api/files.ts

# 2. Show JSON response calls within that handler
rg -nP 'c\.json\(' -C5 server/api/files.ts

# 3. Search for client-side fetch to /files/upload-attachment
rg -nP 'fetch\([^)]*files/upload-attachment' -C10 frontend/src

Length of output: 9655


🏁 Script executed:

#!/bin/bash
# 1. Find client call to cleanup endpoint
rg -n "upload-attachment/cleanup" -C5 frontend/src

# 2. Search for any “cleanup” references in attachment components
rg -n "cleanup" -C5 frontend/src

# 3. Locate generateFileId implementation and its prefix
rg -nP "function\s+generateFileId" -C5 frontend/src

Length of output: 21777


Align cleanup ID with upload

  • The client calls cleanup with its own generateFileId() (ChatBox.tsx:1018), but the server uses attf_<uuid> and never returns it—aborted uploads aren’t cleaned up.
  • Persist the server’s fileId in the upload response (or switch to using the client-provided ID for both storage and cleanup).
  • Add Zod validation for the cleanup body ({ fileId: string }).

/**
* Serve attachment file by fileId
*/
Expand Down
2 changes: 2 additions & 0 deletions server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ import {
handleAttachmentServe,
handleThumbnailServe,
handleAttachmentDeleteApi,
handleAttachmentUploadCleanup,
} from "@/api/files"
import { z } from "zod" // Ensure z is imported if not already at the top for schemas
import {
Expand Down Expand Up @@ -894,6 +895,7 @@ export const AppRoutes = app
)
.post("files/upload", handleFileUpload)
.post("/files/upload-attachment", handleAttachmentUpload)
.post("/files/upload-attachment/cleanup", handleAttachmentUploadCleanup)
.get("/attachments/:fileId", handleAttachmentServe)
.get("/attachments/:fileId/thumbnail", handleThumbnailServe)
.post("/files/delete", zValidator("json", handleAttachmentDeleteSchema), handleAttachmentDeleteApi)
Expand Down