feat: add protected single-stream Sense live view (#26)
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
package liveview
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"yovision.local/sense/app/sense/identity"
|
||||
"yovision.local/sense/internal/platform"
|
||||
)
|
||||
|
||||
type Module struct{ service *Service }
|
||||
|
||||
func NewModule(service *Service) *Module { return &Module{service: service} }
|
||||
func (m *Module) Register(app *platform.App) {
|
||||
app.Handle("GET /api/v1/liveview/routes", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.routes)))
|
||||
app.Handle("POST /api/v1/liveview/sessions", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.create)))
|
||||
app.Handle("GET /api/v1/liveview/sessions/{id}", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.get)))
|
||||
app.Handle("GET /api/v1/liveview/sessions/{id}/player", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.player)))
|
||||
}
|
||||
func (m *Module) routes(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := m.service.Routes(r.Context())
|
||||
if err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
func (m *Module) create(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
RouteID string `json:"route_id"`
|
||||
}
|
||||
if err := platform.DecodeJSON(r, &req); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
principal, _ := identity.PrincipalFromContext(r.Context())
|
||||
session, err := m.service.Create(r.Context(), principal.UserID, req.RouteID)
|
||||
if err != nil {
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusBadRequest, Code: "liveview_unavailable", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusCreated, session)
|
||||
}
|
||||
func (m *Module) get(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := identity.PrincipalFromContext(r.Context())
|
||||
session, err := m.service.Get(r.Context(), principal.UserID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusGone, Code: "session_expired", Message: "播放会话已过期,请重新打开"})
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, session)
|
||||
}
|
||||
|
||||
var playerTemplate = template.Must(template.New("player").Parse(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><style>html,body,iframe{width:100%;height:100%;margin:0;border:0;background:#111}</style></head><body><iframe src="{{.}}" allow="autoplay; fullscreen" title="Sense live video"></iframe></body></html>`))
|
||||
|
||||
func (m *Module) player(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := identity.PrincipalFromContext(r.Context())
|
||||
target, err := m.service.EmbedURL(r.Context(), principal.UserID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "播放会话已过期", http.StatusGone)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-src http: https:; style-src 'unsafe-inline'")
|
||||
_ = playerTemplate.Execute(w, target)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package liveview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"yovision.local/sense/app/sense/media"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
OwnerID string `json:"-"`
|
||||
RouteID string `json:"route_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ProfileToken string `json:"profile_token"`
|
||||
PlayerURL string `json:"player_url"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
type Service struct {
|
||||
base *url.URL
|
||||
ttl time.Duration
|
||||
route func(context.Context, string) (media.Route, error)
|
||||
routes func(context.Context) ([]media.Route, error)
|
||||
mu sync.RWMutex
|
||||
sessions map[string]Session
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(rawBase string, ttl time.Duration) (*Service, error) {
|
||||
parsed, err := url.Parse(strings.TrimRight(rawBase, "/"))
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil {
|
||||
return nil, fmt.Errorf("invalid MediaMTX WebRTC base URL")
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, fmt.Errorf("unsupported MediaMTX WebRTC scheme")
|
||||
}
|
||||
if ttl <= 0 || ttl > 10*time.Minute {
|
||||
ttl = 2 * time.Minute
|
||||
}
|
||||
return &Service{base: parsed, ttl: ttl, route: media.PlaybackRoute, routes: media.PlaybackRoutes, sessions: map[string]Session{}, now: time.Now}, nil
|
||||
}
|
||||
func (s *Service) Routes(ctx context.Context) ([]media.Route, error) {
|
||||
items, err := s.routes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := items[:0]
|
||||
for _, item := range items {
|
||||
if item.Desired == "running" {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (s *Service) Create(ctx context.Context, owner, routeID string) (Session, error) {
|
||||
route, err := s.route(ctx, routeID)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
if route.Desired != "running" {
|
||||
return Session{}, fmt.Errorf("media route is stopped")
|
||||
}
|
||||
id := newID()
|
||||
session := Session{ID: id, OwnerID: owner, RouteID: route.ID, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, PlayerURL: "/api/v1/liveview/sessions/" + id + "/player", Status: route.Actual, Detail: route.Detail, ExpiresAt: s.now().UTC().Add(s.ttl)}
|
||||
s.mu.Lock()
|
||||
s.sessions[id] = session
|
||||
s.mu.Unlock()
|
||||
return session, nil
|
||||
}
|
||||
func (s *Service) Get(ctx context.Context, owner, id string) (Session, error) {
|
||||
s.mu.RLock()
|
||||
session, ok := s.sessions[id]
|
||||
s.mu.RUnlock()
|
||||
if !ok || session.OwnerID != owner || !s.now().Before(session.ExpiresAt) {
|
||||
return Session{}, fmt.Errorf("playback session expired")
|
||||
}
|
||||
route, err := s.route(ctx, session.RouteID)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
session.Status = route.Actual
|
||||
session.Detail = route.Detail
|
||||
return session, nil
|
||||
}
|
||||
func (s *Service) EmbedURL(ctx context.Context, owner, id string) (string, error) {
|
||||
session, err := s.Get(ctx, owner, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
route, err := s.route(ctx, session.RouteID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
target := *s.base
|
||||
target.Path = strings.TrimRight(target.Path, "/") + "/" + route.Path
|
||||
return target.String(), nil
|
||||
}
|
||||
func newID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return "view_" + hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package liveview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"yovision.local/sense/app/sense/media"
|
||||
)
|
||||
|
||||
func TestRejectCredentialBaseURL(t *testing.T) {
|
||||
if _, err := NewService("http://user:pass@127.0.0.1:8889", time.Minute); err == nil {
|
||||
t.Fatal("credential base URL accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionIsOwnerBoundAndExpires(t *testing.T) {
|
||||
service, err := NewService("http://127.0.0.1:8889", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 12, 0, 0, 0, 0, time.UTC)
|
||||
service.now = func() time.Time { return now }
|
||||
route := media.Route{ID: "device:main", DeviceID: "device", ProfileToken: "main", Path: "sense_device_main", Desired: "running", Actual: "ready"}
|
||||
service.route = func(context.Context, string) (media.Route, error) { return route, nil }
|
||||
session, err := service.Create(context.Background(), "owner-a", route.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.Get(context.Background(), "owner-b", session.ID); err == nil {
|
||||
t.Fatal("other owner accessed session")
|
||||
}
|
||||
now = now.Add(2 * time.Minute)
|
||||
if _, err := service.Get(context.Background(), "owner-a", session.ID); err == nil {
|
||||
t.Fatal("expired session remained active")
|
||||
}
|
||||
}
|
||||
func TestTTLIsBounded(t *testing.T) {
|
||||
service, err := NewService("http://127.0.0.1:8889", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if service.ttl != 2*time.Minute {
|
||||
t.Fatalf("ttl=%v", service.ttl)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ type Module struct{ service *Service }
|
||||
|
||||
func NewModule(service *Service) *Module { return &Module{service: service} }
|
||||
func (m *Module) Register(app *platform.App) {
|
||||
setActiveService(m.service)
|
||||
app.Handle("GET /api/v1/media/routes", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.list)))
|
||||
app.Handle("POST /api/v1/media/routes", identity.Require(identity.PermissionMediaWrite, http.HandlerFunc(m.configure)))
|
||||
app.Handle("POST /api/v1/media/routes/{id}/reconcile", identity.Require(identity.PermissionMediaWrite, http.HandlerFunc(m.reconcile)))
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
var activeService atomic.Pointer[Service]
|
||||
|
||||
func setActiveService(service *Service) { activeService.Store(service) }
|
||||
func PlaybackRoute(ctx context.Context, id string) (Route, error) {
|
||||
service := activeService.Load()
|
||||
if service == nil {
|
||||
return Route{}, fmt.Errorf("media service is not ready")
|
||||
}
|
||||
return service.store.Get(ctx, id)
|
||||
}
|
||||
func PlaybackRoutes(ctx context.Context) ([]Route, error) {
|
||||
service := activeService.Load()
|
||||
if service == nil {
|
||||
return nil, fmt.Errorf("media service is not ready")
|
||||
}
|
||||
return service.store.List(ctx)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package sense
|
||||
|
||||
import (
|
||||
"time"
|
||||
"yovision.local/sense/app/sense/liveview"
|
||||
"yovision.local/sense/internal/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerModule(func(app *platform.App) error {
|
||||
service, err := liveview.NewService(valueOr("SENSE_MEDIAMTX_WEBRTC_BASE", "http://127.0.0.1:8889"), 2*time.Minute)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
liveview.NewModule(service).Register(app)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user