feat(lessons): уроки + видео (MinIO stream-proxy)
All checks were successful
CI / test (push) Successful in 27s
Build and Deploy / build-and-deploy (push) Successful in 33s

storage/minio.go:
- New() толерантен к пустым creds → Configured()=false, видео-фичи
  отдают 503; остальное работает.
- GenerateKey/PutObject/Stat/GetObject(Range)/Delete + ParseRange/
  WriteRangeResponse helpers. По паттерну telephony record stream.
- EnsureBucket — best-effort при старте сервиса.

LessonRepository:
- ListByCourse / Get / Create / Update / Delete (возвращает старый
  video_key для MinIO-cleanup) / ReorderInCourse через UNNEST.
- SetVideo — отдельный helper для post-upload UPDATE с возвратом
  старого key (чтобы handler удалил предыдущее видео при замене).

LessonHandler:
- CRUD с проверкой owner курса (authorizeCourseOwner-helper).
- Reorder батч.
- UploadVideo: multipart "video" + duration_sec из формы.
  PutObject в MinIO → SetVideo в БД. При ошибке UPDATE откатываем
  объект из MinIO (PutObject + revert). Старый video_key удаляется
  best-effort.
- StreamVideo: Range-aware прокси по паттерну telephony.
  Content-Disposition: inline + nodownload-заголовки. Гейтит
  is_published || owner. MinIO URL клиенту не светится.
- DeleteVideo: чистит video_key + объект из MinIO.

main.go: 8 новых routes (CRUD + reorder + upload + stream + delete).
Storage инициализируется один раз; ENV-fallback логирует «disabled».

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilya
2026-05-25 23:58:05 +03:00
parent 350703ab83
commit 80c019b791
6 changed files with 924 additions and 11 deletions

231
internal/storage/minio.go Normal file
View File

@@ -0,0 +1,231 @@
// 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{})
}
// 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))
}
}