first commit

This commit is contained in:
2025-11-24 23:10:55 -06:00
commit 8315fa51c9
279 changed files with 74600 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Utility modules for public web frontend."""

View File

@@ -0,0 +1,336 @@
"""
API Client for Public Web Frontend
Provides HTTP request wrapper for communicating with the API backend.
Handles session cookie forwarding, error handling, and response parsing.
"""
import requests
from flask import request as flask_request, session as flask_session
from typing import Optional, Any
from app.config import load_config
from .logging import get_logger
logger = get_logger(__name__)
class APIError(Exception):
"""Base exception for API errors."""
def __init__(self, message: str, status_code: int = 500, details: Optional[dict] = None):
self.message = message
self.status_code = status_code
self.details = details or {}
super().__init__(self.message)
class APITimeoutError(APIError):
"""Raised when API request times out."""
def __init__(self, message: str = "Request timed out"):
super().__init__(message, status_code=504)
class APINotFoundError(APIError):
"""Raised when resource not found (404)."""
def __init__(self, message: str = "Resource not found"):
super().__init__(message, status_code=404)
class APIAuthenticationError(APIError):
"""Raised when authentication fails (401)."""
def __init__(self, message: str = "Authentication required"):
super().__init__(message, status_code=401)
class APIClient:
"""
HTTP client for making requests to the API backend.
Usage:
client = APIClient()
# GET request
response = client.get("/api/v1/characters")
characters = response.get("result", [])
# POST request
response = client.post("/api/v1/characters", data={"name": "Hero"})
# DELETE request
client.delete("/api/v1/characters/123")
"""
def __init__(self):
"""Initialize API client with config."""
self.config = load_config()
self.base_url = self.config.api.base_url.rstrip('/')
self.timeout = self.config.api.timeout
self.verify_ssl = self.config.api.verify_ssl
def _get_cookies(self) -> dict:
"""
Get cookies to forward to API.
Returns:
Dictionary of cookies to forward (session cookie).
"""
cookies = {}
# Get session cookie from Flask session (stored after login)
try:
if 'api_session_cookie' in flask_session:
cookies['coc_session'] = flask_session['api_session_cookie']
except RuntimeError:
# Outside of request context
pass
return cookies
def _save_session_cookie(self, response: requests.Response) -> None:
"""
Save session cookie from API response to Flask session.
Args:
response: Response from requests library.
"""
try:
session_cookie = response.cookies.get('coc_session')
if session_cookie:
flask_session['api_session_cookie'] = session_cookie
logger.debug("Saved API session cookie to Flask session")
except RuntimeError:
# Outside of request context
pass
def _get_headers(self) -> dict:
"""
Get default headers for API requests.
Returns:
Dictionary of headers.
"""
return {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
def _handle_response(self, response: requests.Response) -> dict:
"""
Handle API response and raise appropriate exceptions.
Args:
response: Response from requests library.
Returns:
Parsed JSON response.
Raises:
APIError: For various HTTP error codes.
"""
try:
data = response.json()
except ValueError:
data = {}
# Check for errors
if response.status_code == 401:
error_msg = data.get('error', {}).get('message', 'Authentication required')
raise APIAuthenticationError(error_msg)
if response.status_code == 404:
error_msg = data.get('error', {}).get('message', 'Resource not found')
raise APINotFoundError(error_msg)
if response.status_code >= 400:
error_msg = data.get('error', {}).get('message', f'API error: {response.status_code}')
error_details = data.get('error', {}).get('details', {})
raise APIError(error_msg, response.status_code, error_details)
return data
def get(self, endpoint: str, params: Optional[dict] = None) -> dict:
"""
Make GET request to API.
Args:
endpoint: API endpoint (e.g., "/api/v1/characters").
params: Optional query parameters.
Returns:
Parsed JSON response.
Raises:
APIError: For various error conditions.
"""
url = f"{self.base_url}{endpoint}"
try:
response = requests.get(
url,
params=params,
headers=self._get_headers(),
cookies=self._get_cookies(),
timeout=self.timeout,
verify=self.verify_ssl
)
logger.debug("API GET request", url=url, status=response.status_code)
return self._handle_response(response)
except requests.exceptions.Timeout:
logger.error("API timeout", url=url)
raise APITimeoutError()
except requests.exceptions.ConnectionError as e:
logger.error("API connection error", url=url, error=str(e))
raise APIError(f"Could not connect to API: {str(e)}", status_code=503)
except requests.exceptions.RequestException as e:
logger.error("API request error", url=url, error=str(e))
raise APIError(f"Request failed: {str(e)}")
def post(self, endpoint: str, data: Optional[dict] = None) -> dict:
"""
Make POST request to API.
Args:
endpoint: API endpoint (e.g., "/api/v1/characters").
data: Request body data.
Returns:
Parsed JSON response.
Raises:
APIError: For various error conditions.
"""
url = f"{self.base_url}{endpoint}"
try:
response = requests.post(
url,
json=data or {},
headers=self._get_headers(),
cookies=self._get_cookies(),
timeout=self.timeout,
verify=self.verify_ssl
)
logger.debug("API POST request", url=url, status=response.status_code)
# Save session cookie if present (for login responses)
self._save_session_cookie(response)
return self._handle_response(response)
except requests.exceptions.Timeout:
logger.error("API timeout", url=url)
raise APITimeoutError()
except requests.exceptions.ConnectionError as e:
logger.error("API connection error", url=url, error=str(e))
raise APIError(f"Could not connect to API: {str(e)}", status_code=503)
except requests.exceptions.RequestException as e:
logger.error("API request error", url=url, error=str(e))
raise APIError(f"Request failed: {str(e)}")
def delete(self, endpoint: str) -> dict:
"""
Make DELETE request to API.
Args:
endpoint: API endpoint (e.g., "/api/v1/characters/123").
Returns:
Parsed JSON response.
Raises:
APIError: For various error conditions.
"""
url = f"{self.base_url}{endpoint}"
try:
response = requests.delete(
url,
headers=self._get_headers(),
cookies=self._get_cookies(),
timeout=self.timeout,
verify=self.verify_ssl
)
logger.debug("API DELETE request", url=url, status=response.status_code)
return self._handle_response(response)
except requests.exceptions.Timeout:
logger.error("API timeout", url=url)
raise APITimeoutError()
except requests.exceptions.ConnectionError as e:
logger.error("API connection error", url=url, error=str(e))
raise APIError(f"Could not connect to API: {str(e)}", status_code=503)
except requests.exceptions.RequestException as e:
logger.error("API request error", url=url, error=str(e))
raise APIError(f"Request failed: {str(e)}")
def put(self, endpoint: str, data: Optional[dict] = None) -> dict:
"""
Make PUT request to API.
Args:
endpoint: API endpoint.
data: Request body data.
Returns:
Parsed JSON response.
Raises:
APIError: For various error conditions.
"""
url = f"{self.base_url}{endpoint}"
try:
response = requests.put(
url,
json=data or {},
headers=self._get_headers(),
cookies=self._get_cookies(),
timeout=self.timeout,
verify=self.verify_ssl
)
logger.debug("API PUT request", url=url, status=response.status_code)
return self._handle_response(response)
except requests.exceptions.Timeout:
logger.error("API timeout", url=url)
raise APITimeoutError()
except requests.exceptions.ConnectionError as e:
logger.error("API connection error", url=url, error=str(e))
raise APIError(f"Could not connect to API: {str(e)}", status_code=503)
except requests.exceptions.RequestException as e:
logger.error("API request error", url=url, error=str(e))
raise APIError(f"Request failed: {str(e)}")
# Singleton instance
_api_client: Optional[APIClient] = None
def get_api_client() -> APIClient:
"""
Get singleton API client instance.
Returns:
APIClient instance.
"""
global _api_client
if _api_client is None:
_api_client = APIClient()
return _api_client

View File

@@ -0,0 +1,146 @@
"""
Authentication utilities for public web frontend.
Provides authentication checking and decorators for protected routes.
Uses API backend for session validation.
"""
from functools import wraps
from flask import session, redirect, url_for, request, flash
from .logging import get_logger
logger = get_logger(__file__)
# Track last API validation time per session to avoid excessive checks
_SESSION_VALIDATION_KEY = '_api_validated_at'
def get_current_user():
"""
Get the currently authenticated user from session.
Returns:
Dictionary with user data if authenticated, None otherwise.
"""
# Check if we have user in Flask session
if 'user' in session and session.get('user'):
return session['user']
return None
def require_auth_web(f):
"""
Decorator to require authentication for web routes.
Validates the session with the API backend and redirects to
login if not authenticated.
Args:
f: Flask route function
Returns:
Wrapped function that checks authentication
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user = get_current_user()
if user is None:
logger.info("Unauthenticated access attempt", path=request.path)
# Store the intended destination
session['next'] = request.url
return redirect(url_for('auth_views.login'))
return f(*args, **kwargs)
return decorated_function
def clear_user_session():
"""
Clear user session data.
Should be called after logout.
"""
session.pop('user', None)
session.pop('next', None)
session.pop('api_session_cookie', None)
session.pop(_SESSION_VALIDATION_KEY, None)
logger.debug("User session cleared")
def require_auth_strict(revalidate_interval: int = 300):
"""
Decorator to require authentication with API session validation.
This decorator validates the session with the API backend periodically
to ensure the session is still valid on the server side.
Args:
revalidate_interval: Seconds between API validation checks (default 5 minutes).
Set to 0 to validate on every request.
Returns:
Decorator function.
Usage:
@app.route('/protected')
@require_auth_strict() # Validates every 5 minutes
def protected_route():
pass
@app.route('/sensitive')
@require_auth_strict(revalidate_interval=0) # Validates every request
def sensitive_route():
pass
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
import time
from .api_client import get_api_client, APIAuthenticationError, APIError
user = get_current_user()
if user is None:
logger.info("Unauthenticated access attempt", path=request.path)
session['next'] = request.url
return redirect(url_for('auth_views.login'))
# Check if we need to revalidate with API
current_time = time.time()
last_validated = session.get(_SESSION_VALIDATION_KEY, 0)
if revalidate_interval == 0 or (current_time - last_validated) > revalidate_interval:
try:
# Validate session by hitting a lightweight endpoint
api_client = get_api_client()
api_client.get("/api/v1/auth/me")
# Update validation timestamp
session[_SESSION_VALIDATION_KEY] = current_time
session.modified = True
logger.debug("API session validated", user_id=user.get('id'))
except APIAuthenticationError:
# Session expired on server side
logger.warning(
"API session expired",
user_id=user.get('id'),
path=request.path
)
clear_user_session()
flash('Your session has expired. Please log in again.', 'warning')
session['next'] = request.url
return redirect(url_for('auth_views.login'))
except APIError as e:
# API error - log but allow through (fail open for availability)
logger.error(
"API validation error",
user_id=user.get('id'),
error=str(e)
)
# Don't block the user, but don't update validation timestamp
return f(*args, **kwargs)
return decorated_function
return decorator

View File

@@ -0,0 +1,47 @@
"""
Logging utilities for public web frontend.
Simplified logging wrapper using structlog.
"""
import structlog
import logging
import sys
from pathlib import Path
def setup_logging():
"""Configure structured logging for the web frontend."""
structlog.configure(
processors=[
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.add_log_level,
structlog.processors.StackInfoRenderer(),
structlog.dev.ConsoleRenderer()
],
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
def get_logger(name: str):
"""
Get a logger instance.
Args:
name: Logger name (usually __file__)
Returns:
Configured structlog logger
"""
if isinstance(name, str) and name.endswith('.py'):
# Extract module name from file path
name = Path(name).stem
return structlog.get_logger(name)
# Setup logging on module import
setup_logging()