feat: add protected single-stream Sense live view (#26)

This commit is contained in:
QiuSW
2026-08-12 17:24:47 +08:00
parent 398534aabe
commit 8509699fff
12 changed files with 317 additions and 0 deletions
+66
View File
@@ -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)
}
+112
View File
@@ -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)
}
}
+1
View File
@@ -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
})
}
+1
View File
@@ -0,0 +1 @@
{"route_id":"fixture-device:main","status":"ready","expires_in_seconds":120}
+7
View File
@@ -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.
+5
View File
@@ -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}`)
@@ -0,0 +1,14 @@
<template>
<div class="stream-player">
<div v-if="state === 'loading'" class="player-state"><el-icon class="is-loading" size="42"><Loading /></el-icon><strong>正在打开视频</strong><span>通常需要几秒钟</span></div>
<div v-else-if="state !== 'ready'" class="player-state"><el-icon size="46"><WarningFilled /></el-icon><strong>{{ title }}</strong><span>{{ detail }}</span><el-button type="primary" @click="$emit('retry')">重新连接</el-button></div>
<iframe v-else :key="playerUrl" :src="playerUrl" title="Sense 单路实时视频" allow="autoplay; fullscreen" @load="$emit('loaded')" />
</div>
</template>
<script setup>
import{computed}from'vue'
const props=defineProps({state:{type:String,default:'loading'},detail:{type:String,default:''},playerUrl:{type:String,default:''}});defineEmits(['retry','loaded'])
const title=computed(()=>({stopped:'视频已停止',process_failed:'视频服务未启动',apply_failed:'视频配置失败',unconverged:'视频状态未同步',waiting:'正在等待视频',expired:'播放会话已过期',offline:'视频已断开'})[props.state]||'暂时无法播放')
</script>
<style scoped>.stream-player{position:relative;width:100%;aspect-ratio:16/9;overflow:hidden;border-radius:4px;background:#101419}.stream-player iframe{width:100%;height:100%;border:0}.player-state{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;color:#c9cdd4}.player-state strong{color:#fff;font-size:18px}.player-state span{max-width:70%;text-align:center;font-size:13px}</style>
@@ -0,0 +1,3 @@
import LiveView from '../../views/sense/liveview/LiveView.vue'
export default { path: 'liveview', name: 'sense-liveview', component: LiveView, meta: { title: '实时监看', icon: 'VideoPlay', order: 40, permission: 'media.read' } }
@@ -0,0 +1,19 @@
<template>
<section class="page-container">
<div class="page-heading"><div><h1>实时监看</h1><p>一次打开一路视频,可在已验证的主、子码流之间切换。</p></div><el-tag v-if="session.expires_at" type="info">会话短期有效</el-tag></div>
<el-row :gutter="16">
<el-col :span="7"><el-card shadow="never"><template #header><strong>选择视频</strong></template><el-form label-position="top"><el-form-item label="设备与 Profile"><el-select v-model="selectedRoute" filterable placeholder="选择一路视频" style="width:100%" @change="open"><el-option v-for="item in routes" :key="item.id" :label="`${item.device_id} · ${profileLabel(item.profile_token)}`" :value="item.id"><span>{{ item.device_id }}</span><span class="option-detail">{{ profileLabel(item.profile_token) }} · {{ stateLabel(item.actual) }}</span></el-option></el-select></el-form-item></el-form><el-descriptions v-if="current" :column="1" border><el-descriptions-item label="设备">{{ current.device_id }}</el-descriptions-item><el-descriptions-item label="Profile">{{ profileLabel(current.profile_token) }}</el-descriptions-item><el-descriptions-item label="媒体状态"><el-tag :type="current.actual === 'ready' ? 'success' : 'warning'">{{ stateLabel(current.actual) }}</el-tag></el-descriptions-item><el-descriptions-item label="观看连接">{{ current.readers }}</el-descriptions-item></el-descriptions><el-alert v-else title="先在视频服务中建立并对账媒体路径" type="info" show-icon :closable="false" /></el-card></el-col>
<el-col :span="17"><el-card shadow="never"><template #header><div class="player-header"><strong>单路画面</strong><el-button v-if="selectedRoute" :icon="Refresh" @click="retry">重新连接</el-button></div></template><StreamPlayer :state="playerState" :detail="playerDetail" :player-url="session.player_url" @retry="retry" /></el-card></el-col>
</el-row>
</section>
</template>
<script setup>
import{computed,onBeforeUnmount,onMounted,reactive,ref}from'vue';import{ElMessage}from'element-plus';import{Refresh}from'@element-plus/icons-vue';import{createSession,getSession,playbackRoutes}from'../../../api/sense/liveview';import StreamPlayer from'../../../components/sense/liveview/StreamPlayer.vue'
const routes=ref([]),selectedRoute=ref(''),session=reactive({}),playerState=ref('waiting'),playerDetail=ref('请选择一路视频'),timer=ref();const current=computed(()=>routes.value.find(item=>item.id===selectedRoute.value));function profileLabel(value){return value?.toLowerCase().includes('sub')?'子码流':value?.toLowerCase().includes('main')?'主码流':value}function stateLabel(value){return({ready:'正常',waiting:'等待视频',stopped:'已停止',process_failed:'服务未启动',apply_failed:'配置失败',unconverged:'状态未同步'})[value]||value}
async function load(){routes.value=(await playbackRoutes()).items||[]}
async function open(){if(!selectedRoute.value)return;playerState.value='loading';try{Object.assign(session,await createSession(selectedRoute.value));playerState.value=session.status==='ready'?'ready':session.status;playerDetail.value=session.detail;startPolling()}catch(error){playerState.value='offline';playerDetail.value=error.message||'无法创建播放会话'}}
function startPolling(){clearInterval(timer.value);timer.value=setInterval(async()=>{if(!session.id)return;try{const latest=await getSession(session.id);Object.assign(session,latest);playerState.value=latest.status==='ready'?'ready':latest.status;playerDetail.value=latest.detail}catch{playerState.value='expired';playerDetail.value='播放会话已过期,请重新连接';clearInterval(timer.value)}},5000)}
async function retry(){if(!selectedRoute.value){ElMessage.info('请先选择视频');return}await open()}
onMounted(load);onBeforeUnmount(()=>clearInterval(timer.value))
</script>
<style scoped>.player-header{display:flex;align-items:center;justify-content:space-between}.option-detail{float:right;color:#86909c;font-size:12px}</style>