diff --git a/Sense/server/app/sense/liveview/http.go b/Sense/server/app/sense/liveview/http.go new file mode 100644 index 0000000..1e0c444 --- /dev/null +++ b/Sense/server/app/sense/liveview/http.go @@ -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(``)) + +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) +} diff --git a/Sense/server/app/sense/liveview/service.go b/Sense/server/app/sense/liveview/service.go new file mode 100644 index 0000000..101ee80 --- /dev/null +++ b/Sense/server/app/sense/liveview/service.go @@ -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) +} diff --git a/Sense/server/app/sense/liveview/service_test.go b/Sense/server/app/sense/liveview/service_test.go new file mode 100644 index 0000000..7bd13f9 --- /dev/null +++ b/Sense/server/app/sense/liveview/service_test.go @@ -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) + } +} diff --git a/Sense/server/app/sense/media/http.go b/Sense/server/app/sense/media/http.go index 121a73f..495668b 100644 --- a/Sense/server/app/sense/media/http.go +++ b/Sense/server/app/sense/media/http.go @@ -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))) diff --git a/Sense/server/app/sense/media/route_port.go b/Sense/server/app/sense/media/route_port.go new file mode 100644 index 0000000..59596f8 --- /dev/null +++ b/Sense/server/app/sense/media/route_port.go @@ -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) +} diff --git a/Sense/server/cmd/sense/modules_liveview.go b/Sense/server/cmd/sense/modules_liveview.go new file mode 100644 index 0000000..34e131d --- /dev/null +++ b/Sense/server/cmd/sense/modules_liveview.go @@ -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 + }) +} diff --git a/Sense/testdata/liveview/session.json b/Sense/testdata/liveview/session.json new file mode 100644 index 0000000..6050878 --- /dev/null +++ b/Sense/testdata/liveview/session.json @@ -0,0 +1 @@ +{"route_id":"fixture-device:main","status":"ready","expires_in_seconds":120} diff --git a/Sense/tests/liveview/README.md b/Sense/tests/liveview/README.md new file mode 100644 index 0000000..971c9f1 --- /dev/null +++ b/Sense/tests/liveview/README.md @@ -0,0 +1,7 @@ +# Live-view acceptance + +Sense creates owner-bound, two-minute playback sessions only for configured +media routes. Camera credentials and long-lived tokens are never placed in the +page URL. Automated tests cover URL validation and bounded TTL; a real browser, +MediaMTX WebRTC page and laboratory stream remain target-environment checks. + diff --git a/Sense/ui/src/api/sense/liveview/index.js b/Sense/ui/src/api/sense/liveview/index.js new file mode 100644 index 0000000..650aa43 --- /dev/null +++ b/Sense/ui/src/api/sense/liveview/index.js @@ -0,0 +1,5 @@ +import request from '../../../bootstrap/request' +export const playbackRoutes = () => request.get('/liveview/routes') +export const createSession = (routeID) => request.post('/liveview/sessions', { route_id: routeID }) +export const getSession = (id) => request.get(`/liveview/sessions/${id}`) + diff --git a/Sense/ui/src/components/sense/liveview/StreamPlayer.vue b/Sense/ui/src/components/sense/liveview/StreamPlayer.vue new file mode 100644 index 0000000..3dd5e38 --- /dev/null +++ b/Sense/ui/src/components/sense/liveview/StreamPlayer.vue @@ -0,0 +1,14 @@ +