feat: Funcion de sistema tenants

This commit is contained in:
2026-02-23 13:01:24 -07:00
parent ceea67eb2b
commit 1ccc39732b
58 changed files with 1889 additions and 315 deletions

View File

@@ -16,6 +16,8 @@ settings = get_settings()
class FileHandler:
"""Handler simple para archivos adjuntos"""
_CHUNK_SIZE_BYTES = 1024 * 1024 # 1MB
def __init__(self):
self.upload_path = Path(settings.UPLOAD_PATH)
@@ -24,8 +26,8 @@ class FileHandler:
# Crear directorio si no existe
self.upload_path.mkdir(parents=True, exist_ok=True)
def _validate_file(self, filename: str, file_size: int) -> None:
"""Validar archivo"""
def _validate_extension(self, filename: str) -> str:
"""Validar extensión del archivo y retornarla."""
extension = Path(filename).suffix.lower().lstrip('.')
if extension not in self.allowed_extensions:
@@ -33,32 +35,52 @@ class FileHandler:
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Extensión no permitida: {extension}"
)
if file_size > self.max_size_bytes:
return extension
def _validate_magic_bytes(self, extension: str, first_bytes: bytes) -> None:
"""Validación básica por firma (magic bytes) para tipos comunes."""
signatures = {
# PDFs start with %PDF-
"pdf": [b"%PDF-"],
# PNG signature
"png": [b"\x89PNG\r\n\x1a\n"],
# JPEG starts with FF D8 FF
"jpg": [b"\xff\xd8\xff"],
"jpeg": [b"\xff\xd8\xff"],
# Legacy MS Office (OLE Compound File)
"doc": [b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"],
"xls": [b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"],
# OOXML (zip-based)
"docx": [b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"],
"xlsx": [b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"],
}
# For plain text, we can't reliably validate via magic bytes.
if extension == "txt":
return
allowed = signatures.get(extension)
if not allowed:
return
if not any(first_bytes.startswith(sig) for sig in allowed):
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Archivo muy grande. Máximo: {settings.MAX_UPLOAD_SIZE_MB}MB"
status_code=status.HTTP_400_BAD_REQUEST,
detail="Contenido de archivo no coincide con la extensión declarada",
)
def _calculate_checksums(self, content: bytes) -> Tuple[str, str]:
"""Calcular MD5 y SHA256"""
return hashlib.md5(content).hexdigest(), hashlib.sha256(content).hexdigest()
async def save_upload(self, file: UploadFile, tenant_id: uuid.UUID, ticket_id: uuid.UUID) -> dict:
"""Guardar archivo y retornar metadata"""
if not file.filename:
raise HTTPException(status_code=400, detail="Filename requerido")
content = await file.read()
file_size = len(content)
self._validate_file(file.filename, file_size)
md5_hash, sha256_hash = self._calculate_checksums(content)
extension = self._validate_extension(file.filename)
# Nombre único
extension = Path(file.filename).suffix.lower()
safe_filename = f"{uuid.uuid4().hex}{extension}"
original_extension = Path(file.filename).suffix.lower()
safe_filename = f"{uuid.uuid4().hex}{original_extension}"
# Estructura: uploads/tenant_id/tickets/ticket_id/
file_directory = self.upload_path / str(tenant_id) / "tickets" / str(ticket_id)
@@ -66,10 +88,61 @@ class FileHandler:
file_path = file_directory / safe_filename
relative_path = str(file_path.relative_to(self.upload_path))
# Guardar archivo
with open(file_path, "wb") as f:
f.write(content)
# Guardar archivo (streaming) + checksums incrementales
md5 = hashlib.md5()
sha256 = hashlib.sha256()
file_size = 0
validated_magic = False
first_bytes: bytes = b""
try:
with open(file_path, "wb") as f:
while True:
chunk = await file.read(self._CHUNK_SIZE_BYTES)
if not chunk:
break
if not validated_magic:
first_bytes = chunk[:16]
self._validate_magic_bytes(extension, first_bytes)
validated_magic = True
file_size += len(chunk)
if file_size > self.max_size_bytes:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Archivo muy grande. Máximo: {settings.MAX_UPLOAD_SIZE_MB}MB",
)
md5.update(chunk)
sha256.update(chunk)
f.write(chunk)
if file_size == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Archivo vacío",
)
except HTTPException:
# Eliminar archivo parcial si existe
try:
if file_path.exists():
file_path.unlink()
except Exception:
pass
raise
except Exception as exc:
try:
if file_path.exists():
file_path.unlink()
except Exception:
pass
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error guardando archivo: {exc}",
)
import mimetypes
mime_type = mimetypes.guess_type(file.filename)[0] or "application/octet-stream"
@@ -80,8 +153,8 @@ class FileHandler:
"file_path": relative_path,
"file_size": file_size,
"mime_type": mime_type,
"md5_hash": md5_hash,
"sha256_hash": sha256_hash
"md5_hash": md5.hexdigest(),
"sha256_hash": sha256.hexdigest(),
}
def get_file_path(self, relative_path: str) -> Path: