Files
geo/server/internal/shared/objectstorage/minio.go
T
root 76df672133
Deployment Config CI / Deployment Config (push) Successful in 28s
Frontend CI / Frontend (push) Successful in 2m12s
Backend CI / Backend (push) Successful in 14m44s
feat: add chunked desktop client release uploads
2026-06-01 01:31:54 +08:00

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