422 lines
11 KiB
Go
422 lines
11 KiB
Go
package objectstorage
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"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) {
|
|
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)
|
|
}
|
|
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 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) 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...)
|
|
}
|