Spaces:
Runtime error
Runtime error
| """ | |
| Validadores para arquivos de entrada. | |
| Este módulo contém funções para validar arquivos antes do processamento, | |
| incluindo verificação de tamanho, contagem, MIME type e sanitização de nomes. | |
| """ | |
| import os | |
| import re | |
| from pathlib import Path | |
| from typing import BinaryIO | |
| import config | |
| # Tenta importar python-magic, mas oferece fallback se não disponível | |
| try: | |
| import magic | |
| HAS_MAGIC = True | |
| except ImportError: | |
| HAS_MAGIC = False | |
| class ValidationError(Exception): | |
| """Exceção levantada quando uma validação falha.""" | |
| def __init__(self, message: str, error_code: str = "VALIDATION_ERROR"): | |
| self.message = message | |
| self.error_code = error_code | |
| super().__init__(self.message) | |
| def validate_file_count(files: list) -> bool: | |
| """ | |
| Valida se o número de arquivos está dentro do limite permitido. | |
| Args: | |
| files: Lista de arquivos para validar. | |
| Returns: | |
| True se a contagem está válida. | |
| Raises: | |
| ValidationError: Se houver arquivos demais ou nenhum arquivo. | |
| """ | |
| if not files: | |
| raise ValidationError( | |
| "Nenhum arquivo enviado. Por favor, selecione ao menos um arquivo.", | |
| error_code="NO_FILES" | |
| ) | |
| if len(files) > config.MAX_FILES_PER_SESSION: | |
| raise ValidationError( | |
| f"Muitos arquivos! Máximo permitido: {config.MAX_FILES_PER_SESSION}. " | |
| f"Você enviou: {len(files)}.", | |
| error_code="TOO_MANY_FILES" | |
| ) | |
| return True | |
| def validate_file_size(file_path: str | Path) -> bool: | |
| """ | |
| Valida se o tamanho do arquivo está dentro do limite permitido. | |
| Args: | |
| file_path: Caminho para o arquivo a ser validado. | |
| Returns: | |
| True se o tamanho está válido. | |
| Raises: | |
| ValidationError: Se o arquivo for muito grande. | |
| """ | |
| file_path = Path(file_path) | |
| if not file_path.exists(): | |
| raise ValidationError( | |
| f"Arquivo não encontrado: {file_path.name}", | |
| error_code="FILE_NOT_FOUND" | |
| ) | |
| file_size = file_path.stat().st_size | |
| if file_size > config.MAX_FILE_SIZE_BYTES: | |
| size_mb = file_size / (1024 * 1024) | |
| raise ValidationError( | |
| f"Arquivo muito grande: {file_path.name} ({size_mb:.1f}MB). " | |
| f"Máximo permitido: {config.MAX_FILE_SIZE_MB}MB.", | |
| error_code="FILE_TOO_LARGE" | |
| ) | |
| if file_size == 0: | |
| raise ValidationError( | |
| f"Arquivo vazio: {file_path.name}", | |
| error_code="EMPTY_FILE" | |
| ) | |
| return True | |
| def _get_mime_type_magic(file_path: str | Path) -> str: | |
| """ | |
| Obtém o MIME type usando python-magic. | |
| Args: | |
| file_path: Caminho para o arquivo. | |
| Returns: | |
| String com o MIME type detectado. | |
| """ | |
| mime = magic.Magic(mime=True) | |
| return mime.from_file(str(file_path)) | |
| def _get_mime_type_fallback(file_path: str | Path) -> str: | |
| """ | |
| Fallback para detecção de MIME type sem python-magic. | |
| Usa assinaturas de arquivo (magic bytes). | |
| Args: | |
| file_path: Caminho para o arquivo. | |
| Returns: | |
| String com o MIME type detectado ou extensão-based guess. | |
| """ | |
| file_path = Path(file_path) | |
| # Magic bytes para tipos comuns | |
| signatures = { | |
| b"%PDF": "application/pdf", | |
| b"PK\x03\x04": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", | |
| b"\xd0\xcf\x11\xe0": "application/msword", # OLE Compound Document | |
| } | |
| try: | |
| with open(file_path, "rb") as f: | |
| header = f.read(8) | |
| for sig, mime_type in signatures.items(): | |
| if header.startswith(sig): | |
| return mime_type | |
| except Exception: | |
| pass | |
| # Fallback para extensão | |
| ext = file_path.suffix.lower() | |
| ext_to_mime = { | |
| ".pdf": "application/pdf", | |
| ".doc": "application/msword", | |
| ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", | |
| } | |
| return ext_to_mime.get(ext, "application/octet-stream") | |
| def get_mime_type(file_path: str | Path) -> str: | |
| """ | |
| Obtém o MIME type de um arquivo. | |
| Usa python-magic se disponível, caso contrário usa fallback | |
| baseado em assinaturas de arquivo. | |
| Args: | |
| file_path: Caminho para o arquivo. | |
| Returns: | |
| String com o MIME type detectado. | |
| """ | |
| if HAS_MAGIC: | |
| try: | |
| return _get_mime_type_magic(file_path) | |
| except Exception: | |
| # Fallback se libmagic falhar em runtime (OSError, etc) | |
| pass | |
| return _get_mime_type_fallback(file_path) | |
| def validate_mime_type(file_path: str | Path) -> bool: | |
| """ | |
| Valida se o MIME type do arquivo é suportado. | |
| Args: | |
| file_path: Caminho para o arquivo a ser validado. | |
| Returns: | |
| True se o MIME type é válido. | |
| Raises: | |
| ValidationError: Se o tipo de arquivo não for suportado. | |
| """ | |
| file_path = Path(file_path) | |
| extension = file_path.suffix.lower() | |
| # Verifica se a extensão é suportada | |
| if extension not in config.SUPPORTED_EXTENSIONS: | |
| raise ValidationError( | |
| f"Extensão não suportada: {extension}. " | |
| f"Tipos aceitos: {', '.join(config.SUPPORTED_EXTENSIONS)}", | |
| error_code="UNSUPPORTED_EXTENSION" | |
| ) | |
| # Obtém o MIME type real do arquivo | |
| detected_mime = get_mime_type(file_path) | |
| # Verifica se o MIME type corresponde à extensão | |
| expected_mimes = config.SUPPORTED_MIME_TYPES.get(extension, []) | |
| if detected_mime not in expected_mimes: | |
| # DOCX pode ser detectado como ZIP em alguns casos | |
| if extension == ".docx" and detected_mime == "application/zip": | |
| return True | |
| raise ValidationError( | |
| f"Tipo de arquivo inválido: {file_path.name}. " | |
| f"O conteúdo não corresponde à extensão {extension}. " | |
| f"Detectado: {detected_mime}", | |
| error_code="MIME_MISMATCH" | |
| ) | |
| return True | |
| def sanitize_filename(filename: str) -> str: | |
| """ | |
| Remove caracteres especiais/perigosos do nome de arquivo. | |
| Args: | |
| filename: Nome original do arquivo. | |
| Returns: | |
| Nome de arquivo sanitizado. | |
| """ | |
| if not filename: | |
| return "arquivo_sem_nome" | |
| # Remove caracteres proibidos | |
| for char in config.FORBIDDEN_FILENAME_CHARS: | |
| filename = filename.replace(char, "_") | |
| # Remove caracteres de controle | |
| filename = re.sub(r"[\x00-\x1f\x7f]", "", filename) | |
| # Substitui espaços múltiplos por um único underscore | |
| filename = re.sub(r"\s+", "_", filename) | |
| # Remove underscores múltiplos | |
| filename = re.sub(r"_+", "_", filename) | |
| # Remove underscores no início e fim | |
| filename = filename.strip("_") | |
| # Limita o comprimento | |
| if len(filename) > config.FILENAME_MAX_LENGTH: | |
| # Preserva a extensão | |
| name, ext = os.path.splitext(filename) | |
| max_name_len = config.FILENAME_MAX_LENGTH - len(ext) | |
| filename = name[:max_name_len] + ext | |
| # Se ficou vazio após sanitização | |
| if not filename or filename == "." or filename == "..": | |
| return "arquivo_sanitizado" | |
| return filename | |
| def validate_files(files: list) -> list[tuple[Path, str]]: | |
| """ | |
| Valida uma lista de arquivos completamente. | |
| Args: | |
| files: Lista de arquivos (podem ser paths ou objetos de arquivo). | |
| Returns: | |
| Lista de tuplas (path, nome_sanitizado) para arquivos válidos. | |
| Raises: | |
| ValidationError: Se qualquer validação falhar. | |
| """ | |
| validate_file_count(files) | |
| validated = [] | |
| for file_obj in files: | |
| # Gradio retorna objetos com atributo 'name' | |
| if hasattr(file_obj, "name"): | |
| file_path = Path(file_obj.name) | |
| else: | |
| file_path = Path(file_obj) | |
| # Valida tamanho | |
| validate_file_size(file_path) | |
| # Valida MIME type | |
| validate_mime_type(file_path) | |
| # Sanitiza nome | |
| sanitized_name = sanitize_filename(file_path.name) | |
| validated.append((file_path, sanitized_name)) | |
| return validated | |