Cierra el carril de cara al usuario: adjunta un documento desde la ficha del
embarque y lo ve aparecer con badge "En expediente". Si EFC esta caido lo ve
como "Pendiente de enviar" en vez de perder su trabajo, y el sistema lo entrega
solo cuando EFC vuelve, sin duplicarlo.
La subida es de UN paso -guarda, registra y encola- y responde 201 PASE LO QUE
PASE con EFC. La alternativa era llamar a EFC dentro del request, y ahi un EFC
caido devuelve un error al usuario con el archivo ya subido a medias.
La descarga es un proxy con streaming, no una redireccion: EFC nunca entrega una
URL de MinIO, y reescribir el host de una URL ya firmada invalida su SigV4. El
AsyncClient se crea DENTRO del generador y se cierra en finally; creado fuera,
en un `async with`, se cerraria antes de que empiece el streaming -FastAPI
consume el generador despues de devolver la respuesta- y la descarga moriria a
la mitad. Dos guardas: la pertenencia se valida ANTES de tocar EFC, y se manda
el organizacion_id del expediente para que la verificacion de EFC tambien
dispare. Sin la primera, un document_id que coincidiera leeria el expediente de
otro tenant, y el organizacion_id se deriva del expediente, asi que el CRM iria
a preguntarle a la organizacion de otro cliente.
TRES DEFECTOS PREEXISTENTES QUE ESTA FASE CORRIGE DE PASO:
1. `POST /uploads` hacia `await file.read()` COMPLETO antes de validar el
tamano: un archivo de 2 GB se bufferizaba entero en RAM del proceso solo para
responder 422 despues. Ahora la lectura es por partes y aborta al pasar del
tope.
2. No habia allowlist de extension, a diferencia del avatar y el centro de
ayuda, que si la tienen. La de aqui coincide con la de EFC: aceptar algo que
alla se rechaza guardaria el archivo y condenaria su entrega a `failed`.
3. `GET /uploads/url` validaba SOLO que la key empezara con
tenants/{tid}/companies/{cid}/, lo que permitia firmar una URL de lectura
para CUALQUIER objeto de esa company -incluidos los certificados de la FIEL
bajo certificates/- con solo el permiso de modulo crm.access. Ahora exige
ademas uno de los dos subarboles de documentos del CRM. Hay test por cada
prefijo sensible.
El borrado DESASOCIA, no destruye en EFC: el gateway de Anexo22 nunca llama al
DELETE de EFC -verificado, ese metodo no existe en su cliente- y record.Document
alla no tiene vigencia ni purga, asi que la politica implicita es conservar. Un
documento que manana puede ser parte del expediente de un pedimento real es
riesgo de retencion fiscal.
En el frontend, abrir un documento del expediente NO usa window.open sobre la
URL del proxy: esa llamada no lleva el header Authorization y el backend
responderia 401. Va por api.getBlob y URL.createObjectURL. La descarga se
ramifica en tres y el ORDEN importa: efc_document_id primero, porque al
confirmar la entrega `delete_local` borra la copia local y file_key queda en
NULL. `postFormData` solo publica `fetchApiFormDataPost`, que ya existia con la
maquinaria de progreso pero sin exportar, asi que nadie podia usarlo.
Verificacion:
backend pytest tests/ EXIT=0 178 passed 1 skipped (baseline 70)
frontend pnpm run i18n:compile EXIT=0
pnpm run check EXIT=1 38 errores/17 warnings en 20 archivos
IDENTICO al baseline: 55 hallazgos
antes y despues, cero agregados. La
unica diferencia es que el hallazgo
preexistente de RelatedManager se
movio de la linea 251 a la 317 al
insertar codigo arriba.
pnpm run test:unit EXIT=1 2 failed 43 passed (baseline 2/38);
los 2 rojos son los preexistentes de
backend.test.ts, que pide un backend
vivo en el host `backend` de docker
pnpm run build EXIT=0
El verde se vio fallar primero: reintroduciendo la fuga de `/uploads/url` los
tests de certificates/, doda/ y signatures/ se pusieron rojos; volviendo al
read() completo cayo el test que afirma que el tope corta sin leer el archivo
entero; y agregando un tipo de documento solo en el frontend, el test de paridad
lo nombro: "expected [ 'carta_poder' ] to deeply equal []".
Ticket: T2026-08-046 (fase 7 de 7)
122 lines
4.8 KiB
Python
122 lines
4.8 KiB
Python
"""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, UploadFile, status
|
|
|
|
from core.security import get_current_user
|
|
from core.storage_s3 import 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._-]+")
|
|
|
|
# Trozo de lectura. No es crítico afinarlo: lo que importa es que la lectura sea POR PARTES y no de
|
|
# golpe, para poder abortar en cuanto se pase del tope.
|
|
_CHUNK_BYTES = 1 * 1024 * 1024
|
|
|
|
# Allowlist de extensiones, igual que la que ya tienen el avatar y el centro de ayuda. Coincide con
|
|
# la que EFC aplica del otro lado del carril del expediente: si aquí se aceptara algo que allá se
|
|
# rechaza, el archivo se guardaría y su entrega quedaría condenada a `failed`.
|
|
ALLOWED_UPLOAD_EXTENSIONS = (
|
|
".pdf", ".xml", ".png", ".jpg", ".jpeg", ".json", ".txt", ".zip", ".docx", ".xlsx",
|
|
)
|
|
|
|
# Prefijos que este endpoint puede firmar. Es un subárbol EXPLÍCITO, no toda la company: ver la
|
|
# nota de `get_upload_url`.
|
|
_PREFIJOS_FIRMABLES = ("crm-docs/", "expedientes/")
|
|
|
|
|
|
def _safe_filename(name: str | None) -> str:
|
|
base = (name or "archivo").strip().replace(" ", "_")
|
|
base = _SAFE_NAME.sub("", base) or "archivo"
|
|
return base[:120]
|
|
|
|
|
|
def extension_de(name: str | None) -> str:
|
|
base = (name or "").rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
|
if "." not in base:
|
|
return ""
|
|
return "." + base.rsplit(".", 1)[1].lower()
|
|
|
|
|
|
def validar_extension(name: str | None) -> None:
|
|
if extension_de(name) not in ALLOWED_UPLOAD_EXTENSIONS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="Ese tipo de archivo no está permitido.",
|
|
)
|
|
|
|
|
|
async def leer_acotado(file: UploadFile, max_bytes: int = MAX_UPLOAD_BYTES) -> bytes:
|
|
"""Lee el archivo POR PARTES y aborta en cuanto pasa del tope.
|
|
|
|
``await file.read()`` a secas trae el archivo entero a memoria **antes** de que nadie pueda
|
|
mirar su tamaño: un archivo de 2 GB se bufferiza completo solo para responder 422 después. Aquí
|
|
el corte ocurre al superar el tope, así que el peor caso en RAM es el tope más un trozo.
|
|
"""
|
|
partes: list[bytes] = []
|
|
total = 0
|
|
while True:
|
|
chunk = await file.read(_CHUNK_BYTES)
|
|
if not chunk:
|
|
break
|
|
total += len(chunk)
|
|
if total > max_bytes:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="El archivo excede el tamaño máximo permitido.",
|
|
)
|
|
partes.append(chunk)
|
|
return b"".join(partes)
|
|
|
|
|
|
@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"]
|
|
validar_extension(file.filename)
|
|
content = await leer_acotado(file)
|
|
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")
|
|
|
|
# Y SOLO dentro de los subárboles de documentos. Validar únicamente el prefijo de la company
|
|
# permitía firmar una URL para CUALQUIER objeto suyo —`certificates/` (llaves privadas de la
|
|
# FIEL), `fin-invoices/`, `imports/csv/`— con solo el permiso `crm.access`. El alcance de este
|
|
# endpoint es "los archivos que el CRM subió", no "todo el almacén de la company".
|
|
resto = key[len(prefix):]
|
|
if not resto.startswith(_PREFIJOS_FIRMABLES):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
|
|
|
|
return {"url": presigned_get_url(key)}
|