veza/veza-backend-api/internal/handlers/education_handler.go
senke c0e2fe2e12 fix(v0.12.6.1): remediate remaining 15 MEDIUM + LOW pentest findings
MEDIUM-002: Remove manual X-Forwarded-For parsing in metrics_protection.go,
  use c.ClientIP() only (respects SetTrustedProxies)
MEDIUM-003: Pin ClamAV Docker image to 1.4 across all compose files
MEDIUM-004: Add clampLimit(100) to 15+ handlers that parsed limit directly
MEDIUM-006: Remove unsafe-eval from CSP script-src on Swagger routes
MEDIUM-007: Pin all GitHub Actions to SHA in 11 workflow files
MEDIUM-008: Replace rabbitmq:3-management-alpine with rabbitmq:3-alpine in prod
MEDIUM-009: Add trial-already-used check in subscription service
MEDIUM-010: Add 60s periodic token re-validation to WebSocket connections
MEDIUM-011: Mask email in auth handler logs with maskEmail() helper
MEDIUM-012: Add k-anonymity threshold (k=5) to playback analytics stats
LOW-001: Align frontend password policy to 12 chars (matching backend)
LOW-003: Replace deprecated dotenv with dotenvy crate in Rust stream server
LOW-004: Enable xpack.security in Elasticsearch dev/local compose files
LOW-005: Accept context.Context in CleanupExpiredSessions instead of Background()
LOW-002: Noted — Hyperswitch version update deferred (requires payment integration tests)

29/30 findings remediated. 1 noted (LOW-002).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 06:13:38 +01:00

762 lines
21 KiB
Go

package handlers
import (
"errors"
"net/http"
"strconv"
"veza-backend-api/internal/core/education"
apperrors "veza-backend-api/internal/errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// EducationHandler handles education HTTP endpoints (v0.12.3 F276-F305)
type EducationHandler struct {
service *education.Service
logger *zap.Logger
}
// NewEducationHandler creates a new EducationHandler
func NewEducationHandler(service *education.Service, logger *zap.Logger) *EducationHandler {
return &EducationHandler{
service: service,
logger: logger,
}
}
// --- Course endpoints ---
// CreateCourse handles POST /courses
func (h *EducationHandler) CreateCourse(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
var req education.CreateCourseRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid request: title is required"))
return
}
course, err := h.service.CreateCourse(c.Request.Context(), userID, req)
if err != nil {
if errors.Is(err, education.ErrSlugAlreadyTaken) {
RespondWithAppError(c, apperrors.NewValidationError("Slug is already taken"))
return
}
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to create course", err))
return
}
RespondSuccess(c, http.StatusCreated, course)
}
// UpdateCourse handles PUT /courses/:id
func (h *EducationHandler) UpdateCourse(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
var req education.UpdateCourseRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid request body"))
return
}
course, err := h.service.UpdateCourse(c.Request.Context(), userID, courseID, req)
if err != nil {
h.handleCourseError(c, err)
return
}
RespondSuccess(c, http.StatusOK, course)
}
// PublishCourse handles POST /courses/:id/publish
func (h *EducationHandler) PublishCourse(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
course, err := h.service.PublishCourse(c.Request.Context(), userID, courseID)
if err != nil {
h.handleCourseError(c, err)
return
}
RespondSuccess(c, http.StatusOK, course)
}
// ArchiveCourse handles POST /courses/:id/archive
func (h *EducationHandler) ArchiveCourse(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
course, err := h.service.ArchiveCourse(c.Request.Context(), userID, courseID)
if err != nil {
h.handleCourseError(c, err)
return
}
RespondSuccess(c, http.StatusOK, course)
}
// GetCourse handles GET /courses/:id
func (h *EducationHandler) GetCourse(c *gin.Context) {
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
course, err := h.service.GetCourse(c.Request.Context(), courseID)
if err != nil {
if errors.Is(err, education.ErrCourseNotFound) {
RespondWithAppError(c, apperrors.NewNotFoundError("Course"))
return
}
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get course", err))
return
}
RespondSuccess(c, http.StatusOK, course)
}
// GetCourseBySlug handles GET /courses/slug/:slug
func (h *EducationHandler) GetCourseBySlug(c *gin.Context) {
slug := c.Param("slug")
if slug == "" {
RespondWithAppError(c, apperrors.NewValidationError("Slug is required"))
return
}
course, err := h.service.GetCourseBySlug(c.Request.Context(), slug)
if err != nil {
if errors.Is(err, education.ErrCourseNotFound) {
RespondWithAppError(c, apperrors.NewNotFoundError("Course"))
return
}
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get course", err))
return
}
RespondSuccess(c, http.StatusOK, course)
}
// ListPublishedCourses handles GET /courses
func (h *EducationHandler) ListPublishedCourses(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
limit = clampLimit(limit) // SECURITY(MEDIUM-004)
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
var category *string
if cat := c.Query("category"); cat != "" {
category = &cat
}
var level *education.CourseLevel
if lvl := c.Query("level"); lvl != "" {
l := education.CourseLevel(lvl)
level = &l
}
var language *string
if lang := c.Query("language"); lang != "" {
language = &lang
}
courses, total, err := h.service.ListPublishedCourses(c.Request.Context(), category, level, language, limit, offset)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to list courses", err))
return
}
page := (offset / max(limit, 1)) + 1
totalPages := int((total + int64(limit) - 1) / int64(limit))
RespondSuccess(c, http.StatusOK, gin.H{
"data": courses,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": total,
"total_pages": totalPages,
},
})
}
// ListCreatorCourses handles GET /creators/me/courses
func (h *EducationHandler) ListCreatorCourses(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
limit = clampLimit(limit) // SECURITY(MEDIUM-004)
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
courses, total, err := h.service.ListCreatorCourses(c.Request.Context(), userID, limit, offset)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to list courses", err))
return
}
page := (offset / max(limit, 1)) + 1
totalPages := int((total + int64(limit) - 1) / int64(limit))
RespondSuccess(c, http.StatusOK, gin.H{
"data": courses,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": total,
"total_pages": totalPages,
},
})
}
// DeleteCourse handles DELETE /courses/:id
func (h *EducationHandler) DeleteCourse(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
if err := h.service.DeleteCourse(c.Request.Context(), userID, courseID); err != nil {
h.handleCourseError(c, err)
return
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "Course deleted"})
}
// --- Lesson endpoints ---
// CreateLesson handles POST /courses/:id/lessons
func (h *EducationHandler) CreateLesson(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
var req education.CreateLessonRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid request: title is required"))
return
}
lesson, err := h.service.CreateLesson(c.Request.Context(), userID, courseID, req)
if err != nil {
h.handleCourseError(c, err)
return
}
RespondSuccess(c, http.StatusCreated, lesson)
}
// UpdateLesson handles PUT /courses/:id/lessons/:lesson_id
func (h *EducationHandler) UpdateLesson(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
lessonID, err := uuid.Parse(c.Param("lesson_id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid lesson ID"))
return
}
var req education.UpdateLessonRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid request body"))
return
}
lesson, err := h.service.UpdateLesson(c.Request.Context(), userID, courseID, lessonID, req)
if err != nil {
h.handleCourseError(c, err)
return
}
RespondSuccess(c, http.StatusOK, lesson)
}
// DeleteLesson handles DELETE /courses/:id/lessons/:lesson_id
func (h *EducationHandler) DeleteLesson(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
lessonID, err := uuid.Parse(c.Param("lesson_id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid lesson ID"))
return
}
if err := h.service.DeleteLesson(c.Request.Context(), userID, courseID, lessonID); err != nil {
h.handleCourseError(c, err)
return
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "Lesson deleted"})
}
// GetCourseLessons handles GET /courses/:id/lessons
func (h *EducationHandler) GetCourseLessons(c *gin.Context) {
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
lessons, err := h.service.GetCourseLessons(c.Request.Context(), courseID)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get lessons", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"lessons": lessons})
}
// ReorderLessons handles POST /courses/:id/lessons/reorder
func (h *EducationHandler) ReorderLessons(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
var req education.ReorderLessonsRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid request: lesson_ids is required"))
return
}
if err := h.service.ReorderLessons(c.Request.Context(), userID, courseID, req); err != nil {
h.handleCourseError(c, err)
return
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "Lessons reordered"})
}
// --- Enrollment endpoints ---
// Enroll handles POST /courses/:id/enroll
func (h *EducationHandler) Enroll(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
var req education.EnrollRequest
if err := c.ShouldBindJSON(&req); err != nil {
// Allow empty body for free courses
req = education.EnrollRequest{}
}
enrollment, err := h.service.Enroll(c.Request.Context(), userID, courseID, req)
if err != nil {
switch {
case errors.Is(err, education.ErrCourseNotFound):
RespondWithAppError(c, apperrors.NewNotFoundError("Course"))
case errors.Is(err, education.ErrCourseNotPublished):
RespondWithAppError(c, apperrors.NewValidationError("Course is not published"))
case errors.Is(err, education.ErrAlreadyEnrolled):
RespondWithAppError(c, apperrors.NewValidationError("Already enrolled in this course"))
case errors.Is(err, education.ErrCannotEnrollOwnCourse):
RespondWithAppError(c, apperrors.NewValidationError("Cannot enroll in your own course"))
default:
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to enroll", err))
}
return
}
RespondSuccess(c, http.StatusCreated, enrollment)
}
// GetMyEnrollments handles GET /enrollments
func (h *EducationHandler) GetMyEnrollments(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
limit = clampLimit(limit) // SECURITY(MEDIUM-004)
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
enrollments, total, err := h.service.GetUserEnrollments(c.Request.Context(), userID, limit, offset)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get enrollments", err))
return
}
page := (offset / max(limit, 1)) + 1
totalPages := int((total + int64(limit) - 1) / int64(limit))
RespondSuccess(c, http.StatusOK, gin.H{
"data": enrollments,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": total,
"total_pages": totalPages,
},
})
}
// --- Progress endpoints ---
// UpdateProgress handles POST /lessons/:lesson_id/progress
func (h *EducationHandler) UpdateProgress(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
lessonID, err := uuid.Parse(c.Param("lesson_id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid lesson ID"))
return
}
var req education.UpdateProgressRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid request body"))
return
}
progress, err := h.service.UpdateProgress(c.Request.Context(), userID, lessonID, req)
if err != nil {
switch {
case errors.Is(err, education.ErrLessonNotFound):
RespondWithAppError(c, apperrors.NewNotFoundError("Lesson"))
case errors.Is(err, education.ErrNotEnrolled):
RespondWithAppError(c, apperrors.NewForbiddenError("Not enrolled in this course"))
default:
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to update progress", err))
}
return
}
RespondSuccess(c, http.StatusOK, progress)
}
// GetCourseProgress handles GET /courses/:id/progress
func (h *EducationHandler) GetCourseProgress(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
progress, err := h.service.GetCourseProgress(c.Request.Context(), userID, courseID)
if err != nil {
if errors.Is(err, education.ErrNotEnrolled) {
RespondWithAppError(c, apperrors.NewForbiddenError("Not enrolled in this course"))
return
}
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get progress", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"progress": progress})
}
// --- Certificate endpoints ---
// IssueCertificate handles POST /courses/:id/certificate
func (h *EducationHandler) IssueCertificate(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
cert, err := h.service.IssueCertificate(c.Request.Context(), userID, courseID)
if err != nil {
switch {
case errors.Is(err, education.ErrNotEnrolled):
RespondWithAppError(c, apperrors.NewForbiddenError("Not enrolled in this course"))
case errors.Is(err, education.ErrCourseNotCompleted):
RespondWithAppError(c, apperrors.NewValidationError("Course is not fully completed"))
case errors.Is(err, education.ErrCertificateExists):
RespondWithAppError(c, apperrors.NewValidationError("Certificate already issued"))
default:
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to issue certificate", err))
}
return
}
RespondSuccess(c, http.StatusCreated, cert)
}
// VerifyCertificate handles GET /certificates/:code
func (h *EducationHandler) VerifyCertificate(c *gin.Context) {
code := c.Param("code")
if code == "" {
RespondWithAppError(c, apperrors.NewValidationError("Certificate code is required"))
return
}
cert, err := h.service.GetCertificate(c.Request.Context(), code)
if err != nil {
if errors.Is(err, education.ErrCertificateNotFound) {
RespondWithAppError(c, apperrors.NewNotFoundError("Certificate"))
return
}
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to verify certificate", err))
return
}
RespondSuccess(c, http.StatusOK, cert)
}
// GetMyCertificates handles GET /certificates
func (h *EducationHandler) GetMyCertificates(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
certs, err := h.service.GetUserCertificates(c.Request.Context(), userID)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get certificates", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"certificates": certs})
}
// --- Review endpoints ---
// CreateReview handles POST /courses/:id/reviews
func (h *EducationHandler) CreateReview(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
var req education.CreateReviewRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid request: rating and content are required"))
return
}
review, err := h.service.CreateReview(c.Request.Context(), userID, courseID, req)
if err != nil {
switch {
case errors.Is(err, education.ErrNotEnrolled):
RespondWithAppError(c, apperrors.NewForbiddenError("Must be enrolled to review"))
case errors.Is(err, education.ErrReviewAlreadyExists):
RespondWithAppError(c, apperrors.NewValidationError("Already reviewed this course"))
case errors.Is(err, education.ErrInvalidRating):
RespondWithAppError(c, apperrors.NewValidationError("Rating must be between 1 and 5"))
default:
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to create review", err))
}
return
}
RespondSuccess(c, http.StatusCreated, review)
}
// GetCourseReviews handles GET /courses/:id/reviews
func (h *EducationHandler) GetCourseReviews(c *gin.Context) {
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
limit = clampLimit(limit) // SECURITY(MEDIUM-004)
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
reviews, total, err := h.service.GetCourseReviews(c.Request.Context(), courseID, limit, offset)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get reviews", err))
return
}
page := (offset / max(limit, 1)) + 1
totalPages := int((total + int64(limit) - 1) / int64(limit))
RespondSuccess(c, http.StatusOK, gin.H{
"data": reviews,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": total,
"total_pages": totalPages,
},
})
}
// UploadLessonVideo handles POST /courses/:id/lessons/:lesson_id/video
func (h *EducationHandler) UploadLessonVideo(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
courseID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID"))
return
}
lessonID, err := uuid.Parse(c.Param("lesson_id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid lesson ID"))
return
}
fileHeader, err := c.FormFile("video")
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("No video file provided"))
return
}
// Validate file size (max 500MB)
const maxVideoSize = 500 * 1024 * 1024
if fileHeader.Size > maxVideoSize {
RespondWithAppError(c, apperrors.NewValidationError("Video file too large (max 500MB)"))
return
}
// Validate content type
contentType := fileHeader.Header.Get("Content-Type")
allowedTypes := map[string]bool{
"video/mp4": true,
"video/webm": true,
"video/ogg": true,
"video/avi": true,
}
if !allowedTypes[contentType] {
RespondWithAppError(c, apperrors.NewValidationError("Unsupported video format. Accepted: mp4, webm, ogg, avi"))
return
}
// Save file to upload directory
uploadDir := "/tmp/veza-uploads/videos"
filename := lessonID.String() + "_" + fileHeader.Filename
savePath := uploadDir + "/" + filename
if err := c.SaveUploadedFile(fileHeader, savePath); err != nil {
h.logger.Error("Failed to save video file",
zap.Error(err),
zap.String("lesson_id", lessonID.String()),
)
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to save video", err))
return
}
// Update lesson with video path (triggers transcoding pipeline)
lesson, err := h.service.SetLessonVideoPath(c.Request.Context(), userID, courseID, lessonID, savePath, 0)
if err != nil {
h.handleCourseError(c, err)
return
}
h.logger.Info("Lesson video uploaded",
zap.String("lesson_id", lessonID.String()),
zap.String("course_id", courseID.String()),
zap.Int64("file_size", fileHeader.Size),
)
RespondSuccess(c, http.StatusOK, lesson)
}
// handleCourseError maps common course errors to HTTP responses
func (h *EducationHandler) handleCourseError(c *gin.Context, err error) {
switch {
case errors.Is(err, education.ErrCourseNotFound):
RespondWithAppError(c, apperrors.NewNotFoundError("Course"))
case errors.Is(err, education.ErrLessonNotFound):
RespondWithAppError(c, apperrors.NewNotFoundError("Lesson"))
case errors.Is(err, education.ErrNotCourseOwner):
RespondWithAppError(c, apperrors.NewForbiddenError("You are not the owner of this course"))
default:
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Operation failed", err))
}
}