Checkpoint React frontend migration

This commit is contained in:
Dwindi Ramadhana
2026-06-20 01:43:39 +07:00
parent ab86c254d1
commit b8e201b45f
173 changed files with 34116 additions and 782 deletions

View File

@@ -0,0 +1,3 @@
"""
Core configuration and database utilities.
"""

170
backend/app/core/auth.py Normal file
View File

@@ -0,0 +1,170 @@
"""
Token-based authentication helpers for website-scoped access control.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
from dataclasses import dataclass
from typing import Optional
from fastapi import Header, HTTPException, status
from app.core.config import get_settings
settings = get_settings()
@dataclass
class AuthContext:
website_id: Optional[int]
role: str
wp_user_id: Optional[str] = None
def _b64url_encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def _b64url_decode(raw: str) -> bytes:
padding = "=" * (-len(raw) % 4)
return base64.urlsafe_b64decode((raw + padding).encode("ascii"))
def issue_access_token(
website_id: int | None,
role: str = "student",
wp_user_id: str | None = None,
expires_in_seconds: int = 3600,
) -> str:
payload = {
"website_id": int(website_id) if website_id is not None else None,
"role": role,
"wp_user_id": wp_user_id,
"exp": int(time.time()) + int(expires_in_seconds),
}
payload_bytes = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
payload_b64 = _b64url_encode(payload_bytes)
sig = hmac.new(settings.SECRET_KEY.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
return f"{payload_b64}.{_b64url_encode(sig)}"
def decode_access_token(token: str) -> AuthContext:
try:
payload_b64, sig_b64 = token.split(".", 1)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid access token format",
) from exc
expected_sig = hmac.new(
settings.SECRET_KEY.encode("utf-8"),
payload_b64.encode("ascii"),
hashlib.sha256,
).digest()
provided_sig = _b64url_decode(sig_b64)
if not hmac.compare_digest(provided_sig, expected_sig):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid access token signature",
)
try:
payload = json.loads(_b64url_decode(payload_b64).decode("utf-8"))
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid access token payload",
) from exc
exp = int(payload.get("exp", 0))
if exp <= int(time.time()):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Access token has expired",
)
website_id = payload.get("website_id")
role = payload.get("role")
if not role:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Access token missing required claims",
)
if website_id is None and role != "system_admin":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Access token missing website scope",
)
return AuthContext(
website_id=int(website_id) if website_id is not None else None,
role=str(role),
wp_user_id=payload.get("wp_user_id"),
)
def get_auth_context(
authorization: str | None = Header(None, alias="Authorization"),
x_website_id: str | None = Header(None, alias="X-Website-ID"),
) -> AuthContext:
if authorization is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authorization header is required",
)
parts = authorization.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid Authorization header format. Use: Bearer {token}",
)
context = decode_access_token(parts[1])
# If system_admin explicitly sets a website context via header, use it
if context.role == "system_admin" and x_website_id and x_website_id.isdigit():
context.website_id = int(x_website_id)
return context
def require_website_auth(
auth: AuthContext,
allowed_roles: set[str] | None = None,
) -> Optional[int]:
"""
Check if the authenticated user has required roles.
Returns the website_id if scoped to a specific website.
Returns None if the user is a system_admin with global access and no specific website context.
"""
if allowed_roles is not None and auth.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions for this endpoint",
)
if auth.role == "system_admin":
if auth.website_id is not None:
return auth.website_id
return None
return auth.website_id
def ensure_website_scope_matches(
auth_website_id: int | None,
payload_website_id: int,
) -> None:
if auth_website_id is None:
return
if int(auth_website_id) != int(payload_website_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="website_id in payload must match authenticated website scope",
)

151
backend/app/core/config.py Normal file
View File

@@ -0,0 +1,151 @@
"""
Application configuration using Pydantic Settings.
Loads configuration from environment variables with validation.
"""
from typing import Annotated, Literal, List, Union
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
# Database
DATABASE_URL: str = Field(
default="postgresql+asyncpg://postgres:postgres@localhost:5432/irt_bank_soal",
description="PostgreSQL database URL with asyncpg driver",
)
# FastAPI
SECRET_KEY: str = Field(
default="dev-secret-key-change-in-production",
description="Secret key for JWT token signing",
)
API_V1_STR: str = Field(default="/api/v1", description="API v1 prefix")
PROJECT_NAME: str = Field(default="IRT Bank Soal", description="Project name")
ENVIRONMENT: Literal["development", "staging", "production"] = Field(
default="development", description="Environment name"
)
ENABLE_ADMIN: bool = Field(
default=False,
description="Enable admin UI and admin-only API routes",
)
ADMIN_USERNAME: str = Field(
default="",
description="Admin panel username",
)
ADMIN_PASSWORD: str = Field(
default="",
description="Admin panel password (plain env value)",
)
ADMIN_SESSION_EXPIRE_SECONDS: int = Field(
default=3600,
description="Admin session lifetime in seconds",
)
# OpenRouter (AI Generation)
OPENROUTER_API_KEY: str = Field(
default="", description="OpenRouter API key for AI generation"
)
OPENROUTER_MODEL_QWEN: str = Field(
default="qwen/qwen2.5-32b-instruct",
description="Balanced Qwen model identifier",
)
OPENROUTER_MODEL_CHEAP: str = Field(
default="mistralai/mistral-small-2603",
description="Low-cost model identifier",
)
OPENROUTER_MODEL_LLAMA: str = Field(
default="meta-llama/llama-3.3-70b-instruct",
description="Premium Llama model identifier",
)
OPENROUTER_TIMEOUT: int = Field(default=30, description="OpenRouter API timeout in seconds")
OPENROUTER_PROVIDER_ORDER: List[str] = Field(
default=["NovitaAI", "AkashML", "Inception"],
description="Preferred OpenRouter providers in priority order",
)
OPENROUTER_ALLOW_PROVIDER_FALLBACKS: bool = Field(
default=True,
description="Allow OpenRouter to fallback outside preferred providers",
)
# WordPress Integration
WORDPRESS_API_URL: str = Field(
default="", description="WordPress REST API base URL"
)
WORDPRESS_AUTH_TOKEN: str = Field(
default="", description="WordPress JWT authentication token"
)
# Redis (Celery)
REDIS_URL: str = Field(
default="redis://localhost:6379/0", description="Redis connection URL"
)
CELERY_BROKER_URL: str = Field(
default="redis://localhost:6379/0", description="Celery broker URL"
)
CELERY_RESULT_BACKEND: str = Field(
default="redis://localhost:6379/0", description="Celery result backend URL"
)
# CORS - stored as list, accepts comma-separated string from env
ALLOWED_ORIGINS: Annotated[List[str], NoDecode] = Field(
default=["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:5173"],
description="List of allowed CORS origins",
)
@field_validator("ALLOWED_ORIGINS", mode="before")
@classmethod
def parse_allowed_origins(cls, v: Union[str, List[str]]) -> List[str]:
"""Parse comma-separated origins into list."""
if isinstance(v, str):
return [origin.strip() for origin in v.split(",") if origin.strip()]
return v
@field_validator("OPENROUTER_PROVIDER_ORDER", mode="before")
@classmethod
def parse_provider_order(cls, v: Union[str, List[str]]) -> List[str]:
"""Parse comma-separated OpenRouter provider list into array."""
if isinstance(v, str):
return [provider.strip() for provider in v.split(",") if provider.strip()]
return v
# Global settings instance
_settings: Union[Settings, None] = None
def get_settings() -> Settings:
"""
Get application settings instance.
Returns:
Settings: Application settings
Raises:
ValueError: If settings not initialized
"""
global _settings
if _settings is None:
_settings = Settings()
return _settings
def init_settings(settings: Settings) -> None:
"""
Initialize settings with custom instance (useful for testing).
Args:
settings: Settings instance to use
"""
global _settings
_settings = settings

View File

@@ -0,0 +1,121 @@
"""
Lightweight in-process rate limiting helpers.
"""
from __future__ import annotations
import logging
import threading
import time
from collections import defaultdict, deque
from fastapi import HTTPException, Request, status
from redis.asyncio import Redis
from app.core.config import get_settings
_lock = threading.Lock()
_hits: dict[str, deque[float]] = defaultdict(deque)
_redis_client: Redis | None = None
_redis_unavailable = False
logger = logging.getLogger(__name__)
def _client_ip(request: Request) -> str:
if request.client and request.client.host:
return request.client.host
return "unknown"
def _get_redis_client() -> Redis | None:
global _redis_client
if _redis_unavailable:
return None
if _redis_client is None:
settings = get_settings()
if not settings.REDIS_URL:
return None
_redis_client = Redis.from_url(settings.REDIS_URL, decode_responses=True)
return _redis_client
def _enforce_in_memory_rate_limit(
*,
key: str,
scope: str,
max_requests: int,
window_seconds: int,
) -> None:
now = time.time()
cutoff = now - window_seconds
with _lock:
dq = _hits[key]
while dq and dq[0] <= cutoff:
dq.popleft()
if len(dq) >= max_requests:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Too many requests for {scope}. Please try again later.",
)
dq.append(now)
async def enforce_rate_limit(
request: Request,
*,
scope: str,
max_requests: int,
window_seconds: int,
) -> None:
global _redis_unavailable
ip = _client_ip(request)
key = f"{scope}:{ip}"
redis = _get_redis_client()
if redis is not None:
try:
current = await redis.incr(key)
if current == 1:
await redis.expire(key, window_seconds)
if current > max_requests:
ttl = await redis.ttl(key)
retry_after = ttl if ttl and ttl > 0 else window_seconds
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Too many requests for {scope}. Please try again later.",
headers={"Retry-After": str(retry_after)},
)
return
except HTTPException:
raise
except Exception as exc:
_redis_unavailable = True
logger.warning("Redis rate limiter unavailable; falling back to memory: %s", exc)
_enforce_in_memory_rate_limit(
key=key,
scope=scope,
max_requests=max_requests,
window_seconds=window_seconds,
)
async def close_rate_limit() -> None:
global _redis_client
if _redis_client is None:
return
try:
await _redis_client.aclose()
finally:
_redis_client = None
def reset_rate_limit_state() -> None:
"""Reset local limiter state for tests."""
global _redis_client, _redis_unavailable
_redis_client = None
_redis_unavailable = False
with _lock:
_hits.clear()