package storage import ( "context" "errors" "fmt" "io" "mime" "net/http" "path/filepath" "strconv" "strings" "github.com/google/uuid" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" ) type Config struct { Endpoint string AccessKey string SecretKey string Bucket string UseSSL bool } type Storage struct { cfg Config client *minio.Client } type ObjectInfo struct { Size int64 ContentType string } func New(cfg Config) (*Storage, error) { if cfg.Endpoint == "" || cfg.AccessKey == "" || cfg.SecretKey == "" { return &Storage{cfg: cfg}, nil } cli, err := minio.New(cfg.Endpoint, &minio.Options{ Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), Secure: cfg.UseSSL, }) if err != nil { return nil, fmt.Errorf("init minio client: %w", err) } return &Storage{cfg: cfg, client: cli}, nil } func (s *Storage) Configured() bool { return s.client != nil && s.cfg.Bucket != "" } func (s *Storage) EnsureBucket(ctx context.Context) error { if !s.Configured() { return nil } exists, err := s.client.BucketExists(ctx, s.cfg.Bucket) if err != nil { return fmt.Errorf("check bucket: %w", err) } if exists { return nil } return s.client.MakeBucket(ctx, s.cfg.Bucket, minio.MakeBucketOptions{}) } func (s *Storage) Check(ctx context.Context) error { if !s.Configured() { return errors.New("storage not configured") } exists, err := s.client.BucketExists(ctx, s.cfg.Bucket) if err != nil { return fmt.Errorf("check bucket: %w", err) } if !exists { return fmt.Errorf("bucket %q does not exist", s.cfg.Bucket) } return nil } func GenerateKey(ownerID, filename string) string { ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), ".")) if !AllowedExtension(ext) { ext = "bin" } return fmt.Sprintf("%s/%s.%s", ownerID, uuid.NewString(), ext) } func AllowedExtension(ext string) bool { switch strings.ToLower(ext) { case "doc", "docx", "xls", "xlsx", "xlsm", "ppt", "pptx", "ods", "odt", "odp", "pdf", "png", "jpg", "jpeg", "webp", "gif", "mp4", "webm", "mov", "m4v", "mp3", "wav", "ogg": return true default: return false } } func GuessContentType(filename, clientType string) string { if clientType != "" && clientType != "application/octet-stream" { return clientType } if ext := filepath.Ext(filename); ext != "" { if v := mime.TypeByExtension(ext); v != "" { return v } } return "application/octet-stream" } func (s *Storage) PutObject(ctx context.Context, key string, body io.Reader, size int64, contentType string) error { if !s.Configured() { return errors.New("storage not configured") } _, err := s.client.PutObject(ctx, s.cfg.Bucket, key, body, size, minio.PutObjectOptions{ ContentType: contentType, }) return err } func (s *Storage) Stat(ctx context.Context, key string) (*ObjectInfo, error) { if !s.Configured() { return nil, errors.New("storage not configured") } info, err := s.client.StatObject(ctx, s.cfg.Bucket, key, minio.StatObjectOptions{}) if err != nil { return nil, err } return &ObjectInfo{Size: info.Size, ContentType: info.ContentType}, nil } func (s *Storage) GetObject(ctx context.Context, key string, rangeStart, rangeEnd int64) (io.ReadCloser, *ObjectInfo, error) { if !s.Configured() { return nil, nil, errors.New("storage not configured") } opts := minio.GetObjectOptions{} if rangeStart > 0 || rangeEnd > 0 { _ = opts.SetRange(rangeStart, rangeEnd) } obj, err := s.client.GetObject(ctx, s.cfg.Bucket, key, opts) if err != nil { return nil, nil, err } info, err := obj.Stat() if err != nil { _ = obj.Close() return nil, nil, err } return obj, &ObjectInfo{Size: info.Size, ContentType: info.ContentType}, nil } func (s *Storage) RemoveObject(ctx context.Context, key string) error { if !s.Configured() { return errors.New("storage not configured") } return s.client.RemoveObject(ctx, s.cfg.Bucket, key, minio.RemoveObjectOptions{}) } func ParseRange(header string, totalSize int64) (start, end int64, ok bool) { if !strings.HasPrefix(header, "bytes=") { return 0, 0, false } parts := strings.SplitN(strings.TrimPrefix(header, "bytes="), "-", 2) if len(parts) != 2 { return 0, 0, false } if parts[0] == "" { n, err := strconv.ParseInt(parts[1], 10, 64) if err != nil || n <= 0 || n > totalSize { return 0, 0, false } return totalSize - n, totalSize - 1, true } start, err := strconv.ParseInt(parts[0], 10, 64) if err != nil || start < 0 || start >= totalSize { return 0, 0, false } if parts[1] == "" { return start, totalSize - 1, true } end, err = strconv.ParseInt(parts[1], 10, 64) if err != nil || end < start { return 0, 0, false } if end >= totalSize { end = totalSize - 1 } return start, end, true } func WriteRangeResponse(w http.ResponseWriter, contentType string, totalSize, start, end int64, hasRange bool) { w.Header().Set("Content-Type", contentType) w.Header().Set("Accept-Ranges", "bytes") if hasRange { w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, totalSize)) w.Header().Set("Content-Length", strconv.FormatInt(end-start+1, 10)) w.WriteHeader(http.StatusPartialContent) return } w.Header().Set("Content-Length", strconv.FormatInt(totalSize, 10)) w.WriteHeader(http.StatusOK) }