2026-03-10 09:21:57 +00:00
|
|
|
package handlers
|
|
|
|
|
|
|
|
|
|
import (
|
2026-03-12 04:40:53 +00:00
|
|
|
"crypto/subtle"
|
2026-03-10 09:21:57 +00:00
|
|
|
"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
|
2026-03-12 04:40:53 +00:00
|
|
|
// SECURITY(HIGH-008): Fail-closed when unconfigured, header-only, constant-time compare
|
2026-03-10 09:21:57 +00:00
|
|
|
func validateCallbackSecret(c *gin.Context) bool {
|
|
|
|
|
expect := os.Getenv("RTMP_CALLBACK_SECRET")
|
|
|
|
|
if expect == "" {
|
2026-03-12 04:40:53 +00:00
|
|
|
return false // SECURITY(HIGH-008): fail-closed — reject when secret not configured
|
2026-03-10 09:21:57 +00:00
|
|
|
}
|
|
|
|
|
got := c.GetHeader("X-RTMP-Callback-Secret")
|
2026-03-12 04:40:53 +00:00
|
|
|
// SECURITY(HIGH-008): removed query param fallback — secret must be in header only
|
|
|
|
|
return subtle.ConstantTimeCompare([]byte(got), []byte(expect)) == 1
|
2026-03-10 09:21:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|