Hydra-Bolt commited on
Commit ·
bbfff2e
1
Parent(s): 15c5d41
added
Browse files- .env.example +0 -11
- AUTHENTICATION.md +0 -14
- NEXTJS_INTEGRATION_GUIDE.md +3 -13
- app/api/routes.py +0 -7
- app/auth/routes.py +0 -4
- app/config/settings.py +0 -11
- app/main.py +0 -25
- app/middleware/rate_limit.py +0 -152
- requirements.txt +0 -4
.env.example
CHANGED
|
@@ -20,17 +20,6 @@ SUPABASE_URL="https://your-project.supabase.co"
|
|
| 20 |
SUPABASE_SERVICE_KEY="your-supabase-service-role-key"
|
| 21 |
SUPABASE_ANON_KEY="your-supabase-anon-key"
|
| 22 |
|
| 23 |
-
# Redis Configuration (for rate limiting)
|
| 24 |
-
REDIS_URL="redis://localhost:6379"
|
| 25 |
-
REDIS_HOST="localhost"
|
| 26 |
-
REDIS_PORT=6379
|
| 27 |
-
REDIS_DB=0
|
| 28 |
-
REDIS_PASSWORD=""
|
| 29 |
-
|
| 30 |
-
# Rate Limiting
|
| 31 |
-
RATE_LIMIT_REQUESTS_PER_MINUTE=60
|
| 32 |
-
RATE_LIMIT_BURST=10
|
| 33 |
-
|
| 34 |
# LLM Configuration
|
| 35 |
GOOGLE_API_KEY="your-google-api-key"
|
| 36 |
GROQ_API_KEY="your-groq-api-key"
|
|
|
|
| 20 |
SUPABASE_SERVICE_KEY="your-supabase-service-role-key"
|
| 21 |
SUPABASE_ANON_KEY="your-supabase-anon-key"
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
# LLM Configuration
|
| 24 |
GOOGLE_API_KEY="your-google-api-key"
|
| 25 |
GROQ_API_KEY="your-groq-api-key"
|
AUTHENTICATION.md
CHANGED
|
@@ -162,20 +162,6 @@ curl -X POST "http://localhost:8000/api/v1/extract-narrators" \
|
|
| 162 |
- Service role has full access for admin operations
|
| 163 |
- Automatic user profile creation on signup
|
| 164 |
|
| 165 |
-
## Rate Limiting
|
| 166 |
-
|
| 167 |
-
### Default Limits
|
| 168 |
-
- **Anonymous users**: 60 requests/minute
|
| 169 |
-
- **Authenticated users**: 120 requests/minute
|
| 170 |
-
- **Admin users**: 300 requests/minute
|
| 171 |
-
- **Burst protection**: 10 requests/second
|
| 172 |
-
|
| 173 |
-
### Rate Limit Headers
|
| 174 |
-
Responses include rate limit information:
|
| 175 |
-
- `X-RateLimit-Limit`: Request limit
|
| 176 |
-
- `X-RateLimit-Remaining`: Remaining requests
|
| 177 |
-
- `X-RateLimit-Reset`: Reset time
|
| 178 |
-
|
| 179 |
## Analytics and Monitoring
|
| 180 |
|
| 181 |
### User Analytics
|
|
|
|
| 162 |
- Service role has full access for admin operations
|
| 163 |
- Automatic user profile creation on signup
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
## Analytics and Monitoring
|
| 166 |
|
| 167 |
### User Analytics
|
NEXTJS_INTEGRATION_GUIDE.md
CHANGED
|
@@ -46,8 +46,6 @@ Core Hadith Analysis (all protected except `/api/v1/health` + some analytics):
|
|
| 46 |
- `GET /api/v1/analytics/popular-narrators` (public)
|
| 47 |
- `GET /api/v1/health` (public)
|
| 48 |
|
| 49 |
-
Rate limit errors will return 429 and headers: `X-RateLimit-*`.
|
| 50 |
-
|
| 51 |
---
|
| 52 |
## 3. Environment Variables (Next.js)
|
| 53 |
|
|
@@ -194,11 +192,6 @@ export async function sanadFetch<T>(path: string, init: RequestInit = {}): Promi
|
|
| 194 |
headers.set('Content-Type', 'application/json');
|
| 195 |
|
| 196 |
const res = await fetch(`${BASE}${path}`, { ...init, headers, cache: 'no-store' });
|
| 197 |
-
if (res.status === 429) {
|
| 198 |
-
const limit = res.headers.get('X-RateLimit-Limit');
|
| 199 |
-
const reset = res.headers.get('X-RateLimit-Reset');
|
| 200 |
-
throw new Error(`Rate limited. Limit=${limit} resets at=${reset}`);
|
| 201 |
-
}
|
| 202 |
if (!res.ok) {
|
| 203 |
const body = await res.text();
|
| 204 |
throw new Error(`Sanad API error ${res.status}: ${body}`);
|
|
@@ -354,19 +347,17 @@ export function LoginForm() {
|
|
| 354 |
```
|
| 355 |
|
| 356 |
---
|
| 357 |
-
## 12. Handling
|
| 358 |
|
| 359 |
Pattern:
|
| 360 |
```ts
|
| 361 |
try {
|
| 362 |
const res = await sanadFetch('/api/v1/analyze-narrator', { method: 'POST', body: JSON.stringify({ narrator_name }) });
|
| 363 |
} catch (e: any) {
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
}
|
| 367 |
}
|
| 368 |
```
|
| 369 |
-
Consider exponential backoff for bursts and surface `X-RateLimit-Remaining` to show usage.
|
| 370 |
|
| 371 |
---
|
| 372 |
## 13. Logout Flow
|
|
@@ -442,7 +433,6 @@ export const config = { matcher: ['/dashboard/:path*', '/analysis/:path*'] };
|
|
| 442 |
|---------|-------|-----|
|
| 443 |
| 401 on every request | Missing Authorization header | Ensure proxy sets header after refresh |
|
| 444 |
| 401 after refresh | Refresh token expired/blacklisted | Force logout & re-login |
|
| 445 |
-
| 429 errors | Rate limit exceeded | Slow down / show user retry time |
|
| 446 |
| CORS errors (if bypassing proxy) | Direct browser → FastAPI without proper CORS | Always use Next.js proxy or configure CORS on backend |
|
| 447 |
| Cookie not set in prod | Missing `secure` or domain mismatch | Set correct domain & HTTPS |
|
| 448 |
|
|
|
|
| 46 |
- `GET /api/v1/analytics/popular-narrators` (public)
|
| 47 |
- `GET /api/v1/health` (public)
|
| 48 |
|
|
|
|
|
|
|
| 49 |
---
|
| 50 |
## 3. Environment Variables (Next.js)
|
| 51 |
|
|
|
|
| 192 |
headers.set('Content-Type', 'application/json');
|
| 193 |
|
| 194 |
const res = await fetch(`${BASE}${path}`, { ...init, headers, cache: 'no-store' });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
if (!res.ok) {
|
| 196 |
const body = await res.text();
|
| 197 |
throw new Error(`Sanad API error ${res.status}: ${body}`);
|
|
|
|
| 347 |
```
|
| 348 |
|
| 349 |
---
|
| 350 |
+
## 12. Handling Errors
|
| 351 |
|
| 352 |
Pattern:
|
| 353 |
```ts
|
| 354 |
try {
|
| 355 |
const res = await sanadFetch('/api/v1/analyze-narrator', { method: 'POST', body: JSON.stringify({ narrator_name }) });
|
| 356 |
} catch (e: any) {
|
| 357 |
+
// Handle errors appropriately
|
| 358 |
+
console.error('API Error:', e.message);
|
|
|
|
| 359 |
}
|
| 360 |
```
|
|
|
|
| 361 |
|
| 362 |
---
|
| 363 |
## 13. Logout Flow
|
|
|
|
| 433 |
|---------|-------|-----|
|
| 434 |
| 401 on every request | Missing Authorization header | Ensure proxy sets header after refresh |
|
| 435 |
| 401 after refresh | Refresh token expired/blacklisted | Force logout & re-login |
|
|
|
|
| 436 |
| CORS errors (if bypassing proxy) | Direct browser → FastAPI without proper CORS | Always use Next.js proxy or configure CORS on backend |
|
| 437 |
| Cookie not set in prod | Missing `secure` or domain mismatch | Set correct domain & HTTPS |
|
| 438 |
|
app/api/routes.py
CHANGED
|
@@ -22,7 +22,6 @@ from app.db.models import (
|
|
| 22 |
)
|
| 23 |
from app.agent.services import get_llm_service
|
| 24 |
from app.middleware import get_current_active_user, get_user_ip
|
| 25 |
-
from app.middleware.rate_limit import limiter, authenticated_user_limit
|
| 26 |
from app.services.database import DatabaseService
|
| 27 |
|
| 28 |
router = APIRouter(prefix="/api/v1", tags=["hadith-analysis"])
|
|
@@ -34,7 +33,6 @@ router = APIRouter(prefix="/api/v1", tags=["hadith-analysis"])
|
|
| 34 |
summary="Extract narrators from hadith text",
|
| 35 |
description="Analyzes Arabic hadith text and extracts the chain of narrators (sanad)",
|
| 36 |
)
|
| 37 |
-
@authenticated_user_limit()
|
| 38 |
async def extract_narrators(
|
| 39 |
request: HadithTextRequest,
|
| 40 |
http_request: Request,
|
|
@@ -123,7 +121,6 @@ async def extract_narrators(
|
|
| 123 |
summary="Analyze narrator reliability",
|
| 124 |
description="Takes a narrator name and generates an AI-powered reliability assessment based on the model's knowledge",
|
| 125 |
)
|
| 126 |
-
@authenticated_user_limit()
|
| 127 |
async def analyze_narrator(
|
| 128 |
request: NarratorAnalysisRequest,
|
| 129 |
http_request: Request,
|
|
@@ -196,7 +193,6 @@ async def analyze_narrator(
|
|
| 196 |
summary="Analyze narrator chain",
|
| 197 |
description="Analyzes a complete chain of narrators using enhanced Shamela data + LLM agent",
|
| 198 |
)
|
| 199 |
-
@authenticated_user_limit()
|
| 200 |
async def analyze_narrator_chain(
|
| 201 |
request: Request,
|
| 202 |
narrator_names: List[str] = Body(...),
|
|
@@ -273,7 +269,6 @@ async def analyze_narrator_chain(
|
|
| 273 |
summary="Extract narrators and analyze chain",
|
| 274 |
description="Complete workflow: extract narrators from hadith text and analyze the complete chain",
|
| 275 |
)
|
| 276 |
-
@authenticated_user_limit()
|
| 277 |
async def extract_and_analyze_hadith(
|
| 278 |
request: HadithTextRequest,
|
| 279 |
http_request: Request,
|
|
@@ -393,7 +388,6 @@ async def health_check():
|
|
| 393 |
summary="Get user's extraction history",
|
| 394 |
description="Get the current user's narrator extraction history",
|
| 395 |
)
|
| 396 |
-
@authenticated_user_limit()
|
| 397 |
async def get_user_extractions(
|
| 398 |
request: Request,
|
| 399 |
current_user: User = Depends(get_current_active_user),
|
|
@@ -410,7 +404,6 @@ async def get_user_extractions(
|
|
| 410 |
summary="Get user's analysis history",
|
| 411 |
description="Get the current user's narrator analysis history",
|
| 412 |
)
|
| 413 |
-
@authenticated_user_limit()
|
| 414 |
async def get_user_analyses(
|
| 415 |
request: Request,
|
| 416 |
current_user: User = Depends(get_current_active_user),
|
|
|
|
| 22 |
)
|
| 23 |
from app.agent.services import get_llm_service
|
| 24 |
from app.middleware import get_current_active_user, get_user_ip
|
|
|
|
| 25 |
from app.services.database import DatabaseService
|
| 26 |
|
| 27 |
router = APIRouter(prefix="/api/v1", tags=["hadith-analysis"])
|
|
|
|
| 33 |
summary="Extract narrators from hadith text",
|
| 34 |
description="Analyzes Arabic hadith text and extracts the chain of narrators (sanad)",
|
| 35 |
)
|
|
|
|
| 36 |
async def extract_narrators(
|
| 37 |
request: HadithTextRequest,
|
| 38 |
http_request: Request,
|
|
|
|
| 121 |
summary="Analyze narrator reliability",
|
| 122 |
description="Takes a narrator name and generates an AI-powered reliability assessment based on the model's knowledge",
|
| 123 |
)
|
|
|
|
| 124 |
async def analyze_narrator(
|
| 125 |
request: NarratorAnalysisRequest,
|
| 126 |
http_request: Request,
|
|
|
|
| 193 |
summary="Analyze narrator chain",
|
| 194 |
description="Analyzes a complete chain of narrators using enhanced Shamela data + LLM agent",
|
| 195 |
)
|
|
|
|
| 196 |
async def analyze_narrator_chain(
|
| 197 |
request: Request,
|
| 198 |
narrator_names: List[str] = Body(...),
|
|
|
|
| 269 |
summary="Extract narrators and analyze chain",
|
| 270 |
description="Complete workflow: extract narrators from hadith text and analyze the complete chain",
|
| 271 |
)
|
|
|
|
| 272 |
async def extract_and_analyze_hadith(
|
| 273 |
request: HadithTextRequest,
|
| 274 |
http_request: Request,
|
|
|
|
| 388 |
summary="Get user's extraction history",
|
| 389 |
description="Get the current user's narrator extraction history",
|
| 390 |
)
|
|
|
|
| 391 |
async def get_user_extractions(
|
| 392 |
request: Request,
|
| 393 |
current_user: User = Depends(get_current_active_user),
|
|
|
|
| 404 |
summary="Get user's analysis history",
|
| 405 |
description="Get the current user's narrator analysis history",
|
| 406 |
)
|
|
|
|
| 407 |
async def get_user_analyses(
|
| 408 |
request: Request,
|
| 409 |
current_user: User = Depends(get_current_active_user),
|
app/auth/routes.py
CHANGED
|
@@ -17,7 +17,6 @@ from app.db.models import (
|
|
| 17 |
UserRole
|
| 18 |
)
|
| 19 |
from app.middleware import auth_middleware, get_user_ip
|
| 20 |
-
from app.middleware.rate_limit import limiter, anonymous_user_limit
|
| 21 |
|
| 22 |
|
| 23 |
router = APIRouter(prefix="/auth", tags=["authentication"])
|
|
@@ -84,7 +83,6 @@ async def create_user_session(
|
|
| 84 |
|
| 85 |
|
| 86 |
@router.post("/register", response_model=AuthResponse)
|
| 87 |
-
@anonymous_user_limit()
|
| 88 |
async def register(request: Request, user_data: RegisterRequest):
|
| 89 |
"""Register a new user."""
|
| 90 |
supabase = get_supabase_client()
|
|
@@ -182,7 +180,6 @@ async def register(request: Request, user_data: RegisterRequest):
|
|
| 182 |
|
| 183 |
|
| 184 |
@router.post("/login", response_model=AuthResponse)
|
| 185 |
-
@anonymous_user_limit()
|
| 186 |
async def login(request: Request, credentials: LoginRequest):
|
| 187 |
"""Authenticate user and return tokens."""
|
| 188 |
supabase = get_supabase_client()
|
|
@@ -253,7 +250,6 @@ async def login(request: Request, credentials: LoginRequest):
|
|
| 253 |
|
| 254 |
|
| 255 |
@router.post("/refresh", response_model=AuthResponse)
|
| 256 |
-
@anonymous_user_limit()
|
| 257 |
async def refresh_token(request: Request, token_data: TokenRefreshRequest):
|
| 258 |
"""Refresh access token using refresh token."""
|
| 259 |
try:
|
|
|
|
| 17 |
UserRole
|
| 18 |
)
|
| 19 |
from app.middleware import auth_middleware, get_user_ip
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
router = APIRouter(prefix="/auth", tags=["authentication"])
|
|
|
|
| 83 |
|
| 84 |
|
| 85 |
@router.post("/register", response_model=AuthResponse)
|
|
|
|
| 86 |
async def register(request: Request, user_data: RegisterRequest):
|
| 87 |
"""Register a new user."""
|
| 88 |
supabase = get_supabase_client()
|
|
|
|
| 180 |
|
| 181 |
|
| 182 |
@router.post("/login", response_model=AuthResponse)
|
|
|
|
| 183 |
async def login(request: Request, credentials: LoginRequest):
|
| 184 |
"""Authenticate user and return tokens."""
|
| 185 |
supabase = get_supabase_client()
|
|
|
|
| 250 |
|
| 251 |
|
| 252 |
@router.post("/refresh", response_model=AuthResponse)
|
|
|
|
| 253 |
async def refresh_token(request: Request, token_data: TokenRefreshRequest):
|
| 254 |
"""Refresh access token using refresh token."""
|
| 255 |
try:
|
app/config/settings.py
CHANGED
|
@@ -31,17 +31,6 @@ class Settings:
|
|
| 31 |
SUPABASE_SERVICE_KEY: Optional[str] = os.getenv("SUPABASE_SERVICE_KEY")
|
| 32 |
SUPABASE_ANON_KEY: Optional[str] = os.getenv("SUPABASE_ANON_KEY")
|
| 33 |
|
| 34 |
-
# Redis Configuration
|
| 35 |
-
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379")
|
| 36 |
-
REDIS_HOST: str = os.getenv("REDIS_HOST", "localhost")
|
| 37 |
-
REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379"))
|
| 38 |
-
REDIS_DB: int = int(os.getenv("REDIS_DB", "0"))
|
| 39 |
-
REDIS_PASSWORD: str = os.getenv("REDIS_PASSWORD", "")
|
| 40 |
-
|
| 41 |
-
# Rate Limiting
|
| 42 |
-
RATE_LIMIT_REQUESTS_PER_MINUTE: int = int(os.getenv("RATE_LIMIT_REQUESTS_PER_MINUTE", "60"))
|
| 43 |
-
RATE_LIMIT_BURST: int = int(os.getenv("RATE_LIMIT_BURST", "10"))
|
| 44 |
-
|
| 45 |
# Database
|
| 46 |
DATABASE_URL: Optional[str] = os.getenv("DATABASE_URL")
|
| 47 |
|
|
|
|
| 31 |
SUPABASE_SERVICE_KEY: Optional[str] = os.getenv("SUPABASE_SERVICE_KEY")
|
| 32 |
SUPABASE_ANON_KEY: Optional[str] = os.getenv("SUPABASE_ANON_KEY")
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
# Database
|
| 35 |
DATABASE_URL: Optional[str] = os.getenv("DATABASE_URL")
|
| 36 |
|
app/main.py
CHANGED
|
@@ -8,14 +8,6 @@ from app.config.settings import settings
|
|
| 8 |
from app.api.routes import router
|
| 9 |
from app.auth.routes import router as auth_router
|
| 10 |
|
| 11 |
-
try:
|
| 12 |
-
from slowapi import Limiter, _rate_limit_exceeded_handler
|
| 13 |
-
from slowapi.errors import RateLimitExceeded
|
| 14 |
-
from app.middleware.rate_limit import limiter
|
| 15 |
-
RATE_LIMITING_AVAILABLE = True
|
| 16 |
-
except ImportError:
|
| 17 |
-
RATE_LIMITING_AVAILABLE = False
|
| 18 |
-
|
| 19 |
|
| 20 |
# Create FastAPI application
|
| 21 |
app = FastAPI(
|
|
@@ -26,23 +18,6 @@ app = FastAPI(
|
|
| 26 |
redoc_url="/redoc" if settings.DEBUG else None,
|
| 27 |
)
|
| 28 |
|
| 29 |
-
# Add rate limiting if available
|
| 30 |
-
if RATE_LIMITING_AVAILABLE:
|
| 31 |
-
app.state.limiter = limiter
|
| 32 |
-
|
| 33 |
-
@app.exception_handler(RateLimitExceeded)
|
| 34 |
-
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
| 35 |
-
response = JSONResponse(
|
| 36 |
-
status_code=429,
|
| 37 |
-
content={
|
| 38 |
-
"error": "Rate limit exceeded",
|
| 39 |
-
"message": f"Too many requests. Limit: {exc.detail}",
|
| 40 |
-
"retry_after": exc.retry_after
|
| 41 |
-
}
|
| 42 |
-
)
|
| 43 |
-
response.headers["Retry-After"] = str(exc.retry_after)
|
| 44 |
-
return response
|
| 45 |
-
|
| 46 |
# Add CORS middleware
|
| 47 |
app.add_middleware(
|
| 48 |
CORSMiddleware,
|
|
|
|
| 8 |
from app.api.routes import router
|
| 9 |
from app.auth.routes import router as auth_router
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
# Create FastAPI application
|
| 13 |
app = FastAPI(
|
|
|
|
| 18 |
redoc_url="/redoc" if settings.DEBUG else None,
|
| 19 |
)
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
# Add CORS middleware
|
| 22 |
app.add_middleware(
|
| 23 |
CORSMiddleware,
|
app/middleware/rate_limit.py
DELETED
|
@@ -1,152 +0,0 @@
|
|
| 1 |
-
from fastapi import Request, HTTPException, status
|
| 2 |
-
from slowapi import Limiter, _rate_limit_exceeded_handler
|
| 3 |
-
from slowapi.util import get_remote_address
|
| 4 |
-
from slowapi.errors import RateLimitExceeded
|
| 5 |
-
import redis
|
| 6 |
-
from typing import Optional
|
| 7 |
-
|
| 8 |
-
from app.config.settings import settings
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
def get_client_ip(request: Request) -> str:
|
| 12 |
-
"""Get client IP address for rate limiting."""
|
| 13 |
-
# Check for forwarded headers first
|
| 14 |
-
forwarded = request.headers.get("X-Forwarded-For")
|
| 15 |
-
if forwarded:
|
| 16 |
-
return forwarded.split(",")[0].strip()
|
| 17 |
-
|
| 18 |
-
real_ip = request.headers.get("X-Real-IP")
|
| 19 |
-
if real_ip:
|
| 20 |
-
return real_ip
|
| 21 |
-
|
| 22 |
-
return get_remote_address(request)
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
def get_user_id_from_token(request: Request) -> Optional[str]:
|
| 26 |
-
"""Extract user ID from JWT token for user-based rate limiting."""
|
| 27 |
-
try:
|
| 28 |
-
from app.middleware import auth_middleware
|
| 29 |
-
auth_header = request.headers.get("Authorization")
|
| 30 |
-
if auth_header and auth_header.startswith("Bearer "):
|
| 31 |
-
token = auth_header.split(" ")[1]
|
| 32 |
-
payload = auth_middleware.verify_token(token)
|
| 33 |
-
return payload.get("sub")
|
| 34 |
-
except Exception:
|
| 35 |
-
pass
|
| 36 |
-
return None
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
def rate_limit_key(request: Request) -> str:
|
| 40 |
-
"""Generate rate limiting key based on user or IP."""
|
| 41 |
-
user_id = get_user_id_from_token(request)
|
| 42 |
-
if user_id:
|
| 43 |
-
return f"user:{user_id}"
|
| 44 |
-
return f"ip:{get_client_ip(request)}"
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
# Create Redis client for rate limiting
|
| 48 |
-
try:
|
| 49 |
-
redis_client = redis.Redis(
|
| 50 |
-
host=settings.REDIS_HOST,
|
| 51 |
-
port=settings.REDIS_PORT,
|
| 52 |
-
db=settings.REDIS_DB,
|
| 53 |
-
password=settings.REDIS_PASSWORD if settings.REDIS_PASSWORD else None,
|
| 54 |
-
decode_responses=True
|
| 55 |
-
)
|
| 56 |
-
# Test connection
|
| 57 |
-
redis_client.ping()
|
| 58 |
-
except Exception:
|
| 59 |
-
# Fallback to in-memory storage if Redis is not available
|
| 60 |
-
redis_client = None
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
# Create limiter instance
|
| 64 |
-
limiter = Limiter(
|
| 65 |
-
key_func=rate_limit_key,
|
| 66 |
-
storage_uri=settings.REDIS_URL if redis_client else "memory://",
|
| 67 |
-
default_limits=[f"{settings.RATE_LIMIT_REQUESTS_PER_MINUTE}/minute"]
|
| 68 |
-
)
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
# Custom rate limit exceeded handler
|
| 72 |
-
async def custom_rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
| 73 |
-
"""Custom handler for rate limit exceeded."""
|
| 74 |
-
response = HTTPException(
|
| 75 |
-
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
| 76 |
-
detail={
|
| 77 |
-
"error": "Rate limit exceeded",
|
| 78 |
-
"message": f"Too many requests. Limit: {exc.detail}",
|
| 79 |
-
"retry_after": exc.retry_after
|
| 80 |
-
}
|
| 81 |
-
)
|
| 82 |
-
return response
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
# Rate limiting decorators for different tiers
|
| 86 |
-
def authenticated_user_limit():
|
| 87 |
-
"""Rate limit for authenticated users (higher limit)."""
|
| 88 |
-
return limiter.limit(f"{settings.RATE_LIMIT_REQUESTS_PER_MINUTE * 2}/minute")
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
def anonymous_user_limit():
|
| 92 |
-
"""Rate limit for anonymous users (lower limit)."""
|
| 93 |
-
return limiter.limit(f"{settings.RATE_LIMIT_REQUESTS_PER_MINUTE}/minute")
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
def admin_user_limit():
|
| 97 |
-
"""Rate limit for admin users (highest limit)."""
|
| 98 |
-
return limiter.limit(f"{settings.RATE_LIMIT_REQUESTS_PER_MINUTE * 5}/minute")
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
def burst_limit():
|
| 102 |
-
"""Burst protection limit."""
|
| 103 |
-
return limiter.limit(f"{settings.RATE_LIMIT_BURST}/second")
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
# Middleware class for more granular control
|
| 107 |
-
class RateLimitMiddleware:
|
| 108 |
-
"""Rate limiting middleware with user-aware limits."""
|
| 109 |
-
|
| 110 |
-
def __init__(self):
|
| 111 |
-
self.limiter = limiter
|
| 112 |
-
self.redis_client = redis_client
|
| 113 |
-
|
| 114 |
-
async def check_rate_limit(self, request: Request, limit: str) -> bool:
|
| 115 |
-
"""Check if request exceeds rate limit."""
|
| 116 |
-
try:
|
| 117 |
-
key = rate_limit_key(request)
|
| 118 |
-
|
| 119 |
-
if self.redis_client:
|
| 120 |
-
# Use Redis for rate limiting
|
| 121 |
-
current_count = self.redis_client.incr(key)
|
| 122 |
-
if current_count == 1:
|
| 123 |
-
# Set expiration for new key
|
| 124 |
-
self.redis_client.expire(key, 60) # 1 minute window
|
| 125 |
-
|
| 126 |
-
# Parse limit (e.g., "60/minute")
|
| 127 |
-
limit_count = int(limit.split("/")[0])
|
| 128 |
-
if current_count > limit_count:
|
| 129 |
-
return False
|
| 130 |
-
|
| 131 |
-
return True
|
| 132 |
-
except Exception:
|
| 133 |
-
# If rate limiting fails, allow the request
|
| 134 |
-
return True
|
| 135 |
-
|
| 136 |
-
def get_remaining_requests(self, request: Request, limit: str) -> int:
|
| 137 |
-
"""Get remaining requests for the current window."""
|
| 138 |
-
try:
|
| 139 |
-
if not self.redis_client:
|
| 140 |
-
return 0
|
| 141 |
-
|
| 142 |
-
key = rate_limit_key(request)
|
| 143 |
-
current_count = self.redis_client.get(key) or 0
|
| 144 |
-
limit_count = int(limit.split("/")[0])
|
| 145 |
-
|
| 146 |
-
return max(0, limit_count - int(current_count))
|
| 147 |
-
except Exception:
|
| 148 |
-
return 0
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
# Global instance
|
| 152 |
-
rate_limit_middleware = RateLimitMiddleware()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
requirements.txt
CHANGED
|
@@ -73,10 +73,6 @@ passlib[bcrypt]==1.7.4
|
|
| 73 |
supabase==2.7.4
|
| 74 |
postgrest==0.16.8
|
| 75 |
|
| 76 |
-
# Redis and Rate Limiting
|
| 77 |
-
redis==5.0.1
|
| 78 |
-
slowapi==0.1.9
|
| 79 |
-
|
| 80 |
# Additional dependencies for enhanced security
|
| 81 |
cryptography==42.0.5
|
| 82 |
bcrypt==4.1.2
|
|
|
|
| 73 |
supabase==2.7.4
|
| 74 |
postgrest==0.16.8
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
# Additional dependencies for enhanced security
|
| 77 |
cryptography==42.0.5
|
| 78 |
bcrypt==4.1.2
|