feat: 重建 Sense 单路实时监看 (#68)
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/liveview"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func init() {
|
||||
routerCheckRole = append(routerCheckRole, registerSenseLiveviewRouter)
|
||||
routerNoCheckRole = append(routerNoCheckRole, registerSenseLiveviewPlayerRouter)
|
||||
}
|
||||
|
||||
func registerSenseLiveviewRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||
api := &liveview.API{}
|
||||
r := v1.Group("/liveview").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||
r.GET("/routes", api.List)
|
||||
r.POST("/sessions", api.Create)
|
||||
r.GET("/sessions/:id", api.Get)
|
||||
}
|
||||
|
||||
func registerSenseLiveviewPlayerRouter(v1 *gin.RouterGroup) {
|
||||
api := &liveview.API{}
|
||||
v1.GET("/liveview/player/:id", api.Player)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package liveview
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
)
|
||||
|
||||
type API struct{ api.Api }
|
||||
|
||||
func (e *API) service(c *gin.Context) (*Service, error) {
|
||||
base := coreService.Service{}
|
||||
if err := e.MakeContext(c).MakeOrm().MakeService(&base).Errors; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewService(base.Orm, nil), nil
|
||||
}
|
||||
|
||||
func (e *API) List(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "实时监看服务初始化失败")
|
||||
return
|
||||
}
|
||||
pageIndex, _ := strconv.Atoi(c.DefaultQuery("pageIndex", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||
items, total, err := service.List(c.Request.Context(), PageRequest{Keyword: c.Query("keyword"), PageIndex: pageIndex, PageSize: pageSize})
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.PageOK(items, int(total), pageIndex, pageSize, "查询成功")
|
||||
}
|
||||
|
||||
func (e *API) Create(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "实时监看服务初始化失败")
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
RouteID string `json:"routeId"`
|
||||
}
|
||||
if err = decodeJSON(c, &request); err != nil {
|
||||
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
session, err := service.Create(c.Request.Context(), user.GetUserId(c), request.RouteID, c.Request)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.OK(session, "播放会话已创建")
|
||||
}
|
||||
|
||||
func (e *API) Get(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "实时监看服务初始化失败")
|
||||
return
|
||||
}
|
||||
session, err := service.Get(c.Request.Context(), user.GetUserId(c), c.Param("id"))
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.OK(session, "查询成功")
|
||||
}
|
||||
|
||||
var playerTemplate = template.Must(template.New("liveview-player").Parse(`<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>html,body,iframe{width:100%;height:100%;margin:0;border:0;background:#101419;overflow:hidden}</style></head>
|
||||
<body><iframe src="{{.}}" title="Sense 实时视频" allow="autoplay; fullscreen" referrerpolicy="no-referrer"></iframe></body></html>`))
|
||||
|
||||
func (e *API) Player(c *gin.Context) {
|
||||
target, err := NewService(nil, nil).PlayerTarget(c.Param("id"))
|
||||
if err != nil {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.String(http.StatusGone, "播放会话已过期,请重新连接")
|
||||
return
|
||||
}
|
||||
parsed := template.URL(target)
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("Referrer-Policy", "no-referrer")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("X-Frame-Options", "SAMEORIGIN")
|
||||
c.Header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'self'; frame-src http: https:; style-src 'unsafe-inline'")
|
||||
if err = playerTemplate.Execute(c.Writer, parsed); err != nil {
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *API) writeError(err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidRequest):
|
||||
e.Error(http.StatusBadRequest, err, err.Error())
|
||||
case errors.Is(err, ErrRouteNotFound):
|
||||
e.Error(http.StatusNotFound, err, err.Error())
|
||||
case errors.Is(err, ErrSessionExpired):
|
||||
e.Error(http.StatusGone, err, "播放会话已过期,请重新连接")
|
||||
case strings.Contains(err.Error(), "WEBRTC_PUBLIC_BASE"), strings.Contains(err.Error(), "浏览器可访问"):
|
||||
e.Error(http.StatusServiceUnavailable, err, "浏览器播放地址未正确配置")
|
||||
default:
|
||||
e.Error(http.StatusInternalServerError, err, "实时监看操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSON(c *gin.Context, target any) error {
|
||||
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))), "application/json") {
|
||||
return errors.New("content type must be application/json")
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return errors.New("request body must contain one JSON object")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package liveview
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestPlayerUsesShortLivedCapabilityAndRestrictiveHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
store := NewStore()
|
||||
now := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
|
||||
store.now = func() time.Time { return now }
|
||||
previous := defaultStore
|
||||
defaultStore = store
|
||||
t.Cleanup(func() { defaultStore = previous })
|
||||
store.put(sessionRecord{ID: "view_test", OwnerID: 7, RouteID: "route-1", TargetURL: "http://127.0.0.1:8889/sense_test?controls=true", ExpiresAt: now.Add(sessionTTL)})
|
||||
|
||||
router := gin.New()
|
||||
api := &API{}
|
||||
router.GET("/api/v1/liveview/player/:id", api.Player)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/liveview/player/view_test", nil))
|
||||
if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" || recorder.Header().Get("X-Frame-Options") != "SAMEORIGIN" {
|
||||
t.Fatalf("status=%d headers=%v", recorder.Code, recorder.Header())
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "http://127.0.0.1:8889/sense_test?controls=true") || !strings.Contains(recorder.Header().Get("Content-Security-Policy"), "frame-ancestors 'self'") {
|
||||
t.Fatalf("unexpected wrapper response: %s", recorder.Body.String())
|
||||
}
|
||||
|
||||
now = now.Add(sessionTTL)
|
||||
expired := httptest.NewRecorder()
|
||||
router.ServeHTTP(expired, httptest.NewRequest(http.MethodGet, "/api/v1/liveview/player/view_test", nil))
|
||||
if expired.Code != http.StatusGone {
|
||||
t.Fatalf("expired capability status=%d", expired.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package liveview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidRequest = errors.New("实时监看请求不符合要求")
|
||||
ErrRouteNotFound = errors.New("可监看的视频不存在")
|
||||
ErrSessionExpired = errors.New("播放会话已过期")
|
||||
validMediaPath = regexp.MustCompile(`^[A-Za-z0-9_-]{1,96}$`)
|
||||
validDNSHost = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)
|
||||
)
|
||||
|
||||
const sessionTTL = 2 * time.Minute
|
||||
|
||||
type PageRequest struct {
|
||||
Keyword string
|
||||
PageIndex int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type RouteResponse struct {
|
||||
ID string `json:"id"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
DeviceLocation string `json:"deviceLocation"`
|
||||
ProfileToken string `json:"profileToken"`
|
||||
ProfileName string `json:"profileName"`
|
||||
ProfileKind string `json:"profileKind"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Encoding string `json:"encoding"`
|
||||
Actual string `json:"actual"`
|
||||
Detail string `json:"detail"`
|
||||
Readers int `json:"readers"`
|
||||
}
|
||||
|
||||
type SessionResponse struct {
|
||||
ID string `json:"id"`
|
||||
RouteID string `json:"routeId"`
|
||||
PlayerURL string `json:"playerUrl"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
ProfileName string `json:"profileName"`
|
||||
}
|
||||
|
||||
type routeRecord struct {
|
||||
RouteResponse
|
||||
Path string `gorm:"column:path"`
|
||||
Desired string `gorm:"column:desired"`
|
||||
}
|
||||
|
||||
type sessionRecord struct {
|
||||
ID string
|
||||
OwnerID int
|
||||
RouteID string
|
||||
TargetURL string
|
||||
ExpiresAt time.Time
|
||||
DeviceName string
|
||||
ProfileName string
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]sessionRecord
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{sessions: make(map[string]sessionRecord), now: time.Now}
|
||||
}
|
||||
|
||||
var defaultStore = NewStore()
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
store *Store
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, store *Store) *Service {
|
||||
if store == nil {
|
||||
store = defaultStore
|
||||
}
|
||||
return &Service{db: db, store: store}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, request PageRequest) ([]RouteResponse, int64, error) {
|
||||
if request.PageIndex < 1 {
|
||||
request.PageIndex = 1
|
||||
}
|
||||
if request.PageSize < 1 {
|
||||
request.PageSize = 10
|
||||
}
|
||||
if request.PageSize > 50 {
|
||||
request.PageSize = 50
|
||||
}
|
||||
query := s.routeQuery(ctx).Where("r.desired = ?", "running")
|
||||
keyword := strings.TrimSpace(request.Keyword)
|
||||
if len([]rune(keyword)) > 128 {
|
||||
return nil, 0, ErrInvalidRequest
|
||||
}
|
||||
if keyword != "" {
|
||||
pattern := "%" + escapeLike(keyword) + "%"
|
||||
query = query.Where("LOWER(d.name) LIKE LOWER(?) ESCAPE '\\' OR LOWER(d.location) LIKE LOWER(?) ESCAPE '\\' OR LOWER(p.name) LIKE LOWER(?) ESCAPE '\\'", pattern, pattern, pattern)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var records []routeRecord
|
||||
if err := query.Order("d.name ASC, p.kind ASC, p.width DESC").Offset((request.PageIndex - 1) * request.PageSize).Limit(request.PageSize).Scan(&records).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := make([]RouteResponse, 0, len(records))
|
||||
for _, record := range records {
|
||||
items = append(items, record.RouteResponse)
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, ownerID int, routeID string, request *http.Request) (SessionResponse, error) {
|
||||
if ownerID < 1 || strings.TrimSpace(routeID) == "" {
|
||||
return SessionResponse{}, ErrInvalidRequest
|
||||
}
|
||||
record, err := s.route(ctx, routeID)
|
||||
if err != nil {
|
||||
return SessionResponse{}, err
|
||||
}
|
||||
if record.Desired != "running" || !validMediaPath.MatchString(record.Path) {
|
||||
return SessionResponse{}, ErrRouteNotFound
|
||||
}
|
||||
base, err := playbackBase(request)
|
||||
if err != nil {
|
||||
return SessionResponse{}, err
|
||||
}
|
||||
target := *base
|
||||
target.Path = strings.TrimRight(target.Path, "/") + "/" + record.Path
|
||||
target.RawQuery = "controls=true&muted=true&autoplay=true"
|
||||
id, err := randomID()
|
||||
if err != nil {
|
||||
return SessionResponse{}, err
|
||||
}
|
||||
now := s.store.now().UTC()
|
||||
session := sessionRecord{ID: id, OwnerID: ownerID, RouteID: record.ID, TargetURL: target.String(), ExpiresAt: now.Add(sessionTTL), DeviceName: record.DeviceName, ProfileName: record.ProfileName}
|
||||
s.store.put(session)
|
||||
return responseFrom(session, record), nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, ownerID int, id string) (SessionResponse, error) {
|
||||
session, err := s.store.get(id, ownerID, true)
|
||||
if err != nil {
|
||||
return SessionResponse{}, err
|
||||
}
|
||||
record, err := s.route(ctx, session.RouteID)
|
||||
if err != nil || record.Desired != "running" {
|
||||
return SessionResponse{}, ErrRouteNotFound
|
||||
}
|
||||
session.ExpiresAt = s.store.now().UTC().Add(sessionTTL)
|
||||
s.store.put(session)
|
||||
return responseFrom(session, record), nil
|
||||
}
|
||||
|
||||
func (s *Service) PlayerTarget(id string) (string, error) {
|
||||
session, err := s.store.get(id, 0, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return session.TargetURL, nil
|
||||
}
|
||||
|
||||
func (s *Service) route(ctx context.Context, id string) (routeRecord, error) {
|
||||
var record routeRecord
|
||||
if err := s.routeQuery(ctx).Where("r.id = ?", id).Limit(1).Scan(&record).Error; err != nil {
|
||||
return routeRecord{}, err
|
||||
}
|
||||
if record.ID == "" {
|
||||
return routeRecord{}, ErrRouteNotFound
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *Service) routeQuery(ctx context.Context) *gorm.DB {
|
||||
return s.db.WithContext(ctx).Table("sense_media_routes AS r").
|
||||
Select("r.id, r.device_id, d.name AS device_name, d.location AS device_location, r.profile_token, p.name AS profile_name, p.kind AS profile_kind, p.width, p.height, p.encoding, r.actual, r.detail, r.readers, r.path, r.desired").
|
||||
Joins("JOIN sense_devices AS d ON d.id = r.device_id").
|
||||
Joins("JOIN sense_admission_profiles AS p ON p.device_id = r.device_id AND p.token = r.profile_token").
|
||||
Where("d.status <> ? AND p.verification_status = ?", "disabled", "ready")
|
||||
}
|
||||
|
||||
func responseFrom(session sessionRecord, route routeRecord) SessionResponse {
|
||||
return SessionResponse{ID: session.ID, RouteID: session.RouteID, PlayerURL: "/api/v1/liveview/player/" + session.ID, Status: playbackStatus(route.Actual), Detail: route.Detail, ExpiresAt: session.ExpiresAt, DeviceName: session.DeviceName, ProfileName: session.ProfileName}
|
||||
}
|
||||
|
||||
func playbackStatus(actual string) string {
|
||||
switch actual {
|
||||
case "ready", "waiting", "stopped":
|
||||
return actual
|
||||
case "credential_unavailable", "profile_unavailable":
|
||||
return "authentication_failed"
|
||||
case "path_missing", "apply_failed", "status_unavailable":
|
||||
return "stream_not_found"
|
||||
case "process_unavailable":
|
||||
return "service_unavailable"
|
||||
default:
|
||||
return "offline"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) put(session sessionRecord) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := s.now()
|
||||
for id, item := range s.sessions {
|
||||
if !now.Before(item.ExpiresAt) || item.OwnerID == session.OwnerID {
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
}
|
||||
s.sessions[session.ID] = session
|
||||
}
|
||||
|
||||
func (s *Store) get(id string, ownerID int, checkOwner bool) (sessionRecord, error) {
|
||||
s.mu.RLock()
|
||||
session, ok := s.sessions[id]
|
||||
s.mu.RUnlock()
|
||||
if !ok || !s.now().Before(session.ExpiresAt) || (checkOwner && session.OwnerID != ownerID) {
|
||||
return sessionRecord{}, ErrSessionExpired
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func playbackBase(request *http.Request) (*url.URL, error) {
|
||||
configured := strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE"))
|
||||
if configured != "" {
|
||||
return validatePlaybackBase(configured)
|
||||
}
|
||||
if request == nil || request.Host == "" {
|
||||
return nil, errors.New("无法确定浏览器可访问的视频服务地址")
|
||||
}
|
||||
host := request.Host
|
||||
if parsedHost, _, err := net.SplitHostPort(request.Host); err == nil {
|
||||
host = parsedHost
|
||||
}
|
||||
host = strings.Trim(host, "[]")
|
||||
if host == "" || (net.ParseIP(host) == nil && host != "localhost" && !validDNSHost.MatchString(host)) {
|
||||
return nil, errors.New("无效的请求主机")
|
||||
}
|
||||
scheme := "http"
|
||||
if request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
return validatePlaybackBase(fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(host, "8889")))
|
||||
}
|
||||
|
||||
func validatePlaybackBase(value string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimRight(value, "/"))
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") {
|
||||
return nil, errors.New("SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE 配置不安全")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func randomID() (string, error) {
|
||||
value := make([]byte, 24)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "view_" + hex.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func escapeLike(value string) string {
|
||||
value = strings.ReplaceAll(value, `\`, `\\`)
|
||||
value = strings.ReplaceAll(value, `%`, `\%`)
|
||||
return strings.ReplaceAll(value, `_`, `\_`)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package liveview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func testDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statements := []string{
|
||||
`CREATE TABLE sense_devices (id text primary key, name text, location text, status text)`,
|
||||
`CREATE TABLE sense_admission_profiles (device_id text, token text, name text, kind text, width integer, height integer, encoding text, verification_status text, stream_uri text)`,
|
||||
`CREATE TABLE sense_media_routes (id text primary key, device_id text, profile_token text, path text, desired text, actual text, detail text, readers integer)`,
|
||||
`INSERT INTO sense_devices VALUES ('device-1','东门摄像机','教学楼东门','active')`,
|
||||
`INSERT INTO sense_admission_profiles VALUES ('device-1','main','主码流','main',1920,1080,'H264','ready','rtsp://camera.example/live')`,
|
||||
`INSERT INTO sense_media_routes VALUES ('device-1:main','device-1','main','sense_012345','running','waiting','等待播放器连接并按需拉流',0)`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if err = db.Exec(statement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestListIsPaginatedSearchableAndSecretFree(t *testing.T) {
|
||||
service := NewService(testDB(t), NewStore())
|
||||
items, total, err := service.List(context.Background(), PageRequest{Keyword: "东门", PageIndex: 1, PageSize: 10})
|
||||
if err != nil || total != 1 || len(items) != 1 || items[0].ProfileKind != "main" {
|
||||
t.Fatalf("items=%+v total=%d err=%v", items, total, err)
|
||||
}
|
||||
encoded, _ := json.Marshal(items)
|
||||
if strings.Contains(string(encoded), "rtsp://") || strings.Contains(string(encoded), "sense_012345") {
|
||||
t.Fatalf("response leaked private media data: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionIsOwnerBoundShortLivedAndUsesBrowserHost(t *testing.T) {
|
||||
store := NewStore()
|
||||
now := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
|
||||
store.now = func() time.Time { return now }
|
||||
service := NewService(testDB(t), store)
|
||||
request := httptest.NewRequest("POST", "http://192.0.2.20:18080/api/v1/liveview/sessions", nil)
|
||||
session, err := service.Create(context.Background(), 7, "device-1:main", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(session.PlayerURL, "/api/v1/liveview/player/view_") || strings.Contains(session.PlayerURL, "sense_012345") {
|
||||
t.Fatalf("unsafe player URL: %s", session.PlayerURL)
|
||||
}
|
||||
if _, err = service.Get(context.Background(), 8, session.ID); !errorsIs(err, ErrSessionExpired) {
|
||||
t.Fatalf("another owner accessed session: %v", err)
|
||||
}
|
||||
target, err := service.PlayerTarget(session.ID)
|
||||
if err != nil || target != "http://192.0.2.20:8889/sense_012345?controls=true&muted=true&autoplay=true" {
|
||||
t.Fatalf("target=%q err=%v", target, err)
|
||||
}
|
||||
now = now.Add(sessionTTL)
|
||||
if _, err = service.PlayerTarget(session.ID); !errorsIs(err, ErrSessionExpired) {
|
||||
t.Fatalf("expired session remained valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackBaseRejectsCredentials(t *testing.T) {
|
||||
if _, err := validatePlaybackBase("http://invalid-user@127.0.0.1:8889"); err == nil {
|
||||
t.Fatal("expected credential-bearing base URL to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackStatusKeepsActionableFailuresDistinct(t *testing.T) {
|
||||
for input, want := range map[string]string{"waiting": "waiting", "credential_unavailable": "authentication_failed", "path_missing": "stream_not_found", "process_unavailable": "service_unavailable", "unexpected": "offline"} {
|
||||
if got := playbackStatus(input); got != want {
|
||||
t.Fatalf("playbackStatus(%q)=%q want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func errorsIs(err, target error) bool { return err == target }
|
||||
@@ -0,0 +1,37 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
|
||||
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseLiveview)
|
||||
}
|
||||
|
||||
func migrateSenseLiveview(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseLiveview", Title: "实时监看", Icon: "eye-open", Path: "/sense/liveview", MenuType: "C", Permission: "sense:liveview:view", Component: "/sense/liveview/index", Sort: 8, Visible: "0", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, policy := range [][2]string{{"/api/v1/liveview/routes", "GET"}, {"/api/v1/liveview/sessions", "POST"}, {"/api/v1/liveview/sessions/:id", "GET"}} {
|
||||
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
func TestLiveviewMigrationOnPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("SENSE_LIVEVIEW_MIGRATION_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set SENSE_LIVEVIEW_MIGRATION_TEST_DATABASE_URL to run the PostgreSQL migration test")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const schema = "sense_liveview_68_test"
|
||||
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") })
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = migrateSenseLiveview(db, "2026081420000_liveview.go"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var menus, policies, applied int64
|
||||
db.Model(&migrationModels.SysMenu{}).Where("menu_name = ?", "SenseLiveview").Count(&menus)
|
||||
db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/liveview%").Count(&policies)
|
||||
db.Model(&common.Migration{}).Where("version = ?", "2026081420000_liveview.go").Count(&applied)
|
||||
if menus != 1 || policies != 9 || applied != 1 {
|
||||
t.Fatalf("menus=%d policies=%d applied=%d", menus, policies, applied)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user