added routes and auth for devices and users for MediaMTX server
This commit is contained in:
@@ -27,6 +27,10 @@ srtAddress: :8890
|
|||||||
|
|
||||||
authMethod: http
|
authMethod: http
|
||||||
authHTTPAddress: http://snoop-api:8080/mediamtx/auth
|
authHTTPAddress: http://snoop-api:8080/mediamtx/auth
|
||||||
|
authHTTPExclude:
|
||||||
|
- action: api
|
||||||
|
- action: metrics
|
||||||
|
- action: pprof
|
||||||
|
|
||||||
# Recording (optional)
|
# Recording (optional)
|
||||||
pathDefaults:
|
pathDefaults:
|
||||||
@@ -57,6 +61,15 @@ pathDefaults:
|
|||||||
\"dstFs\":\"minio:livestream\",
|
\"dstFs\":\"minio:livestream\",
|
||||||
\"dstRemote\":\"$MTX_PATH/$f\"}"'
|
\"dstRemote\":\"$MTX_PATH/$f\"}"'
|
||||||
|
|
||||||
|
authInternalUsers:
|
||||||
|
- user: any
|
||||||
|
pass:
|
||||||
|
ips: ['127.0.0.1','::1']
|
||||||
|
permissions:
|
||||||
|
- action: api
|
||||||
|
- action: metrics
|
||||||
|
- action: pprof
|
||||||
|
|
||||||
# Allow all paths by default
|
# Allow all paths by default
|
||||||
paths:
|
paths:
|
||||||
all:
|
all:
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ import (
|
|||||||
"smoop-api/internal/vault"
|
"smoop-api/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type MediaMTXConfig struct {
|
||||||
|
APIBase string // e.g. "http://mediamtx:9997"
|
||||||
|
WebRTCBaseURL string // e.g. "http://mediamtx:8889"
|
||||||
|
PublicBaseURL string // e.g. "https://your-host" (for HLS/WHEP URLs returned to SPA)
|
||||||
|
TokenTTL time.Duration // default ~180s
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DB struct {
|
DB struct {
|
||||||
DSN string
|
DSN string
|
||||||
@@ -23,6 +30,7 @@ type Config struct {
|
|||||||
LivestreamBucket string
|
LivestreamBucket string
|
||||||
PresignTTL time.Duration
|
PresignTTL time.Duration
|
||||||
}
|
}
|
||||||
|
MediaMTX MediaMTXConfig
|
||||||
JWTSecret []byte
|
JWTSecret []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +68,12 @@ func Load() (*Config, error) {
|
|||||||
}
|
}
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
// getStrOpt := func(k, def string) string {
|
||||||
|
// if v, ok := raw[k].(string); ok && v != "" {
|
||||||
|
// return v
|
||||||
|
// }
|
||||||
|
// return def
|
||||||
|
// }
|
||||||
getBool := func(k string) (bool, error) {
|
getBool := func(k string) (bool, error) {
|
||||||
v, ok := raw[k]
|
v, ok := raw[k]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -77,6 +91,32 @@ func Load() (*Config, error) {
|
|||||||
return false, fmt.Errorf("invalid bool for key %s", k)
|
return false, fmt.Errorf("invalid bool for key %s", k)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
getTTL := func(k string, def time.Duration) time.Duration {
|
||||||
|
if v, ok := raw[k].(string); ok && strings.TrimSpace(v) != "" {
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 {
|
||||||
|
return time.Duration(n) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NEW: MediaMTX config FROM ENV (NOT from Vault)
|
||||||
|
getRequiredEnv := func(k string) (string, error) {
|
||||||
|
v := strings.TrimSpace(os.Getenv(k))
|
||||||
|
if v == "" {
|
||||||
|
return "", fmt.Errorf("missing required env %s", k)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
getIntEnv := func(k string, def int) int {
|
||||||
|
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
dbDSN, err := getStr("db_dsn")
|
dbDSN, err := getStr("db_dsn")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -111,14 +151,29 @@ func Load() (*Config, error) {
|
|||||||
if v, ok := raw["minio_livestream_bucket"].(string); ok && v != "" {
|
if v, ok := raw["minio_livestream_bucket"].(string); ok && v != "" {
|
||||||
liveBucket = v
|
liveBucket = v
|
||||||
}
|
}
|
||||||
presignTTL := 15 * time.Minute
|
// presignTTL := 15 * time.Minute
|
||||||
if v, ok := raw["minio_presign_ttl_seconds"].(string); ok && v != "" {
|
// if v, ok := raw["minio_presign_ttl_seconds"].(string); ok && v != "" {
|
||||||
var sec int
|
// var sec int
|
||||||
fmt.Sscanf(v, "%d", &sec)
|
// fmt.Sscanf(v, "%d", &sec)
|
||||||
if sec > 0 {
|
// if sec > 0 {
|
||||||
presignTTL = time.Duration(sec) * time.Second
|
// presignTTL = time.Duration(sec) * time.Second
|
||||||
}
|
// }
|
||||||
|
// }
|
||||||
|
presignTTL := getTTL("minio_presign_ttl_seconds", 15*time.Minute)
|
||||||
|
|
||||||
|
apiBase, err := getRequiredEnv("MEDIAMTX_API_BASE")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
webrtcBase, err := getRequiredEnv("MEDIAMTX_WEBRTC_BASE_URL")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
publicBase, err := getRequiredEnv("PUBLIC_BASE_URL")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tokenTTL := getIntEnv("MEDIAMTX_TOKEN_TTL_SECONDS", 180)
|
||||||
|
|
||||||
cfg := &Config{}
|
cfg := &Config{}
|
||||||
cfg.DB.DSN = dbDSN
|
cfg.DB.DSN = dbDSN
|
||||||
@@ -130,6 +185,14 @@ func Load() (*Config, error) {
|
|||||||
cfg.MinIO.LivestreamBucket = liveBucket
|
cfg.MinIO.LivestreamBucket = liveBucket
|
||||||
cfg.MinIO.PresignTTL = presignTTL
|
cfg.MinIO.PresignTTL = presignTTL
|
||||||
cfg.JWTSecret = []byte(jwt)
|
cfg.JWTSecret = []byte(jwt)
|
||||||
|
|
||||||
|
cfg.MediaMTX = MediaMTXConfig{
|
||||||
|
APIBase: apiBase,
|
||||||
|
WebRTCBaseURL: webrtcBase,
|
||||||
|
PublicBaseURL: publicBase,
|
||||||
|
TokenTTL: time.Duration(tokenTTL),
|
||||||
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +254,21 @@ func LoadDev() (*Config, error) {
|
|||||||
}
|
}
|
||||||
presignTTL := time.Duration(getIntEnv("MINIO_PRESIGN_TTL_SECONDS", 900)) * time.Second
|
presignTTL := time.Duration(getIntEnv("MINIO_PRESIGN_TTL_SECONDS", 900)) * time.Second
|
||||||
|
|
||||||
|
// NEW: MediaMTX envs
|
||||||
|
apiBase, err := getRequired("MEDIAMTX_API_BASE")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
webrtcBase, err := getRequired("MEDIAMTX_WEBRTC_BASE_URL")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
publicBase, err := getRequired("PUBLIC_BASE_URL")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tokenTTL := getIntEnv("MEDIAMTX_TOKEN_TTL_SECONDS", 180)
|
||||||
|
|
||||||
cfg := &Config{}
|
cfg := &Config{}
|
||||||
cfg.DB.DSN = dbDSN
|
cfg.DB.DSN = dbDSN
|
||||||
cfg.MinIO.Endpoint = endpoint
|
cfg.MinIO.Endpoint = endpoint
|
||||||
@@ -201,5 +279,12 @@ func LoadDev() (*Config, error) {
|
|||||||
cfg.MinIO.LivestreamBucket = liveBucket
|
cfg.MinIO.LivestreamBucket = liveBucket
|
||||||
cfg.MinIO.PresignTTL = presignTTL
|
cfg.MinIO.PresignTTL = presignTTL
|
||||||
cfg.JWTSecret = []byte(jwt)
|
cfg.JWTSecret = []byte(jwt)
|
||||||
|
|
||||||
|
cfg.MediaMTX = MediaMTXConfig{
|
||||||
|
APIBase: apiBase,
|
||||||
|
WebRTCBaseURL: webrtcBase,
|
||||||
|
PublicBaseURL: publicBase,
|
||||||
|
TokenTTL: time.Duration(tokenTTL),
|
||||||
|
}
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package crypto
|
package crypto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"smoop-api/internal/dto"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,3 +31,33 @@ func (j *JWTManager) Generate(userID uint, username, role string) (string, error
|
|||||||
func (j *JWTManager) Parse(tok string) (*jwt.Token, error) {
|
func (j *JWTManager) Parse(tok string) (*jwt.Token, error) {
|
||||||
return jwt.Parse(tok, func(t *jwt.Token) (interface{}, error) { return j.secret, nil })
|
return jwt.Parse(tok, func(t *jwt.Token) (interface{}, error) { return j.secret, nil })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (j *JWTManager) GenerateMediaToken(sub uint, act, path string, ttl time.Duration) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
claims := dto.MediaClaims{
|
||||||
|
Act: act,
|
||||||
|
Path: path,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: fmt.Sprintf("%d", sub),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return t.SignedString([]byte(j.secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW: parse a media token into typed claims
|
||||||
|
func (j *JWTManager) ParseMedia(tokenStr string) (*dto.MediaClaims, error) {
|
||||||
|
tok, err := jwt.ParseWithClaims(tokenStr, &dto.MediaClaims{}, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(j.secret), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mc, ok := tok.Claims.(*dto.MediaClaims)
|
||||||
|
if !ok || !tok.Valid {
|
||||||
|
return nil, fmt.Errorf("invalid media token")
|
||||||
|
}
|
||||||
|
return mc, nil
|
||||||
|
}
|
||||||
|
|||||||
45
server/internal/dto/mediamtx.go
Normal file
45
server/internal/dto/mediamtx.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import "github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
|
type MediaClaims struct {
|
||||||
|
Act string `json:"act"` // "publish" or "read"
|
||||||
|
Path string `json:"path"` // e.g. "live/<guid>"
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaMTX external-auth POST body (exact keys per mediamtx.yml sample)
|
||||||
|
type MediaMTXAuthReq struct {
|
||||||
|
User string `json:"user"` // optional
|
||||||
|
Password string `json:"password"` // optional
|
||||||
|
Token string `json:"token"` // from Authorization: Bearer or query (?token=)
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Action string `json:"action"` // publish|read|playback|api|metrics|pprof
|
||||||
|
Path string `json:"path"` // e.g. "live/<guid>"
|
||||||
|
Protocol string `json:"protocol"` // rtsp|rtmp|hls|webrtc|srt
|
||||||
|
ID string `json:"id"` // session id
|
||||||
|
Query string `json:"query"` // raw query string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaMTXAuthResp struct {
|
||||||
|
// empty 200 means allowed; add fields if you want to return JSON
|
||||||
|
// to mediamtx (not required)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token minting
|
||||||
|
type PublishTokenReq struct {
|
||||||
|
GUID string `json:"guid" binding:"required,uuid4"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublishTokenResp struct {
|
||||||
|
WHIP string `json:"whipUrl"` // http://mediamtx:8889/whip/live/<guid>?token=...
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReadTokenReq struct {
|
||||||
|
GUID string `json:"guid" binding:"required,uuid4"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReadTokenResp struct {
|
||||||
|
HLS string `json:"hlsUrl"` // http://<host>/hls/live/<guid>/index.m3u8?token=...
|
||||||
|
WHEP string `json:"whepUrl"` // http://<host>/webrtc/play/live/<guid>?token=...
|
||||||
|
}
|
||||||
256
server/internal/handlers/mediamtx.go
Normal file
256
server/internal/handlers/mediamtx.go
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"smoop-api/internal/config"
|
||||||
|
"smoop-api/internal/crypto"
|
||||||
|
"smoop-api/internal/dto"
|
||||||
|
"smoop-api/internal/models"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MediaMTXHandler struct {
|
||||||
|
jwtMgr *crypto.JWTManager
|
||||||
|
db *gorm.DB
|
||||||
|
cfg config.MediaMTXConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMediaMTXHandler(db *gorm.DB, jwt *crypto.JWTManager, c config.MediaMTXConfig) *MediaMTXHandler {
|
||||||
|
return &MediaMTXHandler{db: db, jwtMgr: jwt, cfg: c}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3.1 External auth endpoint called by MediaMTX
|
||||||
|
// POST /mediamtx/auth
|
||||||
|
func (h *MediaMTXHandler) Auth(c *gin.Context) {
|
||||||
|
var req dto.MediaMTXAuthReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "bad auth body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// token can come from Authorization: Bearer or from query (?token=)
|
||||||
|
tok := extractBearer(c.GetHeader("Authorization"))
|
||||||
|
if tok == "" {
|
||||||
|
tok = tokenFromQuery(req.Query)
|
||||||
|
}
|
||||||
|
if tok == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse & validate media token
|
||||||
|
// parsed, err := h.jwtMgr.Parse(tok)
|
||||||
|
// if err != nil || !parsed.Valid {
|
||||||
|
// c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// var mc dto.MediaClaims
|
||||||
|
// if err := jwt.MapClaims(parsed.Claims.(jwt.MapClaims)).Decode(&mc); err != nil {
|
||||||
|
// c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid claims"})
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // enforce act/path
|
||||||
|
// if mc.Act != req.Action {
|
||||||
|
// c.JSON(http.StatusForbidden, gin.H{"error": "action mismatch"})
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// if mc.Path != req.Path {
|
||||||
|
// c.JSON(http.StatusForbidden, gin.H{"error": "path mismatch"})
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Optional: permission checks by role/device
|
||||||
|
// // READ: admins can read anything; users only devices assigned
|
||||||
|
// // PUBLISH: allow devices (sub=0 or special) or admins
|
||||||
|
// switch req.Action {
|
||||||
|
// case "read":
|
||||||
|
// if !h.canRead(mc.Subject, req.Path) {
|
||||||
|
// c.JSON(http.StatusForbidden, gin.H{"error": "no read permission"})
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// case "publish":
|
||||||
|
// if !h.canPublish(mc.Subject, req.Path) {
|
||||||
|
// c.JSON(http.StatusForbidden, gin.H{"error": "no publish permission"})
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// parse & validate media token
|
||||||
|
mc, err := h.jwtMgr.ParseMedia(tok)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// enforce act/path
|
||||||
|
if mc.Act != req.Action {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "action mismatch"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mc.Path != req.Path {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "path mismatch"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sub := mc.Subject // from RegisteredClaims.Subject
|
||||||
|
switch req.Action {
|
||||||
|
case "read":
|
||||||
|
if !h.canRead(sub, req.Path) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "no read permission"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "publish":
|
||||||
|
if !h.canPublish(sub, req.Path) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "no publish permission"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// allowed
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractBearer(h string) string {
|
||||||
|
if strings.HasPrefix(strings.ToLower(h), "bearer ") {
|
||||||
|
return strings.TrimSpace(h[7:])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
func tokenFromQuery(raw string) string {
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
q, _ := url.ParseQuery(raw)
|
||||||
|
return q.Get("token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// naive helpers: replace with real queries to users/devices tables
|
||||||
|
func (h *MediaMTXHandler) canRead(sub, path string) bool {
|
||||||
|
// path is "live/<guid>"
|
||||||
|
parts := strings.SplitN(path, "/", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guid := parts[1]
|
||||||
|
|
||||||
|
// Find the user; admins -> allow; else check user_devices join
|
||||||
|
var u models.User
|
||||||
|
if err := h.db.Where("id = ?", sub).First(&u).Error; err == nil && u.Role == models.RoleAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// check assignment
|
||||||
|
var count int64
|
||||||
|
_ = h.db.Table("user_devices").
|
||||||
|
Where("user_id = ? AND device_guid = ?", sub, guid).
|
||||||
|
Count(&count).Error
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
func (h *MediaMTXHandler) canPublish(sub, path string) bool {
|
||||||
|
// For devices you may use sub=0 or map to a device row; here: allow admins only
|
||||||
|
var u models.User
|
||||||
|
if err := h.db.Where("id = ?", sub).First(&u).Error; err == nil && u.Role == models.RoleAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3.2 Mint publish token (device flow) -> returns WHIP URL
|
||||||
|
// POST /mediamtx/token/publish {guid}
|
||||||
|
func (h *MediaMTXHandler) MintPublish(c *gin.Context) {
|
||||||
|
var req dto.PublishTokenReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := "live/" + req.GUID
|
||||||
|
tok, _ := h.jwtMgr.GenerateMediaToken(0, "publish", path, h.cfg.TokenTTL) // sub=0 (device)
|
||||||
|
whip := fmt.Sprintf("%s/whip/%s?token=%s", strings.TrimRight(h.cfg.WebRTCBaseURL, "/"), path, url.QueryEscape(tok))
|
||||||
|
c.JSON(http.StatusCreated, dto.PublishTokenResp{WHIP: whip})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3.3 Mint read token (user flow) -> returns HLS + WHEP URLs
|
||||||
|
// POST /mediamtx/token/read {guid}
|
||||||
|
func (h *MediaMTXHandler) MintRead(c *gin.Context) {
|
||||||
|
user, ok := GetUserContext(c)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.ReadTokenReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := "live/" + req.GUID
|
||||||
|
|
||||||
|
// check permission before minting
|
||||||
|
if user.Role != models.RoleAdmin {
|
||||||
|
var count int64
|
||||||
|
_ = h.db.Table("user_devices").
|
||||||
|
Where("user_id = ? AND device_guid = ?", user.ID, req.GUID).
|
||||||
|
Count(&count).Error
|
||||||
|
if count == 0 {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "not allowed for this device"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tok, _ := h.jwtMgr.GenerateMediaToken(user.ID, "read", path, h.cfg.TokenTTL)
|
||||||
|
|
||||||
|
pub := strings.TrimRight(h.cfg.PublicBaseURL, "/")
|
||||||
|
resp := dto.ReadTokenResp{
|
||||||
|
HLS: fmt.Sprintf("%s/hls/%s/index.m3u8?token=%s", pub, path, url.QueryEscape(tok)),
|
||||||
|
WHEP: fmt.Sprintf("%s/webrtc/play/%s?token=%s", pub, path, url.QueryEscape(tok)),
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3.4 Admin "controls" using MediaMTX Control API (v3)
|
||||||
|
type pathsListRes struct {
|
||||||
|
Items []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MediaMTXHandler) ListPaths(c *gin.Context) {
|
||||||
|
// GET {apiBase}/v3/paths/list
|
||||||
|
resp, err := http.Get(strings.TrimRight(h.cfg.APIBase, "/") + "/v3/paths/list")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "mtx api unreachable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
c.JSON(resp.StatusCode, gin.H{"error": "mtx api error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var pl pathsListRes
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&pl); err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "decode error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, pl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MediaMTXHandler) KickWebRTC(c *gin.Context) {
|
||||||
|
// POST {apiBase}/v3/webrtcsessions/kick/{id}
|
||||||
|
id := c.Param("id")
|
||||||
|
reqURL := strings.TrimRight(h.cfg.APIBase, "/") + "/v3/webrtcsessions/kick/" + url.PathEscape(id)
|
||||||
|
httpResp, err := http.Post(reqURL, "application/json", http.NoBody)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "mtx api unreachable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer httpResp.Body.Close()
|
||||||
|
if httpResp.StatusCode/100 != 2 {
|
||||||
|
c.JSON(httpResp.StatusCode, gin.H{"error": "kick failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -26,6 +26,9 @@ func Build(db *gorm.DB, minio *minio.Client, cfg *config.Config) *gin.Engine {
|
|||||||
recH := handlers.NewRecordsHandler(db, minio, cfg.MinIO.RecordsBucket, cfg.MinIO.PresignTTL)
|
recH := handlers.NewRecordsHandler(db, minio, cfg.MinIO.RecordsBucket, cfg.MinIO.PresignTTL)
|
||||||
liveH := handlers.NewLivestreamHandler(minio, cfg.MinIO.LivestreamBucket)
|
liveH := handlers.NewLivestreamHandler(minio, cfg.MinIO.LivestreamBucket)
|
||||||
|
|
||||||
|
// --- MediaMTX handler
|
||||||
|
mediamtxH := handlers.NewMediaMTXHandler(db, jwtMgr, cfg.MediaMTX)
|
||||||
|
|
||||||
// --- Public auth
|
// --- Public auth
|
||||||
r.POST("/auth/signup", authH.SignUp)
|
r.POST("/auth/signup", authH.SignUp)
|
||||||
r.POST("/auth/signin", authH.SignIn)
|
r.POST("/auth/signin", authH.SignIn)
|
||||||
@@ -60,6 +63,16 @@ func Build(db *gorm.DB, minio *minio.Client, cfg *config.Config) *gin.Engine {
|
|||||||
// health
|
// health
|
||||||
r.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
|
r.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
|
||||||
|
|
||||||
|
// --- NEW: MediaMTX integration routes
|
||||||
|
// External auth (called by MediaMTX)
|
||||||
|
r.POST("/mediamtx/auth", mediamtxH.Auth)
|
||||||
|
// Token minting for device/user flows
|
||||||
|
r.POST("/mediamtx/token/publish", mediamtxH.MintPublish)
|
||||||
|
r.POST("/mediamtx/token/read", authMW, mediamtxH.MintRead)
|
||||||
|
// Admin controls
|
||||||
|
r.GET("/mediamtx/paths", authMW, adminOnly, mediamtxH.ListPaths)
|
||||||
|
r.POST("/mediamtx/webrtc/kick/:id", authMW, adminOnly, mediamtxH.KickWebRTC)
|
||||||
|
|
||||||
// sensible defaults
|
// sensible defaults
|
||||||
r.MaxMultipartMemory = 64 << 20 // 64 MiB
|
r.MaxMultipartMemory = 64 << 20 // 64 MiB
|
||||||
_ = time.Now() // appease linters
|
_ = time.Now() // appease linters
|
||||||
|
|||||||
Reference in New Issue
Block a user