"""Subida de archivos a MinIO/S3 para documentos del CRM y Operaciones. Flujo: el frontend sube el archivo aquí, recibe ``file_key`` (permanente) y lo guarda en el documento (crm.documents / ops.shipment_documents). Para abrirlo se pide una URL firmada fresca en ``/uploads/url`` (las presignadas expiran). """ import re import uuid from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status from core.security import get_current_user from core.storage_s3 import get_object_bytes, presigned_get_url, put_object_bytes router = APIRouter() MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB _SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+") def _safe_filename(name: str | None) -> str: base = (name or "archivo").strip().replace(" ", "_") base = _SAFE_NAME.sub("", base) or "archivo" return base[:120] @router.post("/uploads") async def upload_file( file: UploadFile = File(...), company_id: int = Query(..., description="Company ID"), current_user: dict = Depends(get_current_user), ): tenant_id = current_user["tenant_id"] content = await file.read() if len(content) > MAX_UPLOAD_BYTES: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El archivo excede el tamaño máximo permitido (25 MB)", ) filename = _safe_filename(file.filename) key = f"tenants/{tenant_id}/companies/{company_id}/crm-docs/{uuid.uuid4().hex}/{filename}" put_object_bytes(key, content, content_type=file.content_type or "application/octet-stream") return { "file_key": key, "file_url": presigned_get_url(key), "name": file.filename, "content_type": file.content_type, "size_bytes": len(content), } @router.get("/uploads/url") def get_upload_url( key: str = Query(..., description="Object key del archivo en el almacén"), company_id: int = Query(..., description="Company ID"), current_user: dict = Depends(get_current_user), ): tenant_id = current_user["tenant_id"] # Un archivo solo puede consultarse dentro de su propio tenant/company (aislamiento). prefix = f"tenants/{tenant_id}/companies/{company_id}/" if not key.startswith(prefix): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance") return {"url": presigned_get_url(key)} @router.get("/uploads/download") def download_file( key: str = Query(..., description="Object key del archivo en el almacén"), company_id: int = Query(..., description="Company ID"), current_user: dict = Depends(get_current_user), ): """Transmite el archivo por el backend (sin exponer MinIO al navegador). Evita el bug de la URL prefirmada que apunta al host interno ``minio:9000``. """ tenant_id = current_user["tenant_id"] prefix = f"tenants/{tenant_id}/companies/{company_id}/" if not key.startswith(prefix): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance") try: data = get_object_bytes(key) except Exception: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Archivo no encontrado") filename = key.rsplit("/", 1)[-1] return Response( content=data, media_type="application/octet-stream", headers={"Content-Disposition": f'inline; filename="{filename}"'}, )