package controllers import ( "fmt" "mime/multipart" "os" "path/filepath" "strconv" "strings" "time" "github.com/gofiber/fiber/v2" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" ) // GetClienteDocumentos devuelve todos los documentos de un cliente. func GetClienteDocumentos(c *fiber.Ctx) error { id, err := strconv.ParseUint(c.Params("clienteID"), 10, 32) if err != nil { return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) } docs, err := models.GetDocumentosByCliente(uint(id)) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(docs) } // UploadClienteDocumentos recibe uno o más archivos (campo "archivos") y un campo // opcional "fecha_expedicion" (YYYY-MM-DD) por cada archivo o uno compartido. func UploadClienteDocumentos(c *fiber.Ctx) error { id, err := strconv.ParseUint(c.Params("clienteID"), 10, 32) if err != nil { return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) } form, err := c.MultipartForm() if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Formulario inválido: " + err.Error()}) } files := form.File["archivos"] if len(files) == 0 { return c.Status(400).JSON(fiber.Map{"error": "Ningún archivo recibido"}) } // fecha_expedicion puede venir como un array (uno por archivo) o uno solo compartido fechas := form.Value["fecha_expedicion"] nombres := form.Value["nombre"] uploadDir := fmt.Sprintf("uploads/clientes/%d", id) if err := os.MkdirAll(uploadDir, 0750); err != nil { return c.Status(500).JSON(fiber.Map{"error": "No se pudo crear directorio"}) } var creados []models.ClienteDocumento for i, fh := range files { // Validar tamaño máximo: 20 MB if fh.Size > 20*1024*1024 { return c.Status(400).JSON(fiber.Map{"error": fmt.Sprintf("Archivo '%s' supera 20 MB", fh.Filename)}) } // Validar extensión permitida if !extensionPermitida(fh.Filename) { return c.Status(400).JSON(fiber.Map{"error": fmt.Sprintf("Tipo de archivo no permitido: %s", fh.Filename)}) } safeName := sanitizeFilename(fh.Filename) destPath := filepath.Join(uploadDir, fmt.Sprintf("%d_%s", time.Now().UnixNano(), safeName)) if err := saveUploadedFile(fh, destPath); err != nil { return c.Status(500).JSON(fiber.Map{"error": "Error guardando archivo: " + err.Error()}) } doc := models.ClienteDocumento{ ClienteID: uint(id), Archivo: destPath, OriginalName: fh.Filename, TipoMime: fh.Header.Get("Content-Type"), Tamanio: fh.Size, } // Nombre descriptivo if i < len(nombres) && strings.TrimSpace(nombres[i]) != "" { doc.Nombre = strings.TrimSpace(nombres[i]) } else { doc.Nombre = fh.Filename } // Fecha de expedición if i < len(fechas) && fechas[i] != "" { if t, err := time.Parse("2006-01-02", fechas[i]); err == nil { doc.FechaExpedicion = &t } } else if len(fechas) == 1 && fechas[0] != "" { if t, err := time.Parse("2006-01-02", fechas[0]); err == nil { doc.FechaExpedicion = &t } } if err := models.CreateClienteDocumento(&doc); err != nil { return c.Status(500).JSON(fiber.Map{"error": "Error guardando en BD: " + err.Error()}) } creados = append(creados, doc) } return c.Status(201).JSON(fiber.Map{"ok": true, "creados": len(creados), "documentos": creados}) } // DeleteClienteDocumento elimina un documento y su archivo en disco. func DeleteClienteDocumento(c *fiber.Ctx) error { docID, err := strconv.ParseUint(c.Params("docID"), 10, 32) if err != nil { return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) } doc, err := models.GetClienteDocumentoByID(uint(docID)) if err != nil { return c.Status(404).JSON(fiber.Map{"error": "Documento no encontrado"}) } // Eliminar archivo físico (no fatal si no existe) _ = os.Remove(doc.Archivo) if err := models.DeleteClienteDocumento(uint(docID)); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"ok": true, "message": "Documento eliminado"}) } // DownloadClienteDocumento sirve el archivo para descarga directa. func DownloadClienteDocumento(c *fiber.Ctx) error { docID, err := strconv.ParseUint(c.Params("docID"), 10, 32) if err != nil { return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) } doc, err := models.GetClienteDocumentoByID(uint(docID)) if err != nil { return c.Status(404).JSON(fiber.Map{"error": "Documento no encontrado"}) } // Asegurarse de que el path no escape del directorio uploads/ cleanPath := filepath.Clean(doc.Archivo) if !strings.HasPrefix(cleanPath, "uploads/") { return c.Status(403).JSON(fiber.Map{"error": "Acceso denegado"}) } return c.Download(cleanPath, doc.OriginalName) } // ─── helpers ───────────────────────────────────────────────────────────────── var extensionesPermitidas = map[string]bool{ ".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".csv": true, ".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".webp": true, ".txt": true, ".zip": true, ".rar": true, } func extensionPermitida(filename string) bool { ext := strings.ToLower(filepath.Ext(filename)) return extensionesPermitidas[ext] } func sanitizeFilename(name string) string { base := filepath.Base(name) // Eliminar caracteres peligrosos safe := strings.Map(func(r rune) rune { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '-' || r == '_' { return r } return '_' }, base) return safe } func saveUploadedFile(fh *multipart.FileHeader, dest string) error { src, err := fh.Open() if err != nil { return err } defer src.Close() out, err := os.Create(dest) //nolint:gosec if err != nil { return err } defer out.Close() buf := make([]byte, 32*1024) for { n, err := src.Read(buf) if n > 0 { if _, werr := out.Write(buf[:n]); werr != nil { return werr } } if err != nil { break } } return nil }