Spaces:
Running
Running
| """ | |
| Input Validation Layer - Rooting Future Strategy Engine v6.0 | |
| Uses Pydantic for strict schema validation of API requests. | |
| STAB-003: Complete input validation for all endpoints. | |
| """ | |
| import re | |
| from typing import List, Optional, Dict, Any | |
| from pydantic import BaseModel, Field, field_validator | |
| from datetime import datetime | |
| # ============================================================================= | |
| # CONSTANTS | |
| # ============================================================================= | |
| MAX_FILE_SIZE_MB = 10 | |
| ALLOWED_FILE_EXTENSIONS = {'.docx', '.zip'} | |
| ALLOWED_CATEGORIES = [ | |
| 'Serie A', 'Serie B', 'Serie C', 'Serie D', | |
| 'Eccellenza', 'Promozione', 'Prima Categoria', 'Seconda Categoria', 'Terza Categoria', | |
| 'Juniores', 'Allievi', 'Giovanissimi', 'Pulcini', | |
| 'Femminile Serie A', 'Femminile Serie B', 'Femminile Serie C', | |
| 'Altro' | |
| ] | |
| HEX_COLOR_PATTERN = re.compile(r'^#[0-9A-Fa-f]{6}$') | |
| # ============================================================================= | |
| # PLAN GENERATION | |
| # ============================================================================= | |
| class GeneratePlanRequest(BaseModel): | |
| """Validation for plan generation endpoint.""" | |
| club_name: str = Field(..., min_length=2, max_length=100) | |
| city: str = Field(default="Sconosciuta", max_length=100) | |
| region: str = Field(default="Italia", max_length=100) | |
| category: str = Field(..., min_length=2, max_length=50) | |
| foundation_year: Optional[int] = Field(None, ge=1800, le=datetime.now().year) | |
| competitors: List[str] = Field(default_factory=list, max_length=10) | |
| enable_research: bool = True | |
| additional_data: Dict[str, Any] = Field(default_factory=dict) | |
| def club_name_must_not_be_empty(cls, v: str) -> str: | |
| if not v.strip(): | |
| raise ValueError('Il nome del club non può essere vuoto') | |
| # Remove potentially dangerous characters | |
| cleaned = re.sub(r'[<>"\']', '', v.strip()) | |
| return cleaned | |
| def validate_category(cls, v: str) -> str: | |
| # Allow any category but sanitize | |
| return v.strip()[:50] | |
| def validate_competitors(cls, v: List[str]) -> List[str]: | |
| # Limit to 10 competitors, sanitize each | |
| return [re.sub(r'[<>"\']', '', c.strip())[:100] for c in v[:10]] | |
| # ============================================================================= | |
| # SECTION EDITING | |
| # ============================================================================= | |
| class SaveSectionRequest(BaseModel): | |
| """Validation for saving edited section content.""" | |
| plan_id: str = Field(..., min_length=10, max_length=50) | |
| section_key: str = Field(..., min_length=2, max_length=50) | |
| content: str = Field(..., min_length=1, max_length=50000) | |
| def validate_plan_id(cls, v: str) -> str: | |
| # Plan IDs follow pattern: plan_XXXXXX_YYYYMMDDHHMMSS | |
| if not re.match(r'^plan_[a-f0-9]{6}_\d{14}$', v): | |
| raise ValueError('ID piano non valido') | |
| return v | |
| class RegenerateSectionRequest(BaseModel): | |
| """Validation for section regeneration.""" | |
| plan_id: str = Field(..., min_length=10, max_length=50) | |
| section_id: str = Field(..., min_length=2, max_length=50) | |
| context: Optional[str] = Field(default="", max_length=1000) | |
| def validate_plan_id(cls, v: str) -> str: | |
| if not re.match(r'^plan_[a-f0-9]{6}_\d{14}$', v): | |
| raise ValueError('ID piano non valido') | |
| return v | |
| # ============================================================================= | |
| # SHARING | |
| # ============================================================================= | |
| class SharePlanRequest(BaseModel): | |
| """Validation for public plan sharing.""" | |
| plan_id: str = Field(..., min_length=10, max_length=50) | |
| expires_in_days: int = Field(default=30, ge=1, le=365) | |
| password: Optional[str] = Field(default=None, min_length=4, max_length=100) | |
| allow_download: bool = True | |
| def validate_plan_id(cls, v: str) -> str: | |
| if not re.match(r'^plan_[a-f0-9]{6}_\d{14}$', v): | |
| raise ValueError('ID piano non valido') | |
| return v | |
| def validate_password(cls, v: Optional[str]) -> Optional[str]: | |
| if v is not None and len(v) < 4: | |
| raise ValueError('La password deve avere almeno 4 caratteri') | |
| return v | |
| # ============================================================================= | |
| # FILE UPLOAD | |
| # ============================================================================= | |
| class FileUploadRequest(BaseModel): | |
| """Validation for file uploads (metadata only - actual file validation in route).""" | |
| club_name: str = Field(..., min_length=2, max_length=100) | |
| project_id: Optional[str] = Field(default=None, max_length=50) | |
| request_mode: str = Field(default="production", pattern=r'^(production|test|debug)$') | |
| def validate_club_name(cls, v: str) -> str: | |
| cleaned = re.sub(r'[<>"\']', '', v.strip()) | |
| if not cleaned: | |
| raise ValueError('Il nome del club non può essere vuoto') | |
| return cleaned | |
| class HardDataRequest(BaseModel): | |
| """Validation for hard data (colors, financial info).""" | |
| primary_color: Optional[str] = Field(default=None, max_length=7) | |
| secondary_color: Optional[str] = Field(default=None, max_length=7) | |
| budget_annuale: Optional[float] = Field(default=None, ge=0, le=1_000_000_000) | |
| numero_tesserati: Optional[int] = Field(default=None, ge=0, le=10000) | |
| anno_fondazione: Optional[int] = Field(default=None, ge=1800, le=datetime.now().year) | |
| def validate_hex_color(cls, v: Optional[str]) -> Optional[str]: | |
| if v is None: | |
| return v | |
| if not HEX_COLOR_PATTERN.match(v): | |
| raise ValueError('Colore non valido (formato: #RRGGBB)') | |
| return v.upper() | |
| # ============================================================================= | |
| # USER MANAGEMENT | |
| # ============================================================================= | |
| class CreateUserRequest(BaseModel): | |
| """Validation for user creation (admin only).""" | |
| username: str = Field(..., min_length=3, max_length=50) | |
| email: str = Field(..., min_length=5, max_length=100) | |
| password: str = Field(..., min_length=8, max_length=100) | |
| role: str = Field(default="user", pattern=r'^(super_admin|admin|user|viewer)$') | |
| def validate_username(cls, v: str) -> str: | |
| if not re.match(r'^[a-zA-Z0-9_.-]+$', v): | |
| raise ValueError('Username può contenere solo lettere, numeri, _ . -') | |
| return v.lower() | |
| def validate_email(cls, v: str) -> str: | |
| if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', v): | |
| raise ValueError('Email non valida') | |
| return v.lower() | |
| def validate_password(cls, v: str) -> str: | |
| if len(v) < 8: | |
| raise ValueError('La password deve avere almeno 8 caratteri') | |
| if not any(c.isupper() for c in v): | |
| raise ValueError('La password deve contenere almeno una lettera maiuscola') | |
| if not any(c.isdigit() for c in v): | |
| raise ValueError('La password deve contenere almeno un numero') | |
| return v | |
| class AssignPlanRequest(BaseModel): | |
| """Validation for plan assignment.""" | |
| plan_id: str = Field(..., min_length=10, max_length=50) | |
| user_id: int = Field(..., ge=1) | |
| can_edit: bool = Field(default=False) | |
| def validate_plan_id(cls, v: str) -> str: | |
| if not re.match(r'^plan_[a-f0-9]{6}_\d{14}$', v): | |
| raise ValueError('ID piano non valido') | |
| return v | |
| # ============================================================================= | |
| # UTILITY FUNCTIONS | |
| # ============================================================================= | |
| def validate_file_upload(filename: str, file_size: int) -> tuple[bool, str]: | |
| """ | |
| Validate uploaded file. | |
| Args: | |
| filename: Name of the uploaded file | |
| file_size: Size in bytes | |
| Returns: | |
| Tuple of (is_valid, error_message) | |
| """ | |
| from pathlib import Path | |
| if not filename: | |
| return False, "Nome file mancante" | |
| ext = Path(filename).suffix.lower() | |
| if ext not in ALLOWED_FILE_EXTENSIONS: | |
| return False, f"Formato non supportato. Usa: {', '.join(ALLOWED_FILE_EXTENSIONS)}" | |
| max_size_bytes = MAX_FILE_SIZE_MB * 1024 * 1024 | |
| if file_size > max_size_bytes: | |
| return False, f"File troppo grande. Massimo: {MAX_FILE_SIZE_MB}MB" | |
| # Check for suspicious filename patterns | |
| if '..' in filename or '/' in filename or '\\' in filename: | |
| return False, "Nome file non valido" | |
| return True, "" | |
| def sanitize_input(text: str, max_length: int = 1000) -> str: | |
| """ | |
| Sanitize user input text. | |
| Args: | |
| text: Input text to sanitize | |
| max_length: Maximum allowed length | |
| Returns: | |
| Sanitized text | |
| """ | |
| if not text: | |
| return "" | |
| # Remove HTML tags and dangerous characters | |
| cleaned = re.sub(r'<[^>]+>', '', text) | |
| cleaned = re.sub(r'[<>"\']', '', cleaned) | |
| # Truncate if needed | |
| return cleaned[:max_length].strip() | |