feat: add custom avatar upload functionality: #279#321
feat: add custom avatar upload functionality: #279#321vinisha1014 wants to merge 1 commit intoAOSSIE-Org:mainfrom
Conversation
- Add upload controller (backend/controllers/upload_controller.go) - Multipart file upload with 5MB limit - JPG/PNG validation (extension + MIME type) - UUID-based unique filenames - Saves to ./uploads/avatars/ - Updates user avatarUrl in MongoDB - Update server entry point (backend/cmd/server/main.go) - Serve static files from /uploads - Register POST /user/upload-avatar route (JWT protected) - Add uploadAvatar service (frontend/src/services/profileService.ts) - FormData-based file upload with proper error handling - Update Profile page (frontend/src/Pages/Profile.tsx) - File input with client-side validation - Loading spinner during upload - Immediate avatar update on success - Update AvatarModal (frontend/src/components/AvatarModal.tsx) - Add 'Upload Photo' tab alongside existing DiceBear customization - File preview, upload with spinner, error handling - DiceBear fallback preserved
📝 WalkthroughWalkthroughThis pull request introduces an avatar upload feature across the application stack. It adds a backend HTTP endpoint for authenticated avatar uploads with file validation (size, type), a new API service function to handle uploads, and frontend UI components enabling users to upload avatars with real-time preview and error handling. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client (Browser)
participant FE as Frontend Service
participant Server as Backend Server
participant FS as File System
participant DB as MongoDB
Client->>FE: Select avatar file
FE->>FE: Validate file (type, size)
FE->>FE: Get auth token
FE->>FE: Create FormData with file
FE->>Server: POST /user/upload-avatar
Server->>Server: Verify authentication
Server->>Server: Validate file size & MIME type
Server->>FS: Create /uploads directory (if needed)
Server->>FS: Save file with UUID name
Server->>DB: Update user avatarUrl & updatedAt
DB-->>Server: User record updated
Server-->>FE: Return avatar_url + message
FE->>Client: Update UI with new avatar
FE->>Client: Clear file input
Estimated Code Review Effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly Related PRs
Suggested Reviewers
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Tip Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
Adds end-to-end custom avatar upload support (JWT-protected) so users can upload a JPG/PNG avatar (max 5MB), persist it in MongoDB, and serve it via the backend while keeping DiceBear as fallback.
Changes:
- Backend: add
UploadAvatarcontroller with validation + disk persistence; register JWT route and static serving for uploaded files. - Frontend: add
uploadAvatar()API call; add upload UI/flow inProfileand as a new “Upload Photo” tab inAvatarModal.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/services/profileService.ts | Adds uploadAvatar() API client using FormData and JWT. |
| frontend/src/components/AvatarModal.tsx | Adds “Upload Photo” tab with preview and upload handling. |
| frontend/src/Pages/Profile.tsx | Adds hover actions + inline avatar upload with loading spinner. |
| backend/controllers/upload_controller.go | New multipart upload endpoint with validation and DB update. |
| backend/cmd/server/main.go | Serves /uploads statically and registers POST /user/upload-avatar. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| file, header, err := c.Request.FormFile("avatar") | ||
| if err != nil { | ||
| if err.Error() == "http: request body too large" { | ||
| c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "File too large. Maximum size is 5MB."}) | ||
| return | ||
| } | ||
| c.JSON(http.StatusBadRequest, gin.H{"error": "No file provided or invalid upload. Use key 'avatar'."}) | ||
| return |
There was a problem hiding this comment.
The request-size error check relies on comparing err.Error() to a specific string, which is brittle (the error can be wrapped or have a different message). Prefer detecting *http.MaxBytesError via errors.As (or similar) so oversized uploads reliably return 413.
| // Build the public URL | ||
| avatarURL := fmt.Sprintf("http://localhost:1313%s/%s", avatarURLPrefix, uniqueName) |
There was a problem hiding this comment.
avatarURL is built with a hardcoded http://localhost:1313 base, which will break in production and in any non-default dev setup. Consider building the URL from the incoming request (scheme/host, respecting reverse-proxy headers) or storing a relative path like /uploads/avatars/<file> and letting the frontend/baseURL resolve it.
| // Build the public URL | |
| avatarURL := fmt.Sprintf("http://localhost:1313%s/%s", avatarURLPrefix, uniqueName) | |
| // Build the public URL as a relative path; let the frontend/base URL resolve the host and scheme. | |
| avatarURL := fmt.Sprintf("%s/%s", avatarURLPrefix, uniqueName) |
| setUploadError(''); | ||
| setUploadFile(file); | ||
| setUploadPreview(URL.createObjectURL(file)); | ||
| }; |
There was a problem hiding this comment.
URL.createObjectURL(file) is used to generate a preview, but the URL is never revoked. This can leak memory if the modal is opened/closed or the user selects multiple files. Revoke the previous object URL when replacing it and when the modal unmounts/closes (e.g., via an effect cleanup).
| setUploadError('Only JPG and PNG files are allowed.'); | ||
| return; | ||
| } | ||
| if (file.size > 5 * 1024 * 1024) { | ||
| setUploadError('File too large. Maximum size is 5MB.'); |
There was a problem hiding this comment.
On invalid file type/size, handleFileSelect sets an error but doesn’t clear the previous uploadFile/uploadPreview or reset the underlying <input>. This can leave a stale preview selected and can prevent re-selecting the same file (no onChange fire). Consider clearing state (and resetting the input’s value) on validation failure.
| setUploadError('Only JPG and PNG files are allowed.'); | |
| return; | |
| } | |
| if (file.size > 5 * 1024 * 1024) { | |
| setUploadError('File too large. Maximum size is 5MB.'); | |
| setUploadError('Only JPG and PNG files are allowed.'); | |
| setUploadFile(null); | |
| setUploadPreview(null); | |
| e.target.value = ''; | |
| return; | |
| } | |
| if (file.size > 5 * 1024 * 1024) { | |
| setUploadError('File too large. Maximum size is 5MB.'); | |
| setUploadFile(null); | |
| setUploadPreview(null); | |
| e.target.value = ''; |
| // Validate file type | ||
| const allowedTypes = ["image/jpeg", "image/png"]; | ||
| if (!allowedTypes.includes(file.type)) { | ||
| setErrorMessage("Invalid file type. Only JPG and PNG files are allowed."); | ||
| return; | ||
| } | ||
|
|
||
| // Validate file size (5MB) | ||
| const MAX_SIZE = 5 * 1024 * 1024; | ||
| if (file.size > MAX_SIZE) { | ||
| setErrorMessage("File too large. Maximum size is 5MB."); | ||
| return; | ||
| } | ||
|
|
||
| const token = getAuthToken(); | ||
| if (!token) { | ||
| setErrorMessage("Authentication token is missing."); | ||
| return; | ||
| } |
There was a problem hiding this comment.
When avatar upload validation fails (type/size/token) the handler returns early without resetting the hidden file input value, so choosing the same file again may not trigger onChange. Reset fileInputRef.current.value (and possibly clear any prior error) on these early-return paths too.
| // File was saved but DB failed — still return the URL so the frontend can retry | ||
| c.JSON(http.StatusInternalServerError, gin.H{"error": "Avatar saved but failed to update profile. Please try again."}) | ||
| return | ||
| } | ||
| if result.MatchedCount == 0 { | ||
| log.Printf("UploadAvatar: No user found with email: %s", email) |
There was a problem hiding this comment.
If the DB update fails or no user is matched, the avatar file has already been written to disk and is never cleaned up, leading to orphaned files accumulating over time. Either remove the saved file on these failure paths (and/or delete the user’s previous custom avatar when replacing it) or restructure the flow to avoid committing the file until the DB update succeeds.
| // File was saved but DB failed — still return the URL so the frontend can retry | |
| c.JSON(http.StatusInternalServerError, gin.H{"error": "Avatar saved but failed to update profile. Please try again."}) | |
| return | |
| } | |
| if result.MatchedCount == 0 { | |
| log.Printf("UploadAvatar: No user found with email: %s", email) | |
| // DB update failed; remove the saved file to avoid leaving an orphaned avatar on disk. | |
| if removeErr := os.Remove(savePath); removeErr != nil { | |
| log.Printf("UploadAvatar: Failed to remove orphaned avatar file %s: %v", savePath, removeErr) | |
| } | |
| c.JSON(http.StatusInternalServerError, gin.H{"error": "Avatar saved but failed to update profile. Please try again."}) | |
| return | |
| } | |
| if result.MatchedCount == 0 { | |
| log.Printf("UploadAvatar: No user found with email: %s", email) | |
| // No user matched; remove the saved file to avoid leaving an orphaned avatar on disk. | |
| if removeErr := os.Remove(savePath); removeErr != nil { | |
| log.Printf("UploadAvatar: Failed to remove orphaned avatar file %s: %v", savePath, removeErr) | |
| } |
| if err != nil { | ||
| log.Printf("UploadAvatar: Failed to update user avatar in DB: %v", err) | ||
| // File was saved but DB failed — still return the URL so the frontend can retry | ||
| c.JSON(http.StatusInternalServerError, gin.H{"error": "Avatar saved but failed to update profile. Please try again."}) |
There was a problem hiding this comment.
The comment says “still return the URL so the frontend can retry” but the response on DB failure only returns an error field (no URL). Please either include the URL in the error response (if that’s the intended behavior) or update the comment and clean up the saved file so the system doesn’t get into a half-written state.
| c.JSON(http.StatusInternalServerError, gin.H{"error": "Avatar saved but failed to update profile. Please try again."}) | |
| c.JSON(http.StatusInternalServerError, gin.H{ | |
| "error": "Avatar saved but failed to update profile. Please try again.", | |
| "avatar_url": avatarURL, | |
| }) |
| "message": "Avatar uploaded successfully", | ||
| "avatar_url": avatarURL, |
There was a problem hiding this comment.
This endpoint returns avatar_url (snake_case), while the rest of the API uses avatarUrl (camelCase) for the same field (e.g., profile responses). For consistency and to reduce client-side special casing, return avatarUrl here as well (and align the frontend typings/usages accordingly).
| "message": "Avatar uploaded successfully", | |
| "avatar_url": avatarURL, | |
| "message": "Avatar uploaded successfully", | |
| "avatarUrl": avatarURL, |
| buf := make([]byte, 512) | ||
| n, err := file.Read(buf) | ||
| if err != nil { | ||
| return false | ||
| } |
There was a problem hiding this comment.
validateMIMEType returns false on any read error. For small files, Read can return n > 0 with err == io.EOF, which would incorrectly reject otherwise valid images. Consider treating io.EOF as non-fatal when n > 0 (and import/use io).
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
backend/cmd/server/main.go (1)
70-71:⚠️ Potential issue | 🟡 Minor
MkdirAllerror silently discarded and permissions are overly broad.If directory creation fails, the server will start but all uploads will fail with confusing errors. Log or fail fast.
Proposed fix
- // Create uploads directory - os.MkdirAll("uploads", os.ModePerm) + // Create uploads directory + if err := os.MkdirAll("uploads", 0750); err != nil { + log.Fatalf("Failed to create uploads directory: %v", err) + }
🤖 Fix all issues with AI agents
In `@backend/controllers/upload_controller.go`:
- Around line 115-121: The logs in UploadAvatar currently print the user's email
(variables email) on both failure and success paths (checked via
result.MatchedCount and avatarURL); replace those plaintext emails with a
non-PII identifier such as the user's ID (if available on the user object) or a
hashed/truncated version of email (e.g., SHA256 or first/last 4 chars) before
logging so logs no longer contain full PII. Locate the UploadAvatar handler and
update the log.Printf calls to use the chosen safe identifier (derived from
email or using user.ID) while keeping the rest of the messages unchanged.
- Around line 101-127: In the UploadAvatar handler, prevent orphaned avatar
files by reading the user's existing avatarUrl before saving the new file: query
the users collection (e.g., with db.MongoDatabase.Collection("users").FindOne
using the same email/dbCtx), extract the existing filename from the returned
avatarUrl, and delete that file from disk (os.Remove) if it exists and is not
the default placeholder; then proceed to save the new UUID-named file and run
the current UpdateOne call that sets "avatarUrl" and "updatedAt". Ensure
deletion errors are logged but do not block the new upload, and avoid deleting
if avatarUrl is empty or matches a shared default.
- Around line 130-148: The validateMIMEType function currently ignores whether
the uploaded file supports seeking and ignores seek errors—change the logic in
validateMIMEType to assert the file to io.Seeker (or equivalent interface), and
if the assertion fails or seeker.Seek(0, io.SeekStart) returns an error, return
false immediately; this ensures readers like c.SaveUploadedFile will see the
file from the start and prevents saving corrupted uploads. Keep the rest of the
MIME detection flow (reading 512 bytes, DetectContentType, comparing against
allowedMIMEs) unchanged.
- Line 95: Replace the hardcoded "http://localhost:1313" used to build avatarURL
with a dynamic base URL: read the scheme and host from the incoming
*http.Request (request.URL.Scheme or infer via r.TLS and r.Host) or, preferably,
a configurable base URL from your app config, and then join that base with
avatarURLPrefix and uniqueName; update the avatar URL construction (the
avatarURL variable) to use this computed base URL and include a sensible
fallback if neither request-derived host nor config is available.
- Around line 46-53: The current error check compares err.Error() to a string;
instead use errors.As to detect *http.MaxBytesError and handle it specially: in
the upload handler where err is returned (the block using c.JSON), attempt var
maxErr *http.MaxBytesError; if errors.As(err,&maxErr) respond with
http.StatusRequestEntityTooLarge and the same "File too large. Maximum size is
5MB." message, otherwise fall back to the generic BadRequest response; ensure
you import the errors package and reference *http.MaxBytesError and the handler
function that processes the upload (the block currently checking err and calling
c.JSON).
In `@frontend/src/components/AvatarModal.tsx`:
- Around line 428-445: The code in handleFileSelect currently calls
URL.createObjectURL and never revokes it, causing a memory leak; before calling
setUploadPreview(URL.createObjectURL(file)) revoke any existing blob URL (check
the uploadPreview state) via URL.revokeObjectURL(uploadPreview), then set the
new preview, and also add a cleanup (e.g., in an effect that watches
uploadPreview or on modal close/unmount) that revokes the current uploadPreview
when the component unmounts or the modal closes to ensure all created object
URLs are released.
🧹 Nitpick comments (2)
backend/controllers/upload_controller.go (1)
77-77:os.ModePerm(0777) is overly permissive for the upload directory.This grants read/write/execute to all users on the system. Use a more restrictive permission like
0750.Proposed fix
- if err := os.MkdirAll(avatarUploadDir, os.ModePerm); err != nil { + if err := os.MkdirAll(avatarUploadDir, 0750); err != nil {frontend/src/components/AvatarModal.tsx (1)
25-30: Upload state persists across modal open/close cycles.If the user selects a file, closes the modal without uploading, then reopens it, they'll see stale preview/error state. Reset upload-related state when the modal opens.
Proposed fix — add a reset effect
useEffect(() => { if (isOpen) { setActiveTab('customize'); setUploadPreview(null); setUploadFile(null); setUploadError(''); } }, [isOpen]);
| if err != nil { | ||
| if err.Error() == "http: request body too large" { | ||
| c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "File too large. Maximum size is 5MB."}) | ||
| return | ||
| } | ||
| c.JSON(http.StatusBadRequest, gin.H{"error": "No file provided or invalid upload. Use key 'avatar'."}) | ||
| return | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Go http.MaxBytesError type documentation
💡 Result:
http.MaxBytesError is an error type in Go’s standard library package net/http (added in Go 1.19). It’s returned by http.MaxBytesReader when the request body exceeds the configured byte limit. It has a single field Limit int64 and implements error via (*MaxBytesError).Error(). [1], [2]
Docs: see the net/http package documentation on pkg.go.dev (entry type MaxBytesError). [1]
Related note (Go 1.19 release notes): MaxBytesReader now returns this defined error type when the limit is exceeded. [2]
Sources:
[1] pkg.go.dev net/http (type MaxBytesError) (pkg.go.dev)
[2] Go 1.19 release notes (net/http section) (tip.golang.org)
Citations:
- 1: https://pkg.go.dev/net/http?utm_source=openai
- 2: https://tip.golang.org/doc/go1.19?utm_source=openai
🌐 Web query:
Go FormFile large file error handling http.Request
💡 Result:
(*http.Request).FormFile() is convenient but it forces multipart parsing (it calls ParseMultipartForm/ParseForm) and therefore will read/parse the request body rather than letting you “peek” safely first. (pkg.go.dev)
1) Put a hard cap on upload size (recommended)
Wrap the request body before calling ParseMultipartForm / FormFile:
func upload(w http.ResponseWriter, r *http.Request) {
const maxBody = 50 << 20 // 50 MiB (whole request, not just the file)
r.Body = http.MaxBytesReader(w, r.Body, maxBody)
if err := r.ParseMultipartForm(32 << 20); err != nil {
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
http.Error(w, "upload too large", http.StatusRequestEntityTooLarge) // 413
return
}
http.Error(w, "bad multipart form", http.StatusBadRequest)
return
}
f, fh, err := r.FormFile("file")
if err != nil {
http.Error(w, "missing/invalid file", http.StatusBadRequest)
return
}
defer f.Close()
// Optional per-file check (not a security boundary by itself):
if fh.Size > (40 << 20) {
http.Error(w, "file too large", http.StatusRequestEntityTooLarge)
return
}
// stream/save f...
}Why this works:
http.MaxBytesReaderenforces a hard limit and returns a non-nil error (type*http.MaxBytesError) once the limit is exceeded. (pkg.go.dev)- Without this,
ParseMultipartForm(maxMemory)mainly controls memory buffering (rest spills to temp files) and is not a hard upload-size limit by itself. (pkg.go.dev) - When the limit is hit, you’ll often see errors like
multipart: NextPart: http: request body too large. (stackoverflow.com)
2) For truly large files: stream instead of FormFile
Use r.MultipartReader() and process parts sequentially (e.g., io.CopyN to enforce per-file limits) to avoid buffering the whole form. (pkg.go.dev)
Citations:
- 1: https://pkg.go.dev/net/http
- 2: https://pkg.go.dev/net/http
- 3: https://pkg.go.dev/net/http
- 4: https://stackoverflow.com/questions/28073395/limiting-file-size-in-formfile?utm_source=openai
- 5: https://pkg.go.dev/net/http
🏁 Script executed:
grep -n "MaxBytesReader\|c.Request.Body\|FormFile" backend/controllers/upload_controller.go | head -20Repository: AOSSIE-Org/DebateAI
Length of output: 320
🏁 Script executed:
git ls-files backend/ | grep -E "(upload|controller)" | head -10Repository: AOSSIE-Org/DebateAI
Length of output: 465
🏁 Script executed:
wc -l backend/controllers/upload_controller.goRepository: AOSSIE-Org/DebateAI
Length of output: 107
🏁 Script executed:
sed -n '40,60p' backend/controllers/upload_controller.goRepository: AOSSIE-Org/DebateAI
Length of output: 811
🏁 Script executed:
sed -n '1,30p' backend/controllers/upload_controller.goRepository: AOSSIE-Org/DebateAI
Length of output: 611
Use errors.As to check for http.MaxBytesError instead of string comparison.
The string comparison err.Error() == "http: request body too large" is fragile and can vary across Go versions. Since http.MaxBytesReader is already properly set up on line 43, it will return *http.MaxBytesError when the limit is exceeded. Use errors.As to check for this error type instead.
Proposed fix
+ "errors"
...
file, header, err := c.Request.FormFile("avatar")
if err != nil {
- if err.Error() == "http: request body too large" {
+ var maxBytesErr *http.MaxBytesError
+ if errors.As(err, &maxBytesErr) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "File too large. Maximum size is 5MB."})
return
}📝 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.
| if err != nil { | |
| if err.Error() == "http: request body too large" { | |
| c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "File too large. Maximum size is 5MB."}) | |
| return | |
| } | |
| c.JSON(http.StatusBadRequest, gin.H{"error": "No file provided or invalid upload. Use key 'avatar'."}) | |
| return | |
| } | |
| if err != nil { | |
| var maxBytesErr *http.MaxBytesError | |
| if errors.As(err, &maxBytesErr) { | |
| c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "File too large. Maximum size is 5MB."}) | |
| return | |
| } | |
| c.JSON(http.StatusBadRequest, gin.H{"error": "No file provided or invalid upload. Use key 'avatar'."}) | |
| return | |
| } |
🤖 Prompt for AI Agents
In `@backend/controllers/upload_controller.go` around lines 46 - 53, The current
error check compares err.Error() to a string; instead use errors.As to detect
*http.MaxBytesError and handle it specially: in the upload handler where err is
returned (the block using c.JSON), attempt var maxErr *http.MaxBytesError; if
errors.As(err,&maxErr) respond with http.StatusRequestEntityTooLarge and the
same "File too large. Maximum size is 5MB." message, otherwise fall back to the
generic BadRequest response; ensure you import the errors package and reference
*http.MaxBytesError and the handler function that processes the upload (the
block currently checking err and calling c.JSON).
| } | ||
|
|
||
| // Build the public URL | ||
| avatarURL := fmt.Sprintf("http://localhost:1313%s/%s", avatarURLPrefix, uniqueName) |
There was a problem hiding this comment.
Hardcoded localhost:1313 URL will break in any non-local deployment.
The avatar URL stored in MongoDB will be unusable in staging/production. This should use the request's host/scheme or a configurable base URL from the application config.
Proposed fix
- avatarURL := fmt.Sprintf("http://localhost:1313%s/%s", avatarURLPrefix, uniqueName)
+ scheme := "http"
+ if c.Request.TLS != nil {
+ scheme = "https"
+ }
+ avatarURL := fmt.Sprintf("%s://%s%s/%s", scheme, c.Request.Host, avatarURLPrefix, uniqueName)Alternatively, inject a base URL from the application configuration.
📝 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.
| avatarURL := fmt.Sprintf("http://localhost:1313%s/%s", avatarURLPrefix, uniqueName) | |
| scheme := "http" | |
| if c.Request.TLS != nil { | |
| scheme = "https" | |
| } | |
| avatarURL := fmt.Sprintf("%s://%s%s/%s", scheme, c.Request.Host, avatarURLPrefix, uniqueName) |
🤖 Prompt for AI Agents
In `@backend/controllers/upload_controller.go` at line 95, Replace the hardcoded
"http://localhost:1313" used to build avatarURL with a dynamic base URL: read
the scheme and host from the incoming *http.Request (request.URL.Scheme or infer
via r.TLS and r.Host) or, preferably, a configurable base URL from your app
config, and then join that base with avatarURLPrefix and uniqueName; update the
avatar URL construction (the avatarURL variable) to use this computed base URL
and include a sensible fallback if neither request-derived host nor config is
available.
| result, err := db.MongoDatabase.Collection("users").UpdateOne( | ||
| dbCtx, | ||
| bson.M{"email": email}, | ||
| bson.M{"$set": bson.M{ | ||
| "avatarUrl": avatarURL, | ||
| "updatedAt": time.Now(), | ||
| }}, | ||
| ) | ||
| if err != nil { | ||
| log.Printf("UploadAvatar: Failed to update user avatar in DB: %v", err) | ||
| // File was saved but DB failed — still return the URL so the frontend can retry | ||
| c.JSON(http.StatusInternalServerError, gin.H{"error": "Avatar saved but failed to update profile. Please try again."}) | ||
| return | ||
| } | ||
| if result.MatchedCount == 0 { | ||
| log.Printf("UploadAvatar: No user found with email: %s", email) | ||
| c.JSON(http.StatusNotFound, gin.H{"error": "User not found."}) | ||
| return | ||
| } | ||
|
|
||
| log.Printf("UploadAvatar: Successfully uploaded avatar for %s → %s", email, avatarURL) | ||
|
|
||
| c.JSON(http.StatusOK, gin.H{ | ||
| "message": "Avatar uploaded successfully", | ||
| "avatar_url": avatarURL, | ||
| }) | ||
| } |
There was a problem hiding this comment.
No cleanup of old avatar files on re-upload — unbounded disk growth.
Each upload creates a new UUID-named file but never deletes the previous avatar. Over time this leaks disk space. Before saving the new file, query the user's current avatarUrl, extract the filename, and delete it from disk.
🤖 Prompt for AI Agents
In `@backend/controllers/upload_controller.go` around lines 101 - 127, In the
UploadAvatar handler, prevent orphaned avatar files by reading the user's
existing avatarUrl before saving the new file: query the users collection (e.g.,
with db.MongoDatabase.Collection("users").FindOne using the same email/dbCtx),
extract the existing filename from the returned avatarUrl, and delete that file
from disk (os.Remove) if it exists and is not the default placeholder; then
proceed to save the new UUID-named file and run the current UpdateOne call that
sets "avatarUrl" and "updatedAt". Ensure deletion errors are logged but do not
block the new upload, and avoid deleting if avatarUrl is empty or matches a
shared default.
| if result.MatchedCount == 0 { | ||
| log.Printf("UploadAvatar: No user found with email: %s", email) | ||
| c.JSON(http.StatusNotFound, gin.H{"error": "User not found."}) | ||
| return | ||
| } | ||
|
|
||
| log.Printf("UploadAvatar: Successfully uploaded avatar for %s → %s", email, avatarURL) |
There was a problem hiding this comment.
User email logged in plaintext — PII concern.
Lines 116 and 121 log the user's email address. Consider logging a user ID or a hashed/truncated identifier instead to reduce PII exposure in logs.
🤖 Prompt for AI Agents
In `@backend/controllers/upload_controller.go` around lines 115 - 121, The logs in
UploadAvatar currently print the user's email (variables email) on both failure
and success paths (checked via result.MatchedCount and avatarURL); replace those
plaintext emails with a non-PII identifier such as the user's ID (if available
on the user object) or a hashed/truncated version of email (e.g., SHA256 or
first/last 4 chars) before logging so logs no longer contain full PII. Locate
the UploadAvatar handler and update the log.Printf calls to use the chosen safe
identifier (derived from email or using user.ID) while keeping the rest of the
messages unchanged.
| func validateMIMEType(file multipart.File, allowedMIMEs []string) bool { | ||
| buf := make([]byte, 512) | ||
| n, err := file.Read(buf) | ||
| if err != nil { | ||
| return false | ||
| } | ||
|
|
||
| // Reset reader position for subsequent reads | ||
| if seeker, ok := file.(interface{ Seek(int64, int) (int64, error) }); ok { | ||
| seeker.Seek(0, 0) | ||
| } | ||
|
|
||
| detectedType := http.DetectContentType(buf[:n]) | ||
| for _, mime := range allowedMIMEs { | ||
| if detectedType == mime { | ||
| return true | ||
| } | ||
| } | ||
| return false |
There was a problem hiding this comment.
Seek error silently ignored; consider returning false on failure.
If the file doesn't implement Seek or if Seek fails, subsequent reads (e.g., c.SaveUploadedFile) will read from byte 512 onward, producing a corrupt saved file. At minimum, return false when the seek interface is unavailable or when seek errors.
Proposed fix
func validateMIMEType(file multipart.File, allowedMIMEs []string) bool {
buf := make([]byte, 512)
n, err := file.Read(buf)
if err != nil {
return false
}
- // Reset reader position for subsequent reads
- if seeker, ok := file.(interface{ Seek(int64, int) (int64, error) }); ok {
- seeker.Seek(0, 0)
+ seeker, ok := file.(interface{ Seek(int64, int) (int64, error) })
+ if !ok {
+ return false
+ }
+ if _, err := seeker.Seek(0, 0); err != nil {
+ return false
}
detectedType := http.DetectContentType(buf[:n])🤖 Prompt for AI Agents
In `@backend/controllers/upload_controller.go` around lines 130 - 148, The
validateMIMEType function currently ignores whether the uploaded file supports
seeking and ignores seek errors—change the logic in validateMIMEType to assert
the file to io.Seeker (or equivalent interface), and if the assertion fails or
seeker.Seek(0, io.SeekStart) returns an error, return false immediately; this
ensures readers like c.SaveUploadedFile will see the file from the start and
prevents saving corrupted uploads. Keep the rest of the MIME detection flow
(reading 512 bytes, DetectContentType, comparing against allowedMIMEs)
unchanged.
| const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { | ||
| const file = e.target.files?.[0]; | ||
| if (!file) return; | ||
|
|
||
| const allowedTypes = ['image/jpeg', 'image/png']; | ||
| if (!allowedTypes.includes(file.type)) { | ||
| setUploadError('Only JPG and PNG files are allowed.'); | ||
| return; | ||
| } | ||
| if (file.size > 5 * 1024 * 1024) { | ||
| setUploadError('File too large. Maximum size is 5MB.'); | ||
| return; | ||
| } | ||
|
|
||
| setUploadError(''); | ||
| setUploadFile(file); | ||
| setUploadPreview(URL.createObjectURL(file)); | ||
| }; |
There was a problem hiding this comment.
Object URL memory leak — URL.revokeObjectURL is never called.
Each call to URL.createObjectURL (line 444) allocates a blob URL that persists until the page is unloaded or explicitly revoked. Revoke the previous URL before creating a new one, and on modal close/unmount.
Proposed fix
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const allowedTypes = ['image/jpeg', 'image/png'];
if (!allowedTypes.includes(file.type)) {
setUploadError('Only JPG and PNG files are allowed.');
return;
}
if (file.size > 5 * 1024 * 1024) {
setUploadError('File too large. Maximum size is 5MB.');
return;
}
setUploadError('');
setUploadFile(file);
+ if (uploadPreview) URL.revokeObjectURL(uploadPreview);
setUploadPreview(URL.createObjectURL(file));
};Also add cleanup on modal close or via a useEffect cleanup:
useEffect(() => {
return () => {
if (uploadPreview) URL.revokeObjectURL(uploadPreview);
};
}, [uploadPreview]);📝 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.
| const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = e.target.files?.[0]; | |
| if (!file) return; | |
| const allowedTypes = ['image/jpeg', 'image/png']; | |
| if (!allowedTypes.includes(file.type)) { | |
| setUploadError('Only JPG and PNG files are allowed.'); | |
| return; | |
| } | |
| if (file.size > 5 * 1024 * 1024) { | |
| setUploadError('File too large. Maximum size is 5MB.'); | |
| return; | |
| } | |
| setUploadError(''); | |
| setUploadFile(file); | |
| setUploadPreview(URL.createObjectURL(file)); | |
| }; | |
| const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = e.target.files?.[0]; | |
| if (!file) return; | |
| const allowedTypes = ['image/jpeg', 'image/png']; | |
| if (!allowedTypes.includes(file.type)) { | |
| setUploadError('Only JPG and PNG files are allowed.'); | |
| return; | |
| } | |
| if (file.size > 5 * 1024 * 1024) { | |
| setUploadError('File too large. Maximum size is 5MB.'); | |
| return; | |
| } | |
| setUploadError(''); | |
| setUploadFile(file); | |
| if (uploadPreview) URL.revokeObjectURL(uploadPreview); | |
| setUploadPreview(URL.createObjectURL(file)); | |
| }; |
🤖 Prompt for AI Agents
In `@frontend/src/components/AvatarModal.tsx` around lines 428 - 445, The code in
handleFileSelect currently calls URL.createObjectURL and never revokes it,
causing a memory leak; before calling
setUploadPreview(URL.createObjectURL(file)) revoke any existing blob URL (check
the uploadPreview state) via URL.revokeObjectURL(uploadPreview), then set the
new preview, and also add a cleanup (e.g., in an effect that watches
uploadPreview or on modal close/unmount) that revokes the current uploadPreview
when the component unmounts or the modal closes to ensure all created object
URLs are released.
Description
Adds custom avatar upload functionality allowing authenticated users to upload their own profile picture (JPG/PNG, max 5MB). The uploaded avatar is stored on the backend, served statically, and persisted in MongoDB. The existing DiceBear avatar system is fully preserved as a fallback.
Changes
### Backend:
New upload_controller.go — Handles multipart upload with file size (5MB) and type validation (extension + MIME sniffing), UUID filenames, saves to ./uploads/avatars/, updates avatarUrl in MongoDB
Modified main.go — Added router.Static("/uploads", "./uploads") for static serving + registered JWT-protected POST /user/upload-avatar route
### Frontend:
Modified profileService.ts — Added uploadAvatar() using FormData
Modified Profile.tsx — Added file input, client-side validation, upload spinner, instant avatar update on success
Modified AvatarModal.tsx — Added "Upload Photo" tab alongside existing DiceBear customizer with file preview and upload handling
Key Decisions
No new dependencies — uses Gin's built-in multipart support and existing google/uuid package
Dual validation: extension whitelist + MIME type detection via http.DetectContentType()
DiceBear fallback untouched — no regression for users without custom avatars
Both go build and vite build pass with zero errors
Screenshots/Recordings
### Checklist:
Summary by CodeRabbit
Release Notes