Hammad712 commited on
Commit
b2aa058
·
1 Parent(s): e1e15a9

Email based Auth Auth completed

Browse files
.gitignore ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # --------------------------
2
+ # Python
3
+ # --------------------------
4
+ __pycache__/
5
+ *.py[cod]
6
+ *.pyo
7
+ *.pyd
8
+ *.so
9
+ *.egg-info/
10
+ *.egg
11
+ *.log
12
+
13
+ # Virtual environments
14
+ venv/
15
+ env/
16
+ .venv/
17
+
18
+ # Byte-compiled / optimized / DLL files
19
+ *.pyc
20
+ *.pyo
21
+ *.pyd
22
+ *.pdb
23
+
24
+ # --------------------------
25
+ # FastAPI / Uvicorn / Cache
26
+ # --------------------------
27
+ .cache/
28
+ .pytest_cache/
29
+ .mypy_cache/
30
+ .coverage
31
+ htmlcov/
32
+ dist/
33
+ build/
34
+
35
+ # --------------------------
36
+ # Environment / Secrets
37
+ # --------------------------
38
+ .env
39
+ .env.local
40
+ .env.*.local
41
+ *.secret
42
+ *.key
43
+
44
+ # --------------------------
45
+ # IDE / Editors
46
+ # --------------------------
47
+ .vscode/
48
+ .idea/
49
+ *.swp
50
+ *.swo
51
+
52
+ # --------------------------
53
+ # OS Generated
54
+ # --------------------------
55
+ .DS_Store
56
+ Thumbs.db
57
+
58
+ # --------------------------
59
+ # Uploads (if stored locally)
60
+ # --------------------------
61
+ uploads/
62
+ media/
63
+ static/uploads/
64
+ tmp/
65
+
66
+ # --------------------------
67
+ # Mongo / GridFS (if local)
68
+ # --------------------------
69
+ fs/
70
+ *.gridfs
71
+ *.lock
72
+
73
+ # --------------------------
74
+ # Node (if frontend exists)
75
+ # --------------------------
76
+ node_modules/
77
+ npm-debug.log
78
+ yarn-debug.log
79
+ yarn-error.log
Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Base image using Python 3.11
2
+ FROM python:3.11
3
+
4
+ # Create a new user to run the app
5
+ RUN useradd -m -u 1000 user
6
+ USER user
7
+
8
+ # Set environment variables
9
+ ENV PATH="/home/user/.local/bin:$PATH"
10
+
11
+ # Set the working directory
12
+ WORKDIR /app
13
+
14
+ # Copy the requirements and install dependencies
15
+ COPY --chown=user ./requirements.txt requirements.txt
16
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
17
+
18
+ # Copy the rest of the application
19
+ COPY --chown=user . /app
20
+
21
+ # Expose port 7860 for the application
22
+ EXPOSE 7860
23
+
24
+ # Command to run the FastAPI app using uvicorn
25
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
app/core/__init__.py ADDED
File without changes
app/core/config.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/core/config.py
2
+ import os
3
+ from dotenv import load_dotenv
4
+
5
+ load_dotenv()
6
+
7
+ # === MongoDB Cloud Only ===
8
+ CONNECTION_STRING = os.getenv("CONNECTION_STRING")
9
+ DB_NAME = os.getenv("DB_NAME")
10
+ MONGO_COLLECTION = os.getenv("MONGO_COLLECTION")
11
+ AVATAR_COLLECTION = os.getenv("AVATAR_COLLECTION")
12
+
13
+ if not CONNECTION_STRING:
14
+ raise ValueError("❌ Missing CONNECTION_STRING in .env")
15
+
16
+ if not DB_NAME:
17
+ raise ValueError("❌ Missing DB_NAME in .env")
18
+
19
+ if not MONGO_COLLECTION:
20
+ raise ValueError("❌ Missing MONGO_COLLECTION in .env")
21
+
22
+ if not AVATAR_COLLECTION:
23
+ raise ValueError("❌ Missing AVATAR_COLLECTION in .env")
24
+
25
+ # === JWT Settings ===
26
+ SECRET_KEY = os.getenv("SECRET_KEY")
27
+ ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES"))
28
+ REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS"))
29
+
30
+ if not SECRET_KEY:
31
+ raise ValueError("❌ Missing SECRET_KEY in .env")
32
+
app/core/db.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from pymongo import MongoClient
2
+ from app.core.config import CONNECTION_STRING, DB_NAME, MONGO_COLLECTION, AVATAR_COLLECTION
3
+ import gridfs
4
+
5
+ client = MongoClient(CONNECTION_STRING)
6
+ db = client[DB_NAME]
7
+ users_collection = db[MONGO_COLLECTION]
8
+ fs = gridfs.GridFS(db, collection=AVATAR_COLLECTION)
app/main.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from app.routes.auth import router as auth_router
3
+
4
+ app = FastAPI(
5
+ title="User Authentication API",
6
+ description="Handles user signup, login, profile update, and avatar management.",
7
+ version="1.0.0"
8
+ )
9
+
10
+ app.include_router(auth_router)
11
+
12
+ @app.get("/")
13
+ async def root():
14
+ return {"message": "Welcome to the User Auth API"}
app/routes/auth.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/routes/auth.py
2
+ import logging
3
+ from fastapi import APIRouter, HTTPException, Depends, Form, UploadFile, File
4
+ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
5
+ from fastapi.responses import StreamingResponse
6
+ from jose import jwt, JWTError
7
+ from bson import ObjectId
8
+ from typing import Optional
9
+
10
+ from app.schemas.models import User, UserUpdate, Token, LoginResponse, UserResponse
11
+ from app.services.auth_service import (
12
+ hash_password, authenticate, fetch_user,
13
+ create_access_token, create_refresh_token,
14
+ save_avatar, update_user_profile
15
+ )
16
+ from app.core.config import SECRET_KEY
17
+ from app.core.db import users_collection, fs
18
+
19
+ router = APIRouter(prefix="/auth", tags=["auth"])
20
+
21
+ oauth2 = OAuth2PasswordBearer(tokenUrl="/auth/login")
22
+
23
+ logger = logging.getLogger("uvicorn")
24
+
25
+
26
+ def get_current_user(token: str = Depends(oauth2)):
27
+ logger.info("Decoding JWT token for /me or update profile")
28
+ try:
29
+ payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
30
+ email = payload.get("sub")
31
+ logger.info("Token decoded successfully for email: %s", email)
32
+
33
+ user = fetch_user(email)
34
+ if not user:
35
+ logger.warning("User not found for token email: %s", email)
36
+ raise HTTPException(status_code=401, detail="User not found")
37
+
38
+ return user
39
+
40
+ except JWTError as exc:
41
+ logger.error("JWT Error: %s", exc)
42
+ raise HTTPException(status_code=401, detail="Invalid token")
43
+
44
+
45
+ # --------------------------
46
+ # SIGNUP
47
+ # --------------------------
48
+
49
+ @router.post("/signup", response_model=Token)
50
+ async def signup(
51
+ name: str = Form(...),
52
+ email: str = Form(...),
53
+ password: str = Form(...),
54
+ role: Optional[str] = Form("user"),
55
+ avatar: Optional[UploadFile] = File(None)
56
+ ):
57
+ logger.info("Signup attempt for email: %s", email)
58
+
59
+ if fetch_user(email):
60
+ logger.warning("Signup failed — email already registered: %s", email)
61
+ raise HTTPException(status_code=400, detail="Email already registered")
62
+
63
+ _ = User(name=name, email=email, password=password)
64
+
65
+ data = {
66
+ "name": name,
67
+ "email": email,
68
+ "hashed_password": hash_password(password),
69
+ "role": role
70
+ }
71
+
72
+ if avatar:
73
+ logger.info("User uploaded avatar during signup: %s", avatar.filename)
74
+ data["avatar"] = save_avatar(avatar)
75
+
76
+ users_collection.insert_one(data)
77
+ logger.info("User created successfully: %s", email)
78
+
79
+ return {
80
+ "access_token": create_access_token(email),
81
+ "refresh_token": create_refresh_token(email),
82
+ "token_type": "bearer"
83
+ }
84
+
85
+
86
+ # --------------------------
87
+ # LOGIN
88
+ # --------------------------
89
+
90
+ @router.post("/login", response_model=LoginResponse)
91
+ async def login(form: OAuth2PasswordRequestForm = Depends()):
92
+ logger.info("Login attempt for email: %s", form.username)
93
+
94
+ user = authenticate(form.username, form.password)
95
+ if not user:
96
+ logger.warning("Login failed for email: %s", form.username)
97
+ raise HTTPException(status_code=401, detail="Incorrect email or password")
98
+
99
+ logger.info("Login successful for email: %s", user['email'])
100
+
101
+ avatar_url = f"/auth/avatar/{user['avatar']}" if "avatar" in user else None
102
+
103
+ return {
104
+ "access_token": create_access_token(user["email"]),
105
+ "refresh_token": create_refresh_token(user["email"]),
106
+ "token_type": "bearer",
107
+ "name": user["name"],
108
+ "avatar": avatar_url
109
+ }
110
+
111
+
112
+ # --------------------------
113
+ # GET AVATAR
114
+ # --------------------------
115
+
116
+ @router.get("/avatar/{file_id}")
117
+ async def avatar(file_id: str):
118
+ logger.info("Avatar fetch requested for file_id: %s", file_id)
119
+ try:
120
+ file = fs.get(ObjectId(file_id))
121
+ return StreamingResponse(file, media_type=file.content_type)
122
+ except Exception as exc:
123
+ logger.warning("Avatar not found for id %s: %s", file_id, exc)
124
+ raise HTTPException(status_code=404, detail="Avatar not found")
125
+
126
+
127
+ # --------------------------
128
+ # GET /me
129
+ # --------------------------
130
+
131
+ @router.get("/me", response_model=UserResponse)
132
+ def get_me(current_user: dict = Depends(get_current_user)):
133
+ logger.info("Fetching /me for user: %s", current_user.get("email"))
134
+
135
+ avatar_url = f"/auth/avatar/{current_user['avatar']}" if current_user.get("avatar") else None
136
+ return {
137
+ "name": current_user.get("name"),
138
+ "email": current_user.get("email"),
139
+ "avatar": avatar_url,
140
+ "role": current_user.get("role", "user")
141
+ }
142
+
143
+
144
+ # --------------------------
145
+ # UPDATE /me
146
+ # --------------------------
147
+
148
+ @router.put("/me", response_model=UserResponse)
149
+ def update_me(
150
+ name: Optional[str] = Form(None),
151
+ email: Optional[str] = Form(None),
152
+ password: Optional[str] = Form(None),
153
+ avatar: Optional[UploadFile] = File(None),
154
+ current_user: dict = Depends(get_current_user)
155
+ ):
156
+ logger.info("Profile update requested for: %s", current_user.get("email"))
157
+
158
+ if email and email != current_user.get("email"):
159
+ existing = fetch_user(email)
160
+ if existing:
161
+ logger.warning("Email update failed — already in use: %s", email)
162
+ raise HTTPException(status_code=400, detail="Email already in use")
163
+
164
+ updated = update_user_profile(
165
+ current_email=current_user.get("email"),
166
+ name=name,
167
+ new_email=email,
168
+ password=password,
169
+ avatar=avatar
170
+ )
171
+
172
+ logger.info("Profile updated successfully for: %s", updated.get("email"))
173
+
174
+ avatar_url = f"/auth/avatar/{updated['avatar']}" if updated.get("avatar") else None
175
+ return {
176
+ "name": updated.get("name"),
177
+ "email": updated.get("email"),
178
+ "avatar": avatar_url,
179
+ "role": updated.get("role", "user")
180
+ }
app/schemas/models.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/schemas/models.py
2
+ from pydantic import BaseModel, EmailStr, Field, validator
3
+ from typing import Optional
4
+
5
+ class User(BaseModel):
6
+ name: str = Field(..., min_length=3, max_length=50)
7
+ email: EmailStr
8
+ password: str
9
+
10
+ @validator("password")
11
+ def validate_password(cls, value):
12
+ if len(value) < 8:
13
+ raise ValueError("Password must be at least 8 characters long.")
14
+ if not any(char.isdigit() for char in value):
15
+ raise ValueError("Password must include at least one number.")
16
+ if not any(char.isupper() for char in value):
17
+ raise ValueError("Password must include at least one uppercase letter.")
18
+ if not any(char.islower() for char in value):
19
+ raise ValueError("Password must include at least one lowercase letter.")
20
+ if not any(char in "!@#$%^&*()-_+=<>?/" for char in value):
21
+ raise ValueError("Password must include at least one special character.")
22
+ return value
23
+
24
+ class UserUpdate(BaseModel):
25
+ name: Optional[str] = Field(None, min_length=3, max_length=50)
26
+ email: Optional[EmailStr]
27
+ password: Optional[str]
28
+
29
+ class Token(BaseModel):
30
+ access_token: str
31
+ refresh_token: str
32
+ token_type: str
33
+
34
+ class LoginResponse(Token):
35
+ name: str
36
+ avatar: Optional[str] = None
37
+
38
+ class TokenData(BaseModel):
39
+ email: Optional[str] = None
40
+
41
+ class UserResponse(BaseModel):
42
+ """
43
+ Response model for GET /auth/me and PUT /auth/me
44
+ """
45
+ name: str
46
+ email: EmailStr
47
+ avatar: Optional[str] = None
48
+ role: Optional[str] = "user"
app/services/auth_service.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/services/auth_service.py
2
+ from datetime import datetime, timedelta
3
+ from jose import jwt
4
+ from passlib.context import CryptContext
5
+ from fastapi import HTTPException, UploadFile
6
+ from bson import ObjectId
7
+ from app.core.config import SECRET_KEY, ACCESS_TOKEN_EXPIRE_MINUTES, REFRESH_TOKEN_EXPIRE_DAYS
8
+ from app.core.db import users_collection, fs
9
+ import logging
10
+
11
+ # Use a module-specific logger so logs are easy to filter
12
+ logger = logging.getLogger("app.services.auth_service")
13
+
14
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
15
+
16
+
17
+ def hash_password(password: str) -> str:
18
+ """Hash a plaintext password (do NOT log the password)."""
19
+ logger.debug("hash_password called")
20
+ hashed = pwd_context.hash(password)
21
+ logger.debug("Password hashed successfully")
22
+ return hashed
23
+
24
+
25
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
26
+ """Verify plaintext password against stored hash."""
27
+ logger.debug("verify_password called")
28
+ try:
29
+ ok = pwd_context.verify(plain_password, hashed_password)
30
+ logger.debug("Password verification result: %s", ok)
31
+ return ok
32
+ except Exception as exc:
33
+ # Something unexpected happened during verify
34
+ logger.exception("Error verifying password: %s", exc)
35
+ return False
36
+
37
+
38
+ def create_token(data: dict, expires: timedelta):
39
+ """Create a JWT token with expiry. `data` should contain 'sub'."""
40
+ sub = data.get("sub")
41
+ logger.debug("create_token called for subject=%s expires=%s", sub, expires)
42
+ payload = data.copy()
43
+ payload["exp"] = datetime.utcnow() + expires
44
+ try:
45
+ token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")
46
+ logger.info("JWT created for subject=%s", sub)
47
+ return token
48
+ except Exception as exc:
49
+ logger.exception("Failed to create JWT for subject=%s: %s", sub, exc)
50
+ raise HTTPException(status_code=500, detail="Token generation failed") from exc
51
+
52
+
53
+ def create_access_token(email: str):
54
+ logger.debug("create_access_token called for email=%s", email)
55
+ token = create_token({"sub": email}, timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
56
+ logger.info("Access token generated for %s", email)
57
+ return token
58
+
59
+
60
+ def create_refresh_token(email: str):
61
+ logger.debug("create_refresh_token called for email=%s", email)
62
+ token = create_token({"sub": email}, timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS))
63
+ logger.info("Refresh token generated for %s", email)
64
+ return token
65
+
66
+
67
+ def _delete_avatar_file_if_exists(file_id: str) -> None:
68
+ """Delete an avatar file from GridFS if it exists. Non-fatal on failure."""
69
+ if not file_id:
70
+ logger.debug("_delete_avatar_file_if_exists called with empty file_id; skipping")
71
+ return
72
+ try:
73
+ oid = ObjectId(file_id)
74
+ except Exception:
75
+ logger.warning("Invalid avatar file_id, skipping delete: %s", file_id)
76
+ return
77
+
78
+ try:
79
+ fs.delete(oid)
80
+ logger.info("Deleted old avatar from GridFS: %s", file_id)
81
+ except Exception as exc:
82
+ # Non-fatal: log and continue. Common causes: already deleted or invalid id.
83
+ logger.warning("Failed to delete avatar %s from GridFS: %s", file_id, exc)
84
+
85
+
86
+ def save_avatar(file: UploadFile) -> str:
87
+ """Save a new avatar to GridFS and return its file id (string)."""
88
+ logger.debug("save_avatar called filename=%s content_type=%s", getattr(file, "filename", None), getattr(file, "content_type", None))
89
+ allowed = ["image/jpeg", "image/png", "image/gif"]
90
+ if file.content_type not in allowed:
91
+ logger.warning("Rejected avatar upload due to invalid content type: %s", file.content_type)
92
+ raise HTTPException(status_code=400, detail="Invalid image type.")
93
+
94
+ # read file content (UploadFile.file is a file-like object)
95
+ try:
96
+ contents = file.file.read()
97
+ except Exception as exc:
98
+ logger.exception("Failed to read uploaded avatar file: %s", exc)
99
+ raise HTTPException(status_code=400, detail="Failed to read uploaded file") from exc
100
+
101
+ try:
102
+ file_id = fs.put(contents, filename=file.filename, contentType=file.content_type)
103
+ logger.info("Saved avatar to GridFS with id: %s (size=%d bytes)", file_id, len(contents))
104
+ return str(file_id)
105
+ except Exception as exc:
106
+ logger.exception("Failed to save avatar to GridFS: %s", exc)
107
+ raise HTTPException(status_code=500, detail="Failed to store avatar") from exc
108
+
109
+
110
+ def fetch_user(email: str):
111
+ """Return the raw user document (or None) by email."""
112
+ logger.debug("fetch_user called for email=%s", email)
113
+ if not email:
114
+ logger.debug("fetch_user called with empty email")
115
+ return None
116
+ try:
117
+ user = users_collection.find_one({"email": email})
118
+ logger.debug("fetch_user found: %s", bool(user))
119
+ return user
120
+ except Exception as exc:
121
+ logger.exception("Error fetching user %s: %s", email, exc)
122
+ raise HTTPException(status_code=500, detail="Failed to fetch user") from exc
123
+
124
+
125
+ def authenticate(email: str, password: str):
126
+ """Authenticate user by email and password. Returns user dict or None."""
127
+ logger.debug("authenticate called for email=%s", email)
128
+ user = fetch_user(email)
129
+ if not user:
130
+ logger.warning("Authenticate failed: user not found for email=%s", email)
131
+ return None
132
+
133
+ if not verify_password(password, user.get("hashed_password", "")):
134
+ logger.warning("Authenticate failed: invalid credentials for email=%s", email)
135
+ return None
136
+
137
+ logger.info("User authenticated successfully: %s", email)
138
+ return user
139
+
140
+
141
+ def update_user_profile(current_email: str, name: str | None = None, new_email: str | None = None,
142
+ password: str | None = None, avatar: UploadFile | None = None):
143
+ """
144
+ Update profile fields for the user identified by current_email.
145
+ If avatar is provided, the old avatar (if any) will be deleted from GridFS.
146
+ Returns the updated user document (with hashed_password removed).
147
+ """
148
+ logger.info("update_user_profile called for current_email=%s", current_email)
149
+ if not current_email:
150
+ logger.error("update_user_profile missing current_email")
151
+ raise HTTPException(status_code=400, detail="Missing current user email")
152
+
153
+ # Fetch current user to get current avatar id (if any)
154
+ current_user = fetch_user(current_email)
155
+ if not current_user:
156
+ logger.warning("update_user_profile user not found: %s", current_email)
157
+ raise HTTPException(status_code=404, detail="User not found")
158
+
159
+ update_data = {}
160
+ if name:
161
+ update_data["name"] = name
162
+ logger.debug("Will update name for %s", current_email)
163
+ if new_email:
164
+ update_data["email"] = new_email
165
+ logger.debug("Will update email for %s -> %s", current_email, new_email)
166
+ if password:
167
+ update_data["hashed_password"] = hash_password(password)
168
+ logger.debug("Password updated for %s (hashed, not logged)", current_email)
169
+
170
+ if avatar:
171
+ logger.debug("Avatar upload detected for %s; processing replacement", current_email)
172
+ # Delete the old avatar first (if exists)
173
+ old_avatar_id = current_user.get("avatar")
174
+ if old_avatar_id:
175
+ logger.debug("Deleting old avatar id=%s for user=%s", old_avatar_id, current_email)
176
+ _delete_avatar_file_if_exists(old_avatar_id)
177
+
178
+ # Save the new avatar and set the new id
179
+ new_file_id = save_avatar(avatar)
180
+ update_data["avatar"] = new_file_id
181
+ logger.debug("New avatar saved with id=%s for user=%s", new_file_id, current_email)
182
+
183
+ if not update_data:
184
+ logger.info("No update parameters provided for %s", current_email)
185
+ raise HTTPException(status_code=400, detail="No update parameters provided")
186
+
187
+ try:
188
+ result = users_collection.update_one({"email": current_email}, {"$set": update_data})
189
+ except Exception as exc:
190
+ logger.exception("Failed to update user in DB for %s: %s", current_email, exc)
191
+ raise HTTPException(status_code=500, detail="Failed to update user") from exc
192
+
193
+ if result.matched_count == 0:
194
+ logger.warning("Update attempted but user not found during DB update for %s", current_email)
195
+ raise HTTPException(status_code=404, detail="User not found")
196
+
197
+ # fetch and return the updated document (prefer the new email if changed)
198
+ lookup_email = new_email if new_email else current_email
199
+ try:
200
+ updated_user = users_collection.find_one({"email": lookup_email})
201
+ except Exception as exc:
202
+ logger.exception("Failed to fetch updated user %s: %s", lookup_email, exc)
203
+ raise HTTPException(status_code=500, detail="Failed to fetch updated user") from exc
204
+
205
+ if not updated_user:
206
+ logger.error("Updated user document not found after update for %s", lookup_email)
207
+ raise HTTPException(status_code=500, detail="Updated user not found")
208
+
209
+ # Remove hashed_password before returning user object to any caller
210
+ if "hashed_password" in updated_user:
211
+ updated_user.pop("hashed_password", None)
212
+ logger.debug("Removed hashed_password from returned user object for %s", lookup_email)
213
+
214
+ logger.info("User profile updated successfully for %s", lookup_email)
215
+ return updated_user
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ python-dotenv
4
+ pymongo
5
+ passlib
6
+ python-jose
7
+ pydantic[email]
8
+ python-multipart