package storage import ( "context" "fmt" "io" "strings" "time" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/cors" "github.com/minio/minio-go/v7/pkg/credentials" ) type Config struct { Endpoint string AccessKey string SecretKey string Bucket string UseSSL bool CORSOrigins string } type ObjectInfo struct { Size int64 ContentType string } // Storage is deliberately S3-shaped so the MinIO implementation can be // replaced by AWS S3, R2, B2, or another compatible provider later. type Storage interface { EnsureBucket(context.Context) error CreateUploadURL(context.Context, string, string, time.Duration) (string, error) CreateDownloadURL(context.Context, string, time.Duration) (string, error) Delete(context.Context, string) error Stat(context.Context, string) (ObjectInfo, error) Get(context.Context, string) (io.ReadCloser, error) Put(context.Context, string, io.Reader, int64, string) error } type MinIO struct { client *minio.Client bucket string corsOrigins string } func NewMinIO(config Config) (*MinIO, error) { client, err := minio.New(config.Endpoint, &minio.Options{ Creds: credentials.NewStaticV4(config.AccessKey, config.SecretKey, ""), Secure: config.UseSSL, }) if err != nil { return nil, fmt.Errorf("create object storage client: %w", err) } if config.Bucket == "" { return nil, fmt.Errorf("object storage bucket is required") } return &MinIO{client: client, bucket: config.Bucket, corsOrigins: config.CORSOrigins}, nil } func (s *MinIO) EnsureBucket(ctx context.Context) error { exists, err := s.client.BucketExists(ctx, s.bucket) if err != nil { return fmt.Errorf("check object storage bucket: %w", err) } if exists { return nil } if err := s.client.MakeBucket(ctx, s.bucket, minio.MakeBucketOptions{}); err != nil { response := minio.ToErrorResponse(err) if response.Code != "BucketAlreadyExists" && response.Code != "BucketAlreadyOwnedByYou" { return fmt.Errorf("create object storage bucket: %w", err) } } origins := make([]string, 0) for _, origin := range strings.Split(s.corsOrigins, ",") { if value := strings.TrimSpace(origin); value != "" { origins = append(origins, value) } } if len(origins) == 0 { origins = []string{"*"} } if err := s.client.SetBucketCors(ctx, s.bucket, cors.NewConfig([]cors.Rule{{ ID: "studio-browser-uploads", AllowedOrigin: origins, AllowedMethod: []string{"GET", "PUT", "POST", "PATCH", "DELETE", "HEAD"}, AllowedHeader: []string{"*"}, ExposeHeader: []string{"ETag", "x-amz-request-id", "x-amz-id-2"}, MaxAgeSeconds: 3600, }})); err != nil { return fmt.Errorf("configure object storage CORS: %w", err) } return nil } func (s *MinIO) CreateUploadURL(ctx context.Context, key, _ string, expiry time.Duration) (string, error) { url, err := s.client.PresignedPutObject(ctx, s.bucket, key, expiry) if err != nil { return "", fmt.Errorf("create upload URL: %w", err) } return url.String(), nil } func (s *MinIO) CreateDownloadURL(ctx context.Context, key string, expiry time.Duration) (string, error) { url, err := s.client.PresignedGetObject(ctx, s.bucket, key, expiry, nil) if err != nil { return "", fmt.Errorf("create download URL: %w", err) } return url.String(), nil } func (s *MinIO) Delete(ctx context.Context, key string) error { if err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{}); err != nil { return fmt.Errorf("delete object: %w", err) } return nil } func (s *MinIO) Stat(ctx context.Context, key string) (ObjectInfo, error) { info, err := s.client.StatObject(ctx, s.bucket, key, minio.StatObjectOptions{}) if err != nil { return ObjectInfo{}, fmt.Errorf("stat object: %w", err) } return ObjectInfo{Size: info.Size, ContentType: info.ContentType}, nil } func (s *MinIO) Get(ctx context.Context, key string) (io.ReadCloser, error) { object, err := s.client.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{}) if err != nil { return nil, fmt.Errorf("get object: %w", err) } return object, nil } func (s *MinIO) Put(ctx context.Context, key string, reader io.Reader, size int64, contentType string) error { if _, err := s.client.PutObject(ctx, s.bucket, key, reader, size, minio.PutObjectOptions{ContentType: contentType}); err != nil { return fmt.Errorf("put object: %w", err) } return nil } var _ Storage = (*MinIO)(nil)