Spaces:
Running
Running
File size: 9,695 Bytes
38f9c15 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 | """
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)
@field_validator('club_name')
@classmethod
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
@field_validator('category')
@classmethod
def validate_category(cls, v: str) -> str:
# Allow any category but sanitize
return v.strip()[:50]
@field_validator('competitors')
@classmethod
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)
@field_validator('plan_id')
@classmethod
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)
@field_validator('plan_id')
@classmethod
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
@field_validator('plan_id')
@classmethod
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
@field_validator('password')
@classmethod
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)$')
@field_validator('club_name')
@classmethod
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)
@field_validator('primary_color', 'secondary_color')
@classmethod
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)$')
@field_validator('username')
@classmethod
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()
@field_validator('email')
@classmethod
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()
@field_validator('password')
@classmethod
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)
@field_validator('plan_id')
@classmethod
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()
|