package objectstorage import ( "bytes" "context" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" "go.uber.org/zap" "github.com/geo-platform/tenant-api/internal/shared/config" "github.com/geo-platform/tenant-api/internal/shared/middleware" ) type minioClient struct { client *minio.Client bucket string region string logger *zap.Logger cfg config.ObjectStorageConfig } func NewMinIOClient(cfg config.ObjectStorageConfig, logger *zap.Logger) Client { endpoint := strings.TrimSpace(cfg.Endpoint) accessKey := strings.TrimSpace(cfg.AccessKey) secretKey := strings.TrimSpace(cfg.SecretKey) bucket := strings.TrimSpace(cfg.Bucket) if endpoint == "" || accessKey == "" || secretKey == "" || bucket == "" { return disabledClient{reason: "missing object_storage minio endpoint/access_key/secret_key/bucket"} } region := normalizeMinIORegion(cfg) bucketLookup := minio.BucketLookupAuto if cfg.ForcePathStyle || isPathStyleProvider(cfg.Provider) { bucketLookup = minio.BucketLookupPath } client, err := minio.New(endpoint, &minio.Options{ Creds: credentials.NewStaticV4(accessKey, secretKey, ""), Secure: cfg.UseSSL, Region: region, BucketLookup: bucketLookup, }) if err != nil { return disabledClient{reason: fmt.Sprintf("init minio client failed: %v", err)} } return &minioClient{ client: client, bucket: bucket, region: region, logger: logger, cfg: cfg, } } func normalizeMinIORegion(cfg config.ObjectStorageConfig) string { region := strings.TrimSpace(cfg.Region) if region != "" { return region } if isR2Provider(cfg.Provider) { return "auto" } return "" } func isR2Provider(provider string) bool { switch strings.ToLower(strings.TrimSpace(provider)) { case "r2", "cloudflare_r2", "cloudflare-r2": return true default: return false } } func isPathStyleProvider(provider string) bool { switch strings.ToLower(strings.TrimSpace(provider)) { case "r2", "cloudflare_r2", "cloudflare-r2": return true default: return false } } func (c *minioClient) Validate() error { if c == nil || c.client == nil { return fmt.Errorf("%w: minio client is not initialized", ErrNotConfigured) } if strings.TrimSpace(c.bucket) == "" { return fmt.Errorf("%w: minio bucket is empty", ErrNotConfigured) } return nil } func (c *minioClient) PutBytes( ctx context.Context, objectKey string, content []byte, contentType string, ) error { return c.PutReader(ctx, objectKey, bytes.NewReader(content), int64(len(content)), contentType) } func (c *minioClient) PutReader( ctx context.Context, objectKey string, reader io.Reader, size int64, contentType string, ) error { if err := c.Validate(); err != nil { return err } if strings.TrimSpace(objectKey) == "" { return fmt.Errorf("object key is required") } if reader == nil { return fmt.Errorf("object reader is required") } exists, err := c.client.BucketExists(ctx, c.bucket) if err != nil { c.logError(ctx, "minio bucket exists check failed", zap.String("bucket", c.bucket), zap.String("object_key", objectKey), zap.Error(err), ) return fmt.Errorf("check minio bucket: %w", err) } if !exists { if err := c.client.MakeBucket(ctx, c.bucket, minio.MakeBucketOptions{Region: c.region}); err != nil { c.logError(ctx, "minio create bucket failed", zap.String("bucket", c.bucket), zap.String("object_key", objectKey), zap.Error(err), ) return fmt.Errorf("create minio bucket: %w", err) } } _, err = c.client.PutObject(ctx, c.bucket, objectKey, reader, size, minio.PutObjectOptions{ ContentType: contentType, }) if err != nil { c.logError(ctx, "minio put object failed", zap.String("bucket", c.bucket), zap.String("object_key", objectKey), zap.Int64("content_size", size), zap.Error(err), ) return fmt.Errorf("put minio object: %w", err) } return nil } func (c *minioClient) GetBytes(ctx context.Context, objectKey string) ([]byte, error) { reader, err := c.GetReader(ctx, objectKey) if err != nil { return nil, err } defer reader.Close() data, err := io.ReadAll(reader) if err != nil { c.logError(ctx, "minio read object failed", zap.String("bucket", c.bucket), zap.String("object_key", objectKey), zap.Error(err), ) if isMinIOObjectNotFound(err) { return nil, fmt.Errorf("%w: %s", ErrObjectNotFound, objectKey) } return nil, fmt.Errorf("read minio object: %w", err) } return data, nil } func (c *minioClient) GetReader(ctx context.Context, objectKey string) (io.ReadCloser, error) { if err := c.Validate(); err != nil { return nil, err } reader, err := c.client.GetObject(ctx, c.bucket, objectKey, minio.GetObjectOptions{}) if err != nil { c.logError(ctx, "minio get object failed", zap.String("bucket", c.bucket), zap.String("object_key", objectKey), zap.Error(err), ) if isMinIOObjectNotFound(err) { return nil, fmt.Errorf("%w: %s", ErrObjectNotFound, objectKey) } return nil, fmt.Errorf("get minio object: %w", err) } return reader, nil } func isMinIOObjectNotFound(err error) bool { responseErr := minio.ToErrorResponse(err) return responseErr.StatusCode == 404 || responseErr.Code == "NoSuchKey" || responseErr.Code == "NoSuchObject" } func (c *minioClient) Exists(ctx context.Context, objectKey string) (bool, error) { if err := c.Validate(); err != nil { return false, err } if strings.TrimSpace(objectKey) == "" { return false, nil } _, err := c.client.StatObject(ctx, c.bucket, objectKey, minio.StatObjectOptions{}) if err == nil { return true, nil } responseErr := minio.ToErrorResponse(err) if responseErr.StatusCode == 404 || responseErr.Code == "NoSuchKey" || responseErr.Code == "NoSuchObject" { return false, nil } c.logError(ctx, "minio stat object failed", zap.String("bucket", c.bucket), zap.String("object_key", objectKey), zap.Error(err), ) return false, fmt.Errorf("stat minio object: %w", err) } func (c *minioClient) Delete(ctx context.Context, objectKey string) error { if err := c.Validate(); err != nil { return err } if strings.TrimSpace(objectKey) == "" { return nil } err := c.client.RemoveObject(ctx, c.bucket, objectKey, minio.RemoveObjectOptions{}) if err != nil { c.logError(ctx, "minio delete object failed", zap.String("bucket", c.bucket), zap.String("object_key", objectKey), zap.Error(err), ) return fmt.Errorf("delete minio object: %w", err) } return nil } func (c *minioClient) PublicURL(objectKey string) (string, error) { if err := c.Validate(); err != nil { return "", err } if !c.isPublicReadable() { return c.DownloadURL(objectKey) } return buildPublicURL(c.cfg, objectKey, false) } func (c *minioClient) PublicReadableURL(objectKey string) (string, error) { if err := c.Validate(); err != nil { return "", err } if !c.isPublicReadable() { return "", ErrObjectNotPublic } return buildPublicURL(c.cfg, objectKey, false) } func (c *minioClient) isPublicReadable() bool { return isPublicReadACL(c.cfg.ObjectACL) || isPublicReadACL(c.cfg.BucketACL) } func (c *minioClient) DownloadURL(objectKey string) (string, error) { if err := c.Validate(); err != nil { return "", err } objectKey = normalizeObjectKeyPath(objectKey) if objectKey == "" { return "", fmt.Errorf("object key is required") } ttl := c.cfg.SignedURLTTL if ttl <= 0 { ttl = 30 * time.Minute } params := url.Values{} params.Set("response-content-disposition", downloadContentDisposition(objectKey)) u, err := c.client.PresignedGetObject(context.Background(), c.bucket, objectKey, ttl, params) if err != nil { return "", fmt.Errorf("presign minio download url: %w", err) } return u.String(), nil } func (c *minioClient) PresignedPutURL( ctx context.Context, objectKey string, contentType string, contentMD5Base64 string, ttl time.Duration, ) (*PresignedPutResult, error) { if err := c.Validate(); err != nil { return nil, err } objectKey = normalizeObjectKeyPath(objectKey) if objectKey == "" { return nil, fmt.Errorf("object key is required") } if ttl <= 0 { ttl = c.cfg.SignedURLTTL } if ttl <= 0 { ttl = 30 * time.Minute } headers := http.Header{} if contentType = strings.TrimSpace(contentType); contentType != "" { headers.Set("Content-Type", contentType) } if contentMD5Base64 = strings.TrimSpace(contentMD5Base64); contentMD5Base64 != "" { headers.Set("Content-MD5", contentMD5Base64) } var u *url.URL var err error if len(headers) > 0 { u, err = c.client.PresignHeader(ctx, http.MethodPut, c.bucket, objectKey, ttl, nil, headers) } else { u, err = c.client.PresignedPutObject(ctx, c.bucket, objectKey, ttl) } if err != nil { return nil, fmt.Errorf("presign minio put url: %w", err) } return &PresignedPutResult{ URL: u.String(), Headers: headers, ExpiresAt: time.Now().UTC().Add(ttl), }, nil } func (c *minioClient) List(ctx context.Context, in ListInput) (*ListResult, error) { if err := c.Validate(); err != nil { return nil, err } limit := in.Limit if limit <= 0 || limit > 1000 { limit = 100 } delimiter := strings.TrimSpace(in.Delimiter) if delimiter == "" && !in.Recursive { delimiter = "/" } core := minio.Core{Client: c.client} result, err := core.ListObjectsV2( c.bucket, normalizeObjectKeyPath(in.Prefix), normalizeObjectKeyPath(in.StartAfter), strings.TrimSpace(in.ContinuationToken), delimiter, limit, ) if err != nil { c.logError(ctx, "minio list objects failed", zap.String("bucket", c.bucket), zap.String("prefix", in.Prefix), zap.Error(err), ) return nil, fmt.Errorf("list minio objects: %w", err) } out := &ListResult{ Objects: make([]ObjectInfo, 0, len(result.Contents)), CommonPrefixes: make([]string, 0, len(result.CommonPrefixes)), ContinuationToken: result.ContinuationToken, NextContinuationToken: result.NextContinuationToken, IsTruncated: result.IsTruncated, Prefix: result.Prefix, Delimiter: result.Delimiter, } for _, item := range result.Contents { out.Objects = append(out.Objects, ObjectInfo{ Key: item.Key, Size: item.Size, ETag: strings.Trim(item.ETag, `"`), LastModified: item.LastModified, ContentType: item.ContentType, StorageClass: item.StorageClass, }) } for _, prefix := range result.CommonPrefixes { if prefix.Prefix != "" { out.CommonPrefixes = append(out.CommonPrefixes, prefix.Prefix) } } return out, nil } func (c *minioClient) Stat(ctx context.Context, objectKey string) (*ObjectInfo, error) { if err := c.Validate(); err != nil { return nil, err } objectKey = normalizeObjectKeyPath(objectKey) if objectKey == "" { return nil, fmt.Errorf("object key is required") } item, err := c.client.StatObject(ctx, c.bucket, objectKey, minio.StatObjectOptions{}) if err != nil { c.logError(ctx, "minio stat object failed", zap.String("bucket", c.bucket), zap.String("object_key", objectKey), zap.Error(err), ) if isMinIOObjectNotFound(err) { return nil, fmt.Errorf("%w: %s", ErrObjectNotFound, objectKey) } return nil, fmt.Errorf("stat minio object: %w", err) } return &ObjectInfo{ Key: item.Key, Size: item.Size, ETag: strings.Trim(item.ETag, `"`), LastModified: item.LastModified, ContentType: item.ContentType, StorageClass: item.StorageClass, }, nil } func (c *minioClient) Copy(ctx context.Context, sourceKey, destinationKey string) error { if err := c.Validate(); err != nil { return err } sourceKey = normalizeObjectKeyPath(sourceKey) destinationKey = normalizeObjectKeyPath(destinationKey) if sourceKey == "" || destinationKey == "" { return fmt.Errorf("source and destination object keys are required") } if sourceKey == destinationKey { return nil } _, err := c.client.CopyObject(ctx, minio.CopyDestOptions{Bucket: c.bucket, Object: destinationKey}, minio.CopySrcOptions{Bucket: c.bucket, Object: sourceKey}, ) if err != nil { c.logError(ctx, "minio copy object failed", zap.String("bucket", c.bucket), zap.String("source_key", sourceKey), zap.String("destination_key", destinationKey), zap.Error(err), ) if isMinIOObjectNotFound(err) { return fmt.Errorf("%w: %s", ErrObjectNotFound, sourceKey) } return fmt.Errorf("copy minio object: %w", err) } return nil } func (c *minioClient) logError(ctx context.Context, msg string, fields ...zap.Field) { if c == nil || c.logger == nil { return } if requestID := middleware.RequestIDFromContext(ctx); requestID != "" { fields = append(fields, zap.String("request_id", requestID)) } c.logger.Error(msg, fields...) }