Files
learning/internal/storage/minio.go
Ilya 80c019b791
All checks were successful
CI / test (push) Successful in 27s
Build and Deploy / build-and-deploy (push) Successful in 33s
feat(lessons): уроки + видео (MinIO stream-proxy)
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>
2026-05-25 23:58:05 +03:00

232 lines
8.5 KiB
Go
Raw 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{})
}
// 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))
}
}