CyberArena / app /api /daily_challenge.py
Hussien Haider
H
11fa4b6
Raw
History Blame Contribute Delete
10 kB
"""``/api/daily-challenge`` — Daily random challenge with 2× XP.
Every 24 hours a random challenge is picked from the pool. Students
who complete it get double XP (base + bonus via ``/claim``).
"""
import random
from datetime import datetime, date, timedelta, timezone
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from app.core.auth import get_current_user
from app.core.config import SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_EDGE_URL
from app.services.supabase_service import supabase_headers
router = APIRouter()
# Per-type tables we can pick a daily challenge from.
_TABLES: list[tuple[str, str, str]] = [
("encryption_challenges", "crypto", "red"),
("code_fixing_challenges", "code-fixing", "blue"),
("log_analysis_challenges", "log-analysis", "blue"),
("vulnerability_hunter_challenges", "vulnerability-hunter", "blue"),
("web_exploitation_challenges", "web-exploitation", "red"),
]
class DailyChallengeResponse(BaseModel):
challengeId: str
challengeType: str
teamRole: str
title: str
difficulty: str
xpReward: int
xpMultiplier: int = 2
timeRemaining: int # seconds until midnight
completed: bool = False
class ClaimRequest(BaseModel):
challengeId: str
xpAmount: int = 0
class ClaimResponse(BaseModel):
bonus: int
# --------------------------------------------------------------------------- #
# GET /api/daily-challenge #
# --------------------------------------------------------------------------- #
@router.get("/api/daily-challenge")
async def get_daily_challenge(user: dict = Depends(get_current_user)):
today = date.today()
dc = await _fetch_today_daily_challenge(today)
if dc:
challenge_info = await _fetch_challenge_info(
dc["challenge_id"], dc["challenge_type"], dc["team_role"]
)
if challenge_info:
remaining = _seconds_until_midnight()
completed = await _check_user_completed(user["user_id"], today)
return DailyChallengeResponse(
challengeId=dc["challenge_id"],
challengeType=dc["challenge_type"],
teamRole=dc["team_role"],
title=challenge_info["title"],
difficulty=challenge_info["difficulty"],
xpReward=int(dc.get("xp_reward", challenge_info["xp_reward"])),
timeRemaining=remaining,
completed=completed,
)
# No daily challenge for today yet — pick one.
dc = await _pick_daily_challenge(today)
if not dc:
raise HTTPException(status_code=404, detail="لا توجد تحديات متاحة للتحدي اليومي")
challenge_info = await _fetch_challenge_info(
dc["challenge_id"], dc["challenge_type"], dc["team_role"]
)
if not challenge_info:
raise HTTPException(status_code=404, detail="التحدي غير متاح حالياً")
remaining = _seconds_until_midnight()
completed = await _check_user_completed(user["user_id"], today)
return DailyChallengeResponse(
challengeId=dc["challenge_id"],
challengeType=dc["challenge_type"],
teamRole=dc["team_role"],
title=challenge_info["title"],
difficulty=challenge_info["difficulty"],
xpReward=int(dc.get("xp_reward", challenge_info["xp_reward"])),
timeRemaining=remaining,
completed=completed,
)
# --------------------------------------------------------------------------- #
# POST /api/daily-challenge/claim #
# --------------------------------------------------------------------------- #
@router.post("/api/daily-challenge/claim")
async def claim_daily_challenge(
req: ClaimRequest,
request: Request,
user: dict = Depends(get_current_user),
):
today = date.today()
dc = await _fetch_today_daily_challenge(today)
if not dc or dc["challenge_id"] != req.challengeId:
return ClaimResponse(bonus=0)
bonus = req.xpAmount or int(dc.get("xp_reward", 150))
if bonus <= 0:
return ClaimResponse(bonus=0)
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
f"{SUPABASE_EDGE_URL}/apex-xp",
json={"action": "add_xp", "user_id": user["user_id"], "xp_amount": bonus},
)
if resp.status_code != 200:
raise HTTPException(status_code=502, detail="فشل إضافة نقاط XP")
# Mark as completed for this user (idempotent via UNIQUE)
await _mark_user_completed(user["user_id"], today, req.challengeId)
return ClaimResponse(bonus=bonus)
# --------------------------------------------------------------------------- #
# Internal helpers #
# --------------------------------------------------------------------------- #
async def _check_user_completed(user_id: str, today: date) -> bool:
if not SUPABASE_URL or not SUPABASE_ANON_KEY:
return False
url = (
f"{SUPABASE_URL}/rest/v1/user_daily_completions"
f"?user_id=eq.{user_id}&active_date=eq.{today.isoformat()}&limit=1"
)
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(url, headers=supabase_headers())
if r.status_code == 200:
return len(r.json()) > 0
except Exception as e:
print(f"[daily_challenge] check_completed error: {e}")
return False
async def _mark_user_completed(user_id: str, today: date, challenge_id: str):
if not SUPABASE_URL or not SUPABASE_ANON_KEY:
return
url = f"{SUPABASE_URL}/rest/v1/user_daily_completions"
headers = supabase_headers(content_type=True)
headers["Prefer"] = "return=minimal,resolution=ignore-duplicates"
payload = {
"user_id": user_id,
"active_date": today.isoformat(),
"challenge_id": challenge_id,
}
try:
async with httpx.AsyncClient(timeout=10) as client:
await client.post(url, json=payload, headers=headers)
except Exception as e:
print(f"[daily_challenge] mark_completed error: {e}")
async def _fetch_today_daily_challenge(today: date) -> Optional[dict]:
if not SUPABASE_URL or not SUPABASE_ANON_KEY:
return None
url = (
f"{SUPABASE_URL}/rest/v1/daily_challenges"
f"?active_date=eq.{today.isoformat()}&limit=1"
f"&order=created_at.desc"
)
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(url, headers=supabase_headers())
if r.status_code == 200:
rows = r.json()
if rows:
return rows[0]
except Exception as e:
print(f"[daily_challenge] fetch error: {e}")
return None
async def _pick_daily_challenge(today: date) -> Optional[dict]:
if not SUPABASE_URL or not SUPABASE_ANON_KEY:
return None
candidates = []
for table, ctype, team in _TABLES:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(
f"{SUPABASE_URL}/rest/v1/{table}"
f"?select=id,title,difficulty,xp_reward"
f"&order=created_at.desc&limit=50",
headers=supabase_headers(),
)
if r.status_code == 200:
rows = r.json()
for row in rows:
candidates.append((row, ctype, team))
except Exception as e:
print(f"[daily_challenge] {table}: {e}")
continue
if not candidates:
return None
row, ctype, team = random.choice(candidates)
xp_reward = int(row.get("xp_reward") or 150)
payload = {
"challenge_id": row["id"],
"challenge_type": ctype,
"team_role": team,
"xp_reward": xp_reward,
"active_date": today.isoformat(),
}
url = f"{SUPABASE_URL}/rest/v1/daily_challenges"
headers = supabase_headers(content_type=True)
headers["Prefer"] = "return=representation,resolution=ignore-duplicates"
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(url, json=payload, headers=headers)
if r.status_code in (200, 201):
rows = r.json()
if rows:
return rows[0]
else:
print(f"[daily_challenge] insert failed: {r.status_code} {r.text[:200]}")
except Exception as e:
print(f"[daily_challenge] insert exception: {e}")
return payload # best-effort return
async def _fetch_challenge_info(
challenge_id: str, challenge_type: str, team_role: str
) -> Optional[dict]:
table = {
"crypto": "encryption_challenges",
"code-fixing": "code_fixing_challenges",
"log-analysis": "log_analysis_challenges",
"vulnerability-hunter": "vulnerability_hunter_challenges",
"web-exploitation": "web_exploitation_challenges",
}.get(challenge_type)
if not table or not SUPABASE_URL or not SUPABASE_ANON_KEY:
return None
url = f"{SUPABASE_URL}/rest/v1/{table}?id=eq.{challenge_id}&limit=1"
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(url, headers=supabase_headers())
if r.status_code == 200:
rows = r.json()
if rows:
return rows[0]
except Exception as e:
print(f"[daily_challenge] fetch_challenge_info: {e}")
return None
def _seconds_until_midnight() -> int:
now = datetime.now(timezone.utc)
midnight = datetime(now.year, now.month, now.day, tzinfo=timezone.utc).replace(
hour=0, minute=0, second=0
) + timedelta(days=1)
return int((midnight - now).total_seconds())