115 lines
3.1 KiB
Go
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})
|
|
}
|