// Package storage — обёртка над MinIO для хранения видео-уроков. // Структура соответствует telephony-сервису (там records bucket), но // учётка и bucket свои. Если ENV не сконфигурирован — Configured()=false, // upload/stream handler'ы отдают 503. package storage import ( "context" "errors" "fmt" "io" "net/http" "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 } // New — конструктор. Если AccessKey/SecretKey пусты, Configured()=false и // сервис работает без MinIO (видео-фичи отдают 503, остальное работает). // Не создаёт bucket автоматически — это делается вручную при первом // деплое, чтобы случайно не создать в production'е под кривыми creds. 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 } // Configured — true когда MinIO готов принимать запросы (есть creds + // клиент создан). Handler'ы видео-функций гейтятся этим флагом. func (s *Storage) Configured() bool { return s.client != nil && s.cfg.Bucket != "" } // EnsureBucket — создаёт bucket если его нет. Вызывается один раз при // старте сервиса (best-effort: ошибку логируем но не падаем — 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 not found: %s", s.cfg.Bucket) } return nil } // GenerateKey — путь объекта в bucket'е. Структура: /., // где random — короткий uuid для anti-cache + защита от перезаписи случайно. // При замене видео ставится новый key, старый объект остаётся в MinIO // (отдельный GC по «висящим» ключам — пока не реализован). func GenerateKey(lessonID uuid.UUID, filename string) string { ext := "mp4" if i := strings.LastIndex(filename, "."); i >= 0 && i < len(filename)-1 { raw := strings.ToLower(filename[i+1:]) // Whitelist расширений — иначе можно подсунуть исполняемый файл. switch raw { case "mp4", "webm", "mov", "m4v", "ogg": ext = raw } } return fmt.Sprintf("%s/%s.%s", lessonID.String(), uuid.NewString()[:8], ext) } // PutObject — загрузка из reader'а. ContentType определяется по // расширению (whitelist в GenerateKey), но клиент может прислать // свой Content-Type — если корректный video/*, используем его. 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") } if !strings.HasPrefix(contentType, "video/") { contentType = guessContentType(key) } _, err := s.client.PutObject(ctx, s.cfg.Bucket, key, body, size, minio.PutObjectOptions{ ContentType: contentType, }) return err } // guessContentType — fallback для PutObject когда клиент прислал generic // Content-Type (application/octet-stream от curl, например). Whitelist // синхронизирован с GenerateKey. func guessContentType(key string) string { switch { case strings.HasSuffix(key, ".mp4"): return "video/mp4" case strings.HasSuffix(key, ".webm"): return "video/webm" case strings.HasSuffix(key, ".mov"): return "video/quicktime" case strings.HasSuffix(key, ".m4v"): return "video/x-m4v" case strings.HasSuffix(key, ".ogg"): return "video/ogg" default: return "application/octet-stream" } } // ObjectInfo — метаданные объекта (size + content-type). type ObjectInfo struct { Size int64 ContentType string } // Stat — заголовки объекта (HEAD). 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 } // GetObject — открывает stream объекта. Опциональный Range // (rangeStart..rangeEnd, 0 = «не задан») — используется для seek'а // в видео-плеере (HTML5 audio/video шлёт Range: bytes=NNN-). 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 } // Delete — удаление объекта. Используется при замене видео или удалении // урока (best-effort; ошибки не блокируют CRUD, лог + продолжаем). func (s *Storage) Delete(ctx context.Context, key string) error { if !s.Configured() { return nil } return s.client.RemoveObject(ctx, s.cfg.Bucket, key, minio.RemoveObjectOptions{}) } // ParseRange — парсер HTTP Range header'а. Поддерживает bytes=N-M / N- / -M. // Возвращает (start, end, ok). При невалидном — ok=false и handler отдаёт // весь объект 200, как и должен. func ParseRange(header string, totalSize int64) (start, end int64, ok bool) { if !strings.HasPrefix(header, "bytes=") { return 0, 0, false } v := strings.TrimPrefix(header, "bytes=") parts := strings.SplitN(v, "-", 2) if len(parts) != 2 { return 0, 0, false } if parts[0] == "" { // Suffix: bytes=-N (последние N байт). n, err := strconv.ParseInt(parts[1], 10, 64) if err != nil || n <= 0 { return 0, 0, false } return totalSize - n, totalSize - 1, true } start, err := strconv.ParseInt(parts[0], 10, 64) if err != nil || start < 0 { return 0, 0, false } if parts[1] == "" { // Open-ended: bytes=N- → до конца. 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 } // WriteRangeResponse — заполняет writer ответом по результату ParseRange. // При hasRange=true ставит 206 Partial Content + Content-Range/Content-Length; // иначе 200 OK + Content-Length=totalSize. Зовётся handler'ом стрима. 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") w.Header().Set("Cache-Control", "no-store, private, no-cache") w.Header().Set("X-Content-Type-Options", "nosniff") 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) } else { w.Header().Set("Content-Length", strconv.FormatInt(totalSize, 10)) } }