code.gitea.io/gitea@v1.22.3/modules/storage/minio.go (about)

     1  // Copyright 2020 The Gitea Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package storage
     5  
     6  import (
     7  	"context"
     8  	"crypto/tls"
     9  	"fmt"
    10  	"io"
    11  	"net/http"
    12  	"net/url"
    13  	"os"
    14  	"path"
    15  	"strings"
    16  	"time"
    17  
    18  	"code.gitea.io/gitea/modules/log"
    19  	"code.gitea.io/gitea/modules/setting"
    20  	"code.gitea.io/gitea/modules/util"
    21  
    22  	"github.com/minio/minio-go/v7"
    23  	"github.com/minio/minio-go/v7/pkg/credentials"
    24  )
    25  
    26  var (
    27  	_ ObjectStorage = &MinioStorage{}
    28  
    29  	quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
    30  )
    31  
    32  type minioObject struct {
    33  	*minio.Object
    34  }
    35  
    36  func (m *minioObject) Stat() (os.FileInfo, error) {
    37  	oi, err := m.Object.Stat()
    38  	if err != nil {
    39  		return nil, convertMinioErr(err)
    40  	}
    41  
    42  	return &minioFileInfo{oi}, nil
    43  }
    44  
    45  // MinioStorage returns a minio bucket storage
    46  type MinioStorage struct {
    47  	cfg      *setting.MinioStorageConfig
    48  	ctx      context.Context
    49  	client   *minio.Client
    50  	bucket   string
    51  	basePath string
    52  }
    53  
    54  func convertMinioErr(err error) error {
    55  	if err == nil {
    56  		return nil
    57  	}
    58  	errResp, ok := err.(minio.ErrorResponse)
    59  	if !ok {
    60  		return err
    61  	}
    62  
    63  	// Convert two responses to standard analogues
    64  	switch errResp.Code {
    65  	case "NoSuchKey":
    66  		return os.ErrNotExist
    67  	case "AccessDenied":
    68  		return os.ErrPermission
    69  	}
    70  
    71  	return err
    72  }
    73  
    74  var getBucketVersioning = func(ctx context.Context, minioClient *minio.Client, bucket string) error {
    75  	_, err := minioClient.GetBucketVersioning(ctx, bucket)
    76  	return err
    77  }
    78  
    79  // NewMinioStorage returns a minio storage
    80  func NewMinioStorage(ctx context.Context, cfg *setting.Storage) (ObjectStorage, error) {
    81  	config := cfg.MinioConfig
    82  	if config.ChecksumAlgorithm != "" && config.ChecksumAlgorithm != "default" && config.ChecksumAlgorithm != "md5" {
    83  		return nil, fmt.Errorf("invalid minio checksum algorithm: %s", config.ChecksumAlgorithm)
    84  	}
    85  
    86  	log.Info("Creating Minio storage at %s:%s with base path %s", config.Endpoint, config.Bucket, config.BasePath)
    87  
    88  	minioClient, err := minio.New(config.Endpoint, &minio.Options{
    89  		Creds:     credentials.NewStaticV4(config.AccessKeyID, config.SecretAccessKey, ""),
    90  		Secure:    config.UseSSL,
    91  		Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: config.InsecureSkipVerify}},
    92  		Region:    config.Location,
    93  	})
    94  	if err != nil {
    95  		return nil, convertMinioErr(err)
    96  	}
    97  
    98  	// The GetBucketVersioning is only used for checking whether the Object Storage parameters are generally good. It doesn't need to succeed.
    99  	// The assumption is that if the API returns the HTTP code 400, then the parameters could be incorrect.
   100  	// Otherwise even if the request itself fails (403, 404, etc), the code should still continue because the parameters seem "good" enough.
   101  	// Keep in mind that GetBucketVersioning requires "owner" to really succeed, so it can't be used to check the existence.
   102  	// Not using "BucketExists (HeadBucket)" because it doesn't include detailed failure reasons.
   103  	err = getBucketVersioning(ctx, minioClient, config.Bucket)
   104  	if err != nil {
   105  		errResp, ok := err.(minio.ErrorResponse)
   106  		if !ok {
   107  			return nil, err
   108  		}
   109  		if errResp.StatusCode == http.StatusBadRequest {
   110  			log.Error("S3 storage connection failure at %s:%s with base path %s and region: %s", config.Endpoint, config.Bucket, config.Location, errResp.Message)
   111  			return nil, err
   112  		}
   113  	}
   114  
   115  	// Check to see if we already own this bucket
   116  	exists, err := minioClient.BucketExists(ctx, config.Bucket)
   117  	if err != nil {
   118  		return nil, convertMinioErr(err)
   119  	}
   120  
   121  	if !exists {
   122  		if err := minioClient.MakeBucket(ctx, config.Bucket, minio.MakeBucketOptions{
   123  			Region: config.Location,
   124  		}); err != nil {
   125  			return nil, convertMinioErr(err)
   126  		}
   127  	}
   128  
   129  	return &MinioStorage{
   130  		cfg:      &config,
   131  		ctx:      ctx,
   132  		client:   minioClient,
   133  		bucket:   config.Bucket,
   134  		basePath: config.BasePath,
   135  	}, nil
   136  }
   137  
   138  func (m *MinioStorage) buildMinioPath(p string) string {
   139  	p = strings.TrimPrefix(util.PathJoinRelX(m.basePath, p), "/") // object store doesn't use slash for root path
   140  	if p == "." {
   141  		p = "" // object store doesn't use dot as relative path
   142  	}
   143  	return p
   144  }
   145  
   146  func (m *MinioStorage) buildMinioDirPrefix(p string) string {
   147  	// ending slash is required for avoiding matching like "foo/" and "foobar/" with prefix "foo"
   148  	p = m.buildMinioPath(p) + "/"
   149  	if p == "/" {
   150  		p = "" // object store doesn't use slash for root path
   151  	}
   152  	return p
   153  }
   154  
   155  // Open opens a file
   156  func (m *MinioStorage) Open(path string) (Object, error) {
   157  	opts := minio.GetObjectOptions{}
   158  	object, err := m.client.GetObject(m.ctx, m.bucket, m.buildMinioPath(path), opts)
   159  	if err != nil {
   160  		return nil, convertMinioErr(err)
   161  	}
   162  	return &minioObject{object}, nil
   163  }
   164  
   165  // Save saves a file to minio
   166  func (m *MinioStorage) Save(path string, r io.Reader, size int64) (int64, error) {
   167  	uploadInfo, err := m.client.PutObject(
   168  		m.ctx,
   169  		m.bucket,
   170  		m.buildMinioPath(path),
   171  		r,
   172  		size,
   173  		minio.PutObjectOptions{
   174  			ContentType: "application/octet-stream",
   175  			// some storages like:
   176  			// * https://developers.cloudflare.com/r2/api/s3/api/
   177  			// * https://www.backblaze.com/b2/docs/s3_compatible_api.html
   178  			// do not support "x-amz-checksum-algorithm" header, so use legacy MD5 checksum
   179  			SendContentMd5: m.cfg.ChecksumAlgorithm == "md5",
   180  		},
   181  	)
   182  	if err != nil {
   183  		return 0, convertMinioErr(err)
   184  	}
   185  	return uploadInfo.Size, nil
   186  }
   187  
   188  type minioFileInfo struct {
   189  	minio.ObjectInfo
   190  }
   191  
   192  func (m minioFileInfo) Name() string {
   193  	return path.Base(m.ObjectInfo.Key)
   194  }
   195  
   196  func (m minioFileInfo) Size() int64 {
   197  	return m.ObjectInfo.Size
   198  }
   199  
   200  func (m minioFileInfo) ModTime() time.Time {
   201  	return m.LastModified
   202  }
   203  
   204  func (m minioFileInfo) IsDir() bool {
   205  	return strings.HasSuffix(m.ObjectInfo.Key, "/")
   206  }
   207  
   208  func (m minioFileInfo) Mode() os.FileMode {
   209  	return os.ModePerm
   210  }
   211  
   212  func (m minioFileInfo) Sys() any {
   213  	return nil
   214  }
   215  
   216  // Stat returns the stat information of the object
   217  func (m *MinioStorage) Stat(path string) (os.FileInfo, error) {
   218  	info, err := m.client.StatObject(
   219  		m.ctx,
   220  		m.bucket,
   221  		m.buildMinioPath(path),
   222  		minio.StatObjectOptions{},
   223  	)
   224  	if err != nil {
   225  		return nil, convertMinioErr(err)
   226  	}
   227  	return &minioFileInfo{info}, nil
   228  }
   229  
   230  // Delete delete a file
   231  func (m *MinioStorage) Delete(path string) error {
   232  	err := m.client.RemoveObject(m.ctx, m.bucket, m.buildMinioPath(path), minio.RemoveObjectOptions{})
   233  
   234  	return convertMinioErr(err)
   235  }
   236  
   237  // URL gets the redirect URL to a file. The presigned link is valid for 5 minutes.
   238  func (m *MinioStorage) URL(path, name string) (*url.URL, error) {
   239  	reqParams := make(url.Values)
   240  	// TODO it may be good to embed images with 'inline' like ServeData does, but we don't want to have to read the file, do we?
   241  	reqParams.Set("response-content-disposition", "attachment; filename=\""+quoteEscaper.Replace(name)+"\"")
   242  	u, err := m.client.PresignedGetObject(m.ctx, m.bucket, m.buildMinioPath(path), 5*time.Minute, reqParams)
   243  	return u, convertMinioErr(err)
   244  }
   245  
   246  // IterateObjects iterates across the objects in the miniostorage
   247  func (m *MinioStorage) IterateObjects(dirName string, fn func(path string, obj Object) error) error {
   248  	opts := minio.GetObjectOptions{}
   249  	for mObjInfo := range m.client.ListObjects(m.ctx, m.bucket, minio.ListObjectsOptions{
   250  		Prefix:    m.buildMinioDirPrefix(dirName),
   251  		Recursive: true,
   252  	}) {
   253  		object, err := m.client.GetObject(m.ctx, m.bucket, mObjInfo.Key, opts)
   254  		if err != nil {
   255  			return convertMinioErr(err)
   256  		}
   257  		if err := func(object *minio.Object, fn func(path string, obj Object) error) error {
   258  			defer object.Close()
   259  			return fn(strings.TrimPrefix(mObjInfo.Key, m.basePath), &minioObject{object})
   260  		}(object, fn); err != nil {
   261  			return convertMinioErr(err)
   262  		}
   263  	}
   264  	return nil
   265  }
   266  
   267  func init() {
   268  	RegisterStorageType(setting.MinioStorageType, NewMinioStorage)
   269  }