Skip to content
Open
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
11 changes: 11 additions & 0 deletions app/api/dependencies/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,17 @@ async def get_current_user_from_cookie(
request: Request,
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
) -> User:
"""
Retrieve the authenticated user from bearer credentials or an access-token cookie.

Parameters:
db (DbSession): Database session used to load the user.
request (Request): Request containing the access-token cookie when bearer credentials are unavailable.
credentials (HTTPAuthorizationCredentials | None): Optional bearer credentials.

Returns:
User: The authenticated user.
"""
token = None
if credentials is not None:
token = credentials.credentials
Expand Down
27 changes: 25 additions & 2 deletions app/api/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,23 @@ def mount_docs_static(app: FastAPI) -> None:


def register_docs_routes(app: FastAPI) -> None:
"""Register custom Swagger UI and ReDoc routes."""
"""
Register custom Swagger UI and ReDoc routes.

The routes use local documentation assets when available and fall back to jsDelivr assets otherwise. Generated pages include request-specific script nonces and apply a Content Security Policy when CDN assets are used.
"""

@app.get("/docs", include_in_schema=False)
async def custom_docs(request: Request) -> HTMLResponse:
"""
Generate the Swagger UI API documentation page.

Parameters:
request (Request): The incoming request providing the application OpenAPI configuration and security nonce.

Returns:
HTMLResponse: The rendered Swagger UI page, using local assets when available and CDN assets with an appropriate content security policy otherwise.
"""
nonce = request.state.nonce
swagger_dir = APP_DIR / "static" / "swagger"
if swagger_dir.exists():
Expand Down Expand Up @@ -70,6 +83,7 @@ async def custom_docs(request: Request) -> HTMLResponse:

@app.get("/redoc", include_in_schema=False)
async def custom_redoc(request: Request) -> HTMLResponse:
"""Generate the ReDoc API documentation page using local or CDN assets."""
nonce = request.state.nonce
redoc_dir = APP_DIR / "static" / "redoc"
if redoc_dir.exists():
Expand All @@ -95,7 +109,16 @@ async def custom_redoc(request: Request) -> HTMLResponse:


def _inject_inline_script_nonce(html: str, nonce: str) -> str:
"""Add the request nonce to FastAPI's generated inline docs script."""
"""
Add a nonce attribute to FastAPI-generated inline documentation scripts.

Parameters:
html (str): Generated documentation HTML.
nonce (str): Nonce value to add to matching inline script tags.

Returns:
str: Documentation HTML with the nonce added to matching inline scripts.
"""
return html.replace(
"<script>\n const ui =", f'<script nonce="{nonce}">\n const ui =',
).replace("<script>\nconst ui =", f'<script nonce="{nonce}">\nconst ui =')
Expand Down
10 changes: 10 additions & 0 deletions app/api/middleware/prometheus.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ class PrometheusMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""
Collect Prometheus metrics for an HTTP request and its response.

Parameters:
request (Request): The incoming HTTP request.
call_next (Callable): The next middleware or request handler.

Returns:
Response: The response produced by the next middleware or request handler.
"""
if request.url.path == "/metrics":
return await call_next(request)

Expand Down
9 changes: 9 additions & 0 deletions app/api/middleware/request_body_size.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ class RequestBodySizeMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""
Limit request bodies to the configured maximum size.

Parameters:
request (Request): The incoming HTTP request.

Returns:
Response: The downstream response, or a 413 response when the declared body size exceeds the limit.
"""
if request.method in ("GET", "HEAD", "OPTIONS"):
return await call_next(request)

Expand Down
11 changes: 10 additions & 1 deletion app/api/middleware/security_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@


async def add_security_headers(request: Request, call_next: Any) -> Any:
"""Add Content-Security-Policy and other security headers to all responses."""
"""
Add security headers, including a nonce-based Content Security Policy, to the response.

Parameters:
request (Request): The incoming request whose state receives the generated CSP nonce.
call_next (Any): The next middleware or request handler to invoke.

Returns:
Any: The response with security headers applied.
"""
nonce = uuid.uuid4().hex
request.state.nonce = nonce

Expand Down
51 changes: 40 additions & 11 deletions app/api/rate_limit_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,23 @@ def limit(
*args: Any,
**kwargs: Any,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Return a no-op decorator."""
"""
Provide a decorator that leaves the decorated function unchanged.

Returns:
Callable[..., Any]: A decorator that returns the original function.
"""

def noop_decorator(func: Callable[..., Any]) -> Callable[..., Any]:
"""
Return the original function unchanged.

Parameters:
func (Callable[..., Any]): The function to leave undecorated.

Returns:
Callable[..., Any]: The original function.
"""
return func

return noop_decorator
Expand All @@ -42,10 +56,15 @@ async def __call__(self, request: Request, *args: Any, **kwargs: Any) -> None:


def _parse_retry_after(detail: str) -> int:
"""Parse slowapi detail string to get retry-after seconds.

Detail format: "X per Y <unit>" e.g., "5 per 1 minute"
Returns integer seconds until retry is allowed.
"""
Parse a rate-limit detail string into a retry interval.

Parameters:
detail (str): Rate-limit description such as ``"5 per 1 minute"``.

Returns:
int: Retry interval in seconds, defaulting to 60 when the description
cannot be parsed or uses an unsupported unit.
"""
match = re.match(r"(\d+)\s+per\s+(\d+)\s+(\w+)", detail)
if not match:
Expand All @@ -72,12 +91,22 @@ async def rate_limit_exceeded_handler(
request: Request,
exc: Exception,
) -> JSONResponse | HTMLResponse:
"""Handle rate limit exceeded errors with standardized error response.

Returns JSON for API requests (REST clients) and HTML for HTMX requests
(web UI forms). HTMX form submissions that hit the rate limit should not
receive JSON, because the JS error handler may inadvertently swap it into
the DOM target (see renderErrorInTarget in htmx-error-handler.js).
"""
Handle rate-limit violations with a standardized response.

HTMX requests receive an HTML error fragment; other requests receive a JSON
error response. Both responses include the retry interval in the
``Retry-After`` header.

Parameters:
request (Request): The incoming request.
exc (Exception): The exception raised by the rate-limit check.

Returns:
JSONResponse | HTMLResponse: A 429 response in JSON or HTML format.

Raises:
Exception: Re-raises exceptions that are not rate-limit violations.
"""
if not isinstance(exc, RateLimitExceeded):
raise exc
Expand Down
30 changes: 29 additions & 1 deletion app/api/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,18 @@ async def login(
user_data: UserCreate,
db: DbSession,
) -> Token:
"""
Authenticate a user and issue access and refresh tokens.

Parameters:
user_data (UserCreate): User email and password used for authentication.

Returns:
Token: The access token, refresh token, and bearer token type.

Raises:
HTTPException: If the credentials are invalid or the user account is inactive.
"""
result = await db.execute(select(User).where(User.email == user_data.email, not_deleted()))
user = result.scalar_one_or_none()

Expand Down Expand Up @@ -202,6 +214,18 @@ async def refresh(
) -> Token:
# Accept refresh token from body or from HttpOnly cookie
# This allows JS-free refresh via credentials: 'include' sending the cookie
"""
Issue replacement access and refresh tokens using a valid refresh token supplied in the request body or cookie.

Parameters:
token_refresh (TokenRefresh | None): Optional request-body refresh token; the refresh-token cookie is used when omitted.

Returns:
Token: Newly issued access and refresh tokens.

Raises:
HTTPException: If the refresh token is missing, invalid, expired, revoked, malformed, or belongs to an inactive or nonexistent user.
"""
refresh_token_str = token_refresh.refresh_token if token_refresh else None
if not refresh_token_str:
refresh_token_str = request.cookies.get("__Host-refresh_token")
Expand Down Expand Up @@ -302,7 +326,11 @@ async def _blacklist_token_cookie(
verify_fn: Any,
blacklist_fn: Any,
) -> None:
"""Extract jti from a token cookie and blacklist it if valid."""
"""
Blacklist the token identified by a valid cookie value.

The token's remaining lifetime determines the blacklist duration, with a minimum of 60 seconds.
"""
if not token_str:
return
payload = verify_fn(token_str)
Expand Down
45 changes: 41 additions & 4 deletions app/api/routes/chaos.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ class ChaosStatus(BaseModel):


def _scenario_key(scenario: str) -> str:
"""Map a chaos scenario to its configured Redis key.

Parameters:
scenario (str): The chaos scenario name.

Returns:
str: The configured Redis key, or a `chaos:`-prefixed key for unknown scenarios.
"""
return SCENARIO_KEY_MAP.get(scenario, f"chaos:{scenario}")


Expand All @@ -53,6 +61,16 @@ async def inject_chaos(
scenario: str = Form(...),
duration_seconds: int = Form(30),
) -> dict[str, Any] | JSONResponse:
"""
Activate a chaos scenario for a specified duration.

Parameters:
scenario (str): Chaos scenario to activate.
duration_seconds (int): Duration of the activation in seconds.

Returns:
dict[str, Any] | JSONResponse: Activation details on success, or a 403 response when CSRF validation fails.
"""
_require_feature_flag()
if not await validate_csrf_token(request):
return JSONResponse(
Expand Down Expand Up @@ -100,6 +118,11 @@ async def reset_chaos(
request: Request,
_user: CurrentUserFromCookie,
) -> dict[str, Any] | JSONResponse:
"""Reset all active chaos scenarios.

Returns:
dict[str, Any] | JSONResponse: A success response containing the number of deleted scenario keys, or a 403 response when CSRF validation fails.
"""
_require_feature_flag()
if not await validate_csrf_token(request):
return JSONResponse(
Expand All @@ -122,6 +145,11 @@ async def chaos_status(
request: Request,
_user: CurrentUserFromCookie,
) -> dict[str, Any]:
"""Report the active status of each chaos scenario.

Returns:
dict[str, Any]: A response containing the active status for each scenario.
"""
_require_feature_flag()

from core.redis_client import KEY_TO_SCENARIO_FIELD
Expand All @@ -142,10 +170,19 @@ async def chaos_submit_videos(
db: DbSession,
count: int = Form(default=10),
) -> dict[str, Any] | JSONResponse:
"""Bulk submit demo video URLs for chaos lab.

Creates N random download jobs from the demo URL pool.
Only available when FEATURE_CHAOS_API_ENABLED=true.
"""
Submit randomly selected demo video URLs for processing.

The requested count is limited to the range 1–50. An invalid CSRF token produces
a 403 response.

Parameters:
count (int): Number of demo videos to submit.

Returns:
dict[str, Any] | JSONResponse: Submission details containing the number
of jobs created, the effective requested count, and their URLs, or a 403
response when CSRF validation fails.
"""
_require_feature_flag()
if not await validate_csrf_token(request):
Expand Down
7 changes: 6 additions & 1 deletion app/api/routes/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,12 @@ async def _check_redis(redis_url: str) -> str:
},
)
async def health_check() -> HealthStatus:
"""Returns the health status using independent, direct connections.
"""
Reports the health of the database and Redis dependencies.

Returns:
HealthStatus: A timestamped health result with dependency statuses and an
overall status of "healthy" or "unhealthy".
"""
health_status: HealthStatus = {
"status": "healthy",
Expand Down
Loading