"""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