package oss import ( "context" "errors" "io" "net/url" "strings" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" "github.com/tinode/chat/server/media" "github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store/types" ) const minioLocationPrefix = "minio://" type minioConfig struct { Endpoint string `json:"endpoint"` Bucket string `json:"bucket"` Region string `json:"region"` AccessKeyID string `json:"access_key_id"` SecretAccessKey string `json:"secret_access_key"` } type minioStorage struct { client *minio.Client bucket string } func newMinIOStorage(conf minioConfig) (*minioStorage, error) { if conf.Endpoint == "" || conf.Bucket == "" || conf.AccessKeyID == "" || conf.SecretAccessKey == "" { return nil, nil } endpoint := strings.TrimSpace(conf.Endpoint) secure := true if strings.Contains(endpoint, "://") { parsed, err := url.Parse(endpoint) if err != nil || parsed.Host == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { return nil, errors.New("invalid MinIO endpoint") } endpoint = parsed.Host secure = parsed.Scheme == "https" } client, err := minio.New(endpoint, &minio.Options{ Creds: credentials.NewStaticV4(conf.AccessKeyID, conf.SecretAccessKey, ""), Secure: secure, Region: conf.Region, }) if err != nil { return nil, err } return &minioStorage{client: client, bucket: conf.Bucket}, nil } func (oh *ossHandler) uploadMinIO(fd *types.FileDef, file io.Reader) (string, int64, error) { key, err := tenantObjectKey(fd.TenantID, fd.Uid().String32(), fd.CreatedAt) if err != nil { return "", 0, err } if err := store.Files.StartUpload(fd.TenantID, fd); err != nil { return "", 0, err } result, err := oh.minio.client.PutObject(context.Background(), oh.minio.bucket, key, file, remainingSize(file), minio.PutObjectOptions{ ContentType: fd.MimeType, CacheControl: oh.conf.CacheControl, }) if err != nil { return "", 0, err } fd.Location = minioLocation(oh.minio.bucket, key) fd.ETag = strings.Trim(result.ETag, "\"") return oh.conf.ServeURL + media.FileURLName(fd), result.Size, nil } func (oh *ossHandler) uploadPublicMinIO(fd *media.PublicFileDef, file io.Reader) (string, int64, error) { key, err := publicObjectKey(fd.Purpose, fd.ID, fd.CreatedAt) if err != nil { return "", 0, err } result, err := oh.minio.client.PutObject(context.Background(), oh.minio.bucket, key, file, remainingSize(file), minio.PutObjectOptions{ ContentType: fd.MimeType, CacheControl: oh.conf.CacheControl, }) if err != nil { return "", 0, err } fd.Location = minioLocation(oh.minio.bucket, key) fd.ETag = strings.Trim(result.ETag, "\"") return fd.Location, result.Size, nil } func (oh *ossHandler) downloadMinIO(fd *types.FileDef) (*types.FileDef, media.ReadSeekCloser, error) { bucket, key, err := oh.minioObject(fd.Location) if err != nil { return nil, nil, err } if fd.Size <= 0 { meta, err := oh.minio.client.StatObject(context.Background(), bucket, key, minio.StatObjectOptions{}) if err != nil { return nil, nil, err } fd.Size = meta.Size } reader := &ossReadSeekCloser{size: fd.Size} reader.open = func(offset int64) (io.ReadCloser, error) { options := minio.GetObjectOptions{} if offset > 0 { if err := options.SetRange(offset, fd.Size-1); err != nil { return nil, err } } return oh.minio.client.GetObject(context.Background(), bucket, key, options) } return fd, reader, nil } func (oh *ossHandler) deleteMinIO(location string) error { bucket, key, err := oh.minioObject(location) if err != nil { return err } return oh.minio.client.RemoveObject(context.Background(), bucket, key, minio.RemoveObjectOptions{}) } func (oh *ossHandler) minioObject(location string) (string, string, error) { if oh.minio == nil { return "", "", errors.New("MinIO is not configured for stored file") } bucket, key, ok := strings.Cut(strings.TrimPrefix(location, minioLocationPrefix), "/") if !strings.HasPrefix(location, minioLocationPrefix) || !ok || bucket != oh.minio.bucket || key == "" { return "", "", types.ErrMalformed } return bucket, key, nil } func minioLocation(bucket, key string) string { return minioLocationPrefix + bucket + "/" + key } func remainingSize(reader io.Reader) int64 { seeker, ok := reader.(io.Seeker) if !ok { return -1 } current, err := seeker.Seek(0, io.SeekCurrent) if err != nil { return -1 } end, err := seeker.Seek(0, io.SeekEnd) if err != nil { _, _ = seeker.Seek(current, io.SeekStart) return -1 } if _, err := seeker.Seek(current, io.SeekStart); err != nil { return -1 } return end - current }