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