package file import ( "context" "fmt" "io" "net/http" "os" "time" "service-center/internal/auth" "github.com/gofiber/fiber/v2" "github.com/google/uuid" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" ) var allowedMIME = map[string]bool{ "image/jpeg": true, "image/png": true, "image/webp": true, "application/pdf": true, } type Handler struct { minio *minio.Client bucket string } func NewHandler() (*Handler, error) { endpoint := os.Getenv("MINIO_ENDPOINT") accessKey := os.Getenv("MINIO_ACCESS_KEY") secretKey := os.Getenv("MINIO_SECRET_KEY") bucket := os.Getenv("MINIO_BUCKET") useSSL := os.Getenv("MINIO_USE_SSL") == "true" if endpoint == "" { endpoint = "localhost:9000" } if bucket == "" { bucket = "service-center" } mc, err := minio.New(endpoint, &minio.Options{ Creds: credentials.NewStaticV4(accessKey, secretKey, ""), Secure: useSSL, }) if err != nil { return nil, fmt.Errorf("minio.New: %w", err) } ctx := context.Background() exists, err := mc.BucketExists(ctx, bucket) if err != nil { return nil, fmt.Errorf("minio BucketExists: %w", err) } if !exists { if err := mc.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}); err != nil { return nil, fmt.Errorf("minio MakeBucket: %w", err) } } return &Handler{minio: mc, bucket: bucket}, nil } // Upload stores a file (order photo, PDF invoice, etc.) and returns its object key. // Actual linking to an order/client row happens in the domain package that calls this. func (h *Handler) Upload(c *fiber.Ctx) error { _ = auth.StaffID(c) // caller must be authenticated; ownership recorded by the calling domain package fh, err := c.FormFile("file") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "file required"}) } if fh.Size > 20*1024*1024 { return c.Status(413).JSON(fiber.Map{"error": "file too large (max 20MB)"}) } f, err := fh.Open() if err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } defer f.Close() // fh.Header.Get("Content-Type") is the client-supplied multipart part // header — a request can claim "image/png" for any byte stream at all. // Sniff the actual bytes instead (the same algorithm browsers use to // guess a response's type) and trust that for both validation and the // object's stored ContentType. sniff := make([]byte, 512) n, err := f.Read(sniff) if err != nil && err != io.EOF { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } mimeType := http.DetectContentType(sniff[:n]) if !allowedMIME[mimeType] { return c.Status(415).JSON(fiber.Map{"error": "unsupported file type"}) } if _, err := f.Seek(0, io.SeekStart); err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } key := uuid.New().String() + "-" + fh.Filename ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() _, err = h.minio.PutObject(ctx, h.bucket, key, f, fh.Size, minio.PutObjectOptions{ContentType: mimeType}) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "upload failed"}) } return c.Status(201).JSON(fiber.Map{"key": key}) }