feat: 重建设备台账与凭据边界 (#65)
This commit is contained in:
+3
-1
@@ -40,4 +40,6 @@ corepack pnpm@9.15.1 build:prod
|
||||
go run . server -c C:\secure-path\sense-settings.yml
|
||||
```
|
||||
|
||||
仓库不提供默认账号、默认密码或可用密钥。管理员安全初始化由后续工单实现。
|
||||
设备台账本身可以在不配置摄像头凭据的情况下使用。创建或更新 ONVIF/RTSP 凭据前,还必须在启动进程环境中设置 `SENSE_CREDENTIAL_KEY`:该值是随机 32 字节密钥的 Base64 编码,仅保存在仓库外。变量名模板见 `server/config/credential.env.example`;不要把真实值写入配置、脚本、日志或工单。密钥缺失或格式不正确时,Sense 会拒绝凭据写入,不会降级为明文存储。
|
||||
|
||||
仓库不提供默认账号、默认密码或可用密钥。首位管理员通过受仓库外 `SENSE_BOOTSTRAP_TOKEN` 保护的一次性初始化接口创建,详细步骤以项目 Wiki 的本地开发与验证页为准。
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
senseapis "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/apis"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
)
|
||||
|
||||
func init() {
|
||||
routerCheckRole = append(routerCheckRole, registerSenseDeviceRouter)
|
||||
}
|
||||
|
||||
func registerSenseDeviceRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
api := senseapis.Device{}
|
||||
r := v1.Group("/devices").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||
{
|
||||
r.GET("", api.GetPage)
|
||||
r.GET("/:id", api.Get)
|
||||
r.POST("", api.Insert)
|
||||
r.PUT("/:id", api.Update)
|
||||
r.PUT("/:id/disable", api.Disable)
|
||||
r.PUT("/:id/credentials", api.UpdateCredentials)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
EnvironmentKey = "SENSE_CREDENTIAL_KEY"
|
||||
PurposeONVIF = "onvif"
|
||||
PurposeRTSP = "rtsp"
|
||||
keyVersion = "v1"
|
||||
)
|
||||
|
||||
var ErrKeyUnavailable = errors.New("摄像头凭据加密密钥不可用")
|
||||
|
||||
// DeviceCredential is deliberately stored separately from the device ledger.
|
||||
// No HTTP response type embeds this model.
|
||||
type DeviceCredential struct {
|
||||
DeviceID string `gorm:"size:36;primaryKey" json:"-"`
|
||||
Purpose string `gorm:"size:16;primaryKey" json:"-"`
|
||||
Ciphertext []byte `gorm:"type:bytea;not null" json:"-"`
|
||||
KeyVersion string `gorm:"size:16;not null" json:"-"`
|
||||
CreatedAt time.Time `json:"-"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
}
|
||||
|
||||
func (DeviceCredential) TableName() string { return "sense_device_credentials" }
|
||||
|
||||
type Vault struct{ key []byte }
|
||||
|
||||
func NewVaultFromEnvironment() (*Vault, error) {
|
||||
encoded := strings.TrimSpace(os.Getenv(EnvironmentKey))
|
||||
if encoded == "" {
|
||||
return nil, ErrKeyUnavailable
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil || len(decoded) != 32 {
|
||||
return nil, ErrKeyUnavailable
|
||||
}
|
||||
return NewVault(decoded)
|
||||
}
|
||||
|
||||
func NewVault(key []byte) (*Vault, error) {
|
||||
if len(key) != 32 {
|
||||
return nil, ErrKeyUnavailable
|
||||
}
|
||||
copyOfKey := append([]byte(nil), key...)
|
||||
return &Vault{key: copyOfKey}, nil
|
||||
}
|
||||
|
||||
func (v *Vault) Encrypt(deviceID, purpose, username, password string) ([]byte, error) {
|
||||
if err := validateScope(deviceID, purpose); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plaintext, err := json.Marshal(struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}{Username: username, Password: password})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode credential: %w", err)
|
||||
}
|
||||
block, err := aes.NewCipher(v.key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("initialize credential cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("initialize credential gcm: %w", err)
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, fmt.Errorf("generate credential nonce: %w", err)
|
||||
}
|
||||
return gcm.Seal(nonce, nonce, plaintext, associatedData(deviceID, purpose)), nil
|
||||
}
|
||||
|
||||
// Decrypt is an internal adapter boundary. It is intentionally not exposed by
|
||||
// any Sense HTTP handler and must only be used for the matching device/purpose.
|
||||
func (v *Vault) Decrypt(deviceID, purpose string, ciphertext []byte) (string, string, error) {
|
||||
if err := validateScope(deviceID, purpose); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
block, err := aes.NewCipher(v.key)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("initialize credential cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("initialize credential gcm: %w", err)
|
||||
}
|
||||
if len(ciphertext) < gcm.NonceSize() {
|
||||
return "", "", errors.New("invalid credential ciphertext")
|
||||
}
|
||||
plaintext, err := gcm.Open(nil, ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():], associatedData(deviceID, purpose))
|
||||
if err != nil {
|
||||
return "", "", errors.New("credential ciphertext does not match device purpose")
|
||||
}
|
||||
var value struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err = json.Unmarshal(plaintext, &value); err != nil {
|
||||
return "", "", errors.New("invalid credential payload")
|
||||
}
|
||||
return value.Username, value.Password, nil
|
||||
}
|
||||
|
||||
func Version() string { return keyVersion }
|
||||
|
||||
func validateScope(deviceID, purpose string) error {
|
||||
if strings.TrimSpace(deviceID) == "" {
|
||||
return errors.New("device id is required")
|
||||
}
|
||||
if purpose != PurposeONVIF && purpose != PurposeRTSP {
|
||||
return errors.New("unsupported credential purpose")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func associatedData(deviceID, purpose string) []byte {
|
||||
return []byte("sense-device-credential-v1\x00" + deviceID + "\x00" + purpose)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVaultSeparatesDeviceAndPurpose(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vault, err := NewVault(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext, err := vault.Encrypt("device-a", PurposeONVIF, "synthetic-user", "synthetic-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
username, password, err := vault.Decrypt("device-a", PurposeONVIF, ciphertext)
|
||||
if err != nil || username != "synthetic-user" || password != "synthetic-password" {
|
||||
t.Fatalf("credential round trip failed: username=%q err=%v", username, err)
|
||||
}
|
||||
if _, _, err = vault.Decrypt("device-a", PurposeRTSP, ciphertext); err == nil {
|
||||
t.Fatal("credential ciphertext was reusable for another purpose")
|
||||
}
|
||||
if _, _, err = vault.Decrypt("device-b", PurposeONVIF, ciphertext); err == nil {
|
||||
t.Fatal("credential ciphertext was reusable for another device")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultRejectsInvalidKeyLength(t *testing.T) {
|
||||
if _, err := NewVault(make([]byte, 16)); err == nil {
|
||||
t.Fatal("short credential key accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"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"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||
deviceService "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
|
||||
)
|
||||
|
||||
type Device struct{ api.Api }
|
||||
|
||||
func (e Device) GetPage(c *gin.Context) {
|
||||
service := deviceService.Device{}
|
||||
req := dto.PageReq{}
|
||||
if err := e.MakeContext(c).MakeOrm().Bind(&req).MakeService(&service.Service).Errors; err != nil {
|
||||
e.Error(http.StatusBadRequest, err, "查询条件格式不正确")
|
||||
return
|
||||
}
|
||||
list := make([]dto.DeviceResponse, 0)
|
||||
var count int64
|
||||
if err := service.GetPage(&req, &list, &count); err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "设备列表查询失败")
|
||||
return
|
||||
}
|
||||
e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "查询成功")
|
||||
}
|
||||
|
||||
func (e Device) Get(c *gin.Context) {
|
||||
service := deviceService.Device{}
|
||||
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "服务初始化失败")
|
||||
return
|
||||
}
|
||||
var response dto.DeviceResponse
|
||||
if err := service.Get(c.Param("id"), &response); err != nil {
|
||||
e.writeServiceError(err)
|
||||
return
|
||||
}
|
||||
e.OK(response, "查询成功")
|
||||
}
|
||||
|
||||
func (e Device) Insert(c *gin.Context) {
|
||||
service := deviceService.Device{}
|
||||
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "服务初始化失败")
|
||||
return
|
||||
}
|
||||
req := dto.CreateReq{}
|
||||
if err := bindStrictJSON(c, &req); err != nil {
|
||||
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
req.CreateBy = user.GetUserId(c)
|
||||
var response dto.DeviceResponse
|
||||
if err := service.Insert(&req, &response); err != nil {
|
||||
e.writeServiceError(err)
|
||||
return
|
||||
}
|
||||
e.OK(response, "设备创建成功")
|
||||
}
|
||||
|
||||
func (e Device) Update(c *gin.Context) {
|
||||
service := deviceService.Device{}
|
||||
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "服务初始化失败")
|
||||
return
|
||||
}
|
||||
req := dto.UpdateReq{ID: c.Param("id"), UpdateBy: user.GetUserId(c)}
|
||||
if err := bindStrictJSON(c, &req); err != nil {
|
||||
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
var response dto.DeviceResponse
|
||||
if err := service.Update(&req, &response); err != nil {
|
||||
e.writeServiceError(err)
|
||||
return
|
||||
}
|
||||
e.OK(response, "设备更新成功")
|
||||
}
|
||||
|
||||
func (e Device) Disable(c *gin.Context) {
|
||||
service := deviceService.Device{}
|
||||
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "服务初始化失败")
|
||||
return
|
||||
}
|
||||
req := dto.DisableReq{ID: c.Param("id"), UpdateBy: user.GetUserId(c)}
|
||||
if err := bindStrictJSON(c, &req); err != nil {
|
||||
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
var response dto.DeviceResponse
|
||||
if err := service.Disable(&req, &response); err != nil {
|
||||
e.writeServiceError(err)
|
||||
return
|
||||
}
|
||||
e.OK(response, "设备已停用")
|
||||
}
|
||||
|
||||
func (e Device) UpdateCredentials(c *gin.Context) {
|
||||
service := deviceService.Device{}
|
||||
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "服务初始化失败")
|
||||
return
|
||||
}
|
||||
req := dto.CredentialUpdateReq{ID: c.Param("id"), UpdateBy: user.GetUserId(c)}
|
||||
if err := bindStrictJSON(c, &req); err != nil {
|
||||
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
var response dto.DeviceResponse
|
||||
if err := service.UpdateCredentials(&req, &response); err != nil {
|
||||
e.writeServiceError(err)
|
||||
return
|
||||
}
|
||||
e.OK(response, "凭据已安全更新,已请求重新验证")
|
||||
}
|
||||
|
||||
func (e Device) writeServiceError(err error) {
|
||||
switch {
|
||||
case errors.Is(err, deviceService.ErrInvalidDevice):
|
||||
e.Error(http.StatusBadRequest, err, "设备信息不符合要求")
|
||||
case errors.Is(err, deviceService.ErrDeviceNotFound):
|
||||
e.Error(http.StatusNotFound, err, err.Error())
|
||||
case errors.Is(err, deviceService.ErrVersionConflict):
|
||||
e.Error(http.StatusConflict, err, err.Error())
|
||||
case errors.Is(err, credential.ErrKeyUnavailable):
|
||||
e.Error(http.StatusServiceUnavailable, err, "摄像头凭据安全配置不可用")
|
||||
default:
|
||||
e.Error(http.StatusInternalServerError, err, "设备操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
func bindStrictJSON(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, 64<<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,22 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestBindStrictJSONRejectsUnknownDeviceField(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest("POST", "/api/v1/devices", strings.NewReader(`{"name":"测试设备","location":"东门","modality":"video","capabilities":["video"],"password":"must-not-be-accepted"}`))
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
var req dto.CreateReq
|
||||
if err := bindStrictJSON(ctx, &req); err == nil {
|
||||
t.Fatal("unknown credential-like field was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
const (
|
||||
ModalityVideo = "video"
|
||||
StatusPending = "pending"
|
||||
StatusDisabled = "disabled"
|
||||
AdapterReady = "ready"
|
||||
AdapterNotReady = "adapter_not_ready"
|
||||
)
|
||||
|
||||
type Device struct {
|
||||
ID string `gorm:"size:36;primaryKey" json:"id"`
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
Location string `gorm:"size:255;not null;default:''" json:"location"`
|
||||
Modality string `gorm:"size:32;not null;index" json:"modality"`
|
||||
CapabilitiesJSON string `gorm:"column:capabilities;type:jsonb;not null;default:'[]'" json:"-"`
|
||||
Status string `gorm:"size:32;not null;index" json:"status"`
|
||||
AdapterStatus string `gorm:"size:32;not null" json:"adapterStatus"`
|
||||
RTSPSameAsONVIF bool `gorm:"not null;default:true" json:"rtspCredentialSameAsOnvif"`
|
||||
CredentialUpdatedAt *time.Time `json:"credentialUpdatedAt,omitempty"`
|
||||
RetryRequestedAt *time.Time `json:"retryRequestedAt,omitempty"`
|
||||
Version int64 `gorm:"not null;default:1" json:"version"`
|
||||
common.ControlBy
|
||||
common.ModelTime
|
||||
}
|
||||
|
||||
func (Device) TableName() string { return "sense_devices" }
|
||||
@@ -0,0 +1,314 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDeviceNotFound = errors.New("设备不存在")
|
||||
ErrVersionConflict = errors.New("设备已被其他用户更新,请刷新后重试")
|
||||
ErrInvalidDevice = errors.New("设备信息不符合要求")
|
||||
)
|
||||
|
||||
var supportedValues = map[string]struct{}{
|
||||
"video": {}, "radar": {}, "contact": {}, "button": {}, "wearable": {}, "other": {},
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
coreService.Service
|
||||
VaultFactory func() (*credential.Vault, error)
|
||||
}
|
||||
|
||||
type credentialPresence struct {
|
||||
DeviceID string
|
||||
Purpose string
|
||||
}
|
||||
|
||||
func (e *Device) GetPage(req *dto.PageReq, list *[]dto.DeviceResponse, count *int64) error {
|
||||
query := e.Orm.Model(&models.Device{})
|
||||
if keyword := strings.TrimSpace(req.Keyword); keyword != "" {
|
||||
pattern := "%" + strings.ToLower(keyword) + "%"
|
||||
query = query.Where("LOWER(name) LIKE ? OR LOWER(location) LIKE ?", pattern, pattern)
|
||||
}
|
||||
if req.Modality != "" {
|
||||
query = query.Where("modality = ?", req.Modality)
|
||||
}
|
||||
if req.Status != "" {
|
||||
query = query.Where("status = ?", req.Status)
|
||||
}
|
||||
if err := query.Count(count).Error; err != nil {
|
||||
return fmt.Errorf("count devices: %w", err)
|
||||
}
|
||||
pageSize := req.GetPageSize()
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
req.PageSize = pageSize
|
||||
var devices []models.Device
|
||||
if err := query.Order("created_at DESC").Limit(pageSize).Offset((req.GetPageIndex() - 1) * pageSize).Find(&devices).Error; err != nil {
|
||||
return fmt.Errorf("list devices: %w", err)
|
||||
}
|
||||
responses, err := e.responses(devices)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*list = responses
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Device) Get(id string, response *dto.DeviceResponse) error {
|
||||
var model models.Device
|
||||
if err := e.Orm.First(&model, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrDeviceNotFound
|
||||
}
|
||||
return fmt.Errorf("get device: %w", err)
|
||||
}
|
||||
responses, err := e.responses([]models.Device{model})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*response = responses[0]
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Device) Insert(req *dto.CreateReq, response *dto.DeviceResponse) error {
|
||||
name, location, modality, capabilities, err := normalizeDevice(req.Name, req.Location, req.Modality, req.Capabilities)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, _ := json.Marshal(capabilities)
|
||||
adapterStatus := models.AdapterNotReady
|
||||
if modality == models.ModalityVideo {
|
||||
adapterStatus = models.AdapterReady
|
||||
}
|
||||
model := models.Device{
|
||||
ID: uuid.NewString(),
|
||||
Name: name,
|
||||
Location: location,
|
||||
Modality: modality,
|
||||
CapabilitiesJSON: string(encoded),
|
||||
Status: models.StatusPending,
|
||||
AdapterStatus: adapterStatus,
|
||||
RTSPSameAsONVIF: true,
|
||||
Version: 1,
|
||||
}
|
||||
model.CreateBy = req.CreateBy
|
||||
model.UpdateBy = req.CreateBy
|
||||
if err = e.Orm.Create(&model).Error; err != nil {
|
||||
return fmt.Errorf("create device: %w", err)
|
||||
}
|
||||
return e.Get(model.ID, response)
|
||||
}
|
||||
|
||||
func (e *Device) Update(req *dto.UpdateReq, response *dto.DeviceResponse) error {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" || len([]rune(name)) > 128 || len([]rune(req.Location)) > 255 || req.Version < 1 {
|
||||
return ErrInvalidDevice
|
||||
}
|
||||
capabilities, err := normalizeCapabilities(req.Capabilities)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, _ := json.Marshal(capabilities)
|
||||
updates := map[string]any{
|
||||
"name": name, "location": strings.TrimSpace(req.Location), "capabilities": string(encoded),
|
||||
"version": req.Version + 1, "update_by": req.UpdateBy, "updated_at": time.Now().UTC(),
|
||||
}
|
||||
result := e.Orm.Model(&models.Device{}).Where("id = ? AND version = ?", req.ID, req.Version).Updates(updates)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("update device: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return e.notFoundOrConflict(req.ID)
|
||||
}
|
||||
return e.Get(req.ID, response)
|
||||
}
|
||||
|
||||
func (e *Device) Disable(req *dto.DisableReq, response *dto.DeviceResponse) error {
|
||||
if req.Version < 1 {
|
||||
return ErrInvalidDevice
|
||||
}
|
||||
result := e.Orm.Model(&models.Device{}).Where("id = ? AND version = ?", req.ID, req.Version).Updates(map[string]any{
|
||||
"status": models.StatusDisabled, "version": req.Version + 1,
|
||||
"update_by": req.UpdateBy, "updated_at": time.Now().UTC(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("disable device: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return e.notFoundOrConflict(req.ID)
|
||||
}
|
||||
return e.Get(req.ID, response)
|
||||
}
|
||||
|
||||
func (e *Device) UpdateCredentials(req *dto.CredentialUpdateReq, response *dto.DeviceResponse) error {
|
||||
if req.Version < 1 || strings.TrimSpace(req.ONVIFUsername) == "" || req.ONVIFPassword == "" {
|
||||
return ErrInvalidDevice
|
||||
}
|
||||
var target models.Device
|
||||
if err := e.Orm.Select("id", "modality").First(&target, "id = ?", req.ID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrDeviceNotFound
|
||||
}
|
||||
return fmt.Errorf("read credential target: %w", err)
|
||||
}
|
||||
if target.Modality != models.ModalityVideo {
|
||||
return ErrInvalidDevice
|
||||
}
|
||||
if len(req.ONVIFUsername) > 255 || len(req.ONVIFPassword) > 1024 {
|
||||
return ErrInvalidDevice
|
||||
}
|
||||
rtspUsername, rtspPassword := req.RTSPUsername, req.RTSPPassword
|
||||
if req.RTSPSameAsONVIF {
|
||||
rtspUsername, rtspPassword = req.ONVIFUsername, req.ONVIFPassword
|
||||
} else if strings.TrimSpace(rtspUsername) == "" || rtspPassword == "" || len(rtspUsername) > 255 || len(rtspPassword) > 1024 {
|
||||
return ErrInvalidDevice
|
||||
}
|
||||
factory := e.VaultFactory
|
||||
if factory == nil {
|
||||
factory = credential.NewVaultFromEnvironment
|
||||
}
|
||||
vault, err := factory()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
onvifCiphertext, err := vault.Encrypt(req.ID, credential.PurposeONVIF, strings.TrimSpace(req.ONVIFUsername), req.ONVIFPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rtspCiphertext, err := vault.Encrypt(req.ID, credential.PurposeRTSP, strings.TrimSpace(rtspUsername), rtspPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
err = e.Orm.Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&models.Device{}).Where("id = ? AND version = ?", req.ID, req.Version).Updates(map[string]any{
|
||||
"rtsp_same_as_onvif": req.RTSPSameAsONVIF,
|
||||
"credential_updated_at": now, "retry_requested_at": now,
|
||||
"version": req.Version + 1, "update_by": req.UpdateBy, "updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return e.notFoundOrConflictWith(tx, req.ID)
|
||||
}
|
||||
rows := []credential.DeviceCredential{
|
||||
{DeviceID: req.ID, Purpose: credential.PurposeONVIF, Ciphertext: onvifCiphertext, KeyVersion: credential.Version()},
|
||||
{DeviceID: req.ID, Purpose: credential.PurposeRTSP, Ciphertext: rtspCiphertext, KeyVersion: credential.Version()},
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "device_id"}, {Name: "purpose"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"ciphertext", "key_version", "updated_at"}),
|
||||
}).Create(&rows).Error
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrDeviceNotFound) || errors.Is(err, ErrVersionConflict) {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("update device credential: %w", err)
|
||||
}
|
||||
return e.Get(req.ID, response)
|
||||
}
|
||||
|
||||
func (e *Device) responses(devices []models.Device) ([]dto.DeviceResponse, error) {
|
||||
result := make([]dto.DeviceResponse, 0, len(devices))
|
||||
if len(devices) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
ids := make([]string, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
ids = append(ids, device.ID)
|
||||
}
|
||||
var presence []credentialPresence
|
||||
if err := e.Orm.Model(&credential.DeviceCredential{}).Select("device_id", "purpose").Where("device_id IN ?", ids).Find(&presence).Error; err != nil {
|
||||
return nil, fmt.Errorf("read credential status: %w", err)
|
||||
}
|
||||
configured := make(map[string]map[string]bool, len(devices))
|
||||
for _, row := range presence {
|
||||
if configured[row.DeviceID] == nil {
|
||||
configured[row.DeviceID] = map[string]bool{}
|
||||
}
|
||||
configured[row.DeviceID][row.Purpose] = true
|
||||
}
|
||||
for _, device := range devices {
|
||||
var capabilities []string
|
||||
if err := json.Unmarshal([]byte(device.CapabilitiesJSON), &capabilities); err != nil {
|
||||
return nil, fmt.Errorf("decode device capabilities: %w", err)
|
||||
}
|
||||
result = append(result, dto.DeviceResponse{
|
||||
ID: device.ID, Name: device.Name, Location: device.Location, Modality: device.Modality,
|
||||
Capabilities: capabilities, Status: device.Status, AdapterStatus: device.AdapterStatus,
|
||||
ONVIFCredentialConfigured: configured[device.ID][credential.PurposeONVIF],
|
||||
RTSPCredentialConfigured: configured[device.ID][credential.PurposeRTSP],
|
||||
RTSPCredentialSameAsONVIF: device.RTSPSameAsONVIF,
|
||||
RetryPending: device.RetryRequestedAt != nil, Version: device.Version,
|
||||
CreatedAt: device.CreatedAt, UpdatedAt: device.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *Device) notFoundOrConflict(id string) error { return e.notFoundOrConflictWith(e.Orm, id) }
|
||||
|
||||
func (e *Device) notFoundOrConflictWith(db *gorm.DB, id string) error {
|
||||
var count int64
|
||||
if err := db.Model(&models.Device{}).Where("id = ?", id).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return ErrDeviceNotFound
|
||||
}
|
||||
return ErrVersionConflict
|
||||
}
|
||||
|
||||
func normalizeDevice(name, location, modality string, capabilities []string) (string, string, string, []string, error) {
|
||||
name, location, modality = strings.TrimSpace(name), strings.TrimSpace(location), strings.TrimSpace(modality)
|
||||
if modality == "" {
|
||||
modality = models.ModalityVideo
|
||||
}
|
||||
if name == "" || len([]rune(name)) > 128 || len([]rune(location)) > 255 {
|
||||
return "", "", "", nil, ErrInvalidDevice
|
||||
}
|
||||
if _, ok := supportedValues[modality]; !ok {
|
||||
return "", "", "", nil, ErrInvalidDevice
|
||||
}
|
||||
if len(capabilities) == 0 {
|
||||
capabilities = []string{modality}
|
||||
}
|
||||
normalized, err := normalizeCapabilities(capabilities)
|
||||
return name, location, modality, normalized, err
|
||||
}
|
||||
|
||||
func normalizeCapabilities(values []string) ([]string, error) {
|
||||
if len(values) == 0 || len(values) > 16 {
|
||||
return nil, ErrInvalidDevice
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if _, ok := supportedValues[value]; !ok {
|
||||
return nil, ErrInvalidDevice
|
||||
}
|
||||
if !seen[value] {
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
|
||||
)
|
||||
|
||||
func testDeviceService(t *testing.T) (*Device, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&models.Device{}, &credential.DeviceCredential{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
if _, err = rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vault, err := credential.NewVault(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := &Device{Service: coreService.Service{Orm: db}, VaultFactory: func() (*credential.Vault, error) { return vault, nil }}
|
||||
return service, db
|
||||
}
|
||||
|
||||
func TestDeviceLifecycleUsesAllowlistedFieldsAndOptimisticVersion(t *testing.T) {
|
||||
service, _ := testDeviceService(t)
|
||||
var created dto.DeviceResponse
|
||||
err := service.Insert(&dto.CreateReq{Name: "东门摄像机", Location: "教学楼一楼东门", Modality: "video", Capabilities: []string{"video"}, CreateBy: 7}, &created)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.Name != "东门摄像机" || created.Location != "教学楼一楼东门" || created.Version != 1 || created.AdapterStatus != models.AdapterReady {
|
||||
t.Fatalf("unexpected device: %#v", created)
|
||||
}
|
||||
var updated dto.DeviceResponse
|
||||
err = service.Update(&dto.UpdateReq{ID: created.ID, Name: "东门主摄像机", Location: "教学楼一楼东门", Capabilities: []string{"video"}, Version: 1, UpdateBy: 8}, &updated)
|
||||
if err != nil || updated.Version != 2 || updated.Name != "东门主摄像机" {
|
||||
t.Fatalf("update failed: device=%#v err=%v", updated, err)
|
||||
}
|
||||
err = service.Update(&dto.UpdateReq{ID: created.ID, Name: "过期写入", Capabilities: []string{"video"}, Version: 1}, &updated)
|
||||
if err != ErrVersionConflict {
|
||||
t.Fatalf("stale update error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialIsPurposeSeparatedNeverReturnedAndRequestsRetry(t *testing.T) {
|
||||
service, db := testDeviceService(t)
|
||||
var created dto.DeviceResponse
|
||||
if err := service.Insert(&dto.CreateReq{Name: "测试摄像机", Modality: "video", Capabilities: []string{"video"}}, &created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var updated dto.DeviceResponse
|
||||
if err := service.UpdateCredentials(&dto.CredentialUpdateReq{
|
||||
ID: created.ID, ONVIFUsername: "synthetic-onvif-user", ONVIFPassword: "synthetic-onvif-password",
|
||||
RTSPSameAsONVIF: false, RTSPUsername: "synthetic-rtsp-user", RTSPPassword: "synthetic-rtsp-password", Version: created.Version,
|
||||
}, &updated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !updated.ONVIFCredentialConfigured || !updated.RTSPCredentialConfigured || !updated.RetryPending || updated.Version != 2 {
|
||||
t.Fatalf("credential status not reflected: %#v", updated)
|
||||
}
|
||||
encoded, err := json.Marshal(updated)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, secretPart := range []string{"synthetic-onvif", "synthetic-rtsp", "ciphertext", "password", "username"} {
|
||||
if strings.Contains(strings.ToLower(string(encoded)), secretPart) {
|
||||
t.Fatalf("response leaked credential material: %s", encoded)
|
||||
}
|
||||
}
|
||||
var stored []credential.DeviceCredential
|
||||
if err = db.Order("purpose").Find(&stored).Error; err != nil || len(stored) != 2 {
|
||||
t.Fatalf("stored credentials=%d err=%v", len(stored), err)
|
||||
}
|
||||
if string(stored[0].Ciphertext) == string(stored[1].Ciphertext) {
|
||||
t.Fatal("ONVIF and RTSP credentials were not purpose-separated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedModalityIsExplicitlyNotReadyAndPaginationExceedsDefaultQuota(t *testing.T) {
|
||||
service, _ := testDeviceService(t)
|
||||
var radar dto.DeviceResponse
|
||||
for index := 0; index < 20; index++ {
|
||||
var response dto.DeviceResponse
|
||||
if err := service.Insert(&dto.CreateReq{Name: "雷达" + strings.Repeat("号", index+1), Modality: "radar", Capabilities: []string{"radar"}}, &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.AdapterStatus != models.AdapterNotReady {
|
||||
t.Fatalf("adapter status=%s", response.AdapterStatus)
|
||||
}
|
||||
radar = response
|
||||
}
|
||||
var credentialResponse dto.DeviceResponse
|
||||
if err := service.UpdateCredentials(&dto.CredentialUpdateReq{ID: radar.ID, ONVIFUsername: "synthetic", ONVIFPassword: "synthetic", RTSPSameAsONVIF: true, Version: radar.Version}, &credentialResponse); err != ErrInvalidDevice {
|
||||
t.Fatalf("non-video credential update error=%v", err)
|
||||
}
|
||||
request := &dto.PageReq{}
|
||||
request.PageIndex, request.PageSize = 1, 20
|
||||
var list []dto.DeviceResponse
|
||||
var count int64
|
||||
if err := service.GetPage(request, &list, &count); err != nil || count != 20 || len(list) != 20 {
|
||||
t.Fatalf("count=%d items=%d err=%v", count, len(list), err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
commonDto "git.ilapage.cn/ila/yovision/Sense/server/common/dto"
|
||||
)
|
||||
|
||||
type PageReq struct {
|
||||
commonDto.Pagination `search:"-"`
|
||||
Keyword string `form:"keyword"`
|
||||
Modality string `form:"modality"`
|
||||
Status string `form:"status"`
|
||||
}
|
||||
|
||||
type CreateReq struct {
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Modality string `json:"modality"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
CreateBy int `json:"-"`
|
||||
}
|
||||
|
||||
type UpdateReq struct {
|
||||
ID string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Version int64 `json:"version"`
|
||||
UpdateBy int `json:"-"`
|
||||
}
|
||||
|
||||
type DisableReq struct {
|
||||
ID string `json:"-"`
|
||||
Version int64 `json:"version"`
|
||||
UpdateBy int `json:"-"`
|
||||
}
|
||||
|
||||
type CredentialUpdateReq struct {
|
||||
ID string `json:"-"`
|
||||
ONVIFUsername string `json:"onvifUsername"`
|
||||
ONVIFPassword string `json:"onvifPassword"`
|
||||
RTSPSameAsONVIF bool `json:"rtspSameAsOnvif"`
|
||||
RTSPUsername string `json:"rtspUsername"`
|
||||
RTSPPassword string `json:"rtspPassword"`
|
||||
Version int64 `json:"version"`
|
||||
UpdateBy int `json:"-"`
|
||||
}
|
||||
|
||||
type DeviceResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Modality string `json:"modality"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Status string `json:"status"`
|
||||
AdapterStatus string `json:"adapterStatus"`
|
||||
ONVIFCredentialConfigured bool `json:"onvifCredentialConfigured"`
|
||||
RTSPCredentialConfigured bool `json:"rtspCredentialConfigured"`
|
||||
RTSPCredentialSameAsONVIF bool `json:"rtspCredentialSameAsOnvif"`
|
||||
RetryPending bool `json:"retryPending"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
|
||||
"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"
|
||||
)
|
||||
|
||||
type deviceCasbinRule struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
Ptype string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V0 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V1 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V2 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V3 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V4 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V5 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
}
|
||||
|
||||
func (deviceCasbinRule) TableName() string { return "casbin_rule" }
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseDeviceLedger)
|
||||
}
|
||||
|
||||
func migrateSenseDeviceLedger(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&deviceModels.Device{}, &credential.DeviceCredential{}, &deviceCasbinRule{}); err != nil {
|
||||
return err
|
||||
}
|
||||
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{
|
||||
MenuName: "SenseDeviceManage", Title: "设备管理", Icon: "monitor", Path: "/sense/devices",
|
||||
MenuType: "C", Permission: "sense:device:list", ParentId: 0, Component: "/sense/device/index",
|
||||
Sort: 5, Visible: "0", IsFrame: "1",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buttons := make([]migrationModels.SysMenu, 0, 4)
|
||||
for index, definition := range []struct{ name, title, action, permission string }{
|
||||
{"SenseDeviceAdd", "新增设备", "POST", "sense:device:add"},
|
||||
{"SenseDeviceEdit", "编辑设备", "PUT", "sense:device:edit"},
|
||||
{"SenseDeviceDisable", "停用设备", "PUT", "sense:device:disable"},
|
||||
{"SenseDeviceCredential", "更新设备凭据", "PUT", "sense:device:credential"},
|
||||
} {
|
||||
button, err := ensureDeviceMenu(tx, migrationModels.SysMenu{
|
||||
MenuName: definition.name, Title: definition.title, MenuType: "F", Action: definition.action,
|
||||
Permission: definition.permission, ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d", page.MenuId),
|
||||
Sort: index + 1, Visible: "1", IsFrame: "1",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buttons = append(buttons, button)
|
||||
}
|
||||
if err = attachDeviceRole(tx, "implementation_operator", append([]migrationModels.SysMenu{page}, buttons...)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = attachDeviceRole(tx, "site_admin", append([]migrationModels.SysMenu{page}, buttons...)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = attachDeviceRole(tx, "viewer", []migrationModels.SysMenu{page}); err != nil {
|
||||
return err
|
||||
}
|
||||
readPolicies := [][2]string{{"/api/v1/devices", "GET"}, {"/api/v1/devices/:id", "GET"}}
|
||||
writePolicies := [][2]string{{"/api/v1/devices", "POST"}, {"/api/v1/devices/:id", "PUT"}, {"/api/v1/devices/:id/disable", "PUT"}, {"/api/v1/devices/:id/credentials", "PUT"}}
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
policies := append([][2]string{}, readPolicies...)
|
||||
if role != "viewer" {
|
||||
policies = append(policies, writePolicies...)
|
||||
}
|
||||
for _, policy := range policies {
|
||||
rule := deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}
|
||||
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rule).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func ensureDeviceMenu(tx *gorm.DB, desired migrationModels.SysMenu) (migrationModels.SysMenu, error) {
|
||||
var menu migrationModels.SysMenu
|
||||
err := tx.Where("menu_name = ?", desired.MenuName).First(&menu).Error
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return menu, err
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
menu = desired
|
||||
if err = tx.Create(&menu).Error; err != nil {
|
||||
return menu, err
|
||||
}
|
||||
}
|
||||
paths := desired.Paths
|
||||
if desired.ParentId == 0 {
|
||||
paths = fmt.Sprintf("/0/%d", menu.MenuId)
|
||||
}
|
||||
err = tx.Model(&menu).Updates(map[string]any{
|
||||
"title": desired.Title, "icon": desired.Icon, "path": desired.Path, "paths": paths,
|
||||
"menu_type": desired.MenuType, "action": desired.Action, "permission": desired.Permission,
|
||||
"parent_id": desired.ParentId, "component": desired.Component, "sort": desired.Sort,
|
||||
"visible": desired.Visible, "is_frame": desired.IsFrame,
|
||||
}).Error
|
||||
return menu, err
|
||||
}
|
||||
|
||||
func attachDeviceRole(tx *gorm.DB, roleKey string, menus []migrationModels.SysMenu) error {
|
||||
var role migrationModels.SysRole
|
||||
if err := tx.Where("role_key = ?", roleKey).First(&role).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&role).Association("SysMenu").Append(menus)
|
||||
}
|
||||
@@ -136,7 +136,8 @@ func redactAuditValue(value interface{}) {
|
||||
func isSensitiveAuditKey(key string) bool {
|
||||
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
|
||||
switch normalized {
|
||||
case "password", "oldpassword", "newpassword", "token", "refreshtoken", "secret", "code", "uuid":
|
||||
case "password", "oldpassword", "newpassword", "onvifusername", "onvifpassword", "rtspusername", "rtsppassword",
|
||||
"token", "refreshtoken", "secret", "code", "uuid":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -18,6 +18,18 @@ func TestSanitizeAuditJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeAuditJSONHidesCameraCredentialFields(t *testing.T) {
|
||||
value := sanitizeAuditJSON(`{"onvifUsername":"camera-user","onvifPassword":"camera-password","rtspUsername":"stream-user","rtspPassword":"stream-password","name":"东门摄像机"}`)
|
||||
for _, forbidden := range []string{"camera-user", "camera-password", "stream-user", "stream-password"} {
|
||||
if strings.Contains(value, forbidden) {
|
||||
t.Fatalf("credential value leaked in audit JSON: %s", value)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(value, "东门摄像机") {
|
||||
t.Fatalf("non-sensitive device field was unexpectedly removed: %s", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeAuditJSONRejectsUnstructuredBodies(t *testing.T) {
|
||||
if got := sanitizeAuditJSON("password=secret-value"); strings.Contains(got, "secret-value") {
|
||||
t.Fatalf("unstructured body leaked: %s", got)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Required only when creating or updating camera credentials.
|
||||
# Set outside the repository to a Base64-encoded random 32-byte key.
|
||||
SENSE_CREDENTIAL_KEY=
|
||||
@@ -0,0 +1,25 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listDevices(query) {
|
||||
return request({ url: '/api/v1/devices', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function getDevice(id) {
|
||||
return request({ url: `/api/v1/devices/${id}`, method: 'get' })
|
||||
}
|
||||
|
||||
export function addDevice(data) {
|
||||
return request({ url: '/api/v1/devices', method: 'post', data })
|
||||
}
|
||||
|
||||
export function updateDevice(id, data) {
|
||||
return request({ url: `/api/v1/devices/${id}`, method: 'put', data })
|
||||
}
|
||||
|
||||
export function disableDevice(id, data) {
|
||||
return request({ url: `/api/v1/devices/${id}/disable`, method: 'put', data })
|
||||
}
|
||||
|
||||
export function updateDeviceCredentials(id, data) {
|
||||
return request({ url: `/api/v1/devices/${id}/credentials`, method: 'put', data })
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export function createDevicePayload(form) {
|
||||
return {
|
||||
name: form.name,
|
||||
location: form.location,
|
||||
modality: form.modality,
|
||||
capabilities: [...form.capabilities]
|
||||
}
|
||||
}
|
||||
|
||||
export function updateDevicePayload(form) {
|
||||
return {
|
||||
name: form.name,
|
||||
location: form.location,
|
||||
capabilities: [...form.capabilities],
|
||||
version: form.version
|
||||
}
|
||||
}
|
||||
|
||||
export function credentialPayload(form) {
|
||||
return {
|
||||
onvifUsername: form.onvifUsername,
|
||||
onvifPassword: form.onvifPassword,
|
||||
rtspSameAsOnvif: form.rtspSameAsOnvif,
|
||||
rtspUsername: form.rtspSameAsOnvif ? '' : form.rtspUsername,
|
||||
rtspPassword: form.rtspSameAsOnvif ? '' : form.rtspPassword,
|
||||
version: form.version
|
||||
}
|
||||
}
|
||||
|
||||
export function disableDevicePayload(version) {
|
||||
return { version }
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<el-form ref="queryForm" :model="queryParams" :inline="true" class="search-form">
|
||||
<el-form-item label="设备信息" prop="keyword">
|
||||
<el-input v-model="queryParams.keyword" placeholder="名称或安装位置" clearable size="small" @keyup.enter="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="设备类型" prop="modality">
|
||||
<el-select v-model="queryParams.modality" placeholder="全部类型" clearable size="small">
|
||||
<el-option v-for="item in modalityOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="queryParams.status" placeholder="全部状态" clearable size="small">
|
||||
<el-option label="待接入" value="pending" />
|
||||
<el-option label="已停用" value="disabled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" size="small" :icon="Search" @click="handleQuery">搜索</el-button>
|
||||
<el-button size="small" :icon="Refresh" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar mb8">
|
||||
<el-button v-permisaction="['sense:device:add']" type="primary" size="small" :icon="Plus" @click="handleAdd">新增设备</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="deviceList" border stripe>
|
||||
<el-table-column label="设备名称" min-width="150" prop="name" />
|
||||
<el-table-column label="安装位置" min-width="180" prop="location" show-overflow-tooltip />
|
||||
<el-table-column label="设备类型" width="110" align="center">
|
||||
<template #default="scope">{{ modalityLabel(scope.row.modality) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="能力" min-width="130">
|
||||
<template #default="scope">
|
||||
<el-tag v-for="capability in scope.row.capabilities" :key="capability" size="small" class="capability-tag">{{ modalityLabel(capability) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="接入能力" width="130" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.adapterStatus === 'ready' ? 'success' : 'warning'">
|
||||
{{ scope.row.adapterStatus === 'ready' ? '可接入' : '适配器未就绪' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="凭据" width="120" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.onvifCredentialConfigured ? 'success' : 'info'">
|
||||
{{ scope.row.onvifCredentialConfigured ? '已配置' : '未配置' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === 'disabled' ? 'info' : 'success'">{{ scope.row.status === 'disabled' ? '已停用' : '待接入' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="版本" width="80" align="center" prop="version" />
|
||||
<el-table-column label="更新时间" width="180" align="center">
|
||||
<template #default="scope">{{ parseTime(scope.row.updatedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="230" align="center" fixed="right" class-name="small-padding fixed-width">
|
||||
<template #default="scope">
|
||||
<el-button v-permisaction="['sense:device:edit']" type="primary" link size="small" :icon="Edit" @click="handleUpdate(scope.row)">编辑</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button v-permisaction="['sense:device:credential']" type="primary" link size="small" :icon="Key" @click="handleCredential(scope.row)">更新凭据</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button v-if="scope.row.status !== 'disabled'" v-permisaction="['sense:device:disable']" type="danger" link size="small" @click="handleDisable(scope.row)">停用</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total > 0" v-model:current-page="queryParams.pageIndex" v-model:page-size="queryParams.pageSize" :total="total" @pagination="getList" />
|
||||
|
||||
<el-dialog v-model="deviceOpen" :title="deviceTitle" width="620px" :close-on-click-modal="false">
|
||||
<el-form ref="deviceForm" :model="deviceForm" :rules="deviceRules" label-width="100px">
|
||||
<el-form-item label="设备名称" prop="name">
|
||||
<el-input v-model="deviceForm.name" maxlength="128" show-word-limit placeholder="例如:东门摄像机" />
|
||||
</el-form-item>
|
||||
<el-form-item label="安装位置" prop="location">
|
||||
<el-input v-model="deviceForm.location" maxlength="255" show-word-limit placeholder="例如:教学楼一楼东门" />
|
||||
</el-form-item>
|
||||
<el-form-item label="设备类型" prop="modality">
|
||||
<el-select v-model="deviceForm.modality" :disabled="Boolean(deviceForm.id)" @change="handleModalityChange">
|
||||
<el-option v-for="item in modalityOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<span v-if="deviceForm.modality !== 'video'" class="form-tip">该类型尚未提供接入适配器,将显示“适配器未就绪”。</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="设备能力" prop="capabilities">
|
||||
<el-checkbox-group v-model="deviceForm.capabilities">
|
||||
<el-checkbox v-for="item in modalityOptions" :key="item.value" :label="item.value">{{ item.label }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="submitDevice">确定</el-button>
|
||||
<el-button @click="deviceOpen = false">取消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="credentialOpen" title="更新摄像头凭据" width="620px" :close-on-click-modal="false" @closed="resetCredential">
|
||||
<el-alert title="凭据只可写入,保存后不会回显。更新成功会请求后续接入流程重新验证。" type="warning" :closable="false" show-icon class="credential-alert" />
|
||||
<el-form ref="credentialForm" :model="credentialForm" :rules="credentialRules" label-width="120px" autocomplete="off">
|
||||
<el-form-item label="ONVIF 用户名" prop="onvifUsername">
|
||||
<el-input v-model="credentialForm.onvifUsername" maxlength="255" autocomplete="off" />
|
||||
</el-form-item>
|
||||
<el-form-item label="ONVIF 密码" prop="onvifPassword">
|
||||
<el-input v-model="credentialForm.onvifPassword" type="password" show-password maxlength="1024" autocomplete="new-password" />
|
||||
</el-form-item>
|
||||
<el-form-item label="RTSP 凭据">
|
||||
<el-checkbox v-model="credentialForm.rtspSameAsOnvif">与 ONVIF 相同</el-checkbox>
|
||||
</el-form-item>
|
||||
<template v-if="!credentialForm.rtspSameAsOnvif">
|
||||
<el-form-item label="RTSP 用户名" prop="rtspUsername">
|
||||
<el-input v-model="credentialForm.rtspUsername" maxlength="255" autocomplete="off" />
|
||||
</el-form-item>
|
||||
<el-form-item label="RTSP 密码" prop="rtspPassword">
|
||||
<el-input v-model="credentialForm.rtspPassword" type="password" show-password maxlength="1024" autocomplete="new-password" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="submitCredential">安全保存</el-button>
|
||||
<el-button @click="credentialOpen = false">取消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Edit, Key, Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import {
|
||||
addDevice,
|
||||
disableDevice,
|
||||
getDevice,
|
||||
listDevices,
|
||||
updateDevice,
|
||||
updateDeviceCredentials
|
||||
} from '@/api/sense/device'
|
||||
import {
|
||||
createDevicePayload,
|
||||
credentialPayload,
|
||||
disableDevicePayload,
|
||||
updateDevicePayload
|
||||
} from './devicePayload'
|
||||
|
||||
export default {
|
||||
name: 'SenseDeviceManage',
|
||||
setup() {
|
||||
return { Edit, Key, Plus, Refresh, Search }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
total: 0,
|
||||
deviceList: [],
|
||||
deviceOpen: false,
|
||||
credentialOpen: false,
|
||||
deviceTitle: '',
|
||||
queryParams: { pageIndex: 1, pageSize: 10, keyword: '', modality: '', status: '' },
|
||||
modalityOptions: [
|
||||
{ value: 'video', label: '视频设备' },
|
||||
{ value: 'radar', label: '雷达' },
|
||||
{ value: 'contact', label: '门磁' },
|
||||
{ value: 'button', label: '按钮' },
|
||||
{ value: 'wearable', label: '穿戴设备' },
|
||||
{ value: 'other', label: '其他' }
|
||||
],
|
||||
deviceForm: {},
|
||||
credentialForm: {},
|
||||
deviceRules: {
|
||||
name: [{ required: true, message: '设备名称不能为空', trigger: 'blur' }],
|
||||
modality: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
|
||||
capabilities: [{ type: 'array', required: true, min: 1, message: '至少选择一项设备能力', trigger: 'change' }]
|
||||
},
|
||||
credentialRules: {
|
||||
onvifUsername: [{ required: true, message: 'ONVIF 用户名不能为空', trigger: 'blur' }],
|
||||
onvifPassword: [{ required: true, message: 'ONVIF 密码不能为空', trigger: 'blur' }],
|
||||
rtspUsername: [{ required: true, message: 'RTSP 用户名不能为空', trigger: 'blur' }],
|
||||
rtspPassword: [{ required: true, message: 'RTSP 密码不能为空', trigger: 'blur' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.resetDevice()
|
||||
this.resetCredential()
|
||||
this.getList()
|
||||
},
|
||||
methods: {
|
||||
getList() {
|
||||
this.loading = true
|
||||
listDevices(this.queryParams).then(response => {
|
||||
this.deviceList = response.data.list
|
||||
this.total = response.data.count
|
||||
}).finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
modalityLabel(value) {
|
||||
return this.modalityOptions.find(item => item.value === value)?.label || value
|
||||
},
|
||||
resetDevice() {
|
||||
this.deviceForm = { id: undefined, name: '', location: '', modality: 'video', capabilities: ['video'], version: undefined }
|
||||
this.$nextTick(() => this.resetForm('deviceForm'))
|
||||
},
|
||||
resetCredential() {
|
||||
this.credentialForm = {
|
||||
id: undefined,
|
||||
onvifUsername: '',
|
||||
onvifPassword: '',
|
||||
rtspSameAsOnvif: true,
|
||||
rtspUsername: '',
|
||||
rtspPassword: '',
|
||||
version: undefined
|
||||
}
|
||||
this.$nextTick(() => this.resetForm('credentialForm'))
|
||||
},
|
||||
handleQuery() {
|
||||
this.queryParams.pageIndex = 1
|
||||
this.getList()
|
||||
},
|
||||
resetQuery() {
|
||||
this.resetForm('queryForm')
|
||||
this.handleQuery()
|
||||
},
|
||||
handleAdd() {
|
||||
this.resetDevice()
|
||||
this.deviceTitle = '新增设备'
|
||||
this.deviceOpen = true
|
||||
},
|
||||
handleUpdate(row) {
|
||||
this.resetDevice()
|
||||
getDevice(row.id).then(response => {
|
||||
this.deviceForm = { ...response.data, capabilities: [...response.data.capabilities] }
|
||||
this.deviceTitle = '编辑设备'
|
||||
this.deviceOpen = true
|
||||
})
|
||||
},
|
||||
handleModalityChange(value) {
|
||||
this.deviceForm.capabilities = [value]
|
||||
},
|
||||
submitDevice() {
|
||||
this.$refs.deviceForm.validate(valid => {
|
||||
if (!valid) return
|
||||
const request = this.deviceForm.id
|
||||
? updateDevice(this.deviceForm.id, updateDevicePayload(this.deviceForm))
|
||||
: addDevice(createDevicePayload(this.deviceForm))
|
||||
request.then(response => {
|
||||
this.msgSuccess(response.msg)
|
||||
this.deviceOpen = false
|
||||
this.getList()
|
||||
})
|
||||
})
|
||||
},
|
||||
handleCredential(row) {
|
||||
this.resetCredential()
|
||||
this.credentialForm.id = row.id
|
||||
this.credentialForm.version = row.version
|
||||
this.credentialOpen = true
|
||||
},
|
||||
submitCredential() {
|
||||
this.$refs.credentialForm.validate(valid => {
|
||||
if (!valid) return
|
||||
updateDeviceCredentials(this.credentialForm.id, credentialPayload(this.credentialForm)).then(response => {
|
||||
this.msgSuccess(response.msg)
|
||||
this.credentialOpen = false
|
||||
this.getList()
|
||||
})
|
||||
})
|
||||
},
|
||||
handleDisable(row) {
|
||||
this.$confirm(`停用“${row.name}”后,后续接入流程不会继续使用该设备。是否继续?`, '停用设备', {
|
||||
confirmButtonText: '确认停用', cancelButtonText: '取消', type: 'warning'
|
||||
}).then(() => disableDevice(row.id, disableDevicePayload(row.version))).then(response => {
|
||||
this.msgSuccess(response.msg)
|
||||
this.getList()
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.capability-tag {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.form-tip {
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.credential-alert {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
createDevicePayload,
|
||||
credentialPayload,
|
||||
disableDevicePayload,
|
||||
updateDevicePayload
|
||||
} from '@/views/sense/device/devicePayload'
|
||||
|
||||
describe('Sense device payload allowlists', () => {
|
||||
const form = {
|
||||
id: 'read-only-id',
|
||||
name: '东门摄像机',
|
||||
location: '教学楼一楼东门',
|
||||
modality: 'video',
|
||||
capabilities: ['video'],
|
||||
version: 9,
|
||||
adapterStatus: 'ready',
|
||||
unexpected: 'must-not-be-sent'
|
||||
}
|
||||
|
||||
test('create keeps Chinese fields and only writable create fields', () => {
|
||||
expect(createDevicePayload(form)).toEqual({
|
||||
name: '东门摄像机',
|
||||
location: '教学楼一楼东门',
|
||||
modality: 'video',
|
||||
capabilities: ['video']
|
||||
})
|
||||
})
|
||||
|
||||
test('update excludes id, modality and server state while carrying version', () => {
|
||||
expect(updateDevicePayload(form)).toEqual({
|
||||
name: '东门摄像机',
|
||||
location: '教学楼一楼东门',
|
||||
capabilities: ['video'],
|
||||
version: 9
|
||||
})
|
||||
})
|
||||
|
||||
test('credential payload does not send unused RTSP fields', () => {
|
||||
expect(credentialPayload({
|
||||
onvifUsername: 'synthetic-user',
|
||||
onvifPassword: 'synthetic-password',
|
||||
rtspSameAsOnvif: true,
|
||||
rtspUsername: 'must-not-be-sent',
|
||||
rtspPassword: 'must-not-be-sent',
|
||||
version: 9
|
||||
})).toEqual({
|
||||
onvifUsername: 'synthetic-user',
|
||||
onvifPassword: 'synthetic-password',
|
||||
rtspSameAsOnvif: true,
|
||||
rtspUsername: '',
|
||||
rtspPassword: '',
|
||||
version: 9
|
||||
})
|
||||
expect(disableDevicePayload(9)).toEqual({ version: 9 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user