package handlers import ( "crypto/subtle" "net/http" "os" "github.com/gin-gonic/gin" "go.uber.org/zap" apperrors "veza-backend-api/internal/errors" "veza-backend-api/internal/services" ) // LiveStreamCallbackHandler handles Nginx-RTMP on_publish / on_publish_done callbacks (v0.10.6 F471) type LiveStreamCallbackHandler struct { service *services.LiveStreamService logger *zap.Logger } // NewLiveStreamCallbackHandler creates a new callback handler func NewLiveStreamCallbackHandler(service *services.LiveStreamService, logger *zap.Logger) *LiveStreamCallbackHandler { return &LiveStreamCallbackHandler{service: service, logger: logger} } // validateCallbackSecret returns true if the request is authorized // SECURITY(HIGH-008): Fail-closed when unconfigured, header-only, constant-time compare func validateCallbackSecret(c *gin.Context) bool { expect := os.Getenv("RTMP_CALLBACK_SECRET") if expect == "" { return false // SECURITY(HIGH-008): fail-closed — reject when secret not configured } got := c.GetHeader("X-RTMP-Callback-Secret") // SECURITY(HIGH-008): removed query param fallback — secret must be in header only return subtle.ConstantTimeCompare([]byte(got), []byte(expect)) == 1 } // HandlePublish is called by Nginx-RTMP on_publish. Params: name=stream_key // On success: SetIsLive(true), UpdateStreamURL with HLS playlist URL func (h *LiveStreamCallbackHandler) HandlePublish(c *gin.Context) { if !validateCallbackSecret(c) { RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "invalid callback secret")) return } streamKey := c.Query("name") if streamKey == "" { streamKey = c.PostForm("name") } if streamKey == "" { RespondWithAppError(c, apperrors.NewValidationError("missing stream key (name)")) return } stream, err := h.service.GetByStreamKey(c.Request.Context(), streamKey) if err != nil { h.logger.Warn("Live publish: invalid stream key", zap.String("key", streamKey), zap.Error(err)) RespondWithAppError(c, apperrors.NewNotFoundError("invalid stream key")) return } // Nginx-RTMP writes to /tmp/hls/{stream_key}/ so we use stream_key in the URL baseURL := os.Getenv("STREAM_HLS_BASE_URL") if baseURL == "" { baseURL = "http://localhost:18083/live" } streamURL := baseURL + "/" + stream.StreamKey + "/playlist.m3u8" if err := h.service.SetIsLive(c.Request.Context(), stream.ID, true); err != nil { h.logger.Error("Live publish: SetIsLive failed", zap.Error(err)) RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to set stream live", err)) return } if err := h.service.UpdateStreamURL(c.Request.Context(), stream.ID, streamURL); err != nil { h.logger.Error("Live publish: UpdateStreamURL failed", zap.Error(err)) RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to update stream URL", err)) return } if err := h.service.UpdateViewerCount(c.Request.Context(), stream.ID, 0); err != nil { _ = err // non-fatal } h.logger.Info("Live stream started", zap.String("stream_id", stream.ID.String()), zap.String("stream_key", streamKey)) c.AbortWithStatus(http.StatusOK) } // HandlePublishDone is called by Nginx-RTMP on_publish_done. Params: name=stream_key func (h *LiveStreamCallbackHandler) HandlePublishDone(c *gin.Context) { if !validateCallbackSecret(c) { RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "invalid callback secret")) return } streamKey := c.Query("name") if streamKey == "" { streamKey = c.PostForm("name") } if streamKey == "" { RespondWithAppError(c, apperrors.NewValidationError("missing stream key (name)")) return } stream, err := h.service.GetByStreamKey(c.Request.Context(), streamKey) if err != nil { h.logger.Warn("Live publish_done: stream not found", zap.String("key", streamKey)) c.AbortWithStatus(http.StatusOK) // Nginx expects 2xx even if we don't know the stream return } if err := h.service.SetIsLive(c.Request.Context(), stream.ID, false); err != nil { h.logger.Error("Live publish_done: SetIsLive failed", zap.Error(err)) } if err := h.service.UpdateStreamURL(c.Request.Context(), stream.ID, ""); err != nil { h.logger.Error("Live publish_done: UpdateStreamURL failed", zap.Error(err)) } if err := h.service.UpdateViewerCount(c.Request.Context(), stream.ID, -stream.ViewerCount); err != nil { _ = err // reset to 0 } h.logger.Info("Live stream ended", zap.String("stream_id", stream.ID.String())) c.AbortWithStatus(http.StatusOK) }