From f183adfad25c8587cbc58c76c5ab55aab171e94d Mon Sep 17 00:00:00 2001 From: priyanshunitr Date: Thu, 13 Aug 2026 19:43:19 +0530 Subject: [PATCH 1/8] added frontend for profile pic change --- frontend/src/Pages/Profile.tsx | 129 +++++++++++++++++++----- frontend/src/hooks/useUser.ts | 15 ++- frontend/src/services/profileService.ts | 79 ++++++++++++++- 3 files changed, 196 insertions(+), 27 deletions(-) diff --git a/frontend/src/Pages/Profile.tsx b/frontend/src/Pages/Profile.tsx index 1642fbd8..540ad77a 100644 --- a/frontend/src/Pages/Profile.tsx +++ b/frontend/src/Pages/Profile.tsx @@ -47,6 +47,8 @@ import { Pen, X, Image as ImageIcon, + Loader2, + Upload, ChevronRight, Flame, } from "lucide-react"; @@ -73,8 +75,10 @@ import { } from "@/components/ui/chart"; import { getProfile, - updateProfile, + setGeneratedAvatar, checkDisplayNameAvailability, + updateProfile, + uploadAvatar, } from "@/services/profileService"; import { getAuthToken } from "@/utils/auth"; import { DateRange } from "react-day-picker"; @@ -163,6 +167,7 @@ interface FollowUser { } const Profile: React.FC = () => { + const { updateUserAvatar } = useUser(); const [dashboard, setDashboard] = useState(null); const [editingField, setEditingField] = useState(null); const [successMessage, setSuccessMessage] = useState(""); @@ -204,8 +209,9 @@ const Profile: React.FC = () => { from: undefined, to: undefined, }); -const inputRef = useRef(null); -const debounceTimer = useRef | null>(null); + const inputRef = useRef(null); + const avatarFileInputRef = useRef(null); + const [avatarUploading, setAvatarUploading] = useState(false); useEffect(() => { const fetchDashboard = async () => { @@ -292,8 +298,7 @@ const debounceTimer = useRef | null>(null); dashboard.profile.bio, dashboard.profile.twitter, dashboard.profile.instagram, - dashboard.profile.linkedin, - dashboard.profile.avatarUrl + dashboard.profile.linkedin ); setSuccessMessage( `${field.charAt(0).toUpperCase() + field.slice(1)} updated successfully!` @@ -322,16 +327,13 @@ const debounceTimer = useRef | null>(null); return; } try { - setDashboard({ ...dashboard, profile: { ...dashboard.profile, avatarUrl } }); - await updateProfile( - token, - dashboard.profile.displayName, - dashboard.profile.bio, - dashboard.profile.twitter, - dashboard.profile.instagram, - dashboard.profile.linkedin, - avatarUrl - ); + // Optimistically update the local state + setDashboard({ + ...dashboard, + profile: { ...dashboard.profile, avatarUrl }, + }); + await setGeneratedAvatar(token, avatarUrl); + updateUserAvatar(avatarUrl); setSuccessMessage("Avatar updated successfully!"); setErrorMessage(""); } catch { @@ -343,6 +345,56 @@ const debounceTimer = useRef | null>(null); } }; + const handleAvatarUpload = async ( + event: React.ChangeEvent + ) => { + const input = event.currentTarget; + const file = input.files?.[0]; + if (!file || !dashboard?.profile) return; + + const allowedTypes = ["image/jpeg", "image/png", "image/webp"]; + if (!allowedTypes.includes(file.type)) { + setErrorMessage("Please select a JPG, PNG, or WebP image."); + input.value = ""; + return; + } + if (file.size <= 0 || file.size > 5 * 1024 * 1024) { + setErrorMessage("Avatar images must be 5MB or smaller."); + input.value = ""; + return; + } + + const token = getAuthToken(); + if (!token) { + setErrorMessage("Authentication token is missing."); + input.value = ""; + return; + } + + setAvatarUploading(true); + setErrorMessage(""); + try { + const result = await uploadAvatar(token, file); + setDashboard((current) => + current + ? { + ...current, + profile: { ...current.profile, avatarUrl: result.avatarUrl }, + } + : current + ); + updateUserAvatar(result.avatarUrl); + setSuccessMessage("Avatar uploaded successfully!"); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "Failed to upload avatar." + ); + } finally { + setAvatarUploading(false); + input.value = ""; + } + }; + if (loading) { return (
@@ -693,14 +745,45 @@ const debounceTimer = useRef | null>(null); )}
- Avatar - + Avatar + {avatarUploading ? ( +
+ +
+ ) : ( +
+ + +
+ )} +
{ authContext?.loading, ]); + const updateUserAvatar = useCallback( + (avatarUrl: string) => { + setUser((currentUser) => { + if (!currentUser) return currentUser; + const updatedUser = { ...currentUser, avatarUrl }; + localStorage.setItem(USER_CACHE_KEY, JSON.stringify(updatedUser)); + return updatedUser; + }); + }, + [setUser] + ); + return { user, setUser, + updateUserAvatar, isLoading: authContext?.loading || (!user && !!(authContext?.token || localStorage.getItem("token"))), diff --git a/frontend/src/services/profileService.ts b/frontend/src/services/profileService.ts index 00dbab52..27d0ccbd 100644 --- a/frontend/src/services/profileService.ts +++ b/frontend/src/services/profileService.ts @@ -1,5 +1,17 @@ const baseURL = import.meta.env.VITE_BASE_URL; +const getErrorMessage = async (response: Response, fallback: string) => { + const body = await response.json().catch(() => null); + return body?.error || fallback; +}; + +interface PresignedAvatarUpload { + uploadUrl: string; + objectKey: string; + headers: Record; + expiresAt: string; +} + export const getProfile = async (token: string) => { const response = await fetch(`${baseURL}/user/fetchprofile`, { method: "GET", @@ -15,8 +27,7 @@ export const updateProfile = async ( bio: string, twitter?: string, instagram?: string, - linkedin?: string, - avatarUrl?: string + linkedin?: string ) => { const response = await fetch(`${baseURL}/user/updateprofile`, { method: "PUT", @@ -24,7 +35,7 @@ export const updateProfile = async ( "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ displayName, bio, twitter, instagram, linkedin, avatarUrl }), + body: JSON.stringify({ displayName, bio, twitter, instagram, linkedin }), }); if (!response.ok) { const data = await response.json().catch(() => ({})); @@ -48,6 +59,68 @@ export const checkDisplayNameAvailability = async ( return response.json(); }; + +export const uploadAvatar = async (token: string, file: File) => { + const presignResponse = await fetch(`${baseURL}/user/avatar-upload/presign`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ contentType: file.type, fileSize: file.size }), + }); + if (!presignResponse.ok) { + throw new Error( + await getErrorMessage(presignResponse, "Failed to prepare avatar upload") + ); + } + + const presigned = (await presignResponse.json()) as PresignedAvatarUpload; + const uploadResponse = await fetch(presigned.uploadUrl, { + method: "PUT", + headers: presigned.headers, + body: file, + }); + if (!uploadResponse.ok) { + throw new Error("Failed to upload avatar to storage"); + } + + const confirmResponse = await fetch(`${baseURL}/user/avatar-upload/confirm`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ objectKey: presigned.objectKey }), + }); + if (!confirmResponse.ok) { + throw new Error( + await getErrorMessage(confirmResponse, "Failed to confirm avatar upload") + ); + } + + return confirmResponse.json() as Promise<{ + message: string; + avatarUrl: string; + }>; +}; + +export const setGeneratedAvatar = async (token: string, avatarUrl: string) => { + const response = await fetch(`${baseURL}/user/avatar`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ avatarUrl }), + }); + if (!response.ok) { + throw new Error( + await getErrorMessage(response, "Failed to update generated avatar") + ); + } + return response.json() as Promise<{ message: string; avatarUrl: string }>; +}; export const getLeaderboard = async () => { const response = await fetch(`${baseURL}/leaderboard`, { method: "GET", From 6e0394d9f30b91958f76025a7898c42cac84763c Mon Sep 17 00:00:00 2001 From: priyanshunitr Date: Thu, 13 Aug 2026 19:50:43 +0530 Subject: [PATCH 2/8] backend routes + aws setup --- .env.example | 4 + .gitignore | 3 +- README.md | 12 +- backend/cmd/server/main.go | 33 ++- backend/config/config.go | 37 +++ backend/config/config.prod.sample.yml | 8 + backend/controllers/avatar_controller.go | 213 ++++++++++++++++ backend/controllers/avatar_controller_test.go | 21 ++ backend/controllers/profile_controller.go | 7 +- backend/go.mod | 18 ++ backend/go.sum | 36 +++ backend/models/user.go | 12 +- backend/routes/profile.go | 9 +- backend/services/avatar_storage.go | 229 ++++++++++++++++++ backend/services/avatar_storage_test.go | 146 +++++++++++ docs/avatar-storage.md | 109 +++++++++ 16 files changed, 877 insertions(+), 20 deletions(-) create mode 100644 backend/controllers/avatar_controller.go create mode 100644 backend/controllers/avatar_controller_test.go create mode 100644 backend/services/avatar_storage.go create mode 100644 backend/services/avatar_storage_test.go create mode 100644 docs/avatar-storage.md diff --git a/.env.example b/.env.example index 8c878f38..1c104450 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,10 @@ GEMINI_API_KEY=your_gemini_key_here JWT_SECRET=your_jwt_secret_here GOOGLE_CLIENT_ID=your_google_client_id_here +# AWS_REGION=ap-south-1 +# AWS_S3_BUCKET=your_private_avatar_bucket +# AWS_S3_PUBLIC_BASE_URL=https://your-cloudfront-domain.example.com +# AWS_S3_PRESIGN_TTL_SECONDS=300 # SMTP_PASSWORD=your_smtp_password_here (if needed) # Frontend Secrets (place in frontend/.env) diff --git a/.gitignore b/.gitignore index a04c5a40..7a42c1f0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ *.log *.env config.prod.yml -vite.config.ts.timestamp-*.mjs \ No newline at end of file +backend/uploads/ +vite.config.ts.timestamp-*.mjs diff --git a/README.md b/README.md index 78d1ea0c..6fefd2c3 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,17 @@ gemini: --- -### 4. Run the Backend Server +### 4. (Optional) Custom Avatar Storage + +Custom profile picture uploads use presigned Amazon S3 URLs and a private S3 +bucket served through CloudFront. See [Avatar storage setup](docs/avatar-storage.md) +for the required environment variables, IAM policy, bucket CORS, CloudFront +OAC, and abandoned-upload lifecycle rule. The backend still runs when this is +not configured, but custom image uploads remain disabled. + +--- + +### 5. Run the Backend Server From the `backend` directory, start the server: diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 4272c3a3..7e25b140 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -1,11 +1,12 @@ package main import ( + "context" "log" - "os" "strconv" "arguehub/config" + "arguehub/controllers" "arguehub/db" "arguehub/internal/debate" "arguehub/middlewares" @@ -34,8 +35,8 @@ func main() { log.Println("Connected to MongoDB") if err := db.EnsureIndexes(); err != nil { - log.Fatalf("Failed to ensure indexes: %v", err) - } + log.Fatalf("Failed to ensure indexes: %v", err) + } if err := middlewares.InitCasbin("./config/config.prod.yml"); err != nil { log.Fatalf("Failed to initialize Casbin: %v", err) @@ -63,9 +64,24 @@ func main() { utils.SeedDebateData() utils.PopulateTestUsers() - os.MkdirAll("uploads", os.ModePerm) + var avatarStorage services.AvatarStorage + if cfg.S3.HasEndpointConfig() && !cfg.S3.IsConfigured() { + log.Fatal("Incomplete S3 avatar configuration: region, bucket, and publicBaseURL are all required") + } + if cfg.S3.IsConfigured() { + storage, err := services.NewS3AvatarStorage(context.Background(), cfg.S3) + if err != nil { + log.Fatalf("Failed to initialize S3 avatar storage: %v", err) + } + avatarStorage = storage + log.Println("S3 avatar storage initialized") + } else { + log.Println("S3 avatar storage is not configured; custom avatar uploads are disabled") + } + avatarController := controllers.NewAvatarController(avatarStorage) - router := setupRouter(cfg) + // Set up the Gin router and configure routes + router := setupRouter(cfg, avatarController) port := strconv.Itoa(cfg.Server.Port) if err := router.Run(":" + port); err != nil { @@ -73,7 +89,7 @@ func main() { } } -func setupRouter(cfg *config.Config) *gin.Engine { +func setupRouter(cfg *config.Config, avatarController *controllers.AvatarController) *gin.Engine { router := gin.Default() router.SetTrustedProxies([]string{"127.0.0.1", "localhost"}) @@ -105,7 +121,8 @@ func setupRouter(cfg *config.Config) *gin.Engine { { auth.GET("/user/fetchprofile", routes.GetProfileRouteHandler) auth.PUT("/user/updateprofile", routes.UpdateProfileRouteHandler) - auth.GET("/user/check-displayname", routes.CheckDisplayNameRouteHandler) + auth.GET("/user/check-displayname", routes.CheckDisplayNameRouteHandler) + routes.SetupAvatarRoutes(auth, avatarController) auth.GET("/leaderboard", routes.GetLeaderboardRouteHandler) auth.POST("/debate/result", routes.UpdateRatingAfterDebateRouteHandler) @@ -150,4 +167,4 @@ func setupRouter(cfg *config.Config) *gin.Engine { router.GET("/ws/debate/:debateID", websocket.DebateWebsocketHandler) return router -} \ No newline at end of file +} diff --git a/backend/config/config.go b/backend/config/config.go index c9ebfaf4..9754aac7 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -3,10 +3,26 @@ package config import ( "fmt" "os" + "strconv" "gopkg.in/yaml.v3" ) +type S3Config struct { + Region string `yaml:"region"` + Bucket string `yaml:"bucket"` + PublicBaseURL string `yaml:"publicBaseURL"` + PresignTTLSeconds int `yaml:"presignTTLSeconds"` +} + +func (c S3Config) IsConfigured() bool { + return c.Region != "" && c.Bucket != "" && c.PublicBaseURL != "" +} + +func (c S3Config) HasEndpointConfig() bool { + return c.Bucket != "" || c.PublicBaseURL != "" +} + type Config struct { Server struct { Port int `yaml:"port"` @@ -54,6 +70,8 @@ type Config struct { GoogleOAuth struct { ClientID string `yaml:"clientID"` } `yaml:"googleOAuth"` + + S3 S3Config `yaml:"s3"` } // LoadConfig reads the configuration file @@ -86,6 +104,25 @@ func LoadConfig(path string) (*Config, error) { if envGoogleClient := os.Getenv("GOOGLE_CLIENT_ID"); envGoogleClient != "" { cfg.GoogleOAuth.ClientID = envGoogleClient } + if envAWSRegion := os.Getenv("AWS_REGION"); envAWSRegion != "" { + cfg.S3.Region = envAWSRegion + } + if envS3Bucket := os.Getenv("AWS_S3_BUCKET"); envS3Bucket != "" { + cfg.S3.Bucket = envS3Bucket + } + if envS3PublicBaseURL := os.Getenv("AWS_S3_PUBLIC_BASE_URL"); envS3PublicBaseURL != "" { + cfg.S3.PublicBaseURL = envS3PublicBaseURL + } + if envPresignTTL := os.Getenv("AWS_S3_PRESIGN_TTL_SECONDS"); envPresignTTL != "" { + presignTTL, err := strconv.Atoi(envPresignTTL) + if err != nil || presignTTL <= 0 { + return nil, fmt.Errorf("AWS_S3_PRESIGN_TTL_SECONDS must be a positive integer") + } + cfg.S3.PresignTTLSeconds = presignTTL + } + if cfg.S3.PresignTTLSeconds == 0 { + cfg.S3.PresignTTLSeconds = 300 + } // Add other overrides as needed return &cfg, nil diff --git a/backend/config/config.prod.sample.yml b/backend/config/config.prod.sample.yml index 48a187c2..a1352f9f 100644 --- a/backend/config/config.prod.sample.yml +++ b/backend/config/config.prod.sample.yml @@ -41,3 +41,11 @@ googleOAuth: clientID: "" # Google OAuth Client ID for OAuth login # Obtain from Google Cloud Console (APIs & Services > Credentials > OAuth 2.0 Client IDs) + +s3: + region: "" # Example: ap-south-1 + bucket: "" # Private avatar bucket name + publicBaseURL: "" # Example: https://avatars.example.com (CloudFront) + presignTTLSeconds: 300 + # AWS credentials are intentionally not stored here. The AWS SDK uses its + # default credential chain (environment variables, local profile, or IAM role). diff --git a/backend/controllers/avatar_controller.go b/backend/controllers/avatar_controller.go new file mode 100644 index 00000000..291eda23 --- /dev/null +++ b/backend/controllers/avatar_controller.go @@ -0,0 +1,213 @@ +package controllers + +import ( + "context" + "errors" + "log" + "net/http" + "net/url" + "strings" + "time" + + "arguehub/db" + "arguehub/services" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type AvatarController struct { + storage services.AvatarStorage +} + +func NewAvatarController(storage services.AvatarStorage) *AvatarController { + return &AvatarController{storage: storage} +} + +func (controller *AvatarController) PresignUpload(ctx *gin.Context) { + if controller.storage == nil { + ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "Avatar uploads are not configured"}) + return + } + + userID, ok := authenticatedUserID(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + var request struct { + ContentType string `json:"contentType" binding:"required"` + FileSize int64 `json:"fileSize" binding:"required"` + } + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "contentType and fileSize are required"}) + return + } + + upload, err := controller.storage.CreatePresignedUpload( + ctx.Request.Context(), + userID.Hex(), + request.ContentType, + request.FileSize, + ) + if err != nil { + if errors.Is(err, services.ErrInvalidAvatar) { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + log.Printf("failed to presign avatar upload for user %s: %v", userID.Hex(), err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to prepare avatar upload"}) + return + } + + ctx.JSON(http.StatusOK, upload) +} + +func (controller *AvatarController) ConfirmUpload(ctx *gin.Context) { + if controller.storage == nil { + ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "Avatar uploads are not configured"}) + return + } + + userID, ok := authenticatedUserID(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + var request struct { + ObjectKey string `json:"objectKey" binding:"required"` + } + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "objectKey is required"}) + return + } + if !controller.storage.OwnsObject(userID.Hex(), request.ObjectKey) { + ctx.JSON(http.StatusForbidden, gin.H{"error": "Avatar object does not belong to this user"}) + return + } + + operationCtx, cancel := context.WithTimeout(ctx.Request.Context(), 15*time.Second) + defer cancel() + + if err := controller.storage.ValidateUploadedObject(operationCtx, request.ObjectKey); err != nil { + controller.deleteObject(request.ObjectKey, "rejected") + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Uploaded file is not a valid avatar"}) + return + } + avatarKey, err := controller.storage.PromoteUploadedObject(operationCtx, request.ObjectKey) + if err != nil { + controller.deleteObject(request.ObjectKey, "unconfirmed") + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to confirm avatar upload"}) + return + } + + avatarURL := controller.storage.PublicURL(avatarKey) + var previous struct { + AvatarKey string `bson:"avatarKey"` + } + err = db.MongoDatabase.Collection("users").FindOneAndUpdate( + operationCtx, + bson.M{"_id": userID}, + bson.M{"$set": bson.M{ + "avatarUrl": avatarURL, + "avatarKey": avatarKey, + "updatedAt": time.Now(), + }}, + options.FindOneAndUpdate().SetReturnDocument(options.Before).SetProjection(bson.M{"avatarKey": 1}), + ).Decode(&previous) + if err != nil { + controller.deleteObject(avatarKey, "unpersisted") + if errors.Is(err, mongo.ErrNoDocuments) { + ctx.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + log.Printf("failed to persist avatar for user %s: %v", userID.Hex(), err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save avatar to profile"}) + return + } + + if previous.AvatarKey != "" && previous.AvatarKey != avatarKey { + controller.deleteObject(previous.AvatarKey, "previous") + } + + ctx.JSON(http.StatusOK, gin.H{ + "message": "Avatar updated successfully", + "avatarUrl": avatarURL, + }) +} + +func (controller *AvatarController) SetGeneratedAvatar(ctx *gin.Context) { + userID, ok := authenticatedUserID(ctx) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + var request struct { + AvatarURL string `json:"avatarUrl" binding:"required"` + } + if err := ctx.ShouldBindJSON(&request); err != nil || !isAllowedGeneratedAvatarURL(request.AvatarURL) { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Only HTTPS DiceBear avatar URLs are allowed"}) + return + } + + operationCtx, cancel := context.WithTimeout(ctx.Request.Context(), 10*time.Second) + defer cancel() + + var previous struct { + AvatarKey string `bson:"avatarKey"` + } + err := db.MongoDatabase.Collection("users").FindOneAndUpdate( + operationCtx, + bson.M{"_id": userID}, + bson.M{ + "$set": bson.M{"avatarUrl": request.AvatarURL, "updatedAt": time.Now()}, + "$unset": bson.M{"avatarKey": ""}, + }, + options.FindOneAndUpdate().SetReturnDocument(options.Before).SetProjection(bson.M{"avatarKey": 1}), + ).Decode(&previous) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + ctx.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update avatar"}) + return + } + + if controller.storage != nil && previous.AvatarKey != "" { + controller.deleteObject(previous.AvatarKey, "previous") + } + + ctx.JSON(http.StatusOK, gin.H{ + "message": "Avatar updated successfully", + "avatarUrl": request.AvatarURL, + }) +} + +func (controller *AvatarController) deleteObject(objectKey, reason string) { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := controller.storage.DeleteObject(cleanupCtx, objectKey); err != nil { + log.Printf("failed to delete %s avatar object %s: %v", reason, objectKey, err) + } +} + +func authenticatedUserID(ctx *gin.Context) (primitive.ObjectID, bool) { + value, exists := ctx.Get("userID") + if !exists { + return primitive.NilObjectID, false + } + userID, ok := value.(primitive.ObjectID) + return userID, ok && !userID.IsZero() +} + +func isAllowedGeneratedAvatarURL(rawURL string) bool { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + return err == nil && parsed.Scheme == "https" && parsed.Hostname() == "api.dicebear.com" +} diff --git a/backend/controllers/avatar_controller_test.go b/backend/controllers/avatar_controller_test.go new file mode 100644 index 00000000..4c4089f3 --- /dev/null +++ b/backend/controllers/avatar_controller_test.go @@ -0,0 +1,21 @@ +package controllers + +import "testing" + +func TestIsAllowedGeneratedAvatarURL(t *testing.T) { + tests := []struct { + url string + want bool + }{ + {url: "https://api.dicebear.com/9.x/big-ears/svg?seed=Jude", want: true}, + {url: "http://api.dicebear.com/9.x/big-ears/svg?seed=Jude", want: false}, + {url: "https://api.dicebear.com.evil.example/avatar", want: false}, + {url: "javascript:alert(1)", want: false}, + } + + for _, test := range tests { + if got := isAllowedGeneratedAvatarURL(test.url); got != test.want { + t.Errorf("isAllowedGeneratedAvatarURL(%q) = %v, want %v", test.url, got, test.want) + } + } +} diff --git a/backend/controllers/profile_controller.go b/backend/controllers/profile_controller.go index df579a47..88eff78f 100644 --- a/backend/controllers/profile_controller.go +++ b/backend/controllers/profile_controller.go @@ -45,11 +45,13 @@ func extractNameFromEmail(email string) string { func GetProfile(c *gin.Context) { userIDParam := strings.TrimSpace(c.Query("userId")) + // Log detailed request information for debugging log.Printf("GetProfile: Request URL = '%s'", c.Request.URL.String()) log.Printf("GetProfile: Raw Query = '%s'", c.Request.URL.RawQuery) log.Printf("GetProfile: Query params map = %v", c.Request.URL.Query()) log.Printf("GetProfile: userId from c.Query() = '%s'", userIDParam) + // If c.Query() didn't work, try reading from URL.Query() directly if userIDParam == "" { values := c.Request.URL.Query() if val, ok := values["userId"]; ok && len(val) > 0 && val[0] != "" { @@ -58,6 +60,7 @@ func GetProfile(c *gin.Context) { } } + // If still empty, try parsing raw query string manually if userIDParam == "" && c.Request.URL.RawQuery != "" { rawQuery := c.Request.URL.RawQuery parts := strings.Split(rawQuery, "&") @@ -303,7 +306,6 @@ func UpdateProfile(ctx *gin.Context) { Twitter string `json:"twitter"` Instagram string `json:"instagram"` LinkedIn string `json:"linkedin"` - AvatarURL string `json:"avatarUrl"` } if err := ctx.ShouldBindJSON(&updateData); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid body"}) @@ -330,7 +332,6 @@ func UpdateProfile(ctx *gin.Context) { "twitter": strings.TrimSpace(updateData.Twitter), "instagram": strings.TrimSpace(updateData.Instagram), "linkedin": strings.TrimSpace(updateData.LinkedIn), - "avatarUrl": strings.TrimSpace(updateData.AvatarURL), "updatedAt": time.Now(), } @@ -423,4 +424,4 @@ func UpdateEloAfterDebate(ctx *gin.Context) { "winnerNewElo": int(newWinnerElo), "loserNewElo": int(newLoserElo), }) -} \ No newline at end of file +} diff --git a/backend/go.mod b/backend/go.mod index 5e89f658..fc0238cc 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,6 +5,10 @@ go 1.24 toolchain go1.24.4 require ( + github.com/aws/aws-sdk-go-v2 v1.43.5 + github.com/aws/aws-sdk-go-v2/config v1.32.36 + github.com/aws/aws-sdk-go-v2/credentials v1.19.35 + github.com/aws/aws-sdk-go-v2/service/s3 v1.107.1 github.com/casbin/casbin/v2 v2.132.0 github.com/casbin/mongodb-adapter/v3 v3.7.0 github.com/gin-contrib/cors v1.7.2 @@ -25,6 +29,20 @@ require ( cloud.google.com/go/auth v0.15.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.6.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.17 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.36 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.36 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.36 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.37 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.16 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.29 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.36 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.37 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.5.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.33.5 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.45.5 // indirect + github.com/aws/smithy-go v1.27.7 // indirect github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 9acfed59..7be10421 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -6,6 +6,42 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +github.com/aws/aws-sdk-go-v2 v1.43.5 h1:yKT5GYnFWhuDo+DqKvE5ZPwVn3RjC4MAeBtZGlh6AVM= +github.com/aws/aws-sdk-go-v2 v1.43.5/go.mod h1:wZjAJppCntyOGgVSmgVTfDyRJK5PHOasO6Wsy8U7Axk= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.17 h1:mn+Vxb9zgz/FE/yDTcFim3DZ1qpcrxR+qBQkBrl6bzA= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.17/go.mod h1:eDfmEFxu+BSVsUGLbzJhWjpOurv1mqczClS97yI8wdk= +github.com/aws/aws-sdk-go-v2/config v1.32.36 h1:mX6ietU7UlB4w/2IUaexJdsyUDvhTd+jYPjVePiyi6s= +github.com/aws/aws-sdk-go-v2/config v1.32.36/go.mod h1:rMpV4xk7ZK59edraSaHP0jsWrztWTT5tbCwWY495hug= +github.com/aws/aws-sdk-go-v2/credentials v1.19.35 h1:Cxua2RVdRwL0sfjHM/SnQoOnQ7xKng9m5EQBO8BnZlg= +github.com/aws/aws-sdk-go-v2/credentials v1.19.35/go.mod h1:9XQ+RSIGPkycr+oCJYnB1uTv5kMVVR+rd2vYK0Hxj2w= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.36 h1:gucL1KH/PAYbpTpBg09CiVpBdTu4qkCl8C7xOTBixUg= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.36/go.mod h1:usTB+PHhNMhrx2dxUeHcM7OrT5pySvmjYI++IsefPN0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.36 h1:5CrzwxDqf4w3x1Vs3/NiZ0nsC34Hbm3pIDMWbsLebOE= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.36/go.mod h1:A3gHdKZIvG/QXERzZwcxNS3RNDFcRCuhhTFBYp+V/nw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.36 h1:A4N2f4YPcST0v+dWtX+xrpPPCL9VTBhoIFFUWYqbacE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.36/go.mod h1:B/Qr859uxWUEfZeGotK5KAEoof4Q9YWgNtPSwV6jcyk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.37 h1:oyd3ke4V9AhKcRR7rRgxk1VyI+DjK2CBQtbxh3OkdaA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.37/go.mod h1:aA9D7SqfG9IC1b7FLD7Iyc8Q4JN0a8gHhNjN4zPlIaI= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.16 h1:iE4NGbvqUZnHDqddQAauZzCILYtFjOHwRM5MOOKLB5A= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.16/go.mod h1:VsjEgrP+ibcou8TlWA4tYaB+0OojuhirsmCe+U60hTA= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.29 h1:E65Hj648dOV6FuUfI0mYXXhQRHbsi7n+B9h6fZPJO/E= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.29/go.mod h1:xLrF9yNTCs92VZSpdEd68EJbgcdw3SMR74RO6QDzWHE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.36 h1:fx2ujmozWn+C/GtfXfz5k6Ckzza40ElOpIW7d92fLWQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.36/go.mod h1:QT2ufGVJ+xTRxtXPHTQ1kHkAdWIKPCmD+BqYAXWv8/4= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.37 h1:KGHa9iZCrgtkOsFfXb0S4ywsjostA/hau7WE9aSb43E= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.37/go.mod h1:FV79f0DSnZIEGsQjWenENGtUycrasyAaJZO+zRanLHA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.107.1 h1:VUTtUJMuRNMkb/7NIKmd8NQaeQLPGCMoTJxkYKre4qM= +github.com/aws/aws-sdk-go-v2/service/s3 v1.107.1/go.mod h1:WvUaO0lP5GNMs1R6cs6qvB3mqo16GLta8yfOuf55Rpc= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.5 h1:0VTFBfOgPJrUSpGMgzoi8qLcXF5dbmiBuxpo14eBWUw= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.5/go.mod h1:sNZYlBxoohYMBYl47BO/bFtAM6I8HSsPa1qwwPPRGoQ= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.5 h1:jDQARFp1mJ2PEnllQf01nfFXGfWMJ59e0/HCHUTTZCk= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.5/go.mod h1:OcT2AhgTuxGAwZk5hgxaNLGpS33W8s8dUQadGVDVY9I= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.5 h1:8xo1q9ttkYqMJ6vOXX67FPSpVEI7BWKVTKh77g82w+8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.5/go.mod h1:hbBeEUrZg6VddXYZpbKPyF0tl4XEnM+Dbx92RW3vmZI= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.5 h1:eQ5BtXDrPg2wK0AjtVPzeBhUpYPeqHE/ptiH7xJRGek= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.5/go.mod h1:f9ImhnOISY7BuTZLM8qHepCYnglHBVLk5wVzatmP++w= +github.com/aws/smithy-go v1.27.7 h1:Zgj5z4LfcDYoQIVk+n/yGdTkP/2y6ZT5vYxe0fp7bqE= +github.com/aws/smithy-go v1.27.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= diff --git a/backend/models/user.go b/backend/models/user.go index d8c114be..c0aa4b6e 100644 --- a/backend/models/user.go +++ b/backend/models/user.go @@ -17,6 +17,7 @@ type User struct { Volatility float64 `bson:"volatility" json:"volatility"` LastRatingUpdate time.Time `bson:"lastRatingUpdate" json:"lastRatingUpdate"` AvatarURL string `bson:"avatarUrl,omitempty" json:"avatarUrl,omitempty"` + AvatarKey string `bson:"avatarKey,omitempty" json:"-"` Twitter string `bson:"twitter,omitempty" json:"twitter,omitempty"` Instagram string `bson:"instagram,omitempty" json:"instagram,omitempty"` LinkedIn string `bson:"linkedin,omitempty" json:"linkedin,omitempty"` @@ -27,8 +28,9 @@ type User struct { ResetPasswordCode string `bson:"resetPasswordCode,omitempty"` CreatedAt time.Time `bson:"createdAt"` UpdatedAt time.Time `bson:"updatedAt"` - Score int `bson:"score" json:"score"` - Badges []string `bson:"badges,omitempty" json:"badges,omitempty"` - CurrentStreak int `bson:"currentStreak" json:"currentStreak"` - LastActivityDate time.Time `bson:"lastActivityDate,omitempty" json:"lastActivityDate,omitempty"` -} \ No newline at end of file + // Gamification fields + Score int `bson:"score" json:"score"` // Total gamification score + Badges []string `bson:"badges,omitempty" json:"badges,omitempty"` // List of badge names earned + CurrentStreak int `bson:"currentStreak" json:"currentStreak"` // Current daily streak + LastActivityDate time.Time `bson:"lastActivityDate,omitempty" json:"lastActivityDate,omitempty"` // Last activity date for streak calculation +} diff --git a/backend/routes/profile.go b/backend/routes/profile.go index 9f662950..28bd251d 100644 --- a/backend/routes/profile.go +++ b/backend/routes/profile.go @@ -14,11 +14,16 @@ func UpdateProfileRouteHandler(ctx *gin.Context) { controllers.UpdateProfile(ctx) } - func CheckDisplayNameRouteHandler(ctx *gin.Context) { controllers.CheckDisplayName(ctx) } func UpdateEloAfterDebateRouteHandler(ctx *gin.Context) { controllers.UpdateEloAfterDebate(ctx) -} \ No newline at end of file +} + +func SetupAvatarRoutes(group *gin.RouterGroup, controller *controllers.AvatarController) { + group.POST("/user/avatar-upload/presign", controller.PresignUpload) + group.POST("/user/avatar-upload/confirm", controller.ConfirmUpload) + group.PUT("/user/avatar", controller.SetGeneratedAvatar) +} diff --git a/backend/services/avatar_storage.go b/backend/services/avatar_storage.go new file mode 100644 index 00000000..293f3d45 --- /dev/null +++ b/backend/services/avatar_storage.go @@ -0,0 +1,229 @@ +package services + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "path" + "strings" + "time" + + appconfig "arguehub/config" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/google/uuid" +) + +const ( + MaxAvatarSize int64 = 5 << 20 + avatarObjectPrefix = "avatars" + avatarUploadPrefix = "avatar-uploads" + pendingUploadTag = "upload-state=pending" +) + +var ErrInvalidAvatar = errors.New("invalid avatar") + +var avatarExtensions = map[string]string{ + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", +} + +type PresignedAvatarUpload struct { + UploadURL string `json:"uploadUrl"` + ObjectKey string `json:"objectKey"` + Headers map[string]string `json:"headers"` + ExpiresAt time.Time `json:"expiresAt"` +} + +type AvatarStorage interface { + CreatePresignedUpload(ctx context.Context, userID, contentType string, fileSize int64) (*PresignedAvatarUpload, error) + ValidateUploadedObject(ctx context.Context, objectKey string) error + PromoteUploadedObject(ctx context.Context, objectKey string) (string, error) + DeleteObject(ctx context.Context, objectKey string) error + PublicURL(objectKey string) string + OwnsObject(userID, objectKey string) bool +} + +type s3ObjectClient interface { + HeadObject(context.Context, *s3.HeadObjectInput, ...func(*s3.Options)) (*s3.HeadObjectOutput, error) + GetObject(context.Context, *s3.GetObjectInput, ...func(*s3.Options)) (*s3.GetObjectOutput, error) + CopyObject(context.Context, *s3.CopyObjectInput, ...func(*s3.Options)) (*s3.CopyObjectOutput, error) + DeleteObject(context.Context, *s3.DeleteObjectInput, ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) +} + +type S3AvatarStorage struct { + bucket string + publicBaseURL string + presignTTL time.Duration + client s3ObjectClient + presigner *s3.PresignClient +} + +func NewS3AvatarStorage(ctx context.Context, cfg appconfig.S3Config) (*S3AvatarStorage, error) { + if !cfg.IsConfigured() { + return nil, fmt.Errorf("S3 avatar storage requires region, bucket, and publicBaseURL") + } + publicBaseURL, err := url.Parse(cfg.PublicBaseURL) + if err != nil || publicBaseURL.Scheme != "https" || publicBaseURL.Host == "" { + return nil, fmt.Errorf("S3 publicBaseURL must be a valid HTTPS URL") + } + if cfg.PresignTTLSeconds < 60 || cfg.PresignTTLSeconds > 900 { + return nil, fmt.Errorf("S3 presignTTLSeconds must be between 60 and 900") + } + + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(cfg.Region)) + if err != nil { + return nil, fmt.Errorf("load AWS configuration: %w", err) + } + + client := s3.NewFromConfig(awsCfg) + return &S3AvatarStorage{ + bucket: cfg.Bucket, + publicBaseURL: strings.TrimRight(publicBaseURL.String(), "/"), + presignTTL: time.Duration(cfg.PresignTTLSeconds) * time.Second, + client: client, + presigner: s3.NewPresignClient(client), + }, nil +} + +func (s *S3AvatarStorage) CreatePresignedUpload( + ctx context.Context, + userID string, + contentType string, + fileSize int64, +) (*PresignedAvatarUpload, error) { + contentType = normalizeContentType(contentType) + extension, ok := avatarExtensions[contentType] + if !ok { + return nil, fmt.Errorf("%w: unsupported content type", ErrInvalidAvatar) + } + if fileSize <= 0 || fileSize > MaxAvatarSize { + return nil, fmt.Errorf("%w: file must be between 1 byte and %d bytes", ErrInvalidAvatar, MaxAvatarSize) + } + + objectKey := fmt.Sprintf("%s/%s/%s%s", avatarUploadPrefix, userID, uuid.NewString(), extension) + presigned, err := s.presigner.PresignPutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + CacheControl: aws.String("public, max-age=31536000, immutable"), + Key: aws.String(objectKey), + ContentLength: aws.Int64(fileSize), + ContentType: aws.String(contentType), + Tagging: aws.String(pendingUploadTag), + }, func(options *s3.PresignOptions) { + options.Expires = s.presignTTL + }) + if err != nil { + return nil, fmt.Errorf("presign avatar upload: %w", err) + } + + return &PresignedAvatarUpload{ + UploadURL: presigned.URL, + ObjectKey: objectKey, + Headers: map[string]string{ + "Cache-Control": "public, max-age=31536000, immutable", + "Content-Type": contentType, + "x-amz-tagging": pendingUploadTag, + }, + ExpiresAt: time.Now().Add(s.presignTTL).UTC(), + }, nil +} + +func (s *S3AvatarStorage) ValidateUploadedObject(ctx context.Context, objectKey string) error { + head, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(objectKey), + }) + if err != nil { + return fmt.Errorf("inspect uploaded avatar: %w", err) + } + if head.ContentLength == nil || *head.ContentLength <= 0 || *head.ContentLength > MaxAvatarSize { + return fmt.Errorf("uploaded avatar has an invalid size") + } + + declaredType := normalizeContentType(aws.ToString(head.ContentType)) + expectedExtension, ok := avatarExtensions[declaredType] + if !ok || !strings.EqualFold(path.Ext(objectKey), expectedExtension) { + return fmt.Errorf("uploaded avatar has an invalid content type") + } + + object, err := s.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(objectKey), + Range: aws.String("bytes=0-511"), + }) + if err != nil { + return fmt.Errorf("read uploaded avatar signature: %w", err) + } + defer object.Body.Close() + + signature, err := io.ReadAll(io.LimitReader(object.Body, 512)) + if err != nil { + return fmt.Errorf("read uploaded avatar signature: %w", err) + } + if len(signature) == 0 || normalizeContentType(http.DetectContentType(signature)) != declaredType { + return fmt.Errorf("uploaded file content does not match its image type") + } + + return nil +} + +func (s *S3AvatarStorage) PromoteUploadedObject(ctx context.Context, uploadKey string) (string, error) { + avatarKey := strings.Replace(uploadKey, avatarUploadPrefix+"/", avatarObjectPrefix+"/", 1) + _, err := s.client.CopyObject(ctx, &s3.CopyObjectInput{ + Bucket: aws.String(s.bucket), + CopySource: aws.String(url.PathEscape(s.bucket + "/" + uploadKey)), + Key: aws.String(avatarKey), + Tagging: aws.String("upload-state=confirmed"), + TaggingDirective: s3types.TaggingDirectiveReplace, + }) + if err != nil { + return "", fmt.Errorf("promote avatar object: %w", err) + } + + if err := s.DeleteObject(ctx, uploadKey); err != nil { + // The pending-object lifecycle rule will remove this source object later. + log.Printf("failed to delete promoted avatar upload %s: %v", uploadKey, err) + } + return avatarKey, nil +} + +func (s *S3AvatarStorage) DeleteObject(ctx context.Context, objectKey string) error { + if objectKey == "" { + return nil + } + _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(objectKey), + }) + if err != nil { + return fmt.Errorf("delete avatar object: %w", err) + } + return nil +} + +func (s *S3AvatarStorage) PublicURL(objectKey string) string { + segments := strings.Split(objectKey, "/") + for index, segment := range segments { + segments[index] = url.PathEscape(segment) + } + return s.publicBaseURL + "/" + strings.Join(segments, "/") +} + +func (s *S3AvatarStorage) OwnsObject(userID, objectKey string) bool { + expectedPrefix := fmt.Sprintf("%s/%s/", avatarUploadPrefix, userID) + return objectKey == path.Clean(objectKey) && + !strings.Contains(objectKey, "\\") && + strings.HasPrefix(objectKey, expectedPrefix) +} + +func normalizeContentType(contentType string) string { + return strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) +} diff --git a/backend/services/avatar_storage_test.go b/backend/services/avatar_storage_test.go new file mode 100644 index 00000000..3f8d9ba7 --- /dev/null +++ b/backend/services/avatar_storage_test.go @@ -0,0 +1,146 @@ +package services + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +type recordingS3Client struct { + copyInput *s3.CopyObjectInput + deleteInput *s3.DeleteObjectInput +} + +func (client *recordingS3Client) HeadObject( + context.Context, + *s3.HeadObjectInput, + ...func(*s3.Options), +) (*s3.HeadObjectOutput, error) { + panic("unexpected HeadObject call") +} + +func (client *recordingS3Client) GetObject( + context.Context, + *s3.GetObjectInput, + ...func(*s3.Options), +) (*s3.GetObjectOutput, error) { + panic("unexpected GetObject call") +} + +func (client *recordingS3Client) CopyObject( + _ context.Context, + input *s3.CopyObjectInput, + _ ...func(*s3.Options), +) (*s3.CopyObjectOutput, error) { + client.copyInput = input + return &s3.CopyObjectOutput{}, nil +} + +func (client *recordingS3Client) DeleteObject( + _ context.Context, + input *s3.DeleteObjectInput, + _ ...func(*s3.Options), +) (*s3.DeleteObjectOutput, error) { + client.deleteInput = input + return &s3.DeleteObjectOutput{}, nil +} + +func TestNormalizeContentType(t *testing.T) { + if got := normalizeContentType(" Image/PNG; charset=binary "); got != "image/png" { + t.Fatalf("normalizeContentType() = %q, want image/png", got) + } +} + +func TestOwnsObject(t *testing.T) { + storage := &S3AvatarStorage{} + tests := []struct { + name string + userID string + objectKey string + want bool + }{ + {name: "owned upload", userID: "abc", objectKey: "avatar-uploads/abc/photo.png", want: true}, + {name: "another user", userID: "abc", objectKey: "avatar-uploads/def/photo.png", want: false}, + {name: "published object", userID: "abc", objectKey: "avatars/abc/photo.png", want: false}, + {name: "path traversal", userID: "abc", objectKey: "avatar-uploads/abc/../def/photo.png", want: false}, + {name: "backslash", userID: "abc", objectKey: "avatar-uploads/abc\\photo.png", want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := storage.OwnsObject(test.userID, test.objectKey); got != test.want { + t.Fatalf("OwnsObject() = %v, want %v", got, test.want) + } + }) + } +} + +func TestPublicURL(t *testing.T) { + storage := &S3AvatarStorage{publicBaseURL: "https://cdn.example.com"} + if got := storage.PublicURL("avatars/user id/photo.png"); got != "https://cdn.example.com/avatars/user%20id/photo.png" { + t.Fatalf("PublicURL() = %q", got) + } +} + +func TestCreatePresignedUpload(t *testing.T) { + client := s3.NewFromConfig(aws.Config{ + Region: "ap-south-1", + Credentials: credentials.NewStaticCredentialsProvider("test-key", "test-secret", ""), + }) + storage := &S3AvatarStorage{ + bucket: "avatar-bucket", + publicBaseURL: "https://avatars.example.com", + presignTTL: fiveMinutes, + client: client, + presigner: s3.NewPresignClient(client), + } + + upload, err := storage.CreatePresignedUpload(context.Background(), "user123", "image/png", 1024) + if err != nil { + t.Fatalf("CreatePresignedUpload() error = %v", err) + } + if !storage.OwnsObject("user123", upload.ObjectKey) || !strings.HasSuffix(upload.ObjectKey, ".png") { + t.Fatalf("unexpected object key %q", upload.ObjectKey) + } + if upload.Headers["Content-Type"] != "image/png" || + upload.Headers["x-amz-tagging"] != pendingUploadTag || + upload.Headers["Cache-Control"] != "public, max-age=31536000, immutable" { + t.Fatalf("unexpected upload headers: %#v", upload.Headers) + } + if !strings.Contains(upload.UploadURL, "X-Amz-Signature=") { + t.Fatalf("upload URL is not presigned: %q", upload.UploadURL) + } +} + +func TestPromoteUploadedObject(t *testing.T) { + client := &recordingS3Client{} + storage := &S3AvatarStorage{bucket: "avatar-bucket", client: client} + uploadKey := "avatar-uploads/user123/photo.png" + + avatarKey, err := storage.PromoteUploadedObject(context.Background(), uploadKey) + if err != nil { + t.Fatalf("PromoteUploadedObject() error = %v", err) + } + if avatarKey != "avatars/user123/photo.png" { + t.Fatalf("PromoteUploadedObject() key = %q", avatarKey) + } + if client.copyInput == nil || aws.ToString(client.copyInput.Key) != avatarKey { + t.Fatalf("unexpected copy input: %#v", client.copyInput) + } + if aws.ToString(client.copyInput.CopySource) != "avatar-bucket%2Favatar-uploads%2Fuser123%2Fphoto.png" { + t.Fatalf("unexpected copy source: %q", aws.ToString(client.copyInput.CopySource)) + } + if aws.ToString(client.copyInput.Tagging) != "upload-state=confirmed" { + t.Fatalf("unexpected copy tagging: %q", aws.ToString(client.copyInput.Tagging)) + } + if client.deleteInput == nil || aws.ToString(client.deleteInput.Key) != uploadKey { + t.Fatalf("temporary upload was not deleted: %#v", client.deleteInput) + } +} + +const fiveMinutes = 5 * time.Minute diff --git a/docs/avatar-storage.md b/docs/avatar-storage.md new file mode 100644 index 00000000..70504fb2 --- /dev/null +++ b/docs/avatar-storage.md @@ -0,0 +1,109 @@ +# Avatar storage setup + +DebateAI uploads custom profile pictures directly from the browser to a private +Amazon S3 bucket by using a short-lived presigned PUT URL. The backend confirms +the object before storing its public CloudFront URL in MongoDB. + +## Required environment variables + +Add these variables to `backend/.env` in development, or to the backend runtime +environment in production: + +```dotenv +AWS_REGION=ap-south-1 +AWS_S3_BUCKET=debateai-avatars +AWS_S3_PUBLIC_BASE_URL=https://avatars.example.com +AWS_S3_PRESIGN_TTL_SECONDS=300 +``` + +Do not place AWS access keys in the repository or YAML configuration. The AWS +SDK uses its default credential chain. In production, attach an IAM role to the +backend runtime. Local development can use an AWS profile or the standard +`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and optional +`AWS_SESSION_TOKEN` variables. + +If the S3 variables are absent, the server continues to run, DiceBear avatars +continue to work, and custom uploads return `503 Service Unavailable`. + +## Bucket configuration + +Keep S3 Block Public Access enabled. Serve `avatars/*` through a CloudFront +distribution whose S3 origin uses Origin Access Control (OAC). Set +`AWS_S3_PUBLIC_BASE_URL` to the CloudFront distribution URL or its custom +domain. + +The bucket needs CORS permission for direct browser PUT requests. Replace the +origins with the frontend origins used by your environments: + +```json +[ + { + "AllowedOrigins": [ + "http://localhost:5173", + "https://debateai.example.com" + ], + "AllowedMethods": ["PUT"], + "AllowedHeaders": ["cache-control", "content-type", "x-amz-tagging"], + "ExposeHeaders": ["ETag"], + "MaxAgeSeconds": 3000 + } +] +``` + +The backend role needs access only to the avatar prefix: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"], + "Resource": [ + "arn:aws:s3:::debateai-avatars/avatar-uploads/*", + "arn:aws:s3:::debateai-avatars/avatars/*" + ] + } + ] +} +``` + +Presigned uploads are stored under `avatar-uploads/` and tagged +`upload-state=pending`. Confirmation copies a validated upload to the separate +`avatars/` prefix and deletes the temporary object. This prevents a still-valid +presigned URL from overwriting an avatar after validation. Add this lifecycle +rule so abandoned temporary uploads are removed automatically: + +```json +{ + "Rules": [ + { + "ID": "Delete abandoned avatar uploads", + "Status": "Enabled", + "Filter": { + "And": { + "Prefix": "avatar-uploads/", + "Tags": [ + { "Key": "upload-state", "Value": "pending" } + ] + } + }, + "Expiration": { "Days": 1 } + } + ] +} +``` + +## Upload flow + +1. The frontend requests `POST /user/avatar-upload/presign` with file metadata. +2. The backend returns a five-minute S3 PUT URL for + `avatar-uploads/{userId}/{uuid}.{extension}`. +3. The frontend uploads the file directly to S3 using the returned headers. +4. The frontend calls `POST /user/avatar-upload/confirm` with the object key. +5. The backend verifies ownership, size, declared type, and file signature. +6. The backend copies the validated object to `avatars/{userId}/...`, outside + the scope of the upload URL, and deletes the temporary object. +7. MongoDB is updated and the user's previous managed S3 avatar is deleted. + +Accepted formats are JPEG, PNG, and WebP. The maximum size is 5 MB. From 5067d6a2670ef9ef2f7a69196608ea0b64f219ea Mon Sep 17 00:00:00 2001 From: priyanshunitr Date: Thu, 13 Aug 2026 19:51:46 +0530 Subject: [PATCH 3/8] changed gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7a42c1f0..9b03c49c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,4 @@ *.log *.env config.prod.yml -backend/uploads/ vite.config.ts.timestamp-*.mjs From d900ecf76f2441cc311f54fc4bb45b954095433e Mon Sep 17 00:00:00 2001 From: priyanshunitr Date: Sat, 15 Aug 2026 02:30:48 +0530 Subject: [PATCH 4/8] clearing extra docs --- docs/avatar-storage.md | 109 ----------------------------------------- 1 file changed, 109 deletions(-) delete mode 100644 docs/avatar-storage.md diff --git a/docs/avatar-storage.md b/docs/avatar-storage.md deleted file mode 100644 index 70504fb2..00000000 --- a/docs/avatar-storage.md +++ /dev/null @@ -1,109 +0,0 @@ -# Avatar storage setup - -DebateAI uploads custom profile pictures directly from the browser to a private -Amazon S3 bucket by using a short-lived presigned PUT URL. The backend confirms -the object before storing its public CloudFront URL in MongoDB. - -## Required environment variables - -Add these variables to `backend/.env` in development, or to the backend runtime -environment in production: - -```dotenv -AWS_REGION=ap-south-1 -AWS_S3_BUCKET=debateai-avatars -AWS_S3_PUBLIC_BASE_URL=https://avatars.example.com -AWS_S3_PRESIGN_TTL_SECONDS=300 -``` - -Do not place AWS access keys in the repository or YAML configuration. The AWS -SDK uses its default credential chain. In production, attach an IAM role to the -backend runtime. Local development can use an AWS profile or the standard -`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and optional -`AWS_SESSION_TOKEN` variables. - -If the S3 variables are absent, the server continues to run, DiceBear avatars -continue to work, and custom uploads return `503 Service Unavailable`. - -## Bucket configuration - -Keep S3 Block Public Access enabled. Serve `avatars/*` through a CloudFront -distribution whose S3 origin uses Origin Access Control (OAC). Set -`AWS_S3_PUBLIC_BASE_URL` to the CloudFront distribution URL or its custom -domain. - -The bucket needs CORS permission for direct browser PUT requests. Replace the -origins with the frontend origins used by your environments: - -```json -[ - { - "AllowedOrigins": [ - "http://localhost:5173", - "https://debateai.example.com" - ], - "AllowedMethods": ["PUT"], - "AllowedHeaders": ["cache-control", "content-type", "x-amz-tagging"], - "ExposeHeaders": ["ETag"], - "MaxAgeSeconds": 3000 - } -] -``` - -The backend role needs access only to the avatar prefix: - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"], - "Resource": [ - "arn:aws:s3:::debateai-avatars/avatar-uploads/*", - "arn:aws:s3:::debateai-avatars/avatars/*" - ] - } - ] -} -``` - -Presigned uploads are stored under `avatar-uploads/` and tagged -`upload-state=pending`. Confirmation copies a validated upload to the separate -`avatars/` prefix and deletes the temporary object. This prevents a still-valid -presigned URL from overwriting an avatar after validation. Add this lifecycle -rule so abandoned temporary uploads are removed automatically: - -```json -{ - "Rules": [ - { - "ID": "Delete abandoned avatar uploads", - "Status": "Enabled", - "Filter": { - "And": { - "Prefix": "avatar-uploads/", - "Tags": [ - { "Key": "upload-state", "Value": "pending" } - ] - } - }, - "Expiration": { "Days": 1 } - } - ] -} -``` - -## Upload flow - -1. The frontend requests `POST /user/avatar-upload/presign` with file metadata. -2. The backend returns a five-minute S3 PUT URL for - `avatar-uploads/{userId}/{uuid}.{extension}`. -3. The frontend uploads the file directly to S3 using the returned headers. -4. The frontend calls `POST /user/avatar-upload/confirm` with the object key. -5. The backend verifies ownership, size, declared type, and file signature. -6. The backend copies the validated object to `avatars/{userId}/...`, outside - the scope of the upload URL, and deletes the temporary object. -7. MongoDB is updated and the user's previous managed S3 avatar is deleted. - -Accepted formats are JPEG, PNG, and WebP. The maximum size is 5 MB. From 4ba4d57e2c5259f9c76d6aa9b5ebbbbd61dc77fa Mon Sep 17 00:00:00 2001 From: priyanshunitr Date: Mon, 17 Aug 2026 00:55:44 +0530 Subject: [PATCH 5/8] backend configured to s3 publically --- .env.example | 20 +- README.md | 10 +- backend/cmd/server/main.go | 2 +- backend/config/config.go | 8 +- backend/config/config.prod.sample.yml | 3 +- backend/controllers/avatar_controller.go | 5 +- backend/services/avatar_storage.go | 77 ++++--- backend/services/avatar_storage_test.go | 61 ++++-- docs/avatar-storage.md | 255 +++++++++++++++++++++++ 9 files changed, 373 insertions(+), 68 deletions(-) create mode 100644 docs/avatar-storage.md diff --git a/.env.example b/.env.example index 1c104450..55f37934 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,21 @@ -# Backend Secrets (place in backend/.env) +# Backend environment (copy the backend section to backend/.env for Docker Compose) +PORT=1313 +DATABASE_URI=mongodb://localhost:27017/debateai GEMINI_API_KEY=your_gemini_key_here JWT_SECRET=your_jwt_secret_here GOOGLE_CLIENT_ID=your_google_client_id_here -# AWS_REGION=ap-south-1 -# AWS_S3_BUCKET=your_private_avatar_bucket -# AWS_S3_PUBLIC_BASE_URL=https://your-cloudfront-domain.example.com -# AWS_S3_PRESIGN_TTL_SECONDS=300 + +# S3 avatar storage +AWS_REGION=ap-south-1 +AWS_S3_BUCKET=your_public_avatar_bucket_without_dots +AWS_S3_PRESIGN_TTL_SECONDS=300 + +# Local development only. Prefer an IAM role in production. +# Never commit real AWS credentials. +AWS_ACCESS_KEY_ID=your_iam_access_key_id +AWS_SECRET_ACCESS_KEY=your_iam_secret_access_key +# AWS_SESSION_TOKEN=your_temporary_session_token + # SMTP_PASSWORD=your_smtp_password_here (if needed) # Frontend Secrets (place in frontend/.env) diff --git a/README.md b/README.md index 6fefd2c3..21e103f7 100644 --- a/README.md +++ b/README.md @@ -75,11 +75,11 @@ gemini: ### 4. (Optional) Custom Avatar Storage -Custom profile picture uploads use presigned Amazon S3 URLs and a private S3 -bucket served through CloudFront. See [Avatar storage setup](docs/avatar-storage.md) -for the required environment variables, IAM policy, bucket CORS, CloudFront -OAC, and abandoned-upload lifecycle rule. The backend still runs when this is -not configured, but custom image uploads remain disabled. +Custom profile picture uploads use presigned Amazon S3 URLs and permanent public +links from a dedicated S3 bucket. See [Avatar storage setup](docs/avatar-storage.md) +for the bucket policy, CORS, IAM permissions, configuration, and cleanup rule. +The backend still runs when S3 is not configured, but custom uploads remain +disabled. --- diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 7e25b140..3b2a4976 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -66,7 +66,7 @@ func main() { var avatarStorage services.AvatarStorage if cfg.S3.HasEndpointConfig() && !cfg.S3.IsConfigured() { - log.Fatal("Incomplete S3 avatar configuration: region, bucket, and publicBaseURL are all required") + log.Fatal("Incomplete S3 avatar configuration: region and bucket are required") } if cfg.S3.IsConfigured() { storage, err := services.NewS3AvatarStorage(context.Background(), cfg.S3) diff --git a/backend/config/config.go b/backend/config/config.go index 9754aac7..95178eee 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -11,16 +11,15 @@ import ( type S3Config struct { Region string `yaml:"region"` Bucket string `yaml:"bucket"` - PublicBaseURL string `yaml:"publicBaseURL"` PresignTTLSeconds int `yaml:"presignTTLSeconds"` } func (c S3Config) IsConfigured() bool { - return c.Region != "" && c.Bucket != "" && c.PublicBaseURL != "" + return c.Region != "" && c.Bucket != "" } func (c S3Config) HasEndpointConfig() bool { - return c.Bucket != "" || c.PublicBaseURL != "" + return c.Bucket != "" } type Config struct { @@ -110,9 +109,6 @@ func LoadConfig(path string) (*Config, error) { if envS3Bucket := os.Getenv("AWS_S3_BUCKET"); envS3Bucket != "" { cfg.S3.Bucket = envS3Bucket } - if envS3PublicBaseURL := os.Getenv("AWS_S3_PUBLIC_BASE_URL"); envS3PublicBaseURL != "" { - cfg.S3.PublicBaseURL = envS3PublicBaseURL - } if envPresignTTL := os.Getenv("AWS_S3_PRESIGN_TTL_SECONDS"); envPresignTTL != "" { presignTTL, err := strconv.Atoi(envPresignTTL) if err != nil || presignTTL <= 0 { diff --git a/backend/config/config.prod.sample.yml b/backend/config/config.prod.sample.yml index a1352f9f..08e7cf85 100644 --- a/backend/config/config.prod.sample.yml +++ b/backend/config/config.prod.sample.yml @@ -44,8 +44,7 @@ googleOAuth: s3: region: "" # Example: ap-south-1 - bucket: "" # Private avatar bucket name - publicBaseURL: "" # Example: https://avatars.example.com (CloudFront) + bucket: "" # Public avatar bucket name; use a bucket name without dots presignTTLSeconds: 300 # AWS credentials are intentionally not stored here. The AWS SDK uses its # default credential chain (environment variables, local profile, or IAM role). diff --git a/backend/controllers/avatar_controller.go b/backend/controllers/avatar_controller.go index 291eda23..94238104 100644 --- a/backend/controllers/avatar_controller.go +++ b/backend/controllers/avatar_controller.go @@ -94,12 +94,13 @@ func (controller *AvatarController) ConfirmUpload(ctx *gin.Context) { operationCtx, cancel := context.WithTimeout(ctx.Request.Context(), 15*time.Second) defer cancel() - if err := controller.storage.ValidateUploadedObject(operationCtx, request.ObjectKey); err != nil { + sourceETag, err := controller.storage.ValidateUploadedObject(operationCtx, request.ObjectKey) + if err != nil { controller.deleteObject(request.ObjectKey, "rejected") ctx.JSON(http.StatusBadRequest, gin.H{"error": "Uploaded file is not a valid avatar"}) return } - avatarKey, err := controller.storage.PromoteUploadedObject(operationCtx, request.ObjectKey) + avatarKey, err := controller.storage.PromoteUploadedObject(operationCtx, request.ObjectKey, sourceETag) if err != nil { controller.deleteObject(request.ObjectKey, "unconfirmed") ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to confirm avatar upload"}) diff --git a/backend/services/avatar_storage.go b/backend/services/avatar_storage.go index 293f3d45..8ac4f13e 100644 --- a/backend/services/avatar_storage.go +++ b/backend/services/avatar_storage.go @@ -45,8 +45,8 @@ type PresignedAvatarUpload struct { type AvatarStorage interface { CreatePresignedUpload(ctx context.Context, userID, contentType string, fileSize int64) (*PresignedAvatarUpload, error) - ValidateUploadedObject(ctx context.Context, objectKey string) error - PromoteUploadedObject(ctx context.Context, objectKey string) (string, error) + ValidateUploadedObject(ctx context.Context, objectKey string) (string, error) + PromoteUploadedObject(ctx context.Context, objectKey, sourceETag string) (string, error) DeleteObject(ctx context.Context, objectKey string) error PublicURL(objectKey string) string OwnsObject(userID, objectKey string) bool @@ -60,20 +60,19 @@ type s3ObjectClient interface { } type S3AvatarStorage struct { - bucket string - publicBaseURL string - presignTTL time.Duration - client s3ObjectClient - presigner *s3.PresignClient + bucket string + region string + presignTTL time.Duration + client s3ObjectClient + presigner *s3.PresignClient } func NewS3AvatarStorage(ctx context.Context, cfg appconfig.S3Config) (*S3AvatarStorage, error) { if !cfg.IsConfigured() { - return nil, fmt.Errorf("S3 avatar storage requires region, bucket, and publicBaseURL") + return nil, fmt.Errorf("S3 avatar storage requires region and bucket") } - publicBaseURL, err := url.Parse(cfg.PublicBaseURL) - if err != nil || publicBaseURL.Scheme != "https" || publicBaseURL.Host == "" { - return nil, fmt.Errorf("S3 publicBaseURL must be a valid HTTPS URL") + if strings.Contains(cfg.Bucket, ".") { + return nil, fmt.Errorf("direct public S3 avatar bucket name must not contain dots") } if cfg.PresignTTLSeconds < 60 || cfg.PresignTTLSeconds > 900 { return nil, fmt.Errorf("S3 presignTTLSeconds must be between 60 and 900") @@ -86,11 +85,11 @@ func NewS3AvatarStorage(ctx context.Context, cfg appconfig.S3Config) (*S3AvatarS client := s3.NewFromConfig(awsCfg) return &S3AvatarStorage{ - bucket: cfg.Bucket, - publicBaseURL: strings.TrimRight(publicBaseURL.String(), "/"), - presignTTL: time.Duration(cfg.PresignTTLSeconds) * time.Second, - client: client, - presigner: s3.NewPresignClient(client), + bucket: cfg.Bucket, + region: cfg.Region, + presignTTL: time.Duration(cfg.PresignTTLSeconds) * time.Second, + client: client, + presigner: s3.NewPresignClient(client), }, nil } @@ -136,53 +135,58 @@ func (s *S3AvatarStorage) CreatePresignedUpload( }, nil } -func (s *S3AvatarStorage) ValidateUploadedObject(ctx context.Context, objectKey string) error { +func (s *S3AvatarStorage) ValidateUploadedObject(ctx context.Context, objectKey string) (string, error) { head, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(objectKey), }) if err != nil { - return fmt.Errorf("inspect uploaded avatar: %w", err) + return "", fmt.Errorf("inspect uploaded avatar: %w", err) } if head.ContentLength == nil || *head.ContentLength <= 0 || *head.ContentLength > MaxAvatarSize { - return fmt.Errorf("uploaded avatar has an invalid size") + return "", fmt.Errorf("uploaded avatar has an invalid size") + } + if aws.ToString(head.ETag) == "" { + return "", fmt.Errorf("uploaded avatar has no entity tag") } declaredType := normalizeContentType(aws.ToString(head.ContentType)) expectedExtension, ok := avatarExtensions[declaredType] if !ok || !strings.EqualFold(path.Ext(objectKey), expectedExtension) { - return fmt.Errorf("uploaded avatar has an invalid content type") + return "", fmt.Errorf("uploaded avatar has an invalid content type") } object, err := s.client.GetObject(ctx, &s3.GetObjectInput{ - Bucket: aws.String(s.bucket), - Key: aws.String(objectKey), - Range: aws.String("bytes=0-511"), + Bucket: aws.String(s.bucket), + IfMatch: head.ETag, + Key: aws.String(objectKey), + Range: aws.String("bytes=0-511"), }) if err != nil { - return fmt.Errorf("read uploaded avatar signature: %w", err) + return "", fmt.Errorf("read uploaded avatar signature: %w", err) } defer object.Body.Close() signature, err := io.ReadAll(io.LimitReader(object.Body, 512)) if err != nil { - return fmt.Errorf("read uploaded avatar signature: %w", err) + return "", fmt.Errorf("read uploaded avatar signature: %w", err) } if len(signature) == 0 || normalizeContentType(http.DetectContentType(signature)) != declaredType { - return fmt.Errorf("uploaded file content does not match its image type") + return "", fmt.Errorf("uploaded file content does not match its image type") } - return nil + return aws.ToString(head.ETag), nil } -func (s *S3AvatarStorage) PromoteUploadedObject(ctx context.Context, uploadKey string) (string, error) { +func (s *S3AvatarStorage) PromoteUploadedObject(ctx context.Context, uploadKey, sourceETag string) (string, error) { avatarKey := strings.Replace(uploadKey, avatarUploadPrefix+"/", avatarObjectPrefix+"/", 1) _, err := s.client.CopyObject(ctx, &s3.CopyObjectInput{ - Bucket: aws.String(s.bucket), - CopySource: aws.String(url.PathEscape(s.bucket + "/" + uploadKey)), - Key: aws.String(avatarKey), - Tagging: aws.String("upload-state=confirmed"), - TaggingDirective: s3types.TaggingDirectiveReplace, + Bucket: aws.String(s.bucket), + CopySource: aws.String(url.PathEscape(s.bucket + "/" + uploadKey)), + CopySourceIfMatch: aws.String(sourceETag), + Key: aws.String(avatarKey), + Tagging: aws.String("upload-state=confirmed"), + TaggingDirective: s3types.TaggingDirectiveReplace, }) if err != nil { return "", fmt.Errorf("promote avatar object: %w", err) @@ -214,7 +218,12 @@ func (s *S3AvatarStorage) PublicURL(objectKey string) string { for index, segment := range segments { segments[index] = url.PathEscape(segment) } - return s.publicBaseURL + "/" + strings.Join(segments, "/") + return fmt.Sprintf( + "https://%s.s3.%s.amazonaws.com/%s", + s.bucket, + s.region, + strings.Join(segments, "/"), + ) } func (s *S3AvatarStorage) OwnsObject(userID, objectKey string) bool { diff --git a/backend/services/avatar_storage_test.go b/backend/services/avatar_storage_test.go index 3f8d9ba7..9765b210 100644 --- a/backend/services/avatar_storage_test.go +++ b/backend/services/avatar_storage_test.go @@ -1,7 +1,9 @@ package services import ( + "bytes" "context" + "io" "strings" "testing" "time" @@ -14,6 +16,9 @@ import ( type recordingS3Client struct { copyInput *s3.CopyObjectInput deleteInput *s3.DeleteObjectInput + getInput *s3.GetObjectInput + headOutput *s3.HeadObjectOutput + objectBody []byte } func (client *recordingS3Client) HeadObject( @@ -21,15 +26,19 @@ func (client *recordingS3Client) HeadObject( *s3.HeadObjectInput, ...func(*s3.Options), ) (*s3.HeadObjectOutput, error) { - panic("unexpected HeadObject call") + if client.headOutput == nil { + panic("unexpected HeadObject call") + } + return client.headOutput, nil } func (client *recordingS3Client) GetObject( - context.Context, - *s3.GetObjectInput, - ...func(*s3.Options), + _ context.Context, + input *s3.GetObjectInput, + _ ...func(*s3.Options), ) (*s3.GetObjectOutput, error) { - panic("unexpected GetObject call") + client.getInput = input + return &s3.GetObjectOutput{Body: io.NopCloser(bytes.NewReader(client.objectBody))}, nil } func (client *recordingS3Client) CopyObject( @@ -81,8 +90,8 @@ func TestOwnsObject(t *testing.T) { } func TestPublicURL(t *testing.T) { - storage := &S3AvatarStorage{publicBaseURL: "https://cdn.example.com"} - if got := storage.PublicURL("avatars/user id/photo.png"); got != "https://cdn.example.com/avatars/user%20id/photo.png" { + storage := &S3AvatarStorage{bucket: "avatar-bucket", region: "ap-south-1"} + if got := storage.PublicURL("avatars/user id/photo.png"); got != "https://avatar-bucket.s3.ap-south-1.amazonaws.com/avatars/user%20id/photo.png" { t.Fatalf("PublicURL() = %q", got) } } @@ -93,11 +102,11 @@ func TestCreatePresignedUpload(t *testing.T) { Credentials: credentials.NewStaticCredentialsProvider("test-key", "test-secret", ""), }) storage := &S3AvatarStorage{ - bucket: "avatar-bucket", - publicBaseURL: "https://avatars.example.com", - presignTTL: fiveMinutes, - client: client, - presigner: s3.NewPresignClient(client), + bucket: "avatar-bucket", + region: "ap-south-1", + presignTTL: fiveMinutes, + client: client, + presigner: s3.NewPresignClient(client), } upload, err := storage.CreatePresignedUpload(context.Background(), "user123", "image/png", 1024) @@ -117,12 +126,35 @@ func TestCreatePresignedUpload(t *testing.T) { } } +func TestValidateUploadedObjectBindsReadToETag(t *testing.T) { + client := &recordingS3Client{ + headOutput: &s3.HeadObjectOutput{ + ContentLength: aws.Int64(8), + ContentType: aws.String("image/png"), + ETag: aws.String(`"validated-etag"`), + }, + objectBody: []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, + } + storage := &S3AvatarStorage{bucket: "avatar-bucket", client: client} + + etag, err := storage.ValidateUploadedObject(context.Background(), "avatar-uploads/user123/photo.png") + if err != nil { + t.Fatalf("ValidateUploadedObject() error = %v", err) + } + if etag != `"validated-etag"` { + t.Fatalf("ValidateUploadedObject() ETag = %q", etag) + } + if client.getInput == nil || aws.ToString(client.getInput.IfMatch) != etag { + t.Fatalf("GetObject was not bound to validated ETag: %#v", client.getInput) + } +} + func TestPromoteUploadedObject(t *testing.T) { client := &recordingS3Client{} storage := &S3AvatarStorage{bucket: "avatar-bucket", client: client} uploadKey := "avatar-uploads/user123/photo.png" - avatarKey, err := storage.PromoteUploadedObject(context.Background(), uploadKey) + avatarKey, err := storage.PromoteUploadedObject(context.Background(), uploadKey, `"source-etag"`) if err != nil { t.Fatalf("PromoteUploadedObject() error = %v", err) } @@ -135,6 +167,9 @@ func TestPromoteUploadedObject(t *testing.T) { if aws.ToString(client.copyInput.CopySource) != "avatar-bucket%2Favatar-uploads%2Fuser123%2Fphoto.png" { t.Fatalf("unexpected copy source: %q", aws.ToString(client.copyInput.CopySource)) } + if aws.ToString(client.copyInput.CopySourceIfMatch) != `"source-etag"` { + t.Fatalf("unexpected copy source ETag: %q", aws.ToString(client.copyInput.CopySourceIfMatch)) + } if aws.ToString(client.copyInput.Tagging) != "upload-state=confirmed" { t.Fatalf("unexpected copy tagging: %q", aws.ToString(client.copyInput.Tagging)) } diff --git a/docs/avatar-storage.md b/docs/avatar-storage.md new file mode 100644 index 00000000..40ecb4c0 --- /dev/null +++ b/docs/avatar-storage.md @@ -0,0 +1,255 @@ +# Avatar storage setup + +DebateAI uses one Amazon S3 bucket for custom profile pictures. The browser +uploads an image directly to S3 with a short-lived presigned PUT URL, and the +backend stores the permanent public S3 link in MongoDB. + +CloudFront and a separate public base URL are not required. + +## Requirements + +- Use a dedicated bucket that contains only public profile pictures. +- Use a bucket name without dots so its virtual-hosted HTTPS URL works normally. +- Keep temporary `avatar-uploads/*` objects private. +- Allow public read access only to confirmed `avatars/*` objects. +- Do not grant public upload, delete, or bucket-list access. + +Example bucket name: + +```text +debateai-profile-images-374445650164 +``` + +Bucket names are globally unique, so choose a different suffix if necessary. + +## 1. Backend configuration + +The server reads `backend/config/config.prod.yml`. Add: + +```yaml +s3: + region: "us-east-1" + bucket: "debateai-profile-images-374445650164" + presignTTLSeconds: 300 +``` + +The backend automatically creates final image links in this form: + +```text +https://debateai-profile-images-374445650164.s3.us-east-1.amazonaws.com/avatars/{userId}/{uuid}.png +``` + +No `publicBaseURL` value is needed. + +These process environment variables can override the YAML settings: + +```dotenv +AWS_REGION=us-east-1 +AWS_S3_BUCKET=debateai-profile-images-374445650164 +AWS_S3_PRESIGN_TTL_SECONDS=300 +``` + +The repository does not automatically load `backend/.env`. Export variables in +the process that starts the Go server or configure the IDE/container to load +them. + +If S3 configuration is absent, the backend continues to run, but custom avatar +uploads return `503 Service Unavailable`. + +## 2. Backend AWS credentials + +The AWS SDK uses its default credential chain. Prefer an IAM role attached to +the production compute service. Local development can use an AWS profile or the +standard `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and optional +`AWS_SESSION_TOKEN` process variables. + +Never put AWS credentials in frontend code or commit them to Git. The browser +receives only an object-specific presigned URL. + +Attach this policy to the backend IAM identity after replacing the bucket name: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ManageProfileImages", + "Effect": "Allow", + "Action": [ + "s3:PutObject", + "s3:PutObjectTagging", + "s3:GetObject", + "s3:DeleteObject" + ], + "Resource": [ + "arn:aws:s3:::debateai-profile-images-374445650164/avatar-uploads/*", + "arn:aws:s3:::debateai-profile-images-374445650164/avatars/*" + ] + } + ] +} +``` + +`s3:PutObjectTagging` is required because temporary objects are tagged +`upload-state=pending` and confirmed objects are tagged +`upload-state=confirmed`. + +## 3. Public read access for confirmed avatars + +The permanent link works only when `avatars/*` is publicly readable. + +In **S3 → bucket → Permissions**, adjust Block Public Access so the bucket can +accept a public read bucket policy. Account-level or organization-level Block +Public Access must not override the bucket configuration. + +Then add this bucket policy after replacing the bucket name: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "PublicReadConfirmedProfileImages", + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::debateai-profile-images-374445650164/avatars/*" + } + ] +} +``` + +This policy does not allow the public to upload, delete, list the bucket, or +read `avatar-uploads/*`. Because confirmed profile pictures are intentionally +public, never store private documents or sensitive images in this bucket. + +## 4. Browser upload CORS + +In **S3 → bucket → Permissions → Cross-origin resource sharing (CORS)**, add: + +```json +[ + { + "AllowedOrigins": [ + "http://localhost:5173", + "https://your-frontend.example.com" + ], + "AllowedMethods": ["PUT"], + "AllowedHeaders": [ + "cache-control", + "content-type", + "x-amz-tagging" + ], + "ExposeHeaders": ["ETag"], + "MaxAgeSeconds": 3000 + } +] +``` + +Replace the production origin with the exact deployed frontend origin. Do not +use `"*"` for production origins. + +## 5. Remove abandoned temporary uploads + +The browser might upload a temporary object and close before confirmation. Add +an S3 lifecycle rule with: + +- prefix: `avatar-uploads/` +- tag key: `upload-state` +- tag value: `pending` +- expiration: 1 day + +Equivalent lifecycle configuration: + +```json +{ + "Rules": [ + { + "ID": "Delete abandoned avatar uploads", + "Status": "Enabled", + "Filter": { + "And": { + "Prefix": "avatar-uploads/", + "Tags": [ + { + "Key": "upload-state", + "Value": "pending" + } + ] + } + }, + "Expiration": { + "Days": 1 + } + } + ] +} +``` + +If versioning is enabled, also expire noncurrent versions so replaced or +deleted profile images do not accumulate. + +## Upload behavior + +1. The frontend requests a presigned upload URL. +2. The browser uploads directly to `avatar-uploads/{userId}/{uuid}.{extension}`. +3. The backend verifies ownership, size, MIME type, image signature, and S3 + entity tag. +4. The backend copies the validated object to `avatars/{userId}/...` and deletes + the temporary object. +5. MongoDB stores the permanent S3 URL and the object key. +6. When the user changes their profile picture, the previous S3 object is + deleted. + +Accepted formats are JPEG, PNG, and WebP. The maximum size is 5 MB. Confirmed +objects use a one-year immutable browser cache because every replacement gets a +new UUID and therefore a new URL. + +## Verification + +1. Restart the backend after configuring S3. +2. Upload a JPEG, PNG, or WebP smaller than 5 MB from the profile page. +3. Confirm the presign endpoint returns `200`. +4. Confirm the browser's direct S3 PUT returns `200`. +5. Confirm the upload-confirmation endpoint returns `200` and an `avatarUrl`. +6. Confirm the temporary object is gone from `avatar-uploads/`. +7. Confirm the final object exists under `avatars/`. +8. Open `avatarUrl` in a private browser window; it should load without AWS + credentials. +9. Change the profile picture and confirm the old S3 object is deleted. + +## Troubleshooting + +### `503 Avatar uploads are not configured` + +Both region and bucket are required. Remember that `backend/.env` is not loaded +automatically. + +### Backend refuses a bucket name + +The direct public HTTPS implementation requires a bucket name without dots. +Create a dedicated bucket using letters, numbers, and hyphens. + +### Direct S3 PUT returns `403 AccessDenied` + +Check that the backend IAM identity has `s3:PutObject` and +`s3:PutObjectTagging` on `avatar-uploads/*`, and confirm the presigned URL has +not expired. + +### Browser reports a CORS failure + +Add the exact frontend origin and all signed upload headers to the bucket's CORS +configuration. + +### Permanent image URL returns `403` + +Check the public-read bucket policy for `avatars/*` and verify that bucket, +account, or organization Block Public Access settings are not overriding it. + +## AWS references + +- [Uploading objects with presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html) +- [Required permissions for S3 operations](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-with-s3-policy-actions.html) +- [Configuring S3 CORS](https://docs.aws.amazon.com/AmazonS3/latest/userguide/ManageCorsUsing.html) +- [S3 bucket naming rules](https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html) +- [S3 lifecycle configuration elements](https://docs.aws.amazon.com/AmazonS3/latest/userguide/intro-lifecycle-rules.html) From 19a021f3333415fce8b4ddcaa90b9a66ce56f45e Mon Sep 17 00:00:00 2001 From: priyanshunitr Date: Wed, 19 Aug 2026 11:08:35 +0530 Subject: [PATCH 6/8] fix: restore profile debounce after rebase --- frontend/src/Pages/Profile.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/Pages/Profile.tsx b/frontend/src/Pages/Profile.tsx index 540ad77a..511eb06c 100644 --- a/frontend/src/Pages/Profile.tsx +++ b/frontend/src/Pages/Profile.tsx @@ -210,6 +210,7 @@ const Profile: React.FC = () => { to: undefined, }); const inputRef = useRef(null); + const debounceTimer = useRef | null>(null); const avatarFileInputRef = useRef(null); const [avatarUploading, setAvatarUploading] = useState(false); @@ -1262,4 +1263,4 @@ const Profile: React.FC = () => { ); }; -export default Profile; \ No newline at end of file +export default Profile; From 7a930655e46721fab9591b820c3972daa854aba3 Mon Sep 17 00:00:00 2001 From: priyanshunitr Date: Thu, 20 Aug 2026 00:30:38 +0530 Subject: [PATCH 7/8] profile pic resize --- frontend/src/Pages/Profile.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/Pages/Profile.tsx b/frontend/src/Pages/Profile.tsx index 511eb06c..52a6a80f 100644 --- a/frontend/src/Pages/Profile.tsx +++ b/frontend/src/Pages/Profile.tsx @@ -745,12 +745,12 @@ const Profile: React.FC = () => {
)}
-
+
Avatar {avatarUploading ? (
From 72ae5cbf4604e2848d1ec7334f7e8764a4c5a387 Mon Sep 17 00:00:00 2001 From: priyanshunitr Date: Sat, 22 Aug 2026 02:19:43 +0530 Subject: [PATCH 8/8] issue resolved --- backend/config/config.go | 2 +- backend/config/config_test.go | 33 +++++++++++++++++++++++ backend/controllers/profile_controller.go | 19 ------------- 3 files changed, 34 insertions(+), 20 deletions(-) create mode 100644 backend/config/config_test.go diff --git a/backend/config/config.go b/backend/config/config.go index 95178eee..039f990d 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -19,7 +19,7 @@ func (c S3Config) IsConfigured() bool { } func (c S3Config) HasEndpointConfig() bool { - return c.Bucket != "" + return c.Region != "" || c.Bucket != "" } type Config struct { diff --git a/backend/config/config_test.go b/backend/config/config_test.go new file mode 100644 index 00000000..205f3632 --- /dev/null +++ b/backend/config/config_test.go @@ -0,0 +1,33 @@ +package config + +import "testing" + +func TestS3ConfigEndpointState(t *testing.T) { + tests := []struct { + name string + config S3Config + hasEndpointConfig bool + isConfigured bool + }{ + {name: "empty", config: S3Config{}, hasEndpointConfig: false, isConfigured: false}, + {name: "region only", config: S3Config{Region: "us-east-1"}, hasEndpointConfig: true, isConfigured: false}, + {name: "bucket only", config: S3Config{Bucket: "avatar-bucket"}, hasEndpointConfig: true, isConfigured: false}, + { + name: "complete", + config: S3Config{Region: "us-east-1", Bucket: "avatar-bucket"}, + hasEndpointConfig: true, + isConfigured: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := test.config.HasEndpointConfig(); got != test.hasEndpointConfig { + t.Fatalf("HasEndpointConfig() = %v, want %v", got, test.hasEndpointConfig) + } + if got := test.config.IsConfigured(); got != test.isConfigured { + t.Fatalf("IsConfigured() = %v, want %v", got, test.isConfigured) + } + }) + } +} diff --git a/backend/controllers/profile_controller.go b/backend/controllers/profile_controller.go index 88eff78f..7c6931c0 100644 --- a/backend/controllers/profile_controller.go +++ b/backend/controllers/profile_controller.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "log" "math" "net/http" "net/url" @@ -45,18 +44,11 @@ func extractNameFromEmail(email string) string { func GetProfile(c *gin.Context) { userIDParam := strings.TrimSpace(c.Query("userId")) - // Log detailed request information for debugging - log.Printf("GetProfile: Request URL = '%s'", c.Request.URL.String()) - log.Printf("GetProfile: Raw Query = '%s'", c.Request.URL.RawQuery) - log.Printf("GetProfile: Query params map = %v", c.Request.URL.Query()) - log.Printf("GetProfile: userId from c.Query() = '%s'", userIDParam) - // If c.Query() didn't work, try reading from URL.Query() directly if userIDParam == "" { values := c.Request.URL.Query() if val, ok := values["userId"]; ok && len(val) > 0 && val[0] != "" { userIDParam = strings.TrimSpace(val[0]) - log.Printf("GetProfile: Got userId from URL.Query(): '%s'", userIDParam) } } @@ -70,20 +62,14 @@ func GetProfile(c *gin.Context) { if decoded, err := url.QueryUnescape(userIDParam); err == nil { userIDParam = strings.TrimSpace(decoded) } - log.Printf("GetProfile: Extracted userId from raw query: '%s'", userIDParam) break } } } - log.Printf("GetProfile: Final userId param = '%s'", userIDParam) - if userIDParam != "" && userIDParam != "undefined" && userIDParam != "null" { - log.Printf("GetProfile: Processing userId query param: '%s'", userIDParam) - userID, err := primitive.ObjectIDFromHex(userIDParam) if err != nil { - log.Printf("GetProfile: Invalid ObjectID format: '%s', error: %v", userIDParam, err) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID format", "provided": userIDParam}) return } @@ -94,13 +80,10 @@ func GetProfile(c *gin.Context) { var user models.User err = db.MongoDatabase.Collection("users").FindOne(dbCtx, bson.M{"_id": userID}).Decode(&user) if err != nil { - log.Printf("GetProfile: User not found in DB for ID: '%s', error: %v", userIDParam, err) c.JSON(http.StatusNotFound, gin.H{"error": "User not found", "userId": userIDParam}) return } - log.Printf("GetProfile: Found user - ID: %s, Email: %s, DisplayName: %s", user.ID.Hex(), user.Email, user.DisplayName) - displayName := user.DisplayName if displayName == "" { displayName = extractNameFromEmail(user.Email) @@ -127,8 +110,6 @@ func GetProfile(c *gin.Context) { return } - log.Printf("GetProfile: No userId query param provided, falling back to authenticated user") - email := c.GetString("email") if email == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized", "message": "Missing email in context"})