Momal commited on
Commit
bcaf4d8
·
1 Parent(s): dcd02ea

feat: sync latest backend with auth, usage tracking, and admin bypass

Browse files
app/api/auth.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Authentication dependencies for FastAPI routes."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+ from fastapi import Depends, HTTPException, status
7
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
8
+
9
+ from jose import JWTError, jwt
10
+
11
+ from app.core.config import settings
12
+
13
+ security = HTTPBearer(auto_error=False)
14
+
15
+
16
+ @dataclass
17
+ class AuthenticatedUser:
18
+ """Authenticated user extracted from JWT token."""
19
+ google_id: str
20
+ email: str
21
+ name: str
22
+ picture: Optional[str] = None
23
+
24
+
25
+ def _decode_token(token: str) -> dict:
26
+ """Decode and validate a JWT token."""
27
+ try:
28
+ payload = jwt.decode(
29
+ token,
30
+ settings.nextauth_secret,
31
+ algorithms=["HS256"],
32
+ )
33
+ return payload
34
+ except JWTError:
35
+ raise HTTPException(
36
+ status_code=status.HTTP_401_UNAUTHORIZED,
37
+ detail="Invalid or expired token",
38
+ headers={"WWW-Authenticate": "Bearer"},
39
+ )
40
+
41
+
42
+ async def get_current_user(
43
+ credentials: HTTPAuthorizationCredentials = Depends(security),
44
+ ) -> AuthenticatedUser:
45
+ """Require a valid JWT and return the authenticated user."""
46
+ if credentials is None:
47
+ raise HTTPException(
48
+ status_code=status.HTTP_401_UNAUTHORIZED,
49
+ detail="Authentication required",
50
+ headers={"WWW-Authenticate": "Bearer"},
51
+ )
52
+
53
+ payload = _decode_token(credentials.credentials)
54
+ return AuthenticatedUser(
55
+ google_id=payload.get("sub", ""),
56
+ email=payload.get("email", ""),
57
+ name=payload.get("name", ""),
58
+ picture=payload.get("picture"),
59
+ )
60
+
61
+
62
+ async def get_optional_user(
63
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
64
+ ) -> Optional[AuthenticatedUser]:
65
+ """Return authenticated user if token is present, otherwise None."""
66
+ if credentials is None:
67
+ return None
68
+
69
+ try:
70
+ payload = _decode_token(credentials.credentials)
71
+ return AuthenticatedUser(
72
+ google_id=payload.get("sub", ""),
73
+ email=payload.get("email", ""),
74
+ name=payload.get("name", ""),
75
+ picture=payload.get("picture"),
76
+ )
77
+ except HTTPException:
78
+ return None
app/api/routes/auth.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Authentication routes for user profile management."""
2
+
3
+ import json
4
+ import logging
5
+ from datetime import datetime
6
+
7
+ from fastapi import APIRouter, Depends
8
+ from pydantic import BaseModel
9
+
10
+ from app.api.auth import AuthenticatedUser, get_current_user
11
+ from app.core.config import settings
12
+ from app.core.redis import get_redis
13
+ from app.models.user import UserProfile
14
+
15
+ logger = logging.getLogger(__name__)
16
+ router = APIRouter()
17
+
18
+ REDIS_USER_PREFIX = "user:"
19
+
20
+
21
+ class UpdateProfileRequest(BaseModel):
22
+ """Request body for profile updates."""
23
+ name: str | None = None
24
+ preferred_intensity: str | None = None
25
+
26
+
27
+ @router.get("/user/me")
28
+ async def get_me(user: AuthenticatedUser = Depends(get_current_user)):
29
+ """Get current user profile. Auto-creates on first login."""
30
+ redis = await get_redis()
31
+ key = f"{REDIS_USER_PREFIX}{user.google_id}"
32
+
33
+ existing = await redis.get(key)
34
+ if existing:
35
+ profile = UserProfile.model_validate_json(existing)
36
+ # Update picture/email in case they changed on Google side
37
+ profile.picture = user.picture or profile.picture
38
+ profile.email = user.email or profile.email
39
+ profile.updated_at = datetime.utcnow()
40
+ await redis.set(
41
+ key,
42
+ profile.model_dump_json(),
43
+ ex=settings.user_profile_ttl_seconds,
44
+ )
45
+ return profile.model_dump()
46
+
47
+ # First login — create new profile
48
+ profile = UserProfile(
49
+ google_id=user.google_id,
50
+ email=user.email,
51
+ name=user.name,
52
+ picture=user.picture,
53
+ )
54
+ await redis.set(
55
+ key,
56
+ profile.model_dump_json(),
57
+ ex=settings.user_profile_ttl_seconds,
58
+ )
59
+ logger.info(f"Created new user profile for {user.email}")
60
+ return profile.model_dump()
61
+
62
+
63
+ @router.put("/user/profile")
64
+ async def update_profile(
65
+ body: UpdateProfileRequest,
66
+ user: AuthenticatedUser = Depends(get_current_user),
67
+ ):
68
+ """Update user profile fields."""
69
+ redis = await get_redis()
70
+ key = f"{REDIS_USER_PREFIX}{user.google_id}"
71
+
72
+ existing = await redis.get(key)
73
+ if existing:
74
+ profile = UserProfile.model_validate_json(existing)
75
+ else:
76
+ # Shouldn't happen but handle gracefully
77
+ profile = UserProfile(
78
+ google_id=user.google_id,
79
+ email=user.email,
80
+ name=user.name,
81
+ picture=user.picture,
82
+ )
83
+
84
+ if body.name is not None:
85
+ profile.name = body.name
86
+ if body.preferred_intensity is not None:
87
+ if body.preferred_intensity in ("conservative", "moderate", "aggressive"):
88
+ profile.preferred_intensity = body.preferred_intensity
89
+
90
+ profile.updated_at = datetime.utcnow()
91
+
92
+ await redis.set(
93
+ key,
94
+ profile.model_dump_json(),
95
+ ex=settings.user_profile_ttl_seconds,
96
+ )
97
+
98
+ return profile.model_dump()
app/api/routes/usage.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Usage tracking for freemium gating."""
2
+
3
+ from datetime import datetime
4
+
5
+ from fastapi import APIRouter, Depends
6
+
7
+ from app.api.auth import AuthenticatedUser, get_current_user
8
+ from app.core.config import settings
9
+ from app.core.redis import get_redis
10
+
11
+ router = APIRouter()
12
+
13
+ USAGE_PREFIX = "usage:"
14
+
15
+
16
+ def _usage_key(google_id: str) -> str:
17
+ """Return Redis key for current month's usage."""
18
+ month = datetime.utcnow().strftime("%Y-%m")
19
+ return f"{USAGE_PREFIX}{google_id}:{month}"
20
+
21
+
22
+ def _resets_at() -> str:
23
+ """Return ISO date of first day of next month."""
24
+ now = datetime.utcnow()
25
+ if now.month == 12:
26
+ first_next = datetime(now.year + 1, 1, 1)
27
+ else:
28
+ first_next = datetime(now.year, now.month + 1, 1)
29
+ return first_next.strftime("%Y-%m-%d")
30
+
31
+
32
+ def _is_admin(user: AuthenticatedUser) -> bool:
33
+ """Check if the user is an admin (unlimited usage)."""
34
+ return user.email.lower() in settings.admin_email_set
35
+
36
+
37
+ @router.get("/usage")
38
+ async def get_usage(user: AuthenticatedUser = Depends(get_current_user)):
39
+ """Get current month's usage for authenticated user."""
40
+ # Admins get unlimited usage
41
+ if _is_admin(user):
42
+ return {"used": 0, "limit": 999999, "resets_at": _resets_at(), "is_admin": True}
43
+
44
+ redis = await get_redis()
45
+ key = _usage_key(user.google_id)
46
+ raw = await redis.get(key)
47
+ used = int(raw) if raw else 0
48
+ return {
49
+ "used": used,
50
+ "limit": settings.free_tier_limit,
51
+ "resets_at": _resets_at(),
52
+ }
53
+
54
+
55
+ @router.post("/usage/increment")
56
+ async def increment_usage(user: AuthenticatedUser = Depends(get_current_user)):
57
+ """Increment usage counter. Called when a customization completes."""
58
+ # Admins skip counting entirely
59
+ if _is_admin(user):
60
+ return {"used": 0, "limit": 999999, "resets_at": _resets_at(), "is_admin": True}
61
+
62
+ redis = await get_redis()
63
+ key = _usage_key(user.google_id)
64
+ new_count = await redis.incr(key)
65
+ # Set TTL only on first increment (when count is 1)
66
+ if new_count == 1:
67
+ await redis.expire(key, settings.usage_ttl_seconds)
68
+ return {
69
+ "used": new_count,
70
+ "limit": settings.free_tier_limit,
71
+ "resets_at": _resets_at(),
72
+ }
app/core/config.py CHANGED
@@ -1,4 +1,3 @@
1
- from pydantic import field_validator
2
  from pydantic_settings import BaseSettings, SettingsConfigDict
3
 
4
 
@@ -22,10 +21,24 @@ class Settings(BaseSettings):
22
  # Session
23
  session_ttl_seconds: int = 7200
24
 
25
- @field_validator("redis_url")
26
- @classmethod
27
- def strip_redis_url(cls, v: str) -> str:
28
- return v.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
 
31
  settings = Settings()
 
 
1
  from pydantic_settings import BaseSettings, SettingsConfigDict
2
 
3
 
 
21
  # Session
22
  session_ttl_seconds: int = 7200
23
 
24
+ # Auth
25
+ nextauth_secret: str = ""
26
+ frontend_url: str = "http://localhost:3000"
27
+ user_profile_ttl_seconds: int = 30 * 24 * 60 * 60 # 30 days
28
+
29
+ # Usage limits
30
+ free_tier_limit: int = 3
31
+ usage_ttl_seconds: int = 35 * 24 * 60 * 60 # 35 days
32
+
33
+ # Admin emails (comma-separated) — unlimited usage, no gating
34
+ admin_emails: str = ""
35
+
36
+ @property
37
+ def admin_email_set(self) -> set[str]:
38
+ """Parse comma-separated admin emails into a set for fast lookup."""
39
+ if not self.admin_emails:
40
+ return set()
41
+ return {e.strip().lower() for e in self.admin_emails.split(",") if e.strip()}
42
 
43
 
44
  settings = Settings()
app/core/redis.py CHANGED
@@ -1,5 +1,4 @@
1
  from __future__ import annotations
2
- import ssl
3
  from contextlib import asynccontextmanager
4
  from typing import Optional
5
 
@@ -10,20 +9,11 @@ from app.core.config import settings
10
  _fastapi_client: Optional[redis.Redis] = None
11
 
12
 
13
- def _redis_kwargs() -> dict:
14
- """Extra kwargs for rediss:// (TLS) connections like Upstash."""
15
- if settings.redis_url.startswith("rediss://"):
16
- return {"ssl_cert_reqs": None}
17
- return {}
18
-
19
-
20
  async def get_redis() -> redis.Redis:
21
  """Get Redis client for FastAPI (reuses connection)."""
22
  global _fastapi_client
23
  if _fastapi_client is None:
24
- _fastapi_client = redis.from_url(
25
- settings.redis_url, decode_responses=True, **_redis_kwargs()
26
- )
27
  return _fastapi_client
28
 
29
 
@@ -38,9 +28,7 @@ async def close_redis():
38
  @asynccontextmanager
39
  async def get_redis_for_worker():
40
  """Get fresh Redis client for Celery workers (new connection per task)."""
41
- client = redis.from_url(
42
- settings.redis_url, decode_responses=True, **_redis_kwargs()
43
- )
44
  try:
45
  yield client
46
  finally:
 
1
  from __future__ import annotations
 
2
  from contextlib import asynccontextmanager
3
  from typing import Optional
4
 
 
9
  _fastapi_client: Optional[redis.Redis] = None
10
 
11
 
 
 
 
 
 
 
 
12
  async def get_redis() -> redis.Redis:
13
  """Get Redis client for FastAPI (reuses connection)."""
14
  global _fastapi_client
15
  if _fastapi_client is None:
16
+ _fastapi_client = redis.from_url(settings.redis_url, decode_responses=True)
 
 
17
  return _fastapi_client
18
 
19
 
 
28
  @asynccontextmanager
29
  async def get_redis_for_worker():
30
  """Get fresh Redis client for Celery workers (new connection per task)."""
31
+ client = redis.from_url(settings.redis_url, decode_responses=True)
 
 
32
  try:
33
  yield client
34
  finally:
app/llm/factory.py CHANGED
@@ -5,8 +5,6 @@ from typing import Dict, List, Tuple, Type
5
  from app.llm.base import LLMProvider
6
  from app.llm.openai_provider import OpenAIProvider
7
  from app.llm.google_provider import GoogleProvider
8
- from app.llm.zai_provider import ZAIProvider
9
- from app.llm.groq_provider import GroqProvider
10
  from app.llm.fallback_provider import FallbackLLMProvider
11
  from app.core.config import settings
12
 
@@ -17,15 +15,11 @@ class LLMFactory:
17
  _providers: Dict[str, Type[LLMProvider]] = {
18
  "openai": OpenAIProvider,
19
  "google": GoogleProvider,
20
- "zai": ZAIProvider,
21
- "groq": GroqProvider,
22
  }
23
 
24
  _default_models: Dict[str, Tuple[str, str]] = {
25
  "openai": ("gpt-4o-mini", "gpt-4o"),
26
  "google": ("gemini-3-flash-preview", "gemini-3-flash-preview"),
27
- "zai": ("glm-4.7", "glm-4.7"),
28
- "groq": ("llama-3.1-8b-instant", "llama-3.1-8b-instant"),
29
  }
30
 
31
  @classmethod
@@ -43,8 +37,6 @@ class LLMFactory:
43
  api_keys = {
44
  "openai": settings.openai_api_key,
45
  "google": settings.google_api_key,
46
- "zai": settings.zai_api_key,
47
- "groq": settings.groq_api_key,
48
  }
49
 
50
  # Get primary provider's model
@@ -89,7 +81,5 @@ class LLMFactory:
89
  keys = {
90
  "openai": settings.openai_api_key,
91
  "google": settings.google_api_key,
92
- "zai": settings.zai_api_key,
93
- "groq": settings.groq_api_key,
94
  }
95
  return keys.get(settings.llm_provider, "")
 
5
  from app.llm.base import LLMProvider
6
  from app.llm.openai_provider import OpenAIProvider
7
  from app.llm.google_provider import GoogleProvider
 
 
8
  from app.llm.fallback_provider import FallbackLLMProvider
9
  from app.core.config import settings
10
 
 
15
  _providers: Dict[str, Type[LLMProvider]] = {
16
  "openai": OpenAIProvider,
17
  "google": GoogleProvider,
 
 
18
  }
19
 
20
  _default_models: Dict[str, Tuple[str, str]] = {
21
  "openai": ("gpt-4o-mini", "gpt-4o"),
22
  "google": ("gemini-3-flash-preview", "gemini-3-flash-preview"),
 
 
23
  }
24
 
25
  @classmethod
 
37
  api_keys = {
38
  "openai": settings.openai_api_key,
39
  "google": settings.google_api_key,
 
 
40
  }
41
 
42
  # Get primary provider's model
 
81
  keys = {
82
  "openai": settings.openai_api_key,
83
  "google": settings.google_api_key,
 
 
84
  }
85
  return keys.get(settings.llm_provider, "")
app/llm/fallback_provider.py CHANGED
@@ -2,7 +2,6 @@ from __future__ import annotations
2
  import asyncio
3
  import logging
4
  import re
5
- import time
6
  from typing import Any, Dict, List, Optional
7
 
8
  from app.llm.base import LLMProvider
@@ -19,9 +18,9 @@ class FallbackLLMProvider(LLMProvider):
19
  """LLM provider with automatic fallback and retry with exponential backoff."""
20
 
21
  # Retry configuration
22
- MAX_RETRIES_PER_PROVIDER = 3
23
- INITIAL_BACKOFF_SECONDS = 5
24
- MAX_BACKOFF_SECONDS = 65
25
 
26
  def __init__(self, providers: List[LLMProvider]):
27
  if not providers:
@@ -74,12 +73,7 @@ class FallbackLLMProvider(LLMProvider):
74
 
75
  for attempt in range(self.MAX_RETRIES_PER_PROVIDER):
76
  try:
77
- # Track time to first token (TTFT)
78
- start_time = time.time()
79
- result = await call_func(*args, **kwargs)
80
- ttft = (time.time() - start_time) * 1000 # Convert to milliseconds
81
- logger.info(f"⏱️ {provider.__class__.__name__} TTFT: {ttft:.2f}ms ({ttft/1000:.3f}s)")
82
- return result
83
  except Exception as e:
84
  last_error = e
85
 
 
2
  import asyncio
3
  import logging
4
  import re
 
5
  from typing import Any, Dict, List, Optional
6
 
7
  from app.llm.base import LLMProvider
 
18
  """LLM provider with automatic fallback and retry with exponential backoff."""
19
 
20
  # Retry configuration
21
+ MAX_RETRIES_PER_PROVIDER = 2
22
+ INITIAL_BACKOFF_SECONDS = 2
23
+ MAX_BACKOFF_SECONDS = 30
24
 
25
  def __init__(self, providers: List[LLMProvider]):
26
  if not providers:
 
73
 
74
  for attempt in range(self.MAX_RETRIES_PER_PROVIDER):
75
  try:
76
+ return await call_func(*args, **kwargs)
 
 
 
 
 
77
  except Exception as e:
78
  last_error = e
79
 
app/main.py CHANGED
@@ -4,7 +4,8 @@ from fastapi import FastAPI, Request
4
  from fastapi.middleware.cors import CORSMiddleware
5
  from fastapi.responses import JSONResponse
6
  from app.core.redis import close_redis
7
- from app.api.routes import upload, analyze, progress, result, export, compare
 
8
 
9
  logger = logging.getLogger(__name__)
10
 
@@ -22,19 +23,17 @@ app = FastAPI(
22
  lifespan=lifespan,
23
  )
24
 
25
- import os
26
-
27
- allowed_origins = [
28
- "http://localhost:3000",
29
- ]
30
- # Add production Vercel URL if set
31
- vercel_url = os.environ.get("FRONTEND_URL")
32
- if vercel_url:
33
- allowed_origins.append(vercel_url)
34
 
35
  app.add_middleware(
36
  CORSMiddleware,
37
- allow_origins=allowed_origins,
38
  allow_credentials=True,
39
  allow_methods=["*"],
40
  allow_headers=["*"],
@@ -86,7 +85,8 @@ app.include_router(analyze.router, prefix="/api", tags=["analyze"])
86
  app.include_router(progress.router, prefix="/api", tags=["progress"])
87
  app.include_router(result.router, prefix="/api", tags=["result"])
88
  app.include_router(export.router, prefix="/api", tags=["export"])
89
- app.include_router(compare.router, prefix="/api", tags=["compare"])
 
90
 
91
 
92
  @app.get("/health")
 
4
  from fastapi.middleware.cors import CORSMiddleware
5
  from fastapi.responses import JSONResponse
6
  from app.core.redis import close_redis
7
+ from app.api.routes import upload, analyze, progress, result, export, auth, usage
8
+ from app.core.config import settings
9
 
10
  logger = logging.getLogger(__name__)
11
 
 
23
  lifespan=lifespan,
24
  )
25
 
26
+ # Build CORS origins list: always include localhost for dev, plus production URL
27
+ cors_origins = ["http://localhost:3000"]
28
+ if settings.frontend_url and settings.frontend_url != "http://localhost:3000":
29
+ cors_origins.append(settings.frontend_url)
30
+ # Also accept any Vercel preview deployments
31
+ if settings.frontend_url and "vercel.app" in settings.frontend_url:
32
+ cors_origins.append("https://mycv-buddy.vercel.app")
 
 
33
 
34
  app.add_middleware(
35
  CORSMiddleware,
36
+ allow_origins=cors_origins,
37
  allow_credentials=True,
38
  allow_methods=["*"],
39
  allow_headers=["*"],
 
85
  app.include_router(progress.router, prefix="/api", tags=["progress"])
86
  app.include_router(result.router, prefix="/api", tags=["result"])
87
  app.include_router(export.router, prefix="/api", tags=["export"])
88
+ app.include_router(auth.router, prefix="/api", tags=["auth"])
89
+ app.include_router(usage.router, prefix="/api", tags=["usage"])
90
 
91
 
92
  @app.get("/health")
app/models/user.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """User profile model stored in Redis."""
2
+
3
+ from typing import List, Optional
4
+ from pydantic import BaseModel, Field
5
+ from datetime import datetime
6
+
7
+
8
+ class UserProfile(BaseModel):
9
+ """User profile stored as JSON in Redis with key user:{google_id}."""
10
+
11
+ google_id: str
12
+ email: str
13
+ name: str
14
+ picture: Optional[str] = None
15
+
16
+ # Preferences
17
+ preferred_intensity: str = "moderate" # conservative | moderate | aggressive
18
+
19
+ # Session linking
20
+ linked_sessions: List[str] = Field(default_factory=list)
21
+
22
+ # Timestamps
23
+ created_at: datetime = Field(default_factory=datetime.utcnow)
24
+ updated_at: datetime = Field(default_factory=datetime.utcnow)
25
+
26
+ class Config:
27
+ json_encoders = {
28
+ datetime: lambda v: v.isoformat(),
29
+ }
app/workers/celery_app.py CHANGED
@@ -1,16 +1,10 @@
1
  from celery import Celery
2
  from app.core.config import settings
3
 
4
- # Celery requires explicit ssl_cert_reqs param for rediss:// URLs
5
- _redis_url = settings.redis_url
6
- if _redis_url.startswith("rediss://") and "ssl_cert_reqs" not in _redis_url:
7
- sep = "&" if "?" in _redis_url else "?"
8
- _redis_url = f"{_redis_url}{sep}ssl_cert_reqs=CERT_NONE"
9
-
10
  celery_app = Celery(
11
  "cv_buddy",
12
- broker=_redis_url,
13
- backend=_redis_url,
14
  include=["app.workers.tasks"],
15
  )
16
 
 
1
  from celery import Celery
2
  from app.core.config import settings
3
 
 
 
 
 
 
 
4
  celery_app = Celery(
5
  "cv_buddy",
6
+ broker=settings.redis_url,
7
+ backend=settings.redis_url,
8
  include=["app.workers.tasks"],
9
  )
10
 
requirements.txt CHANGED
@@ -23,6 +23,9 @@ beautifulsoup4>=4.12.0
23
  openai>=1.10.0
24
  google-generativeai>=0.4.0
25
 
 
 
 
26
  # Utilities
27
  pydantic>=2.5.0
28
  pydantic-settings>=2.1.0
 
23
  openai>=1.10.0
24
  google-generativeai>=0.4.0
25
 
26
+ # Auth
27
+ python-jose[cryptography]>=3.3.0
28
+
29
  # Utilities
30
  pydantic>=2.5.0
31
  pydantic-settings>=2.1.0