CyberArena / app /services /supabase_service.py
Hussien Haider
H
cb16781
Raw
History Blame Contribute Delete
9.3 kB
"""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