Spaces:
Sleeping
Sleeping
| """Supabase PostgREST client for the per-type challenge tables. | |
| Wraps the raw ``httpx`` calls in small, typed async functions. The | |
| challenge-loader / scenario-service modules build on top of these. | |
| """ | |
| import random | |
| import re | |
| from typing import Optional | |
| import httpx | |
| from app.core.config import SUPABASE_URL, SUPABASE_ANON_KEY | |
| # --------------------------------------------------------------------------- # | |
| # Input sanitization # | |
| # --------------------------------------------------------------------------- # | |
| def _sanitize_id(value: str) -> str: | |
| """Strip anything that isn't a UUID or alphanumeric — prevents PostgREST injection.""" | |
| if not value: | |
| return "" | |
| return re.sub(r"[^a-zA-Z0-9\-]", "", value)[:128] | |
| def _sanitize_string(value: str, max_len: int = 200) -> str: | |
| """Strip PostgREST operators and limit length.""" | |
| if not value: | |
| return "" | |
| # Remove PostgREST filter operators | |
| cleaned = re.sub(r"[=&<>!|;()\[\]{}]", "", value) | |
| return cleaned[:max_len] | |
| # --------------------------------------------------------------------------- # | |
| # Table routing # | |
| # --------------------------------------------------------------------------- # | |
| def scenario_table(team_role: str = "", challenge_type: str = "") -> str: | |
| """Pick the right per-type table for a challenge lookup. | |
| ``challenge_type`` can be one of: ``"crypto"``, ``"steganography"``, | |
| ``"code-fixing"``, ``"log-analysis"``, ``"vulnerability-hunter"``. | |
| If omitted we fall back to ``"encryption_challenges"`` for legacy | |
| behavior (Blue/Red crypto). | |
| """ | |
| if challenge_type == "crypto": | |
| return "encryption_challenges" | |
| if challenge_type == "steganography": | |
| return "steganography_challenges" | |
| if challenge_type == "code-fixing": | |
| return "code_fixing_challenges" | |
| if challenge_type == "log-analysis": | |
| return "log_analysis_challenges" | |
| if challenge_type == "vulnerability-hunter": | |
| return "vulnerability_hunter_challenges" | |
| if challenge_type == "web-exploitation": | |
| return "web_exploitation_challenges" | |
| # Auto-detect (legacy callers that pass only team_role still work) | |
| return "encryption_challenges" | |
| # --------------------------------------------------------------------------- # | |
| # HTTP helpers # | |
| # --------------------------------------------------------------------------- # | |
| def supabase_headers(content_type: bool = False) -> dict: | |
| headers = { | |
| "apikey": SUPABASE_ANON_KEY, | |
| "Authorization": f"Bearer {SUPABASE_ANON_KEY}", | |
| } | |
| if content_type: | |
| headers["Content-Type"] = "application/json" | |
| return headers | |
| # --------------------------------------------------------------------------- # | |
| # Scenario CRUD # | |
| # --------------------------------------------------------------------------- # | |
| async def get_supabase_scenario_count(team_role: str, challenge_type: str = "") -> int: | |
| if not SUPABASE_ANON_KEY or not SUPABASE_URL: | |
| return 0 | |
| table = scenario_table(team_role, challenge_type) | |
| url = f"{SUPABASE_URL}/rest/v1/{table}?select=id" | |
| try: | |
| async with httpx.AsyncClient() as client: | |
| resp = await client.get(url, headers=supabase_headers()) | |
| if resp.status_code == 200: | |
| return len(resp.json()) | |
| except Exception as e: | |
| print(f"Error checking Supabase scenario count: {e}") | |
| return 0 | |
| async def fetch_scenario_by_id( | |
| team_role: str, scenario_id: str, challenge_type: str = "" | |
| ) -> Optional[dict]: | |
| if not SUPABASE_ANON_KEY or not SUPABASE_URL: | |
| return None | |
| # Sanitize inputs to prevent PostgREST injection | |
| scenario_id = _sanitize_id(scenario_id) | |
| team_role = _sanitize_string(team_role, 10) | |
| if not scenario_id or not team_role: | |
| return None | |
| async def _fetch(tbl: str) -> Optional[dict]: | |
| url = f"{SUPABASE_URL}/rest/v1/{tbl}?id=eq.{scenario_id}&team_role=eq.{team_role}" | |
| try: | |
| async with httpx.AsyncClient() as client: | |
| resp = await client.get(url, headers=supabase_headers()) | |
| if resp.status_code == 200: | |
| rows = resp.json() | |
| if rows: | |
| return rows[0] | |
| except Exception as e: | |
| print(f"Error fetching scenario {scenario_id} from {tbl}: {e}") | |
| return None | |
| if challenge_type: | |
| tbl = scenario_table(team_role, challenge_type) | |
| return await _fetch(tbl) | |
| # Search all tables for this team role | |
| tables = [] | |
| if team_role == "red": | |
| tables = ["encryption_challenges", "steganography_challenges", "web_exploitation_challenges"] | |
| elif team_role == "blue": | |
| tables = [ | |
| "code_fixing_challenges", | |
| "log_analysis_challenges", | |
| "vulnerability_hunter_challenges", | |
| ] | |
| else: | |
| tables = [ | |
| "encryption_challenges", | |
| "steganography_challenges", | |
| "web_exploitation_challenges", | |
| "code_fixing_challenges", | |
| "log_analysis_challenges", | |
| "vulnerability_hunter_challenges", | |
| ] | |
| for tbl in tables: | |
| row = await _fetch(tbl) | |
| if row: | |
| return row | |
| return None | |
| async def fetch_random_scenario_from_supabase( | |
| team_role: str, module: str, challenge_type: str = "" | |
| ) -> Optional[dict]: | |
| if not SUPABASE_ANON_KEY or not SUPABASE_URL: | |
| return None | |
| table = scenario_table(team_role, challenge_type) | |
| # Sanitize inputs | |
| team_role = _sanitize_string(team_role, 10) | |
| module = _sanitize_string(module, 50) | |
| if not team_role: | |
| return None | |
| # When ``module`` is empty we deliberately skip the module filter | |
| # — this is the "give me any row of this type" code path used by | |
| # the dashboard list. The previous behaviour built a | |
| # ``module=eq.`` clause, which matched nothing because the module | |
| # column never stores an empty string. | |
| params: list[str] = [f"team_role=eq.{team_role}"] | |
| if module: | |
| params.append(f"module=eq.{module}") | |
| # Ask for a small page (PostgREST default is 1000). The pool sizes | |
| # we deal with are < 30, so a single page is enough. | |
| params.append("limit=50") | |
| url = f"{SUPABASE_URL}/rest/v1/{table}?{'&'.join(params)}" | |
| try: | |
| async with httpx.AsyncClient() as client: | |
| resp = await client.get(url, headers=supabase_headers()) | |
| if resp.status_code == 200: | |
| scenarios = resp.json() | |
| if scenarios: | |
| return random.choice(scenarios) | |
| except Exception as e: | |
| print(f"Error fetching scenario from Supabase: {e}") | |
| return None | |
| async def fetch_all_scenarios_for_type( | |
| team_role: str, challenge_type: str = "", limit: int = 100 | |
| ) -> list[dict]: | |
| """Return every row for a (team, type) pair, optionally limited. | |
| Used by ``/api/training/list`` — the dashboard wants a complete | |
| catalogue, not a random sample. The function caps the result at | |
| ``limit`` (default 100) so a runaway pool can't blow up the | |
| payload. | |
| """ | |
| if not SUPABASE_ANON_KEY or not SUPABASE_URL: | |
| return [] | |
| table = scenario_table(team_role, challenge_type) | |
| url = ( | |
| f"{SUPABASE_URL}/rest/v1/{table}" | |
| f"?team_role=eq.{team_role}&limit={int(limit)}&order=created_at.desc" | |
| ) | |
| try: | |
| async with httpx.AsyncClient() as client: | |
| resp = await client.get(url, headers=supabase_headers()) | |
| if resp.status_code == 200: | |
| rows = resp.json() | |
| return list(rows) if isinstance(rows, list) else [] | |
| except Exception as e: | |
| print(f"Error fetching scenarios for {table}: {e}") | |
| return [] | |
| async def delete_scenario_from_supabase( | |
| scenario_id: str, team_role: str, challenge_type: str = "" | |
| ): | |
| if not SUPABASE_ANON_KEY or not SUPABASE_URL: | |
| return | |
| table = scenario_table(team_role, challenge_type) | |
| url = f"{SUPABASE_URL}/rest/v1/{table}?id=eq.{scenario_id}&team_role=eq.{team_role}" | |
| try: | |
| async with httpx.AsyncClient() as client: | |
| await client.delete(url, headers=supabase_headers()) | |
| except Exception as e: | |
| print(f"Error deleting scenario {scenario_id} from Supabase: {e}") | |
| async def insert_scenario_to_supabase( | |
| scenario_data: dict, team_role: str, challenge_type: str = "" | |
| ) -> Optional[dict]: | |
| if not SUPABASE_ANON_KEY or not SUPABASE_URL: | |
| return None | |
| table = scenario_table(team_role, challenge_type) | |
| url = f"{SUPABASE_URL}/rest/v1/{table}" | |
| headers = supabase_headers(content_type=True) | |
| headers["Prefer"] = "return=representation" | |
| try: | |
| async with httpx.AsyncClient() as client: | |
| resp = await client.post(url, json=scenario_data, headers=headers) | |
| if resp.status_code in (200, 201): | |
| rows = resp.json() | |
| if rows: | |
| return rows[0] | |
| except Exception as e: | |
| print(f"Error inserting scenario to Supabase: {e}") | |
| return None | |