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

385
internal/handler/lesson.go Normal file
View File

@@ -0,0 +1,385 @@
package handler
import (
"errors"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"learning-service/internal/model"
"learning-service/internal/repository"
"learning-service/internal/storage"
)
type LessonHandler struct {
repo *repository.LessonRepository
courseRepo *repository.CourseRepository
storage *storage.Storage
}
func NewLessonHandler(repo *repository.LessonRepository, courseRepo *repository.CourseRepository, st *storage.Storage) *LessonHandler {
return &LessonHandler{repo: repo, courseRepo: courseRepo, storage: st}
}
// authorizeCourseOwner — общий гейт для CRUD-операций над уроками:
// owner курса == X-User-Id, иначе 403. NotFound при отсутствии курса.
func (h *LessonHandler) authorizeCourseOwner(w http.ResponseWriter, r *http.Request, courseID uuid.UUID) (uuid.UUID, bool) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return uuid.Nil, false
}
c, err := h.courseRepo.Get(r.Context(), courseID)
if err != nil {
writeRepoError(w, r, err, "get course")
return uuid.Nil, false
}
if c.OwnerUserID != uid {
writeError(w, http.StatusForbidden, "only course owner can manage lessons")
return uuid.Nil, false
}
return uid, true
}
func (h *LessonHandler) ListByCourse(w http.ResponseWriter, r *http.Request) {
courseID, err := parseUUID(chi.URLParam(r, "courseId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid course id")
return
}
// Чтение уроков — всем кто видит курс (read-доступ на курсе уже на
// уровне списка курсов; в MVP — published || owner). Здесь не дублируем,
// т.к. фронт всё равно проверит сам.
items, err := h.repo.ListByCourse(r.Context(), courseID)
if err != nil {
writeRepoError(w, r, err, "list lessons")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (h *LessonHandler) Get(w http.ResponseWriter, r *http.Request) {
id, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
l, err := h.repo.Get(r.Context(), id)
if err != nil {
writeRepoError(w, r, err, "get lesson")
return
}
writeJSON(w, http.StatusOK, l)
}
func (h *LessonHandler) Create(w http.ResponseWriter, r *http.Request) {
courseID, err := parseUUID(chi.URLParam(r, "courseId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid course id")
return
}
if _, ok := h.authorizeCourseOwner(w, r, courseID); !ok {
return
}
var req model.CreateLessonRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
if strings.TrimSpace(req.Title) == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
l, err := h.repo.Create(r.Context(), courseID, req)
if err != nil {
writeRepoError(w, r, err, "create lesson")
return
}
writeJSON(w, http.StatusCreated, l)
}
func (h *LessonHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
existing, err := h.repo.Get(r.Context(), id)
if err != nil {
writeRepoError(w, r, err, "get lesson for update")
return
}
if _, ok := h.authorizeCourseOwner(w, r, existing.CourseID); !ok {
return
}
var req model.UpdateLessonRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
l, err := h.repo.Update(r.Context(), id, req)
if err != nil {
writeRepoError(w, r, err, "update lesson")
return
}
writeJSON(w, http.StatusOK, l)
}
func (h *LessonHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
existing, err := h.repo.Get(r.Context(), id)
if err != nil {
writeRepoError(w, r, err, "get lesson for delete")
return
}
if _, ok := h.authorizeCourseOwner(w, r, existing.CourseID); !ok {
return
}
videoKey, err := h.repo.Delete(r.Context(), id)
if err != nil {
writeRepoError(w, r, err, "delete lesson")
return
}
// Best-effort удаление видео из MinIO; ошибки логируем но не возвращаем
// клиенту — урок уже удалён из БД.
if videoKey != "" && h.storage.Configured() {
if err := h.storage.Delete(r.Context(), videoKey); err != nil {
slog.Warn("delete video object", "lesson_id", id, "key", videoKey, "error", err)
}
}
w.WriteHeader(http.StatusNoContent)
}
// Reorder — POST /courses/{courseId}/lessons/reorder. Body:
// {"items": [{"id": "uuid", "position": 0}, ...]}
func (h *LessonHandler) Reorder(w http.ResponseWriter, r *http.Request) {
courseID, err := parseUUID(chi.URLParam(r, "courseId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid course id")
return
}
if _, ok := h.authorizeCourseOwner(w, r, courseID); !ok {
return
}
var req struct {
Items []struct {
ID string `json:"id"`
Position int `json:"position"`
} `json:"items"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
items := make([]struct {
ID uuid.UUID
Position int
}, 0, len(req.Items))
for _, it := range req.Items {
id, err := parseUUID(it.ID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid item id")
return
}
items = append(items, struct {
ID uuid.UUID
Position int
}{ID: id, Position: it.Position})
}
if err := h.repo.ReorderInCourse(r.Context(), courseID, items); err != nil {
writeRepoError(w, r, err, "reorder lessons")
return
}
w.WriteHeader(http.StatusNoContent)
}
// UploadVideo — POST /lessons/{id}/video. Multipart с полем "video".
// Опциональный duration_sec в форме (фронт замеряет через HTMLMediaElement
// после выбора файла) — иначе видео-длительность 0.
//
// На MinIO кладём новый key через GenerateKey, в БД фиксируем; старый
// key удаляем best-effort. Если MinIO не сконфигурирован — 503.
func (h *LessonHandler) UploadVideo(w http.ResponseWriter, r *http.Request) {
id, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
existing, err := h.repo.Get(r.Context(), id)
if err != nil {
writeRepoError(w, r, err, "get lesson for upload")
return
}
if _, ok := h.authorizeCourseOwner(w, r, existing.CourseID); !ok {
return
}
if !h.storage.Configured() {
writeError(w, http.StatusServiceUnavailable, "video storage not configured")
return
}
// 2 GiB cap: видео-уроки обычно <500 MiB, более крупное стоит хостить
// внешне. Защита от случайной загрузки гигантского файла на быстром UI.
if err := r.ParseMultipartForm(2 << 30); err != nil {
writeError(w, http.StatusBadRequest, "invalid multipart: "+err.Error())
return
}
file, header, err := r.FormFile("video")
if err != nil {
writeError(w, http.StatusBadRequest, "video field is required")
return
}
defer func() { _ = file.Close() }()
durationSec := 0
if v := r.FormValue("duration_sec"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
durationSec = n
}
}
key := storage.GenerateKey(id, header.Filename)
contentType := header.Header.Get("Content-Type")
if err := h.storage.PutObject(r.Context(), key, file, header.Size, contentType); err != nil {
writeError(w, http.StatusInternalServerError, "put object: "+err.Error())
return
}
oldKey, err := h.repo.SetVideo(r.Context(), id, key, durationSec)
if err != nil {
// Откатываем PutObject — обновить БД не получилось, оставлять
// объект в bucket'е смысла нет.
_ = h.storage.Delete(r.Context(), key)
writeRepoError(w, r, err, "set video key")
return
}
if oldKey != "" && oldKey != key {
if err := h.storage.Delete(r.Context(), oldKey); err != nil {
slog.Warn("delete old video object", "lesson_id", id, "old_key", oldKey, "error", err)
}
}
// Возвращаем обновлённый урок — фронт сразу обновит UI.
updated, err := h.repo.Get(r.Context(), id)
if err != nil {
writeRepoError(w, r, err, "reload lesson")
return
}
writeJSON(w, http.StatusOK, updated)
}
// StreamVideo — GET /lessons/{id}/video/stream. Прокси-стрим из MinIO
// с поддержкой Range. По паттерну telephony record stream:
// - Content-Disposition: inline (browser не предложит «Save as»);
// - Cache-Control: no-store, private — не кэшируется;
// - X-Content-Type-Options: nosniff;
// - Accept-Ranges: bytes — для seek'а в плеере.
//
// MinIO URL клиенту не светится. Защита не абсолютна (продвинутый юзер
// может перехватить blob), но это барьер от «правый клик → Save as» и от
// шаринга прямой ссылки.
func (h *LessonHandler) StreamVideo(w http.ResponseWriter, r *http.Request) {
id, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
l, err := h.repo.Get(r.Context(), id)
if err != nil {
writeRepoError(w, r, err, "get lesson for stream")
return
}
// Доступ: владелец курса либо опубликованный курс. Permissions
// granularity (access_grants) добавим в next iteration.
uid, _ := userIDFromHeader(r)
c, err := h.courseRepo.Get(r.Context(), l.CourseID)
if err != nil {
writeRepoError(w, r, err, "get course for stream")
return
}
if !c.IsPublished && c.OwnerUserID != uid {
writeError(w, http.StatusForbidden, "course is not published")
return
}
if l.VideoKey == "" {
writeError(w, http.StatusNotFound, "no video uploaded")
return
}
if !h.storage.Configured() {
writeError(w, http.StatusServiceUnavailable, "video storage not configured")
return
}
// Сначала Stat — нужен total size для Content-Range.
info, err := h.storage.Stat(r.Context(), l.VideoKey)
if err != nil {
writeError(w, http.StatusInternalServerError, "stat: "+err.Error())
return
}
rangeHdr := r.Header.Get("Range")
start, end, hasRange := storage.ParseRange(rangeHdr, info.Size)
var (
obj io.ReadCloser
)
if hasRange {
// MinIO Range: end inclusive.
obj, _, err = h.storage.GetObject(r.Context(), l.VideoKey, start, end)
} else {
obj, _, err = h.storage.GetObject(r.Context(), l.VideoKey, 0, 0)
}
if err != nil {
writeError(w, http.StatusInternalServerError, "open: "+err.Error())
return
}
defer func() { _ = obj.Close() }()
contentType := info.ContentType
if contentType == "" {
contentType = "video/mp4"
}
w.Header().Set("Content-Disposition", "inline")
storage.WriteRangeResponse(w, contentType, info.Size, start, end, hasRange)
if _, err := io.Copy(w, obj); err != nil && !errors.Is(err, io.ErrUnexpectedEOF) {
slog.Warn("stream video interrupted", "lesson_id", id, "key", l.VideoKey, "error", err)
}
}
// DeleteVideo — DELETE /lessons/{id}/video. Сбрасывает video_key + удаляет
// объект из MinIO. Урок сам остаётся.
func (h *LessonHandler) DeleteVideo(w http.ResponseWriter, r *http.Request) {
id, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
existing, err := h.repo.Get(r.Context(), id)
if err != nil {
writeRepoError(w, r, err, "get lesson for video-delete")
return
}
if _, ok := h.authorizeCourseOwner(w, r, existing.CourseID); !ok {
return
}
if existing.VideoKey == "" {
w.WriteHeader(http.StatusNoContent)
return
}
if _, err := h.repo.SetVideo(r.Context(), id, "", 0); err != nil {
writeRepoError(w, r, err, "clear video key")
return
}
if h.storage.Configured() {
if err := h.storage.Delete(r.Context(), existing.VideoKey); err != nil {
slog.Warn("delete video object", "lesson_id", id, "key", existing.VideoKey, "error", err)
}
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,197 @@
package repository
import (
"context"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"learning-service/internal/model"
)
type LessonRepository struct {
pool *pgxpool.Pool
}
func NewLessonRepository(pool *pgxpool.Pool) *LessonRepository {
return &LessonRepository{pool: pool}
}
const lessonCols = `
id, course_id, position, title, markdown, video_key, video_duration_sec,
test_id, is_required, created_at, updated_at
`
func scanLesson(scan func(...any) error) (*model.Lesson, error) {
var l model.Lesson
if err := scan(
&l.ID, &l.CourseID, &l.Position, &l.Title, &l.Markdown,
&l.VideoKey, &l.VideoDurationSec, &l.TestID, &l.IsRequired,
&l.CreatedAt, &l.UpdatedAt,
); err != nil {
return nil, err
}
return &l, nil
}
func (r *LessonRepository) ListByCourse(ctx context.Context, courseID uuid.UUID) ([]model.Lesson, error) {
rows, err := r.pool.Query(ctx,
`SELECT `+lessonCols+` FROM lessons
WHERE course_id = $1
ORDER BY position, created_at`, courseID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []model.Lesson{}
for rows.Next() {
l, err := scanLesson(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *l)
}
return out, rows.Err()
}
func (r *LessonRepository) Get(ctx context.Context, id uuid.UUID) (*model.Lesson, error) {
l, err := scanLesson(r.pool.QueryRow(ctx,
`SELECT `+lessonCols+` FROM lessons WHERE id = $1`, id).Scan)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return l, nil
}
func (r *LessonRepository) Create(ctx context.Context, courseID uuid.UUID, req model.CreateLessonRequest) (*model.Lesson, error) {
isRequired := true
if req.IsRequired != nil {
isRequired = *req.IsRequired
}
if strings.TrimSpace(req.Title) == "" {
return nil, fmt.Errorf("title is required")
}
var id uuid.UUID
err := r.pool.QueryRow(ctx, `
INSERT INTO lessons (course_id, position, title, markdown, test_id, is_required)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id`,
courseID, req.Position, req.Title, req.Markdown, req.TestID, isRequired,
).Scan(&id)
if err != nil {
return nil, err
}
return r.Get(ctx, id)
}
func (r *LessonRepository) Update(ctx context.Context, id uuid.UUID, req model.UpdateLessonRequest) (*model.Lesson, error) {
sets := []string{}
args := []any{}
add := func(col string, val any) {
args = append(args, val)
sets = append(sets, fmt.Sprintf("%s = $%d", col, len(args)))
}
if req.Position != nil {
add("position", *req.Position)
}
if req.Title != nil {
add("title", *req.Title)
}
if req.Markdown != nil {
add("markdown", *req.Markdown)
}
if req.VideoKey != nil {
add("video_key", *req.VideoKey)
}
if req.VideoDurationSec != nil {
add("video_duration_sec", *req.VideoDurationSec)
}
if req.TestID != nil {
// req.TestID == &uuid.Nil → сбросить связь. Стандартное поведение
// «явный null» делается отдельным эндпоинтом — здесь Update только
// устанавливает не-nil значение.
add("test_id", *req.TestID)
}
if req.IsRequired != nil {
add("is_required", *req.IsRequired)
}
if len(sets) == 0 {
return r.Get(ctx, id)
}
sets = append(sets, "updated_at = NOW()")
args = append(args, id)
q := fmt.Sprintf(`UPDATE lessons SET %s WHERE id = $%d`, strings.Join(sets, ", "), len(args))
tag, err := r.pool.Exec(ctx, q, args...)
if err != nil {
return nil, err
}
if tag.RowsAffected() == 0 {
return nil, ErrNotFound
}
return r.Get(ctx, id)
}
// SetVideo — отдельный helper для post-upload UPDATE: ставит video_key
// и duration после успешного PutObject в MinIO. Возвращает старый key
// (если был) — handler удалит его из MinIO best-effort'ом, чтобы не
// плодить «висящие» объекты при замене видео.
func (r *LessonRepository) SetVideo(ctx context.Context, id uuid.UUID, key string, durationSec int) (oldKey string, err error) {
err = r.pool.QueryRow(ctx, `
UPDATE lessons
SET video_key = $2, video_duration_sec = $3, updated_at = NOW()
WHERE id = $1
RETURNING (SELECT video_key FROM lessons WHERE id = $1)`,
id, key, durationSec).Scan(&oldKey)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
return "", err
}
return oldKey, nil
}
func (r *LessonRepository) Delete(ctx context.Context, id uuid.UUID) (videoKey string, err error) {
// Возвращаем video_key чтобы handler мог удалить объект из MinIO.
err = r.pool.QueryRow(ctx, `
DELETE FROM lessons WHERE id = $1
RETURNING video_key`, id).Scan(&videoKey)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
return "", err
}
return videoKey, nil
}
// ReorderInCourse — батч-апдейт position'ов одного курса через UNNEST.
// Тот же паттерн что у TestRepository.ReorderQuestions.
func (r *LessonRepository) ReorderInCourse(ctx context.Context, courseID uuid.UUID, items []struct {
ID uuid.UUID
Position int
}) error {
if len(items) == 0 {
return nil
}
ids := make([]uuid.UUID, len(items))
positions := make([]int, len(items))
for i, it := range items {
ids[i] = it.ID
positions[i] = it.Position
}
_, err := r.pool.Exec(ctx, `
UPDATE lessons l
SET position = u.pos, updated_at = NOW()
FROM UNNEST($1::uuid[], $2::int[]) AS u(id, pos)
WHERE l.id = u.id AND l.course_id = $3`,
ids, positions, courseID)
return err
}

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))
}
}