Files
aura-crm/core/backend/internal/file/handler.go
T

115 lines
3.1 KiB
Go

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})
}