package controllers import ( "fmt" "os" "path/filepath" "strconv" "strings" "github.com/gofiber/fiber/v2" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" "github.com/sujit-baniya/fiber-boilerplate/pkg/services" ) // El repositorio de archivos del espacio. Los archivos NO se sirven por el // estático de /uploads (que solo sabe si hay sesión de panel, no de quién es // el archivo): salen por el endpoint de descarga de acá, que valida el tenant. const umindArchivoMaxBytes = 25 << 20 // 25 MB // GetUmindArchivosHandler — GET /umind/archivos?tenant_id=N func GetUmindArchivosHandler(c *fiber.Ctx) error { tenantID, _ := strconv.ParseUint(c.Query("tenant_id"), 10, 64) if err := accesoTenant(c, uint(tenantID)); err != nil { return err } items, err := models.GetUmindArchivosByTenant(uint(tenantID)) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } usado := models.EspacioUsadoUmind(uint(tenantID)) resp := fiber.Map{"items": items, "usado_bytes": usado} if plan := planDelTenant(uint(tenantID)); plan != nil && plan.MaxAlmacenamientoMB > 0 { resp["cuota_bytes"] = int64(plan.MaxAlmacenamientoMB) << 20 } return c.JSON(resp) } // CreateUmindArchivoRepoHandler sube un archivo al repositorio del espacio. // POST /umind/archivos (multipart: tenant_id, archivo, descripcion) func CreateUmindArchivoRepoHandler(c *fiber.Ctx) error { tenantID, _ := strconv.ParseUint(c.FormValue("tenant_id"), 10, 64) if err := accesoTenant(c, uint(tenantID)); err != nil { return err } fh, err := c.FormFile("archivo") if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subí un archivo"}) } if fh.Size > umindArchivoMaxBytes { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el archivo supera los 25 MB"}) } if !extensionPermitida(fh.Filename) { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ese tipo de archivo no está permitido"}) } // La cuota se mira antes de escribir: rechazar después de copiar 25 MB al // disco es cobrarle el espacio igual. if plan := planDelTenant(uint(tenantID)); plan != nil && plan.MaxAlmacenamientoMB > 0 { cuota := int64(plan.MaxAlmacenamientoMB) << 20 if models.EspacioUsadoUmind(uint(tenantID))+fh.Size > cuota { return c.Status(fiber.StatusConflict).JSON(fiber.Map{ "error": fmt.Sprintf("tu plan incluye %d MB de archivos y ya no entra. Borrá alguno o escribinos para ampliarlo.", plan.MaxAlmacenamientoMB), }) } } dir := filepath.Join("uploads", "umind", strconv.FormatUint(tenantID, 10)) if err := os.MkdirAll(dir, 0o755); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo guardar el archivo"}) } nombre := sanitizeFilename(fh.Filename) destino := filepath.Join(dir, fmt.Sprintf("%d_%s", models.AhoraUnix(), nombre)) if err := saveUploadedFile(fh, destino); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo guardar el archivo"}) } archivo := &models.UmindArchivo{ TenantID: uint(tenantID), Nombre: fh.Filename, Archivo: destino, TipoMime: fh.Header.Get("Content-Type"), Tamanio: fh.Size, Origen: "subido", Descripcion: strings.TrimSpace(c.FormValue("descripcion")), } if err := models.CreateUmindArchivo(archivo); err != nil { _ = os.Remove(destino) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "item": archivo}) } // DownloadUmindArchivoHandler — GET /umind/archivos/:id/descargar func DownloadUmindArchivoHandler(c *fiber.Ctx) error { archivo, err := archivoConAcceso(c) if err != nil { return err } // Defensa en profundidad: la ruta viene de nuestra base, pero si alguna vez // entrara con "..", esto la corta antes de salir de uploads/. limpio := filepath.Clean(archivo.Archivo) if !strings.HasPrefix(limpio, "uploads/") { return errSinAcceso(c) } if _, err := os.Stat(limpio); err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "el archivo ya no está en el servidor"}) } c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, sanitizeFilename(archivo.Nombre))) return c.SendFile(limpio) } // DeleteUmindArchivoHandler — DELETE /umind/archivos/:id func DeleteUmindArchivoHandler(c *fiber.Ctx) error { archivo, err := archivoConAcceso(c) if err != nil { return err } if err := models.DeleteUmindArchivo(archivo.ID); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } // El archivo en disco se borra después del registro: si falla el borrado // físico queda un huérfano, que es mejor que una fila apuntando a la nada. _ = os.Remove(filepath.Clean(archivo.Archivo)) return c.JSON(fiber.Map{"ok": true}) } // UsarArchivoComoConocimientoHandler manda un archivo ya subido por la ingesta // del agente, sin tener que volver a subirlo. // POST /umind/archivos/:id/conocimiento {agente_id} func UsarArchivoComoConocimientoHandler(c *fiber.Ctx) error { archivo, err := archivoConAcceso(c) if err != nil { return err } var req struct { AgenteID uint `json:"agente_id"` } if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) } agente, err := accesoAgente(c, req.AgenteID) if err != nil { return err } // El agente tiene que ser del mismo espacio que el archivo — si no, sería // cargarle a un agente los papeles de otro negocio. if agente.TenantID != archivo.TenantID { return errSinAcceso(c) } datos, err := os.ReadFile(filepath.Clean(archivo.Archivo)) if err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "el archivo ya no está en el servidor"}) } texto, err := services.ExtraerTextoDeArchivo(req.AgenteID, archivo.Nombre, datos) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) } doc := &models.UmindDocumento{ AgenteID: req.AgenteID, Tipo: "archivo", Origen: archivo.Nombre, Contenido: texto, Estado: "procesando", } if err := models.CreateUmindDocumento(doc); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } _ = models.VincularArchivoADocumento(archivo.ID, doc.ID) go services.IngestarTexto(req.AgenteID, doc.ID, texto) return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"ok": true, "documento_id": doc.ID}) } // archivoConAcceso resuelve el :id y valida que el espacio sea del solicitante. func archivoConAcceso(c *fiber.Ctx) (*models.UmindArchivo, error) { id, err := strconv.ParseUint(c.Params("id"), 10, 64) if err != nil { return nil, c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"}) } archivo, err := models.GetUmindArchivoByID(uint(id)) if err != nil { return nil, errSinAcceso(c) } if err := accesoTenant(c, archivo.TenantID); err != nil { return nil, err } return archivo, nil } func planDelTenant(tenantID uint) *models.UmindPlan { tenant, err := models.GetUmindTenantByID(tenantID) if err != nil || tenant.PlanID == nil { return nil } plan, err := models.GetUmindPlanByID(*tenant.PlanID) if err != nil { return nil } return plan }