first commit

This commit is contained in:
Anurag Jangid 2026-10-08 02:52:58 +05:30
commit 34ea15a54d
246 changed files with 18660 additions and 0 deletions

View file

@ -0,0 +1,5 @@
from app.core.config import Settings, get_settings
settings = get_settings()
__all__ = ["Settings", "get_settings", "settings"]

105
backend/app/core/config.py Normal file
View file

@ -0,0 +1,105 @@
from functools import lru_cache
from pydantic import field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
_INSECURE_SECRET_KEYS = frozenset(
{
"change-me",
"change-me-to-a-long-random-string-in-production",
"changeme",
"secret",
}
)
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
environment: str = "development"
database_url: str
secret_key: str
access_token_expire_minutes: int = 60 * 24
payment_provider: str = "mock"
payment_mock_secret: str = "dev-mock-payment-secret-change-me"
razorpay_key_id: str | None = None
razorpay_key_secret: str | None = None
razorpay_webhook_secret: str | None = None
order_payment_timeout_minutes: int = 15
ticket_verify_api_key: str | None = None
@field_validator("secret_key")
@classmethod
def secret_key_must_be_strong_in_production(cls, value: str, info) -> str:
environment = (info.data.get("environment") or "development").lower()
if environment == "production" and value.strip().lower() in _INSECURE_SECRET_KEYS:
raise ValueError("SECRET_KEY must be set to a long random value in production")
if environment == "production" and len(value) < 32:
raise ValueError("SECRET_KEY must be at least 32 characters in production")
return value
@field_validator("access_token_expire_minutes")
@classmethod
def token_ttl_reasonable(cls, value: int) -> int:
if value < 5 or value > 60 * 24 * 14:
raise ValueError("ACCESS_TOKEN_EXPIRE_MINUTES must be between 5 and 20160")
return value
cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173"
upload_dir: str = "uploads"
storage_backend: str = "local"
public_site_url: str = "http://localhost:5173"
public_api_url: str = "http://localhost:8000"
admin_email: str = "admin@desiclans.in"
admin_password: str = "changeme"
smtp_host: str | None = None
smtp_port: int = 587
smtp_user: str | None = None
smtp_password: str | None = None
smtp_from: str | None = None
smtp_use_tls: bool = True
alert_notify_email: str | None = None
alert_click_threshold: int = 50
aws_access_key_id: str | None = None
aws_secret_access_key: str | None = None
aws_region: str = "ap-south-1"
s3_bucket: str | None = None
s3_public_base_url: str | None = None
@property
def cors_origin_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
@property
def smtp_configured(self) -> bool:
return bool(self.smtp_host and self.alert_notify_email)
@model_validator(mode="after")
def production_hardening(self) -> "Settings":
if self.environment.lower() != "production":
return self
if self.payment_provider.lower() == "mock":
raise ValueError("PAYMENT_PROVIDER must be razorpay in production")
if not self.ticket_verify_api_key or len(self.ticket_verify_api_key) < 16:
raise ValueError("TICKET_VERIFY_API_KEY is required in production (min 16 characters)")
if self.admin_password.strip().lower() in {"changeme", "change-me", "password"}:
raise ValueError("ADMIN_PASSWORD must be changed from the default in production")
if not self.cors_origin_list:
raise ValueError("CORS_ORIGINS must list at least one origin in production")
if self.payment_provider.lower() == "razorpay":
if not self.razorpay_key_id or not self.razorpay_key_secret or not self.razorpay_webhook_secret:
raise ValueError(
"RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET, and RAZORPAY_WEBHOOK_SECRET are required in production"
)
return self
@lru_cache
def get_settings() -> Settings:
return Settings()

76
backend/app/core/deps.py Normal file
View file

@ -0,0 +1,76 @@
from collections.abc import Callable, Generator
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.core.config import Settings, get_settings
from app.core.security import decode_admin_access_token, decode_user_access_token
from app.db.session import get_db as _get_db
from app.models.entities import AdminUser
from app.models.enums import AdminRole
from app.models.user import User
_bearer = HTTPBearer(auto_error=False)
def get_db() -> Generator[Session, None, None]:
yield from _get_db()
SettingsDep = Annotated[Settings, Depends(get_settings)]
DbSession = Annotated[Session, Depends(get_db)]
def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
db: DbSession,
settings: SettingsDep,
) -> User:
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
user_id = decode_user_access_token(credentials.credentials, settings)
if user_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
user = db.query(User).filter(User.id == user_id).first()
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
def get_current_admin(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
db: DbSession,
settings: SettingsDep,
) -> AdminUser:
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
admin_id = decode_admin_access_token(credentials.credentials, settings)
if admin_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
admin = db.query(AdminUser).filter(AdminUser.id == admin_id).first()
if not admin:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin not found")
return admin
CurrentAdmin = Annotated[AdminUser, Depends(get_current_admin)]
def require_admin_roles(*roles: AdminRole) -> Callable:
allowed = set(roles)
def _checker(admin: CurrentAdmin) -> AdminUser:
if admin.role not in allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
return admin
return _checker
EditorAdmin = Annotated[AdminUser, Depends(require_admin_roles(AdminRole.editor, AdminRole.super_admin))]
SuperAdmin = Annotated[AdminUser, Depends(require_admin_roles(AdminRole.super_admin))]

View file

@ -0,0 +1,23 @@
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
class AppError(Exception):
"""Application-level error mapped to an HTTP response."""
def __init__(self, message: str, status_code: int = 400) -> None:
self.message = message
self.status_code = status_code
super().__init__(message)
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def handle_app_error(_request: Request, exc: AppError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.message})
@app.exception_handler(Exception)
async def handle_unexpected_error(_request: Request, exc: Exception) -> JSONResponse:
if app.debug:
return JSONResponse(status_code=500, content={"detail": str(exc)})
return JSONResponse(status_code=500, content={"detail": "Internal server error"})

View file

@ -0,0 +1,37 @@
import threading
import time
from collections import defaultdict
from fastapi import Request
from app.core.errors import AppError
_buckets: dict[str, list[float]] = defaultdict(list)
_lock = threading.Lock()
def _client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client and request.client.host:
return request.client.host
return "unknown"
def enforce_rate_limit(request: Request, *, scope: str, limit: int, window_seconds: int) -> None:
"""In-memory sliding window limiter (per process). Use edge/WAF limits in production at scale."""
import os
if os.environ.get("DESI_CLANS_DISABLE_RATE_LIMIT", "").lower() in {"1", "true", "yes"}:
return
if limit <= 0:
return
key = f"{scope}:{_client_ip(request)}"
now = time.time()
with _lock:
window = _buckets[key]
window[:] = [stamp for stamp in window if now - stamp < window_seconds]
if len(window) >= limit:
raise AppError("Too many requests. Try again shortly.", status_code=429)
window.append(now)

View file

@ -0,0 +1,72 @@
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import Settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
TOKEN_TYPE_USER = "user"
TOKEN_TYPE_ADMIN = "admin"
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_user_access_token(user_id: int, settings: Settings) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {
"sub": str(user_id),
"exp": expire,
"type": TOKEN_TYPE_USER,
}
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
def decode_user_access_token(token: str, settings: Settings) -> int | None:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
except JWTError:
return None
if payload.get("type") != TOKEN_TYPE_USER:
return None
subject = payload.get("sub")
if not subject:
return None
try:
return int(subject)
except (TypeError, ValueError):
return None
def create_admin_access_token(admin_id: int, settings: Settings) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {
"sub": str(admin_id),
"exp": expire,
"type": TOKEN_TYPE_ADMIN,
}
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
def decode_admin_access_token(token: str, settings: Settings) -> int | None:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
except JWTError:
return None
if payload.get("type") != TOKEN_TYPE_ADMIN:
return None
subject = payload.get("sub")
if not subject:
return None
try:
return int(subject)
except (TypeError, ValueError):
return None