Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 88 additions & 10 deletions backend/app/api/auth.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from passlib.context import CryptContext
Expand All @@ -17,39 +17,76 @@
# In-memory tracking of failed attempts by email to enforce captcha
failed_login_attempts = {}

SECRET_KEY = os.environ.get("SECRET_KEY")
SECRET_KEY = os.environ.get("SECRET_KEY", "fallback_secret_key_for_dev")
if not SECRET_KEY:
raise RuntimeError(
"SECRET_KEY environment variable is not set. "
"Add SECRET_KEY=<your-secret> to your .env file before starting the server."
)
ALGORITHM = "HS256"
TOKEN_DAYS = 7
ACCESS_TOKEN_MINUTES = 15
REFRESH_TOKEN_DAYS = 7

pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
router = APIRouter()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)


# -- Helper: build JWT --
def create_token(email: str) -> str:
def create_access_token(email: str) -> str:
return jwt.encode(
{
"sub": email,
"exp": datetime.now(timezone.utc) + timedelta(days=TOKEN_DAYS)
"type": "access",
"exp": datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_MINUTES)
},
SECRET_KEY,
algorithm=ALGORITHM
)

def create_refresh_token(email: str) -> str:
return jwt.encode(
{
"sub": email,
"type": "refresh",
"exp": datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_DAYS)
},
SECRET_KEY,
algorithm=ALGORITHM
)

def set_auth_cookies(response: Response, access_token: str, refresh_token: str):
response.set_cookie(
key="access_token",
value=f"Bearer {access_token}",
httponly=True,
secure=True,
samesite="lax",
max_age=ACCESS_TOKEN_MINUTES * 60
)
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=True,
samesite="lax",
max_age=REFRESH_TOKEN_DAYS * 24 * 60 * 60
)

# -- Helper: get current user from token --
def get_current_user(
token: str = Depends(oauth2_scheme),
request: Request,
db: Session = Depends(get_db)
) -> models.User:
token = request.cookies.get("access_token")
if not token or not token.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Not authenticated")
token = token.split(" ")[1]

try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
if payload.get("type") != "access":
raise HTTPException(401, "Invalid token type.")
email: str = payload.get("sub")
if not email:
raise HTTPException(401, "Invalid token.")
Expand All @@ -65,7 +102,7 @@ def get_current_user(
# -- Register --
@router.post("/register", response_model=Token, status_code=201)
@limiter.limit("5/minute")
def register(request: Request, payload: UserRegister, db: Session = Depends(get_db)):
def register(request: Request, response: Response, payload: UserRegister, db: Session = Depends(get_db)):
if db.query(models.User).filter(models.User.email == payload.email).first():
raise HTTPException(409, "Email already registered.")
if db.query(models.User).filter(models.User.username == payload.username).first():
Expand All @@ -81,8 +118,13 @@ def register(request: Request, payload: UserRegister, db: Session = Depends(get_
db.commit()
db.refresh(user)

access_token = create_access_token(user.email)
refresh_token = create_refresh_token(user.email)

set_auth_cookies(response, access_token, refresh_token)

return Token(
access_token = create_token(user.email),
access_token = access_token,
token_type = "bearer",
user = UserOut.model_validate(user)
)
Expand Down Expand Up @@ -144,11 +186,47 @@ def login(request: Request, payload: UserLogin, db: Session = Depends(get_db)):
failed_login_attempts.pop(payload.email, None)

return Token(
access_token = create_token(user.email),
access_token = access_token,
token_type = "bearer",
user = UserOut.model_validate(user)
)

@router.post("/refresh")
def refresh_token(request: Request, response: Response, db: Session = Depends(get_db)):
token = request.cookies.get("refresh_token")
if not token:
raise HTTPException(status_code=401, detail="Refresh token missing")
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
if payload.get("type") != "refresh":
raise HTTPException(401, "Invalid token type.")
email: str = payload.get("sub")
if not email:
raise HTTPException(401, "Invalid token.")
except JWTError:
raise HTTPException(401, "Invalid or expired refresh token.")

user = db.query(models.User).filter(models.User.email == email).first()
if not user or not user.is_active:
raise HTTPException(401, "User not found or inactive.")

access_token = create_access_token(user.email)
# Issue a new access token while keeping the same refresh token
response.set_cookie(
key="access_token",
value=f"Bearer {access_token}",
httponly=True,
secure=True,
samesite="lax",
max_age=ACCESS_TOKEN_MINUTES * 60
)
return {"message": "Token refreshed successfully"}

@router.post("/logout")
def logout(response: Response):
response.delete_cookie("access_token")
response.delete_cookie("refresh_token")
return {"message": "Successfully logged out"}

@router.get("/me", response_model=UserOut)
def get_me(current_user: models.User = Depends(get_current_user)):
Expand Down
34 changes: 34 additions & 0 deletions backend/app/api/products.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,37 @@ def get_product(product_id: int, db: Session = Depends(get_db)):
if not product:
raise HTTPException(status_code=404, detail="Product not found")
return product

@router.post("/checkout")
def checkout_cart(request: schemas.CheckoutRequest, db: Session = Depends(get_db)):
# Sort items to prevent deadlocks when locking multiple rows
items = sorted(request.items, key=lambda x: x.name)

try:
# Atomic block
for item in items:
product = db.query(models.Product).filter(
models.Product.name == item.name
).with_for_update().first()

if not product:
db.rollback()
raise HTTPException(status_code=400, detail=f"Product '{item.name}' not found")

if product.stock < item.quantity:
db.rollback()
raise HTTPException(
status_code=400,
detail=f"Insufficient stock for '{product.name}'. Only {product.stock} remaining."
)

product.stock -= item.quantity

db.commit()
return {"status": "success", "message": "Order placed successfully"}

except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Internal server error during checkout: {str(e)}")
13 changes: 12 additions & 1 deletion backend/app/api/recommendation.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from typing import List
import hashlib
import os
from .. import models, schemas
from ..database import get_db
from ..vector_search.faiss_index import get_similar_product_ids
from ..rules.engine import filter_by_rules
from ..limiter import limiter

router = APIRouter()
SALT = os.environ.get("SECRET_KEY", "fallback_secret_key_for_dev").encode('utf-8')

@router.post("/recommend", response_model=List[schemas.Product])
@limiter.limit("20/minute")
Expand Down Expand Up @@ -41,7 +44,15 @@ def track_feedback(request: Request, interaction: schemas.InteractionCreate, db:
product = db.query(models.Product).filter(models.Product.id == interaction.product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
new_interaction = models.Interaction(**interaction.model_dump())

# Anonymize PII (like raw IP addresses) via salted hashing before database insertion
hashed_user_id = hashlib.sha256(interaction.user_id.encode('utf-8') + SALT).hexdigest()

new_interaction = models.Interaction(
user_id=hashed_user_id,
product_id=interaction.product_id,
interaction_type=interaction.interaction_type
)
db.add(new_interaction)
db.commit()
return {"status": "success"}
1 change: 1 addition & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class Product(Base):
subcategory = Column(String, index=True, nullable=True) # e.g. top, bottom, shoes
color = Column(String, nullable=True)
style = Column(String, nullable=True)
stock = Column(Integer, default=10, nullable=False)

class Interaction(Base):
__tablename__ = "interactions"
Expand Down
12 changes: 12 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ class ProductBase(BaseModel):
subcategory: Optional[str] = None
color: Optional[str] = None
style: Optional[str] = None
stock: int = 10

class CheckoutItem(BaseModel):
name: str
quantity: int

class CheckoutRequest(BaseModel):
items: list[CheckoutItem]

class ProductCreate(ProductBase):
id: int
Expand Down Expand Up @@ -56,10 +64,14 @@ class UserRegister(BaseModel):
@field_validator("password")
@classmethod
def password_complexity(cls, v: str) -> str:
if not re.search(r"[a-z]", v):
raise ValueError("Password must contain at least one lowercase letter.")
if not re.search(r"[A-Z]", v):
raise ValueError("Password must contain at least one uppercase letter.")
if not re.search(r"\d", v):
raise ValueError("Password must contain at least one digit.")
if not re.search(r"[!@#$%^&*(),.?\":{}|<>]", v):
raise ValueError("Password must contain at least one special character.")
return v


Expand Down
5 changes: 1 addition & 4 deletions login.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,7 @@ document.addEventListener('DOMContentLoaded', function () {
);
}

localStorage.setItem(
'token',
data.access_token
);
// Token is now set as an HttpOnly cookie automatically

localStorage.setItem(
'loggedInUser',
Expand Down
13 changes: 12 additions & 1 deletion shop.html
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,18 @@ <h1>Shop the Latest Trends</h1>
return;
}
box.style.display = "block";
listContainer.innerHTML = searches.map(s => `<span style="background: #e2e8f0; padding: 2px 6px; border-radius: 4px; margin-right: 5px; cursor: pointer;" onclick="document.getElementById('searchInput').value='${s}'; document.getElementById('searchBtn').click();">${s}</span>`).join("");
listContainer.innerHTML = "";

searches.forEach(s => {
const span = document.createElement("span");
span.style.cssText = "background: #e2e8f0; padding: 2px 6px; border-radius: 4px; margin-right: 5px; cursor: pointer;";
span.textContent = s;
span.addEventListener("click", () => {
document.getElementById("searchInput").value = s;
document.getElementById("searchBtn").click();
});
listContainer.appendChild(span);
});
};

searchBtn.addEventListener("click", () => {
Expand Down
Loading