Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/backend.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v2
uses: actions/setup-python@v5
with:
python-version: 3.11
- name: Linting
Expand Down
2 changes: 1 addition & 1 deletion backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ The `EPA` class (in `main.py`) extends the `Model` base class (`src/models/templ

Each team's rating is a `SkewNormal` distribution (`src/models/epa/math.py`).

**EPA dimensions** (for 2016+): `[total, auto, teleop, endgame, rp_1, rp_2, rp_3, tiebreaker, comp_1..comp_18]` — indices 0-25. Pre-2016 only uses `total`.
**EPA dimensions** (for 2016+): `[total, auto, teleop, endgame, rp_1, rp_2, rp_3, tiebreaker, comp_0..comp_9]` — indices 0-17 (max). Pre-2016 only uses `total`.

**Key constants** (`src/models/epa/constants.py`):
- `NORM_MEAN = 1500`, `NORM_SD = 250` — normalized EPA scale
Expand Down
24 changes: 11 additions & 13 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,45 +3,35 @@

from dotenv import load_dotenv # type: ignore

# from fastapi import APIRouter, Depends, FastAPI, Request, Security
# from fastapi.exceptions import HTTPException
from fastapi import APIRouter, FastAPI, Request
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.security.api_key import APIKeyHeader
from pyinstrument import Profiler

load_dotenv()

# flake8: noqa E402
from src.api.router import router as api_router

# from src.constants import AUTH_KEY_BLACKLIST, CONN_STR, PROD
from src.auth import generate_api_key, get_api_key
from src.constants import CONN_STR, PROD
from src.data.router import (
data_router as data_data_router,
site_router as data_site_router,
)
from src.site.router import router as site_router

# from src.utils.utils import is_uuid

"""
SETUP
"""

api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)


app = FastAPI(
title="Statbotics REST API",
description="The REST API for Statbotics. Please be nice to our servers! If you are looking to do large-scale data science projects, use the CSV exports on the GitHub repo.",
version="3.0.0",
# dependencies=[Security(get_api_key)],
dependencies=[Depends(get_api_key)],
swagger_ui_parameters={"persistAuthorization": True},
)

# Removed CORS to enable website integrationss
origins = [
"http://localhost:3000",
"https://statbotics.io",
Expand Down Expand Up @@ -89,6 +79,14 @@ def get_info():
}


@router.get("/generate_key/{counter}")
def get_generate_key(counter: int, request: Request):
origin = request.headers.get("origin", "")
if origin not in origins:
raise HTTPException(status_code=403, detail="Forbidden")
return {"key": generate_api_key(counter)}


app.include_router(router, include_in_schema=False)
app.include_router(api_router, prefix="/v3")
app.include_router(data_data_router, prefix="/v3/data", include_in_schema=False)
Expand Down
3 changes: 0 additions & 3 deletions backend/src/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
from src.api.match import get_match_cached, get_matches_cached
from src.api.team import get_team_cached, get_teams_cached
from src.api.team_event import get_team_event_cached, get_team_events_cached
from src.api.team_match import get_team_match_cached, get_team_matches_cached
from src.api.team_year import get_team_year_cached, get_team_years_cached
from src.api.year import get_year_cached, get_years_cached

Expand All @@ -13,8 +12,6 @@
"get_matches_cached",
"get_team_event_cached",
"get_team_events_cached",
"get_team_match_cached",
"get_team_matches_cached",
"get_team_year_cached",
"get_team_years_cached",
"get_team_cached",
Expand Down
2 changes: 1 addition & 1 deletion backend/src/api/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

event_type_query = Query(
None,
description="One of [`regional`, `district`, `district_cmp`, `champs_div`, `einstein`, or `offseason`].",
description="One of [`regional`, `district`, `district_cmp`, `champs_div`, or `einstein`].",
)

limit_query = Query(
Expand Down
2 changes: 0 additions & 2 deletions backend/src/api/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from src.api.match import router as match_router
from src.api.team import router as team_router
from src.api.team_event import router as team_event_router
from src.api.team_match import router as team_match_router
from src.api.team_year import router as team_year_router
from src.api.year import router as year_router

Expand All @@ -15,7 +14,6 @@
router.include_router(event_router, tags=["Event"])
router.include_router(team_event_router, tags=["TeamEvent"])
router.include_router(match_router, tags=["Match"])
router.include_router(team_match_router, tags=["TeamMatch"])


@router.get("/")
Expand Down
123 changes: 0 additions & 123 deletions backend/src/api/team_match.py

This file was deleted.

100 changes: 100 additions & 0 deletions backend/src/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import hashlib
import os
import time
from collections import defaultdict
from typing import Optional

from fastapi import HTTPException, Request, Security
from fastapi.security.api_key import APIKeyHeader

from src.constants import AUTH_KEY_BLACKLIST

# Secret used to generate and validate API keys. Set via environment variable.
API_SECRET = os.getenv("API_SECRET", "dev-secret")

# Rate limiting: max requests per key per minute
RATE_LIMIT = int(os.getenv("API_RATE_LIMIT", "100"))

# In-memory rate limit tracking: key -> list of request timestamps
_rate_limits: dict[str, list[float]] = defaultdict(list)

api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)


def generate_api_key(counter: int) -> str:
"""Generate a UUID-format API key from a counter value.

Format: first 8 hex chars = zero-padded counter,
remaining 24 hex chars = truncated SHA-256(secret + counter).
Formatted with dashes as a UUID.
"""
counter_hex = f"{counter:08x}"
hash_hex = hashlib.sha256(f"{API_SECRET}{counter}".encode()).hexdigest()[:24]
raw = counter_hex + hash_hex
# Format as UUID: 8-4-4-4-12
return f"{raw[0:8]}-{raw[8:12]}-{raw[12:16]}-{raw[16:20]}-{raw[20:32]}"


def validate_api_key(key: str) -> bool:
"""Validate that a UUID-format API key was generated with our secret."""
stripped = key.replace("-", "")
if len(stripped) != 32:
return False

counter_hex = stripped[:8]
provided_hash = stripped[8:]

try:
counter = int(counter_hex, 16)
except ValueError:
return False

expected_hash = hashlib.sha256(f"{API_SECRET}{counter}".encode()).hexdigest()[:24]
return provided_hash == expected_hash


def _check_rate_limit(key: str) -> None:
"""Enforce per-key rate limiting using in-memory sliding window."""
now = time.time()
window_start = now - 60

# Prune old entries
_rate_limits[key] = [t for t in _rate_limits[key] if t > window_start]

if len(_rate_limits[key]) >= RATE_LIMIT:
raise HTTPException(
status_code=429, detail="Rate limit exceeded. Max 100 requests per minute."
)

_rate_limits[key].append(now)


async def get_api_key(
request: Request,
api_key: Optional[str] = Security(api_key_header),
) -> str:
"""FastAPI dependency that validates the API key and enforces rate limits."""
# Skip auth for non-API routes (site, data, docs, root)
path = request.url.path
if (
not path.startswith("/v3/")
or path.startswith("/v3/site")
or path.startswith("/v3/data")
):
return ""

if not api_key:
raise HTTPException(
status_code=401, detail="Missing API key. Pass it via the X-API-Key header."
)

if not validate_api_key(api_key):
raise HTTPException(status_code=403, detail="Invalid API key.")

counter_hex = api_key.replace("-", "")[:8]
if counter_hex in AUTH_KEY_BLACKLIST:
raise HTTPException(status_code=403, detail="API key has been revoked.")

_check_rate_limit(api_key)

return api_key
Loading
Loading