From 8e68807bd6a6cbd515bf39ebc3776054c4806e17 Mon Sep 17 00:00:00 2001 From: uttam verma Date: Wed, 27 May 2026 22:16:23 +0530 Subject: [PATCH 1/6] Fix file validation and add upload progress UI (#142) --- frontend/src/components/chat/ChatPanel.tsx | 16 +-- frontend/src/components/chat/FileUpload.tsx | 95 ++++++++++++++++++ frontend/src/hooks/useFileUpload.ts | 103 ++++++++++++++++++++ frontend/src/lib/fileValidation.ts | 59 +++++++++++ frontend/src/styles/fileUpload.css | 58 +++++++++++ 5 files changed, 317 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/chat/FileUpload.tsx create mode 100644 frontend/src/hooks/useFileUpload.ts create mode 100644 frontend/src/lib/fileValidation.ts create mode 100644 frontend/src/styles/fileUpload.css diff --git a/frontend/src/components/chat/ChatPanel.tsx b/frontend/src/components/chat/ChatPanel.tsx index 26f6036..e8b6a19 100644 --- a/frontend/src/components/chat/ChatPanel.tsx +++ b/frontend/src/components/chat/ChatPanel.tsx @@ -11,6 +11,7 @@ import { ModeToggle } from "../ui/mode-toggle" import { Check, CheckCheck, Image as ImageIcon } from "lucide-react" import { SettingsDrawer } from "../ui/settings-drawer" import MediaGallery from "./MediaGallery" +import { FileUpload } from "./FileUpload" function MessageTick({ seen, @@ -477,20 +478,7 @@ export default function ChatPanel({ /> )} - + {image && (
(null); + const { progress, status, error, fileName, upload, cancel, reset } = useFileUpload(); + + const handleFileSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + await upload(file, roomId); + // Reset input so same file can be re-selected + if (inputRef.current) inputRef.current.value = ""; + }; + + return ( +
+ {/* Hidden native input */} + + + {/* Upload trigger button */} + {status === "idle" || status === "success" || status === "cancelled" ? ( + + ) : null} + + {/* Progress bar — shown during upload */} + {status === "uploading" || status === "validating" ? ( +
+ {fileName} + +
+
+
+ + {progress}% + + {/* Cancel button */} + +
+ ) : null} + + {/* Error message */} + {status === "error" && error && ( +
+ + {error} + +
+ )} + + {/* Success */} + {status === "success" && ( +
+ + File sent! +
+ )} +
+ ); +} diff --git a/frontend/src/hooks/useFileUpload.ts b/frontend/src/hooks/useFileUpload.ts new file mode 100644 index 0000000..22f58b1 --- /dev/null +++ b/frontend/src/hooks/useFileUpload.ts @@ -0,0 +1,103 @@ +import { useState, useRef, useCallback } from "react"; +import { validateFile } from "../lib/fileValidation"; +import { useMessageContext } from "../app/MessageContext"; + +interface UploadState { + progress: number; // 0-100 + status: "idle" | "validating" | "uploading" | "success" | "error" | "cancelled"; + error: string | null; + fileName: string | null; +} + +export function useFileUpload() { + const [state, setState] = useState({ + progress: 0, + status: "idle", + error: null, + fileName: null, + }); + + const { sendMessage } = useMessageContext(); + const readerRef = useRef(null); + + const upload = useCallback(async (file: File, roomId: string) => { + // Step 1: Validate + setState({ progress: 0, status: "validating", error: null, fileName: file.name }); + + const validation = validateFile(file); + if (!validation.valid) { + setState((s) => ({ ...s, status: "error", error: validation.error })); + return; + } + + // Step 2: Read file and track progress, then send via existing API + setState((s) => ({ ...s, status: "uploading" })); + + const reader = new FileReader(); + readerRef.current = reader; + + return new Promise((resolve, reject) => { + // ✅ Track progress for reading large files + reader.onprogress = (e) => { + if (e.lengthComputable) { + const pct = Math.round((e.loaded / e.total) * 100); + setState((s) => ({ ...s, progress: pct })); + } + }; + + reader.onload = async () => { + try { + if (readerRef.current === null) { + reject(); + return; // cancelled + } + const base64 = reader.result as string; + + // Force progress to 100 once read + setState((s) => ({ ...s, progress: 100 })); + + // Send via existing sendMessage API which expects base64 + await sendMessage(roomId, "", base64); + + setState((s) => ({ ...s, status: "success", progress: 100 })); + resolve(); + } catch (err: any) { + if (err?.message?.includes("413") || err?.status === 413) { + setState((s) => ({ + ...s, status: "error", + error: "File too large. Server rejected the upload.", + })); + } else { + setState((s) => ({ + ...s, status: "error", + error: err?.message || "Upload failed. Please try again.", + })); + } + reject(); + } + }; + + reader.onerror = () => { + setState((s) => ({ ...s, status: "error", error: "File reading error." })); + reject(); + }; + + reader.readAsDataURL(file); + }); + }, [sendMessage]); + + // ✅ Cancel in-flight upload + const cancel = useCallback(() => { + if (readerRef.current) { + readerRef.current.abort(); + readerRef.current = null; + setState({ progress: 0, status: "cancelled", error: null, fileName: null }); + } + }, []); + + const reset = useCallback(() => { + setState({ progress: 0, status: "idle", error: null, fileName: null }); + }, []); + + return { ...state, upload, cancel, reset }; +} diff --git a/frontend/src/lib/fileValidation.ts b/frontend/src/lib/fileValidation.ts new file mode 100644 index 0000000..bde2094 --- /dev/null +++ b/frontend/src/lib/fileValidation.ts @@ -0,0 +1,59 @@ +export const FILE_CONFIG = { + maxSizeMB: 10, + maxSizeBytes: 10 * 1024 * 1024, // 10MB + + // Allowed types for a developer chat platform + allowedTypes: [ + // Images + "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", + // Documents + "application/pdf", "text/plain", "text/markdown", + "application/json", "text/csv", + // Code files + "text/javascript", "text/typescript", "text/html", "text/css", + "text/x-python", "text/x-java-source", + // Archives (safe) + "application/zip", + ], + + // Blocked extensions (double-check beyond MIME type) + blockedExtensions: [ + ".exe", ".sh", ".bat", ".cmd", ".ps1", ".php", + ".py", ".rb", ".pl", ".vbs", ".js", // executables + ".dll", ".so", ".dylib", // binaries + ], +} as const; + +export type ValidationResult = + | { valid: true } + | { valid: false; error: string }; + +export function validateFile(file: File): ValidationResult { + // Check size + if (file.size > FILE_CONFIG.maxSizeBytes) { + const sizeMB = (file.size / 1024 / 1024).toFixed(1); + return { + valid: false, + error: `File too large (${sizeMB}MB). Maximum allowed size is ${FILE_CONFIG.maxSizeMB}MB.`, + }; + } + + // Check MIME type + if (!FILE_CONFIG.allowedTypes.includes(file.type as any)) { + return { + valid: false, + error: `File type "${file.type || "unknown"}" is not allowed. Only images, documents, and code files are permitted.`, + }; + } + + // Check extension (defense in depth) + const ext = "." + file.name.split(".").pop()?.toLowerCase(); + if (FILE_CONFIG.blockedExtensions.includes(ext as any)) { + return { + valid: false, + error: `Files with extension "${ext}" are not allowed for security reasons.`, + }; + } + + return { valid: true }; +} diff --git a/frontend/src/styles/fileUpload.css b/frontend/src/styles/fileUpload.css new file mode 100644 index 0000000..33cc9b1 --- /dev/null +++ b/frontend/src/styles/fileUpload.css @@ -0,0 +1,58 @@ +.upload-progress-container { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: var(--bg-secondary, #f5f5f5); + border-radius: 8px; + min-width: 240px; +} + +.upload-filename { + font-size: 0.75rem; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.progress-bar-track { + flex: 1; + height: 6px; + background: #ddd; + border-radius: 3px; + overflow: hidden; +} + +.progress-bar-fill { + height: 100%; + background: #3b82f6; + border-radius: 3px; + transition: width 0.2s ease; +} + +.upload-pct { + font-size: 0.7rem; + color: #666; + min-width: 30px; +} + +.upload-error { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: #fef2f2; + border: 1px solid #fca5a5; + border-radius: 6px; + font-size: 0.8rem; + color: #dc2626; +} + +.upload-success { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.8rem; + color: #16a34a; +} From fc1765673df77f97ba34256baf863f7d775561af Mon Sep 17 00:00:00 2001 From: uttam verma Date: Wed, 27 May 2026 22:44:20 +0530 Subject: [PATCH 2/6] fix(e2e): Fix race condition in waitForResponse for messages API --- frontend/e2e/message.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/e2e/message.spec.ts b/frontend/e2e/message.spec.ts index a55cdd5..5d08b67 100644 --- a/frontend/e2e/message.spec.ts +++ b/frontend/e2e/message.spec.ts @@ -117,9 +117,6 @@ test.describe("Message Flow", () => { } }) - // Navigate to chat - await page.goto("/chat") - // Wait for the users to load const sidebarResponsePromise = page.waitForResponse( (response) => @@ -128,6 +125,10 @@ test.describe("Message Flow", () => { response.request().method() === "GET" && response.status() === 200, ) + + // Navigate to chat + await page.goto("/chat") + await sidebarResponsePromise // Wait for "Alice" to appear in the sidebar From 31258bc381a33e44e494b0e7071549757a78c563 Mon Sep 17 00:00:00 2001 From: uttam verma Date: Wed, 27 May 2026 22:56:34 +0530 Subject: [PATCH 3/6] fix: resolve jwt malformed CI errors and improve auth token handling --- backend/middleware/auth.js | 8 ++++++-- backend/server.js | 8 ++++++-- frontend/e2e/message.spec.ts | 5 +++++ frontend/src/app/fetcher.ts | 2 +- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 86fefed..c7fea72 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -4,7 +4,7 @@ import User from "../models/User.js" export const protectRoute = async (req, res, next) => { const token = req.headers.authorization?.split(" ")[1] - if (!token) { + if (!token || token === "undefined" || token === "null") { return res.status(401).json({ message: "Not authorized, no token" }) } @@ -35,7 +35,11 @@ export const protectRoute = async (req, res, next) => { .status(401) .json({ code: "TOKEN_EXPIRED", message: "Access token expired" }) } - console.error("Token verification failed:", error) + if (error.name === "JsonWebTokenError") { + console.warn(`Token verification failed: ${error.message}`) + } else { + console.error("Token verification failed:", error) + } res.status(401).json({ message: "Not authorized, token failed" }) } } diff --git a/backend/server.js b/backend/server.js index bf003c4..a760fe8 100644 --- a/backend/server.js +++ b/backend/server.js @@ -122,7 +122,7 @@ io.use((socket, next) => { io.use(async (socket, next) => { const token = socket.handshake.auth?.token - if (!token) { + if (!token || token === "undefined" || token === "null") { return next(new Error("Authentication error: No token provided")) } @@ -139,7 +139,11 @@ io.use(async (socket, next) => { if (err.name === "TokenExpiredError") { return next(new Error("Authentication error: Token expired")) } - console.error("Socket auth token invalid:", err.message) + if (err.name === "JsonWebTokenError") { + console.warn(`Socket auth token invalid: ${err.message}`) + } else { + console.error("Socket auth token invalid:", err.message) + } return next(new Error("Authentication error: Invalid token")) } }) diff --git a/frontend/e2e/message.spec.ts b/frontend/e2e/message.spec.ts index 5d08b67..831224e 100644 --- a/frontend/e2e/message.spec.ts +++ b/frontend/e2e/message.spec.ts @@ -126,6 +126,11 @@ test.describe("Message Flow", () => { response.status() === 200, ) + // Inject token before navigation to mimic proper hydration + await page.addInitScript(() => { + window.localStorage.setItem('token', 'mock-access-token') + }) + // Navigate to chat await page.goto("/chat") diff --git a/frontend/src/app/fetcher.ts b/frontend/src/app/fetcher.ts index 4ae61cd..7f40cd1 100644 --- a/frontend/src/app/fetcher.ts +++ b/frontend/src/app/fetcher.ts @@ -52,7 +52,7 @@ async function fetcher( } const t = getToken() - if (t) headers["Authorization"] = `Bearer ${t}` + if (t && t !== "undefined" && t !== "null") headers["Authorization"] = `Bearer ${t}` // Always include credentials for cookies (refresh token) const fetchOptions: RequestInit = { From c0df47afee45c210224affd3a185a4e46719d953 Mon Sep 17 00:00:00 2001 From: uttam verma Date: Wed, 27 May 2026 23:08:49 +0530 Subject: [PATCH 4/6] test: fix playwright e2e race conditions by using Promise.all --- frontend/e2e/message.spec.ts | 49 +++++++++++++++++------------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/frontend/e2e/message.spec.ts b/frontend/e2e/message.spec.ts index 831224e..57cfced 100644 --- a/frontend/e2e/message.spec.ts +++ b/frontend/e2e/message.spec.ts @@ -117,24 +117,23 @@ test.describe("Message Flow", () => { } }) - // Wait for the users to load - const sidebarResponsePromise = page.waitForResponse( - (response) => - response.url().includes("api/v1/messages") && - !response.url().includes("friend-id") && - response.request().method() === "GET" && - response.status() === 200, - ) - // Inject token before navigation to mimic proper hydration await page.addInitScript(() => { window.localStorage.setItem('token', 'mock-access-token') }) - - // Navigate to chat - await page.goto("/chat") - await sidebarResponsePromise + // Navigate to chat and wait for the users to load simultaneously + const [sidebarResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes("api/v1/messages") && + !response.url().includes("friend-id") && + response.request().method() === "GET" && + response.status() === 200, + { timeout: 15000 } + ), + page.goto("/chat") + ]) // Wait for "Alice" to appear in the sidebar await expect(page.getByText("Alice").first()).toBeVisible({ @@ -151,19 +150,17 @@ test.describe("Message Flow", () => { const input = page.getByPlaceholder("Type a message...") await input.fill("Hello Playwright!") - // Prepare to wait for the send message response - const sendResponsePromise = page.waitForResponse( - (response) => - response.url().includes("api/v1/messages/send/friend-id") && - response.request().method() === "POST" && - response.status() === 201, - ) - - // Click Send - await page.getByRole("button", { name: "Send" }).click() - - // Wait for the send message response - await sendResponsePromise + // Click Send and wait for the network response simultaneously + const [sendResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes("api/v1/messages/send/friend-id") && + response.request().method() === "POST" && + response.status() === 201, + { timeout: 10000 } + ), + page.getByRole("button", { name: "Send" }).click() + ]) // Verify the new message appears in the UI await expect(page.getByText("Hello Playwright!")).toBeVisible() From 93a3b830f753036c345665b009abd91ed69d2688 Mon Sep 17 00:00:00 2001 From: uttam verma Date: Thu, 28 May 2026 22:53:57 +0530 Subject: [PATCH 5/6] fix: resolve null reference error in ChatPanel for FileUpload --- frontend/src/components/chat/ChatPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/chat/ChatPanel.tsx b/frontend/src/components/chat/ChatPanel.tsx index e8b6a19..e6c167f 100644 --- a/frontend/src/components/chat/ChatPanel.tsx +++ b/frontend/src/components/chat/ChatPanel.tsx @@ -478,7 +478,7 @@ export default function ChatPanel({ />
)} - + {image && (
Date: Thu, 28 May 2026 22:58:43 +0530 Subject: [PATCH 6/6] fix: resolve playwright test timeouts and null reference in ChatPanel --- frontend/e2e/message.spec.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/frontend/e2e/message.spec.ts b/frontend/e2e/message.spec.ts index d5dd3b6..9eb0fed 100644 --- a/frontend/e2e/message.spec.ts +++ b/frontend/e2e/message.spec.ts @@ -197,7 +197,7 @@ test.describe("Message Flow", () => { page.waitForResponse( (response) => response.url().includes("api/v1/messages") && - !response.url().includes("friend-id") && + !response.url().includes(friendId) && response.request().method() === "GET" && response.status() === 200, { timeout: 15000 } @@ -210,9 +210,6 @@ test.describe("Message Flow", () => { timeout: 10000, }) - // The app auto-selects the first user; wait for the conversation fetch to complete - await historyResponsePromise - // Verify chat history loaded await expect(page.getByText("Hi there")).toBeVisible({ timeout: 10000 }) @@ -224,7 +221,7 @@ test.describe("Message Flow", () => { const [sendResponse] = await Promise.all([ page.waitForResponse( (response) => - response.url().includes("api/v1/messages/send/friend-id") && + response.url().includes(`api/v1/messages/send/${friendId}`) && response.request().method() === "POST" && response.status() === 201, { timeout: 10000 }