Files
learning/internal/storage/minio.go
Grendgi 5ad2a8a33e
Some checks failed
CI / hygiene (push) Successful in 2s
Build and Deploy / build-and-deploy (push) Successful in 27s
CI / test (push) Failing after 19s
feat: expose learning video health detail
2026-06-17 16:03:25 +03:00

246 lines
8.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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'е. Структура: <lesson_id>/<random>.<ext>,
// где 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))
}
}