Spaces:
Running
Running
| """ | |
| Maria PT - HuggingFace Spaces Backend | |
| ====================================== | |
| Auth: SHA-256 hash check (primary) or Cloudflare Referer/Domain check (fallback) | |
| Endpoints: GET /ping | POST /base_start | |
| """ | |
| import os | |
| import hashlib | |
| import logging | |
| import httpx | |
| from fastapi import FastAPI, Request, HTTPException, status | |
| from fastapi.responses import JSONResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel | |
| from pymongo import MongoClient | |
| from pymongo.errors import ConnectionFailure, OperationFailure | |
| from typing import Optional | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # Logging | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger("maria_pt") | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # Environment / Secrets (set in HF Space Settings β Secrets) | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| def _require_secret(key: str) -> str: | |
| value = os.environ.get(key) | |
| if not value: | |
| raise RuntimeError(f"Missing required secret: {key}") | |
| return value | |
| EXPECTED_HASH = _require_secret("EXPECTED_HASH") | |
| MONGO_PASSWORD = _require_secret("MONGO_PASSWORD") | |
| MONGO_DB = os.environ.get("MONGO_DB", "MariaPTDB") | |
| MONGO_COLLECTION = os.environ.get("MONGO_COLL", "MariaPTColl") | |
| MONGO_URI = os.environ.get("MONGO_URI") or \ | |
| f"mongodb+srv://testuser:{MONGO_PASSWORD}@cluster0.ntz2mpi.mongodb.net/" | |
| # Cloudflare Turnstile secret (placeholder β paste your real secret in HF Secrets) | |
| CF_TURNSTILE_SECRET = os.environ.get("CF_TURNSTILE_SECRET", "PLACEHOLDER_CF_TURNSTILE_SECRET") | |
| ALLOWED_DOMAIN = os.environ.get("ALLOWED_DOMAIN", "buildwithsupratim.github.io") | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # FastAPI App | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI(title="Maria PT API", version="1.0.0", docs_url=None, redoc_url=None) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["https://buildwithsupratim.github.io"], | |
| allow_credentials=True, | |
| allow_methods=["GET", "POST"], | |
| allow_headers=["*"], | |
| ) | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # MongoDB Client (lazy singleton) | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| _mongo_client: Optional[MongoClient] = None | |
| def get_mongo_collection(): | |
| global _mongo_client | |
| if _mongo_client is None: | |
| _mongo_client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000) | |
| db = _mongo_client[MONGO_DB] | |
| coll = db[MONGO_COLLECTION] | |
| # Ensure capped (100 MB) β silently ignored if collection already exists | |
| try: | |
| db.create_collection( | |
| MONGO_COLLECTION, | |
| capped=True, | |
| size=100_000_000, | |
| ) | |
| except Exception: | |
| pass | |
| return coll | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # Auth Helpers | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| def _hash_auth_code(auth_code: str) -> str: | |
| return hashlib.sha256(auth_code.encode()).hexdigest() | |
| def _primary_auth(request: Request) -> bool: | |
| """Check SHA-256 of auth_code header against EXPECTED_HASH.""" | |
| auth_code = request.headers.get("auth_code") or request.headers.get("Auth-Code") | |
| if not auth_code: | |
| return False | |
| return _hash_auth_code(auth_code) == EXPECTED_HASH | |
| async def _cloudflare_domain_check(request: Request) -> bool: | |
| """ | |
| Cloudflare-backed fallback: | |
| 1. Extract the Referer / Origin header. | |
| 2. Verify it belongs to ALLOWED_DOMAIN (domain-level check). | |
| 3. Optionally verify a Cloudflare Turnstile token if present in headers. | |
| """ | |
| # ββ Step 1: Domain/Referer check ββββββββββββββββββββββββββββββββββββββ | |
| referer = request.headers.get("referer", "") | |
| origin = request.headers.get("origin", "") | |
| referer_ok = ALLOWED_DOMAIN in referer | |
| origin_ok = ALLOWED_DOMAIN in origin | |
| if not (referer_ok or origin_ok): | |
| logger.warning("Blocked β domain not allowed. Referer=%s Origin=%s", referer, origin) | |
| return False | |
| # ββ Step 2: Cloudflare Turnstile token (optional header) βββββββββββββ | |
| # If the front-end sends a CF-Turnstile-Token header we verify it. | |
| # If no token is sent we fall back to domain-only gating. | |
| cf_token = request.headers.get("CF-Turnstile-Token") | |
| if cf_token and CF_TURNSTILE_SECRET != "PLACEHOLDER_CF_TURNSTILE_SECRET": | |
| try: | |
| async with httpx.AsyncClient(timeout=5.0) as client: | |
| resp = await client.post( | |
| "https://challenges.cloudflare.com/turnstile/v0/siteverify", | |
| data={ | |
| "secret": CF_TURNSTILE_SECRET, | |
| "response": cf_token, | |
| "remoteip": request.client.host, | |
| }, | |
| ) | |
| result = resp.json() | |
| if not result.get("success"): | |
| logger.warning("Cloudflare Turnstile rejected: %s", result) | |
| return False | |
| except Exception as exc: | |
| logger.error("Turnstile verification error: %s", exc) | |
| return False | |
| return True | |
| async def require_auth(request: Request): | |
| """ | |
| Gate every protected endpoint. | |
| Primary β SHA-256 hash check on auth_code header. | |
| Fallback β Cloudflare domain / Turnstile check. | |
| Block β 403 + client IP is logged (extend here for real IP-ban storage). | |
| """ | |
| if _primary_auth(request): | |
| return # β Primary auth passed | |
| if await _cloudflare_domain_check(request): | |
| return # β Cloudflare check passed | |
| # β Both checks failed β block | |
| client_ip = request.client.host | |
| logger.warning("BLOCKED IP: %s", client_ip) | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail="Access denied. Invalid credentials or unauthorized origin.", | |
| ) | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # Request Schema | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| class BaseStartRequest(BaseModel): | |
| request: str # e.g. "send_code" | |
| student_name: str # e.g. "Greti" | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # Response Builders | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| def build_learning_path(doc: dict, student_name: str) -> dict: | |
| """ | |
| Merge the MongoDB document with the student_name from the API call, | |
| then build the full learning_path response with blank/initialised fields. | |
| """ | |
| curriculum_objectives = doc.get("curriculum_objectives", []) | |
| # ββ Normalise curriculum (topic β topics key) βββββββββββββββββββββββββ | |
| normalised_curriculum = [] | |
| for item in curriculum_objectives: | |
| normalised_curriculum.append({ | |
| "topics": item.get("topic", ""), | |
| "content": item.get("content", ""), | |
| "learning_objectives": item.get("learning_objectives", []), | |
| }) | |
| # ββ assessment_stages: initialise with FIRST topic only βββββββββββββββ | |
| first_topic = curriculum_objectives[0] if curriculum_objectives else {} | |
| first_learning_objectives = [ | |
| { | |
| "goal": goal, | |
| "teach": "not_complete", | |
| "re_teach": "not_complete", | |
| "show_and_tell":"not_complete", | |
| "assess": "not_complete", | |
| } | |
| for goal in first_topic.get("learning_objectives", []) | |
| ] | |
| current_learning = [ | |
| { | |
| "topic": first_topic.get("topic", ""), | |
| "content": first_topic.get("content", ""), | |
| "learning_objectives": first_learning_objectives, | |
| } | |
| ] if first_topic else [] | |
| return { | |
| "learning_path": { | |
| "board": doc.get("board", ""), | |
| "class": doc.get("class", ""), | |
| "subject": doc.get("subject", ""), | |
| "student_name": student_name, # from API call | |
| "teacher_persona": doc.get("teacher_persona", ""), | |
| "curriculum_objectives": normalised_curriculum, | |
| "chat_history": [], # blank β yet to start | |
| "scratchpad": [], # blank β yet to start | |
| "assessment_stages": { | |
| "current_learning": current_learning, | |
| }, | |
| } | |
| } | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # Endpoints | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| async def ping(request: Request): | |
| """Health-check endpoint β wakes the Space if sleeping.""" | |
| await require_auth(request) | |
| return JSONResponse(content={"status": "alive"}) | |
| async def base_start(request: Request, body: BaseStartRequest): | |
| """ | |
| Accepts { request, student_name }, queries MongoDB for matching request, | |
| and returns an initialised learning_path JSON. | |
| """ | |
| await require_auth(request) | |
| # ββ MongoDB Lookup ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| try: | |
| collection = get_mongo_collection() | |
| doc = collection.find_one( | |
| {"request": body.request}, | |
| {"_id": 0}, # exclude Mongo internal _id | |
| ) | |
| except (ConnectionFailure, OperationFailure) as exc: | |
| logger.error("MongoDB error: %s", exc) | |
| raise HTTPException( | |
| status_code=status.HTTP_503_SERVICE_UNAVAILABLE, | |
| detail="Database connection error. Please try again later.", | |
| ) | |
| if doc is None: | |
| raise HTTPException( | |
| status_code=status.HTTP_404_NOT_FOUND, | |
| detail=f"No curriculum found for request '{body.request}'.", | |
| ) | |
| # ββ Build & Return Response βββββββββββββββββββββββββββββββββββββββββββ | |
| response_payload = build_learning_path(doc, body.student_name) | |
| return JSONResponse(content=response_payload) | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # Entry point (for local testing) | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=True) | |