diff --git a/frontend/src/components/ChatBox.tsx b/frontend/src/components/ChatBox.tsx index aecde7b7b..27e02f902 100644 --- a/frontend/src/components/ChatBox.tsx +++ b/frontend/src/components/ChatBox.tsx @@ -417,6 +417,7 @@ export const ChatBox = React.forwardRef( const scrollPositionRef = useRef(0) const navigate = useNavigate() const fileInputRef = useRef(null) + const uploadControllersRef = useRef>(new Map()) const [showReferenceBox, setShowReferenceBox] = useState(false) const [searchMode, setSearchMode] = useState<"citations" | "global">( @@ -842,6 +843,10 @@ export const ChatBox = React.forwardRef( ) 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) @@ -850,6 +855,7 @@ export const ChatBox = React.forwardRef( { method: "POST", body: formData, + signal: controller.signal, }, ) @@ -874,6 +880,12 @@ export const ChatBox = React.forwardRef( 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) => @@ -889,6 +901,8 @@ export const ChatBox = React.forwardRef( }) return null } finally { + // Clean up the controller reference + uploadControllersRef.current.delete(selectedFile.id) setUploadingFilesCount((prev) => prev - 1) } }) @@ -980,6 +994,48 @@ export const ChatBox = React.forwardRef( 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 + 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 { @@ -1007,7 +1063,7 @@ export const ChatBox = React.forwardRef( } return prev.filter((f) => f.id !== id) }) - }, [selectedFiles]) + }, [selectedFiles, toast]) const { handleFileSelect, handleFileChange } = createFileSelectionHandlers( fileInputRef, @@ -2213,6 +2269,12 @@ export const ChatBox = React.forwardRef( .map((f) => f.preview) .filter(Boolean) as string[] cleanupPreviewUrls(previewUrls) + + // Abort all ongoing uploads + uploadControllersRef.current.forEach((controller) => { + controller.abort() + }) + uploadControllersRef.current.clear() } }, []) diff --git a/server/api/files.ts b/server/api/files.ts index 12f573257..5079bd29d 100644 --- a/server/api/files.ts +++ b/server/api/files.ts @@ -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 + 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" }) + } +} + /** * Serve attachment file by fileId */ diff --git a/server/server.ts b/server/server.ts index fee9d330d..a04c66120 100644 --- a/server/server.ts +++ b/server/server.ts @@ -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 { @@ -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)