package storage import ( "context" "fmt" "io" "net/url" "path/filepath" "strings" "time" "github.com/google/uuid" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" ) type MinioConfig struct { Endpoint string AccessKey string SecretKey string Bucket string UseSSL bool PublicURL string } type MinioStorage struct { client *minio.Client bucket string publicURL string } type UploadResult struct { Key string `json:"key"` Bucket string `json:"bucket"` Size int64 `json:"size"` ETag string `json:"etag"` PublicURL string `json:"publicUrl,omitempty"` } func NewMinioStorage(cfg MinioConfig) (*MinioStorage, error) { client, err := minio.New(cfg.Endpoint, &minio.Options{ Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), Secure: cfg.UseSSL, }) if err != nil { return nil, fmt.Errorf("failed to create minio client: %w", err) } ctx := context.Background() exists, err := client.BucketExists(ctx, cfg.Bucket) if err != nil { return nil, fmt.Errorf("failed to check bucket: %w", err) } if !exists { if err := client.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{}); err != nil { return nil, fmt.Errorf("failed to create bucket: %w", err) } } return &MinioStorage{ client: client, bucket: cfg.Bucket, publicURL: strings.TrimRight(cfg.PublicURL, "/"), }, nil } func (s *MinioStorage) GetPublicURL(key string) string { if s.publicURL != "" { return s.publicURL + "/" + s.bucket + "/" + key } return "" } func (s *MinioStorage) Bucket() string { return s.bucket } func (s *MinioStorage) Upload(ctx context.Context, reader io.Reader, size int64, filename, contentType string) (*UploadResult, error) { ext := filepath.Ext(filename) key := generateStorageKey(ext) opts := minio.PutObjectOptions{ ContentType: contentType, } info, err := s.client.PutObject(ctx, s.bucket, key, reader, size, opts) if err != nil { return nil, fmt.Errorf("failed to upload file: %w", err) } return &UploadResult{ Key: key, Bucket: s.bucket, Size: info.Size, ETag: info.ETag, }, nil } func (s *MinioStorage) UploadWithKey(ctx context.Context, key string, reader io.Reader, size int64, contentType string) (*UploadResult, error) { opts := minio.PutObjectOptions{ ContentType: contentType, CacheControl: "public, max-age=2592000", } info, err := s.client.PutObject(ctx, s.bucket, key, reader, size, opts) if err != nil { return nil, fmt.Errorf("failed to upload file: %w", err) } return &UploadResult{ Key: key, Bucket: s.bucket, Size: info.Size, ETag: info.ETag, }, nil } func (s *MinioStorage) UploadUserFile(ctx context.Context, userID string, reader io.Reader, size int64, filename, contentType string) (*UploadResult, error) { ext := filepath.Ext(filename) key := fmt.Sprintf("users/%s/%s%s", userID, uuid.New().String(), ext) opts := minio.PutObjectOptions{ ContentType: contentType, UserMetadata: map[string]string{ "original-name": filename, "user-id": userID, }, } info, err := s.client.PutObject(ctx, s.bucket, key, reader, size, opts) if err != nil { return nil, fmt.Errorf("failed to upload file: %w", err) } return &UploadResult{ Key: key, Bucket: s.bucket, Size: info.Size, ETag: info.ETag, }, nil } func (s *MinioStorage) Download(ctx context.Context, key string) (io.ReadCloser, *minio.ObjectInfo, error) { obj, err := s.client.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{}) if err != nil { return nil, nil, fmt.Errorf("failed to get object: %w", err) } info, err := obj.Stat() if err != nil { obj.Close() return nil, nil, fmt.Errorf("failed to stat object: %w", err) } return obj, &info, nil } func (s *MinioStorage) Delete(ctx context.Context, key string) error { return s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{}) } func (s *MinioStorage) DeleteUserFiles(ctx context.Context, userID string) error { prefix := fmt.Sprintf("users/%s/", userID) objectsCh := s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{ Prefix: prefix, Recursive: true, }) for obj := range objectsCh { if obj.Err != nil { return obj.Err } if err := s.client.RemoveObject(ctx, s.bucket, obj.Key, minio.RemoveObjectOptions{}); err != nil { return err } } return nil } func (s *MinioStorage) GetPresignedURL(ctx context.Context, key string, expiry time.Duration) (string, error) { presignedURL, err := s.client.PresignedGetObject(ctx, s.bucket, key, expiry, url.Values{}) if err != nil { return "", fmt.Errorf("failed to generate presigned URL: %w", err) } return presignedURL.String(), nil } func (s *MinioStorage) GetPresignedUploadURL(ctx context.Context, key string, expiry time.Duration) (string, error) { presignedURL, err := s.client.PresignedPutObject(ctx, s.bucket, key, expiry) if err != nil { return "", fmt.Errorf("failed to generate presigned upload URL: %w", err) } return presignedURL.String(), nil } func (s *MinioStorage) ListUserFiles(ctx context.Context, userID string) ([]minio.ObjectInfo, error) { prefix := fmt.Sprintf("users/%s/", userID) var files []minio.ObjectInfo objectsCh := s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{ Prefix: prefix, Recursive: true, }) for obj := range objectsCh { if obj.Err != nil { return nil, obj.Err } files = append(files, obj) } return files, nil } func (s *MinioStorage) GetUserStorageUsage(ctx context.Context, userID string) (int64, error) { files, err := s.ListUserFiles(ctx, userID) if err != nil { return 0, err } var total int64 for _, f := range files { total += f.Size } return total, nil } func (s *MinioStorage) CopyObject(ctx context.Context, srcKey, dstKey string) error { _, err := s.client.CopyObject(ctx, minio.CopyDestOptions{Bucket: s.bucket, Object: dstKey}, minio.CopySrcOptions{Bucket: s.bucket, Object: srcKey}, ) return err } func (s *MinioStorage) ObjectExists(ctx context.Context, key string) (bool, error) { _, err := s.client.StatObject(ctx, s.bucket, key, minio.StatObjectOptions{}) if err != nil { errResp := minio.ToErrorResponse(err) if errResp.Code == "NoSuchKey" { return false, nil } return false, err } return true, nil } func generateStorageKey(ext string) string { now := time.Now() return fmt.Sprintf("%d/%02d/%02d/%s%s", now.Year(), now.Month(), now.Day(), uuid.New().String(), strings.ToLower(ext), ) }