diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index a4a346e7..a9fcea68 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -6,6 +6,7 @@ import ( "strconv" "arguehub/config" + "arguehub/controllers" "arguehub/db" "arguehub/internal/debate" "arguehub/middlewares" @@ -94,6 +95,9 @@ func setupRouter(cfg *config.Config) *gin.Engine { })) router.OPTIONS("/*path", func(c *gin.Context) { c.Status(204) }) + // Serve uploaded files statically + router.Static("/uploads", "./uploads") + // Public routes for authentication router.POST("/signup", routes.SignUpRouteHandler) router.POST("/verifyEmail", routes.VerifyEmailRouteHandler) @@ -116,6 +120,7 @@ func setupRouter(cfg *config.Config) *gin.Engine { { auth.GET("/user/fetchprofile", routes.GetProfileRouteHandler) auth.PUT("/user/updateprofile", routes.UpdateProfileRouteHandler) + auth.POST("/user/upload-avatar", controllers.UploadAvatar) auth.GET("/leaderboard", routes.GetLeaderboardRouteHandler) auth.POST("/debate/result", routes.UpdateRatingAfterDebateRouteHandler) diff --git a/backend/controllers/upload_controller.go b/backend/controllers/upload_controller.go new file mode 100644 index 00000000..08ffe0a6 --- /dev/null +++ b/backend/controllers/upload_controller.go @@ -0,0 +1,149 @@ +package controllers + +import ( + "context" + "fmt" + "log" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "arguehub/db" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.mongodb.org/mongo-driver/bson" +) + +const ( + maxAvatarSize = 5 << 20 // 5 MB + avatarUploadDir = "./uploads/avatars" + avatarURLPrefix = "/uploads/avatars" +) + +// allowedAvatarExtensions maps lowercase extensions to their expected MIME types. +var allowedAvatarExtensions = map[string][]string{ + ".jpg": {"image/jpeg"}, + ".jpeg": {"image/jpeg"}, + ".png": {"image/png"}, +} + +// UploadAvatar handles avatar file uploads for authenticated users. +func UploadAvatar(c *gin.Context) { + email := c.GetString("email") + if email == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + // Limit request body size + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxAvatarSize+512) // small buffer for multipart overhead + + 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 + } + defer file.Close() + + // Validate file size (belt-and-suspenders; MaxBytesReader already limits) + if header.Size > maxAvatarSize { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "File too large. Maximum size is 5MB."}) + return + } + + // Validate file extension + ext := strings.ToLower(filepath.Ext(header.Filename)) + allowedMIMEs, extAllowed := allowedAvatarExtensions[ext] + if !extAllowed { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file type. Only JPG, JPEG, and PNG files are allowed."}) + return + } + + // Validate MIME type by reading file header + if !validateMIMEType(file, allowedMIMEs) { + c.JSON(http.StatusBadRequest, gin.H{"error": "File content does not match allowed image types (JPG/PNG)."}) + return + } + + // Ensure upload directory exists + if err := os.MkdirAll(avatarUploadDir, os.ModePerm); err != nil { + log.Printf("UploadAvatar: Failed to create upload directory: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Server error. Could not prepare upload directory."}) + return + } + + // Generate unique filename + uniqueName := uuid.New().String() + ext + savePath := filepath.Join(avatarUploadDir, uniqueName) + + // Save the uploaded file + if err := c.SaveUploadedFile(header, savePath); err != nil { + log.Printf("UploadAvatar: Failed to save file: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save avatar file."}) + return + } + + // Build the public URL + avatarURL := fmt.Sprintf("http://localhost:1313%s/%s", avatarURLPrefix, uniqueName) + + // Update user's avatarUrl in database + dbCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + 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, + }) +} + +// validateMIMEType reads the first 512 bytes to detect the real content type. +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 +} diff --git a/frontend/src/Pages/Profile.tsx b/frontend/src/Pages/Profile.tsx index b89ede6b..eaba44ac 100644 --- a/frontend/src/Pages/Profile.tsx +++ b/frontend/src/Pages/Profile.tsx @@ -71,7 +71,7 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; -import { getProfile, updateProfile } from "@/services/profileService"; +import { getProfile, updateProfile, uploadAvatar } from "@/services/profileService"; import { getAuthToken } from "@/utils/auth"; import { DateRange } from "react-day-picker"; import AvatarModal from "../components/AvatarModal"; @@ -177,6 +177,8 @@ const Profile: React.FC = () => { to: undefined, }); const inputRef = useRef(null); + const fileInputRef = useRef(null); + const [avatarUploading, setAvatarUploading] = useState(false); useEffect(() => { const fetchDashboard = async () => { @@ -315,6 +317,52 @@ const Profile: React.FC = () => { } }; + const handleAvatarUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file || !dashboard?.profile) return; + + // 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; + } + + setAvatarUploading(true); + setErrorMessage(""); + + try { + const result = await uploadAvatar(token, file); + // Update local state immediately + setDashboard({ + ...dashboard, + profile: { ...dashboard.profile, avatarUrl: result.avatar_url }, + }); + setSuccessMessage("Avatar uploaded successfully!"); + } catch (err) { + setErrorMessage( + err instanceof Error ? err.message : "Failed to upload avatar." + ); + } finally { + setAvatarUploading(false); + // Reset file input so re-selecting the same file triggers onChange + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }; + if (loading) { return (
@@ -769,18 +817,40 @@ const Profile: React.FC = () => { )}
- Avatar +
+
+ ) : ( + Avatar + )} +
+ + +
+ -
= ({ const [seed, setSeed] = useState( currentAvatar?.split('seed=')[1]?.split('&')[0] || 'Jude' ); + const [activeTab, setActiveTab] = useState<'customize' | 'upload'>('customize'); + const [uploadPreview, setUploadPreview] = useState(null); + const [uploadFile, setUploadFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(''); + const uploadInputRef = useRef(null); const [backgroundColor, setBackgroundColor] = useState('b6e3f4'); const [ear, setEar] = useState('variant01'); const [eyes, setEyes] = useState('variant01'); @@ -417,6 +425,49 @@ const AvatarModal: React.FC = ({ if (!isOpen) return null; + const handleFileSelect = (e: React.ChangeEvent) => { + 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 handleUploadSubmit = async () => { + if (!uploadFile) return; + const token = getAuthToken(); + if (!token) { + setUploadError('Authentication token missing. Please log in.'); + return; + } + + setUploading(true); + setUploadError(''); + + try { + const result = await uploadAvatar(token, uploadFile); + onSelectAvatar(result.avatar_url); + setUploadPreview(null); + setUploadFile(null); + onClose(); + } catch (err) { + setUploadError(err instanceof Error ? err.message : 'Upload failed.'); + } finally { + setUploading(false); + } + }; + return (
@@ -430,13 +481,85 @@ const AvatarModal: React.FC = ({
Avatar Preview
+ + {/* Tab Switcher */} +
+ + +
+ {activeTab === 'upload' ? ( + /* Upload Photo Tab */ +
+
uploadInputRef.current?.click()} + className='w-full max-w-sm border-2 border-dashed border-muted-foreground/30 rounded-lg p-8 flex flex-col items-center gap-3 cursor-pointer hover:border-primary/50 transition-colors' + > + +

+ Click to select a photo +
+ JPG or PNG, max 5MB +

+
+ + + {uploadError && ( +

{uploadError}

+ )} + +
+ + +
+
+ ) : ( + <> {/* Character Selection */}
@@ -747,6 +870,8 @@ const AvatarModal: React.FC = ({ Cancel
+ + )}
); diff --git a/frontend/src/services/profileService.ts b/frontend/src/services/profileService.ts index 2aa96f85..c5c2f971 100644 --- a/frontend/src/services/profileService.ts +++ b/frontend/src/services/profileService.ts @@ -36,6 +36,32 @@ export const updateProfile = async ( } return response.json(); }; +export const uploadAvatar = async ( + token: string, + file: File +): Promise<{ message: string; avatar_url: string }> => { + const formData = new FormData(); + formData.append("avatar", file); + + const response = await fetch(`${baseURL}/user/upload-avatar`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + }); + + if (response.status === 413) { + throw new Error("File too large. Maximum size is 5MB."); + } + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.error || "Failed to upload avatar"); + } + + return response.json(); +}; + export const getLeaderboard = async () => { const response = await fetch(`${baseURL}/leaderboard`, { method: "GET",