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>
386 lines
12 KiB
Go
386 lines
12 KiB
Go
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)
|
||
}
|