Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e6b5fa0d9 | ||
|
|
4c4b11470d | ||
|
|
2d501eabae | ||
|
|
f841ef1913 | ||
|
|
8b3d814628 | ||
|
|
6d35fa7514 | ||
|
|
efb48b4030 | ||
|
|
e80180e2b6 | ||
|
|
6d90d54592 | ||
|
|
997900dd08 | ||
|
|
9c8a322607 | ||
|
|
118423a128 | ||
|
|
c7c380458f | ||
|
|
a82718ad13 | ||
|
|
c524a7f2c2 | ||
|
|
5177861c7c | ||
|
|
e2e18b0380 | ||
|
|
8509699fff | ||
|
|
398534aabe | ||
|
|
4f17cca91f | ||
|
|
5b8f4a4c8c | ||
|
|
b9416ebc08 | ||
|
|
2c0e37eff7 | ||
|
|
2e22252feb | ||
|
|
d2169990d3 |
@@ -0,0 +1,15 @@
|
||||
# Upstream sources
|
||||
|
||||
Sense is an independent product derived from the frozen GoAdmin baseline in
|
||||
the repository root `goadmin-baseline.json`.
|
||||
|
||||
- go-admin: `https://github.com/go-admin-team/go-admin`, commit
|
||||
`f06540883b41d03782bb6b2c4150f298f328c6b6`, MIT.
|
||||
- go-admin-ui: `https://github.com/go-admin-team/go-admin-ui`, commit
|
||||
`67d393d713877572fab0b897296a4c1d525fc81d`, MIT.
|
||||
- go-admin-doc: documentation reference only, commit
|
||||
`424855aacf6905f3fde860c3331385cb25529a0d`, MIT.
|
||||
|
||||
The local copies under `D:\github\goadmin` are read-only references and are
|
||||
not a distributable or reproducible source identifier.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 go-admin-team
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 wenjianzhang
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Sense
|
||||
|
||||
Sense is the independently deployable intelligent NVR and video-sensing
|
||||
management product in YoVision. It uses Go 1.26.5, PostgreSQL, Vue 3 and
|
||||
Element Plus. Brain and Bell are not required for startup or smoke tests.
|
||||
|
||||
## Toolchain
|
||||
|
||||
Use the exact versions from the repository root `goadmin-baseline.json`:
|
||||
|
||||
- Go 1.26.5
|
||||
- Node.js 22.22.1
|
||||
- pnpm 9.15.1
|
||||
|
||||
The repositories under `D:\github\goadmin` are read-only references. Do not
|
||||
develop Sense inside them.
|
||||
|
||||
```powershell
|
||||
$env:PATH = 'C:\Users\ila20\AppData\Local\Temp\yovision-tools\go1.26.5\go\bin;' + $env:PATH
|
||||
powershell -ExecutionPolicy Bypass -File .\Sense\scripts\bootstrap\check-toolchain.ps1
|
||||
```
|
||||
|
||||
## Backend
|
||||
|
||||
Production/default startup requires an independent PostgreSQL URL. No default
|
||||
password or signing secret is provided.
|
||||
|
||||
```powershell
|
||||
cd Sense\server
|
||||
$env:SENSE_DATABASE_MODE = 'postgres'
|
||||
$env:SENSE_DATABASE_URL = '<provided outside the repository>'
|
||||
go run .
|
||||
```
|
||||
|
||||
For an isolated smoke test only:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\Sense\scripts\bootstrap\smoke.ps1
|
||||
```
|
||||
|
||||
Health endpoints are `GET /healthz` and `GET /readyz`. Upstream demo and code
|
||||
generation routes are deliberately not registered.
|
||||
|
||||
## Frontend
|
||||
|
||||
```powershell
|
||||
cd Sense\ui
|
||||
corepack pnpm@9.15.1 install --frozen-lockfile
|
||||
corepack pnpm@9.15.1 lint
|
||||
corepack pnpm@9.15.1 build
|
||||
```
|
||||
|
||||
The frontend automatically discovers project-local route and store modules.
|
||||
Feature tickets add one route module without exposing unrelated GoAdmin demo
|
||||
pages.
|
||||
|
||||
## Windows package
|
||||
|
||||
With the frozen Go toolchain first in `PATH`, build the backend, frontend and
|
||||
ZIP archive with one command from the repository root:
|
||||
|
||||
```powershell
|
||||
cmd /c .\Sense\scripts\package-windows.bat
|
||||
```
|
||||
|
||||
The ignored local artifact is `Sense\dist\sense-windows-amd64.zip`. It contains
|
||||
the Windows amd64 server, compiled UI, empty example configuration, upstream
|
||||
licenses, a start script and an operations note. Run `start-sense.bat demo` only
|
||||
for a temporary local preview; production startup requires externally supplied
|
||||
PostgreSQL and secret environment variables.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copy values into a repository-external environment store. Do not commit secrets.
|
||||
SENSE_HTTP_ADDRESS=127.0.0.1:18080
|
||||
SENSE_DATABASE_MODE=postgres
|
||||
SENSE_DATABASE_URL=
|
||||
SENSE_LOG_LEVEL=info
|
||||
SENSE_UI_STATIC_DIR=
|
||||
SENSE_IDENTITY_SIGNING_KEY=
|
||||
SENSE_BOOTSTRAP_TOKEN=
|
||||
SENSE_COOKIE_SECURE=true
|
||||
SENSE_CREDENTIAL_KEY=
|
||||
SENSE_ONVIF_DISCOVERY_IP=
|
||||
SENSE_MEDIAMTX_BINARY=
|
||||
SENSE_MEDIAMTX_CONFIG=
|
||||
SENSE_MEDIAMTX_API=http://127.0.0.1:9997
|
||||
SENSE_MEDIAMTX_WEBRTC_BASE=http://127.0.0.1:8889
|
||||
@@ -0,0 +1,20 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$expectedGo = 'go version go1.26.5 windows/amd64'
|
||||
$actualGo = (& go version).Trim()
|
||||
if ($actualGo -ne $expectedGo) {
|
||||
throw "Go toolchain mismatch. Expected '$expectedGo', got '$actualGo'."
|
||||
}
|
||||
|
||||
$actualNode = (& node --version).Trim()
|
||||
if ($actualNode -ne 'v22.22.1') {
|
||||
throw "Node.js mismatch. Expected 'v22.22.1', got '$actualNode'."
|
||||
}
|
||||
|
||||
$actualPnpm = (& corepack pnpm@9.15.1 --version).Trim()
|
||||
if ($actualPnpm -ne '9.15.1') {
|
||||
throw "pnpm mismatch. Expected '9.15.1', got '$actualPnpm'."
|
||||
}
|
||||
|
||||
Write-Output 'Sense toolchain matches goadmin-baseline.json.'
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$senseRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$serverRoot = Join-Path $senseRoot 'server'
|
||||
$env:SENSE_DATABASE_MODE = 'memory'
|
||||
$env:SENSE_HTTP_ADDRESS = '127.0.0.1:18080'
|
||||
|
||||
$process = Start-Process -FilePath 'go' -ArgumentList @('run', '.') -WorkingDirectory $serverRoot -PassThru -WindowStyle Hidden
|
||||
try {
|
||||
$ready = $false
|
||||
for ($attempt = 0; $attempt -lt 30; $attempt++) {
|
||||
try {
|
||||
$health = Invoke-RestMethod -Uri 'http://127.0.0.1:18080/healthz' -TimeoutSec 1
|
||||
$readiness = Invoke-RestMethod -Uri 'http://127.0.0.1:18080/readyz' -TimeoutSec 1
|
||||
if ($health.status -eq 'ok' -and $readiness.status -eq 'ready') {
|
||||
$ready = $true
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
}
|
||||
if (-not $ready) {
|
||||
throw 'Sense smoke server did not become ready.'
|
||||
}
|
||||
|
||||
try {
|
||||
Invoke-WebRequest -Uri 'http://127.0.0.1:18080/api/v1/demo' -TimeoutSec 2 | Out-Null
|
||||
throw 'Disabled demo route unexpectedly returned success.'
|
||||
} catch {
|
||||
if ($_.Exception.Response.StatusCode.value__ -ne 404) { throw }
|
||||
}
|
||||
Write-Output 'Sense independent smoke test passed.'
|
||||
} finally {
|
||||
if (-not $process.HasExited) { Stop-Process -Id $process.Id }
|
||||
$process.WaitForExit()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
for %%I in ("%~dp0..") do set "SENSE_ROOT=%%~fI"
|
||||
for %%I in ("%SENSE_ROOT%\dist") do set "OUTPUT_ROOT=%%~fI"
|
||||
for %%I in ("%OUTPUT_ROOT%\sense-windows-amd64") do set "PACKAGE_DIR=%%~fI"
|
||||
for %%I in ("%OUTPUT_ROOT%\sense-windows-amd64.zip") do set "ZIP_PATH=%%~fI"
|
||||
for %%I in ("%SENSE_ROOT%\ui\dist") do set "UI_DIST=%%~fI"
|
||||
|
||||
if /I not "%PACKAGE_DIR%"=="%SENSE_ROOT%\dist\sense-windows-amd64" (
|
||||
echo [ERROR] Unsafe package directory: %PACKAGE_DIR%
|
||||
exit /b 1
|
||||
)
|
||||
if /I not "%ZIP_PATH%"=="%SENSE_ROOT%\dist\sense-windows-amd64.zip" (
|
||||
echo [ERROR] Unsafe ZIP path: %ZIP_PATH%
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
where go >nul 2>nul || goto :missing_go
|
||||
for /f "tokens=3" %%V in ('go version') do set "GO_VERSION=%%V"
|
||||
if not "%GO_VERSION%"=="go1.26.5" (
|
||||
echo [ERROR] Go 1.26.5 is required, found %GO_VERSION%.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
where node >nul 2>nul || goto :missing_node
|
||||
for /f "delims=" %%V in ('node --version') do set "NODE_VERSION=%%V"
|
||||
if not "%NODE_VERSION%"=="v22.22.1" (
|
||||
echo [ERROR] Node.js 22.22.1 is required, found %NODE_VERSION%.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
where corepack >nul 2>nul || goto :missing_corepack
|
||||
for /f "delims=" %%V in ('corepack pnpm@9.15.1 --version') do set "PNPM_VERSION=%%V"
|
||||
if not "%PNPM_VERSION%"=="9.15.1" (
|
||||
echo [ERROR] pnpm 9.15.1 is required, found %PNPM_VERSION%.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [1/5] Installing frozen frontend dependencies...
|
||||
pushd "%SENSE_ROOT%\ui" || goto :failed
|
||||
call corepack pnpm@9.15.1 install --frozen-lockfile || goto :failed_popd
|
||||
|
||||
echo [2/5] Building frontend...
|
||||
call corepack pnpm@9.15.1 build || goto :failed_popd
|
||||
popd
|
||||
|
||||
if exist "%PACKAGE_DIR%" rmdir /s /q "%PACKAGE_DIR%"
|
||||
if exist "%ZIP_PATH%" del /q "%ZIP_PATH%"
|
||||
mkdir "%PACKAGE_DIR%\config" || goto :failed
|
||||
|
||||
echo [3/5] Building Windows backend...
|
||||
set "CGO_ENABLED=0"
|
||||
set "GOOS=windows"
|
||||
set "GOARCH=amd64"
|
||||
pushd "%SENSE_ROOT%\server" || goto :failed
|
||||
go build -trimpath -ldflags "-s -w" -o "%PACKAGE_DIR%\sense-server.exe" . || goto :failed_popd
|
||||
popd
|
||||
|
||||
echo [4/5] Assembling package...
|
||||
robocopy "%UI_DIST%" "%PACKAGE_DIR%\ui" /E /NFL /NDL /NJH /NJS /NP >nul
|
||||
if errorlevel 8 goto :failed
|
||||
robocopy "%SENSE_ROOT%\LICENSES" "%PACKAGE_DIR%\LICENSES" /E /NFL /NDL /NJH /NJS /NP >nul
|
||||
if errorlevel 8 goto :failed
|
||||
copy /y "%SENSE_ROOT%\config\sense.env.example" "%PACKAGE_DIR%\config\sense.env.example" >nul || goto :failed
|
||||
copy /y "%SENSE_ROOT%\scripts\runtime\start-sense.bat" "%PACKAGE_DIR%\start-sense.bat" >nul || goto :failed
|
||||
copy /y "%SENSE_ROOT%\scripts\runtime\README-WINDOWS.md" "%PACKAGE_DIR%\README-WINDOWS.md" >nul || goto :failed
|
||||
|
||||
echo [5/5] Creating ZIP...
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "Compress-Archive -Path '%PACKAGE_DIR%' -DestinationPath '%ZIP_PATH%' -Force" || goto :failed
|
||||
|
||||
echo.
|
||||
echo Package directory: %PACKAGE_DIR%
|
||||
echo ZIP archive: %ZIP_PATH%
|
||||
exit /b 0
|
||||
|
||||
:failed_popd
|
||||
popd
|
||||
:failed
|
||||
echo [ERROR] Packaging failed.
|
||||
exit /b 1
|
||||
|
||||
:missing_go
|
||||
echo [ERROR] Go was not found in PATH.
|
||||
exit /b 1
|
||||
|
||||
:missing_node
|
||||
echo [ERROR] Node.js was not found in PATH.
|
||||
exit /b 1
|
||||
|
||||
:missing_corepack
|
||||
echo [ERROR] Corepack was not found in PATH.
|
||||
exit /b 1
|
||||
@@ -0,0 +1,32 @@
|
||||
# Sense Windows 运行包
|
||||
|
||||
此目录同时包含 Sense 后端和已经构建好的前端静态页面。Brain、Bell 无需启动。
|
||||
|
||||
## 快速查看
|
||||
|
||||
在命令提示符中运行:
|
||||
|
||||
```bat
|
||||
start-sense.bat demo
|
||||
```
|
||||
|
||||
然后访问 <http://127.0.0.1:18080>。`demo` 使用内存数据,进程退出后数据会丢失,仅用于本机查看,不能用于生产。
|
||||
|
||||
## 生产启动
|
||||
|
||||
生产环境必须先安装并准备独立 PostgreSQL,然后在仓库和运行包之外安全设置以下环境变量:
|
||||
|
||||
- `SENSE_DATABASE_URL`:PostgreSQL 连接地址。
|
||||
- `SENSE_IDENTITY_SIGNING_KEY`:至少 32 个字符的会话签名密钥。
|
||||
- `SENSE_BOOTSTRAP_TOKEN`:首次初始化使用的引导令牌。
|
||||
- `SENSE_CREDENTIAL_KEY`:Base64 编码的 32 字节摄像头凭据加密密钥。
|
||||
|
||||
按需要设置 `SENSE_HTTP_ADDRESS`、MediaMTX 和 ONVIF 参数后运行:
|
||||
|
||||
```bat
|
||||
start-sense.bat
|
||||
```
|
||||
|
||||
完整变量名可参考 `config\sense.env.example`。示例文件只有空值和非秘密默认值,启动脚本不会自动读取它;请使用 Windows 环境变量或外部秘密管理工具注入真实值。
|
||||
|
||||
健康检查为 `GET /healthz` 和 `GET /readyz`。MediaMTX 仍是独立程序,本运行包不会安装 PostgreSQL、MediaMTX 或 Windows 服务。
|
||||
@@ -0,0 +1,45 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
set "SENSE_UI_STATIC_DIR=%~dp0ui"
|
||||
|
||||
if /I "%~1"=="demo" goto :demo
|
||||
|
||||
set "SENSE_DATABASE_MODE=postgres"
|
||||
if "%SENSE_DATABASE_URL%"=="" goto :missing_database_url
|
||||
if "%SENSE_IDENTITY_SIGNING_KEY%"=="" goto :missing_identity_key
|
||||
if "%SENSE_BOOTSTRAP_TOKEN%"=="" goto :missing_bootstrap_token
|
||||
if "%SENSE_CREDENTIAL_KEY%"=="" goto :missing_credential_key
|
||||
|
||||
echo Starting Sense in production mode at %SENSE_HTTP_ADDRESS%...
|
||||
"%~dp0sense-server.exe"
|
||||
exit /b %ERRORLEVEL%
|
||||
|
||||
:demo
|
||||
set "SENSE_DATABASE_MODE=memory"
|
||||
if "%SENSE_HTTP_ADDRESS%"=="" set "SENSE_HTTP_ADDRESS=127.0.0.1:18080"
|
||||
echo Starting Sense in temporary demo mode at http://%SENSE_HTTP_ADDRESS% ...
|
||||
echo Demo data is discarded when the process stops. Do not use this mode in production.
|
||||
"%~dp0sense-server.exe"
|
||||
exit /b %ERRORLEVEL%
|
||||
|
||||
:missing_database_url
|
||||
echo [ERROR] SENSE_DATABASE_URL is required in production mode.
|
||||
goto :configuration_help
|
||||
|
||||
:missing_identity_key
|
||||
echo [ERROR] SENSE_IDENTITY_SIGNING_KEY is required in production mode.
|
||||
goto :configuration_help
|
||||
|
||||
:missing_bootstrap_token
|
||||
echo [ERROR] SENSE_BOOTSTRAP_TOKEN is required in production mode.
|
||||
goto :configuration_help
|
||||
|
||||
:missing_credential_key
|
||||
echo [ERROR] SENSE_CREDENTIAL_KEY is required in production mode.
|
||||
|
||||
:configuration_help
|
||||
echo Set the required environment variables outside this directory.
|
||||
echo See README-WINDOWS.md and config\sense.env.example.
|
||||
exit /b 1
|
||||
@@ -0,0 +1,92 @@
|
||||
package mediamtx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Source struct {
|
||||
Path string
|
||||
URI string
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
type PathStatus struct {
|
||||
Name string `json:"name"`
|
||||
Ready bool `json:"ready"`
|
||||
Readers int `json:"readers"`
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
}
|
||||
type Controller interface {
|
||||
Apply(context.Context, Source) error
|
||||
Status(context.Context, string) (PathStatus, error)
|
||||
}
|
||||
type HTTPController struct {
|
||||
base string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewHTTPController(base string) *HTTPController {
|
||||
return &HTTPController{base: strings.TrimRight(base, "/"), client: &http.Client{Timeout: 5 * time.Second}}
|
||||
}
|
||||
func (c *HTTPController) Apply(ctx context.Context, source Source) error {
|
||||
parsed, err := url.Parse(source.URI)
|
||||
if err != nil || parsed.User != nil {
|
||||
return fmt.Errorf("invalid credential-free media source")
|
||||
}
|
||||
if source.Username != "" {
|
||||
parsed.User = url.UserPassword(source.Username, source.Password)
|
||||
}
|
||||
payload := map[string]any{"source": parsed.String(), "sourceOnDemand": true, "rtspTransport": "tcp"}
|
||||
data, _ := json.Marshal(payload)
|
||||
endpoint := c.base + "/v3/config/paths/replace/" + url.PathEscape(source.Path)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mediamtx control unavailable: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
io.Copy(io.Discard, io.LimitReader(res.Body, 1<<20))
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return fmt.Errorf("mediamtx apply returned %d", res.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *HTTPController) Status(ctx context.Context, path string) (PathStatus, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+"/v3/paths/get/"+url.PathEscape(path), nil)
|
||||
if err != nil {
|
||||
return PathStatus{}, err
|
||||
}
|
||||
res, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return PathStatus{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode == http.StatusNotFound {
|
||||
return PathStatus{Name: path}, nil
|
||||
}
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return PathStatus{}, fmt.Errorf("mediamtx status returned %d", res.StatusCode)
|
||||
}
|
||||
var raw struct {
|
||||
Name string `json:"name"`
|
||||
Ready bool `json:"ready"`
|
||||
BytesReceived int64 `json:"bytesReceived"`
|
||||
Readers []any `json:"readers"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&raw); err != nil {
|
||||
return PathStatus{}, err
|
||||
}
|
||||
return PathStatus{Name: raw.Name, Ready: raw.Ready, Readers: len(raw.Readers), BytesReceived: raw.BytesReceived}, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package mediamtx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyBuildsCredentialSourceOnlyInTransientBody(t *testing.T) {
|
||||
var body map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.String(), "password") {
|
||||
t.Fatal("credential leaked in control URL")
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := NewHTTPController(server.URL)
|
||||
if err := client.Apply(context.Background(), Source{Path: "sense_test", URI: "rtsp://camera.invalid/main", Username: "fixture-user", Password: "fixture-password"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["source"] != "rtsp://fixture-user:fixture-password@camera.invalid/main" {
|
||||
t.Fatalf("body=%#v", body)
|
||||
}
|
||||
}
|
||||
func TestApplyRejectsCredentialURI(t *testing.T) {
|
||||
client := NewHTTPController("http://127.0.0.1")
|
||||
if err := client.Apply(context.Background(), Source{Path: "x", URI: "rtsp://user:pass@camera.invalid/main"}); err == nil {
|
||||
t.Fatal("credential URI accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package mediamtx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ProcessState struct {
|
||||
Running bool `json:"running"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
Restarts int `json:"restarts"`
|
||||
LastExit string `json:"last_exit,omitempty"`
|
||||
}
|
||||
type Process interface {
|
||||
Start(context.Context) error
|
||||
Stop(context.Context) error
|
||||
State() ProcessState
|
||||
}
|
||||
type Supervisor struct {
|
||||
binary string
|
||||
config string
|
||||
maxRestarts int
|
||||
mu sync.Mutex
|
||||
command *exec.Cmd
|
||||
state ProcessState
|
||||
}
|
||||
|
||||
func NewSupervisor(binary, config string, maxRestarts int) *Supervisor {
|
||||
if maxRestarts < 0 {
|
||||
maxRestarts = 0
|
||||
}
|
||||
return &Supervisor{binary: binary, config: config, maxRestarts: maxRestarts}
|
||||
}
|
||||
func (s *Supervisor) Start(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.state.Running {
|
||||
return nil
|
||||
}
|
||||
if s.binary == "" {
|
||||
return fmt.Errorf("SENSE_MEDIAMTX_BINARY is not configured")
|
||||
}
|
||||
if s.state.LastExit != "" {
|
||||
if s.state.Restarts >= s.maxRestarts {
|
||||
return fmt.Errorf("MediaMTX restart limit reached")
|
||||
}
|
||||
s.state.Restarts++
|
||||
}
|
||||
arguments := []string{}
|
||||
if s.config != "" {
|
||||
arguments = append(arguments, s.config)
|
||||
}
|
||||
command := exec.CommandContext(context.Background(), s.binary, arguments...)
|
||||
if err := command.Start(); err != nil {
|
||||
return fmt.Errorf("start MediaMTX: %w", err)
|
||||
}
|
||||
s.command = command
|
||||
s.state.Running = true
|
||||
s.state.PID = command.Process.Pid
|
||||
s.state.LastExit = ""
|
||||
go s.wait(command)
|
||||
return nil
|
||||
}
|
||||
func (s *Supervisor) wait(command *exec.Cmd) {
|
||||
err := command.Wait()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.command != command {
|
||||
return
|
||||
}
|
||||
s.state.Running = false
|
||||
s.state.PID = 0
|
||||
if err != nil {
|
||||
s.state.LastExit = err.Error()
|
||||
} else {
|
||||
s.state.LastExit = "exited"
|
||||
}
|
||||
}
|
||||
func (s *Supervisor) Stop(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
command := s.command
|
||||
if command == nil || !s.state.Running {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
process := command.Process
|
||||
s.mu.Unlock()
|
||||
if err := process.Signal(os.Interrupt); err != nil {
|
||||
if killErr := process.Kill(); killErr != nil {
|
||||
return fmt.Errorf("stop owned MediaMTX process: %w", killErr)
|
||||
}
|
||||
}
|
||||
ticker := time.NewTicker(50 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
s.mu.Lock()
|
||||
running := s.state.Running
|
||||
s.mu.Unlock()
|
||||
if !running {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
func (s *Supervisor) State() ProcessState { s.mu.Lock(); defer s.mu.Unlock(); return s.state }
|
||||
@@ -0,0 +1,109 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Credential struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
type Profile struct {
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Encoding string `json:"encoding"`
|
||||
StreamURI string `json:"stream_uri"`
|
||||
}
|
||||
type Client interface {
|
||||
Profiles(context.Context, string, Credential) ([]Profile, error)
|
||||
}
|
||||
|
||||
type HTTPClient struct{ client *http.Client }
|
||||
|
||||
func NewHTTPClient(timeout time.Duration) *HTTPClient {
|
||||
if timeout <= 0 {
|
||||
timeout = 8 * time.Second
|
||||
}
|
||||
return &HTTPClient{client: &http.Client{Timeout: timeout}}
|
||||
}
|
||||
func (c *HTTPClient) Profiles(ctx context.Context, address string, credential Credential) ([]Profile, error) {
|
||||
endpoint, err := validateEndpoint(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := `<?xml version="1.0"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetProfiles xmlns="http://www.onvif.org/ver10/media/wsdl"/></s:Body></s:Envelope>`
|
||||
data, err := c.soap(ctx, endpoint, credential, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles, err := ParseProfiles(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range profiles {
|
||||
uriBody := fmt.Sprintf(`<?xml version="1.0"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetStreamUri xmlns="http://www.onvif.org/ver10/media/wsdl"><StreamSetup><Stream xmlns="http://www.onvif.org/ver10/schema">RTP-Unicast</Stream><Transport xmlns="http://www.onvif.org/ver10/schema"><Protocol>RTSP</Protocol></Transport></StreamSetup><ProfileToken>%s</ProfileToken></GetStreamUri></s:Body></s:Envelope>`, xmlEscape(profiles[i].Token))
|
||||
streamData, err := c.soap(ctx, endpoint, credential, uriBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles[i].StreamURI, err = ParseStreamURI(streamData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return profiles, nil
|
||||
}
|
||||
func (c *HTTPClient) soap(ctx context.Context, endpoint string, credential Credential, body string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/soap+xml; charset=utf-8")
|
||||
if credential.Username != "" {
|
||||
req.SetBasicAuth(credential.Username, credential.Password)
|
||||
}
|
||||
res, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("onvif request failed: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(res.Body, 2<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode == http.StatusUnauthorized {
|
||||
return nil, fmt.Errorf("authentication_failed")
|
||||
}
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("onvif_http_%d", res.StatusCode)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
func validateEndpoint(value string) (string, error) {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "", fmt.Errorf("invalid ONVIF address")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return "", fmt.Errorf("credentials must not appear in ONVIF address")
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return "", fmt.Errorf("unsupported ONVIF scheme")
|
||||
}
|
||||
return parsed.String(), nil
|
||||
}
|
||||
func xmlEscape(value string) string {
|
||||
var b strings.Builder
|
||||
_ = xml.EscapeText(&b, []byte(value))
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const discoveryProbe = `<?xml version="1.0"?><e:Envelope xmlns:e="http://www.w3.org/2003/05/soap-envelope" xmlns:w="http://schemas.xmlsoap.org/ws/2004/08/addressing" xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery" xmlns:dn="http://www.onvif.org/ver10/network/wsdl"><e:Header><w:MessageID>uuid:sense-controlled-discovery</w:MessageID><w:To e:mustUnderstand="true">urn:schemas-xmlsoap-org:ws:2005:04:discovery</w:To><w:Action e:mustUnderstand="true">http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</w:Action></e:Header><e:Body><d:Probe><d:Types>dn:NetworkVideoTransmitter</d:Types></d:Probe></e:Body></e:Envelope>`
|
||||
|
||||
func Discover(ctx context.Context, localIP string, timeout time.Duration) ([]string, error) {
|
||||
ip := net.ParseIP(localIP)
|
||||
if ip == nil {
|
||||
return nil, fmt.Errorf("approved discovery interface is not configured")
|
||||
}
|
||||
approved := false
|
||||
interfaces, _ := net.Interfaces()
|
||||
for _, iface := range interfaces {
|
||||
addresses, _ := iface.Addrs()
|
||||
for _, address := range addresses {
|
||||
if strings.HasPrefix(address.String(), ip.String()+"/") {
|
||||
approved = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !approved {
|
||||
return nil, fmt.Errorf("approved discovery IP is not a local interface")
|
||||
}
|
||||
connection, err := net.ListenUDP("udp4", &net.UDPAddr{IP: ip, Port: 0})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer connection.Close()
|
||||
deadline := time.Now().Add(timeout)
|
||||
_ = connection.SetDeadline(deadline)
|
||||
target := &net.UDPAddr{IP: net.ParseIP("239.255.255.250"), Port: 3702}
|
||||
if _, err := connection.WriteToUDP([]byte(discoveryProbe), target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found := map[string]bool{}
|
||||
buffer := make([]byte, 65535)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return keys(found), nil
|
||||
default:
|
||||
}
|
||||
n, _, err := connection.ReadFromUDP(buffer)
|
||||
if err != nil {
|
||||
if networkErr, ok := err.(net.Error); ok && networkErr.Timeout() {
|
||||
return keys(found), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
for _, value := range extractXAddrs(string(buffer[:n])) {
|
||||
found[value] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
func extractXAddrs(value string) []string {
|
||||
startTag, endTag := "<d:XAddrs>", "</d:XAddrs>"
|
||||
start := strings.Index(value, startTag)
|
||||
if start < 0 {
|
||||
return nil
|
||||
}
|
||||
end := strings.Index(value[start:], endTag)
|
||||
if end < 0 {
|
||||
return nil
|
||||
}
|
||||
return strings.Fields(value[start+len(startTag) : start+end])
|
||||
}
|
||||
func keys(values map[string]bool) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
for value := range values {
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type profileEnvelope struct {
|
||||
Profiles []struct {
|
||||
Token string `xml:"token,attr"`
|
||||
Name string `xml:"Name"`
|
||||
Encoder struct {
|
||||
Encoding string `xml:"Encoding"`
|
||||
Resolution struct {
|
||||
Width int `xml:"Width"`
|
||||
Height int `xml:"Height"`
|
||||
} `xml:"Resolution"`
|
||||
} `xml:"VideoEncoderConfiguration"`
|
||||
} `xml:"Body>GetProfilesResponse>Profiles"`
|
||||
}
|
||||
|
||||
func ParseProfiles(data []byte) ([]Profile, error) {
|
||||
var envelope profileEnvelope
|
||||
if err := xml.Unmarshal(data, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("parse ONVIF profiles: %w", err)
|
||||
}
|
||||
if len(envelope.Profiles) == 0 {
|
||||
return nil, fmt.Errorf("no_profiles")
|
||||
}
|
||||
result := make([]Profile, 0, len(envelope.Profiles))
|
||||
for _, value := range envelope.Profiles {
|
||||
result = append(result, Profile{Token: value.Token, Name: value.Name, Width: value.Encoder.Resolution.Width, Height: value.Encoder.Resolution.Height, Encoding: value.Encoder.Encoding})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func ParseStreamURI(data []byte) (string, error) {
|
||||
decoder := xml.NewDecoder(strings.NewReader(string(data)))
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stream_uri_not_found")
|
||||
}
|
||||
start, ok := token.(xml.StartElement)
|
||||
if ok && start.Name.Local == "Uri" {
|
||||
var value string
|
||||
if err := decoder.DecodeElement(&value, &start); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.Contains(value, "@") {
|
||||
return "", fmt.Errorf("stream_uri_contains_credentials")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseFixtureProfilesAndStreamURI(t *testing.T) {
|
||||
profilesData, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "..", "testdata", "onvif", "profiles.xml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profiles, err := ParseProfiles(profilesData)
|
||||
if err != nil || len(profiles) != 2 || profiles[0].Width != 1920 {
|
||||
t.Fatalf("profiles=%#v err=%v", profiles, err)
|
||||
}
|
||||
uriData, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "..", "testdata", "onvif", "stream-uri.xml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uri, err := ParseStreamURI(uriData)
|
||||
if err != nil || uri != "rtsp://camera.invalid/live/main" {
|
||||
t.Fatalf("uri=%q err=%v", uri, err)
|
||||
}
|
||||
}
|
||||
func TestRejectCredentialInStreamURI(t *testing.T) {
|
||||
if _, err := ParseStreamURI([]byte(`<Uri>rtsp://user:pass@camera.invalid/live</Uri>`)); err == nil {
|
||||
t.Fatal("credential URI accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package rtsp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Credential struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
type Result struct {
|
||||
Status string `json:"status"`
|
||||
LatencyMS int64 `json:"latency_ms"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
type Verifier interface {
|
||||
Verify(context.Context, string, Credential) (Result, error)
|
||||
}
|
||||
type NetVerifier struct{ Timeout time.Duration }
|
||||
|
||||
func (v NetVerifier) Verify(ctx context.Context, raw string, credential Credential) (Result, error) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme != "rtsp" || parsed.Host == "" {
|
||||
return Result{}, fmt.Errorf("invalid_rtsp_uri")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return Result{}, fmt.Errorf("rtsp_uri_contains_credentials")
|
||||
}
|
||||
address := parsed.Host
|
||||
if !strings.Contains(address, ":") {
|
||||
address += ":554"
|
||||
}
|
||||
timeout := v.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
dialer := net.Dialer{Timeout: timeout}
|
||||
started := time.Now()
|
||||
connection, err := dialer.DialContext(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
return Result{Status: "unreachable", Detail: "无法连接视频端口"}, nil
|
||||
}
|
||||
defer connection.Close()
|
||||
_ = connection.SetDeadline(time.Now().Add(timeout))
|
||||
authorization := ""
|
||||
if credential.Username != "" {
|
||||
authorization = "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(credential.Username+":"+credential.Password)) + "\r\n"
|
||||
}
|
||||
request := fmt.Sprintf("OPTIONS %s RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: YoVision-Sense\r\n%s\r\n", parsed.String(), authorization)
|
||||
if _, err := connection.Write([]byte(request)); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
line, err := bufio.NewReader(connection).ReadString('\n')
|
||||
if err != nil {
|
||||
return Result{Status: "timeout", Detail: "等待视频响应超时"}, nil
|
||||
}
|
||||
status := "ready"
|
||||
detail := "码流可访问"
|
||||
if strings.Contains(line, " 401 ") {
|
||||
status = "authentication_failed"
|
||||
detail = "设备拒绝了当前凭据"
|
||||
} else if !strings.Contains(line, " 200 ") {
|
||||
status = "failed"
|
||||
detail = "设备返回非成功状态"
|
||||
}
|
||||
return Result{Status: status, LatencyMS: time.Since(started).Milliseconds(), Detail: detail}, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package admission
|
||||
|
||||
import (
|
||||
"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) {
|
||||
setCurrentService(m.service)
|
||||
app.Handle("GET /api/v1/admission/discover", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.discover)))
|
||||
app.Handle("POST /api/v1/admission/probe", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.probe)))
|
||||
app.Handle("GET /api/v1/admission/devices/{id}", identity.Require(identity.PermissionDeviceRead, http.HandlerFunc(m.get)))
|
||||
}
|
||||
func (m *Module) discover(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := m.service.Discover(r.Context())
|
||||
if err != nil {
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusServiceUnavailable, Code: "discovery_unavailable", Message: "未配置获准的发现网卡"})
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
func (m *Module) probe(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
if err := platform.DecodeJSON(r, &request); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
actor, _ := identity.PrincipalFromContext(r.Context())
|
||||
result, err := m.service.Probe(r.Context(), actor, request.DeviceID, request.Address)
|
||||
if err != nil {
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusBadRequest, Code: "admission_failed", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
func (m *Module) get(w http.ResponseWriter, r *http.Request) {
|
||||
result, ok := m.service.Get(r.PathValue("id"))
|
||||
if !ok {
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusNotFound, Code: "admission_not_found", Message: "尚未执行接入验证"})
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package admission
|
||||
|
||||
import "fmt"
|
||||
|
||||
var currentService *Service
|
||||
|
||||
func setCurrentService(service *Service) { currentService = service }
|
||||
|
||||
// VerifiedProfile is a Sense-internal projection, not a cross-product contract.
|
||||
func VerifiedProfile(deviceID, token string) (Profile, error) {
|
||||
if currentService == nil {
|
||||
return Profile{}, fmt.Errorf("admission service is not ready")
|
||||
}
|
||||
result, ok := currentService.Get(deviceID)
|
||||
if !ok {
|
||||
return Profile{}, fmt.Errorf("device admission has not been verified")
|
||||
}
|
||||
for _, profile := range result.Profiles {
|
||||
if profile.Token == token && profile.Verification.Status == "ready" {
|
||||
return profile, nil
|
||||
}
|
||||
}
|
||||
return Profile{}, fmt.Errorf("verified profile not found")
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package admission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"yovision.local/sense/app/sense/adapters/onvif"
|
||||
"yovision.local/sense/app/sense/adapters/rtsp"
|
||||
"yovision.local/sense/app/sense/device"
|
||||
"yovision.local/sense/app/sense/identity"
|
||||
)
|
||||
|
||||
type Profile struct {
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Encoding string `json:"encoding"`
|
||||
StreamURI string `json:"stream_uri"`
|
||||
Kind string `json:"kind"`
|
||||
Verification rtsp.Result `json:"verification"`
|
||||
}
|
||||
type Result struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Address string `json:"address"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
Profiles []Profile `json:"profiles"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
}
|
||||
type Service struct {
|
||||
onvif onvif.Client
|
||||
rtsp rtsp.Verifier
|
||||
discoveryIP string
|
||||
discoveryTimeout time.Duration
|
||||
mu sync.RWMutex
|
||||
results map[string]Result
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(client onvif.Client, verifier rtsp.Verifier, discoveryIP string) *Service {
|
||||
return &Service{onvif: client, rtsp: verifier, discoveryIP: discoveryIP, discoveryTimeout: 3 * time.Second, results: map[string]Result{}, now: time.Now}
|
||||
}
|
||||
func (s *Service) Discover(ctx context.Context) ([]string, error) {
|
||||
if strings.TrimSpace(s.discoveryIP) == "" {
|
||||
return nil, fmt.Errorf("discovery_not_configured")
|
||||
}
|
||||
return onvif.Discover(ctx, s.discoveryIP, s.discoveryTimeout)
|
||||
}
|
||||
func (s *Service) Probe(ctx context.Context, actor identity.Principal, deviceID, address string) (Result, error) {
|
||||
credential, err := device.ReadCredential(ctx, deviceID)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("credential_required")
|
||||
}
|
||||
profiles, err := s.onvif.Profiles(ctx, address, onvif.Credential{Username: credential.Username, Password: credential.Password})
|
||||
if err != nil {
|
||||
status, detail := classify(err)
|
||||
result := Result{DeviceID: deviceID, Address: address, Status: status, Detail: detail, CheckedAt: s.now().UTC()}
|
||||
s.save(result)
|
||||
identity.RecordAudit(ctx, actor.UserID, "admission.probe", deviceID, "failure", map[string]any{"status": status})
|
||||
return result, nil
|
||||
}
|
||||
items := make([]Profile, 0, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
verification, verifyErr := s.rtsp.Verify(ctx, profile.StreamURI, rtsp.Credential{Username: credential.Username, Password: credential.Password})
|
||||
if verifyErr != nil {
|
||||
verification = rtsp.Result{Status: "failed", Detail: "视频地址格式不正确"}
|
||||
}
|
||||
kind := "other"
|
||||
items = append(items, Profile{Token: profile.Token, Name: profile.Name, Width: profile.Width, Height: profile.Height, Encoding: profile.Encoding, StreamURI: profile.StreamURI, Kind: kind, Verification: verification})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Width*items[i].Height > items[j].Width*items[j].Height })
|
||||
if len(items) > 0 {
|
||||
items[0].Kind = "main"
|
||||
}
|
||||
if len(items) > 1 {
|
||||
items[len(items)-1].Kind = "sub"
|
||||
}
|
||||
status := "ready"
|
||||
detail := "设备与视频 Profile 已验证"
|
||||
for _, item := range items {
|
||||
if item.Verification.Status != "ready" {
|
||||
status = "profile_failed"
|
||||
detail = "部分视频 Profile 验证失败"
|
||||
}
|
||||
}
|
||||
result := Result{DeviceID: deviceID, Address: address, Status: status, Detail: detail, Profiles: items, CheckedAt: s.now().UTC()}
|
||||
s.save(result)
|
||||
identity.RecordAudit(ctx, actor.UserID, "admission.probe", deviceID, "success", map[string]any{"profile_count": len(items), "status": status})
|
||||
return result, nil
|
||||
}
|
||||
func (s *Service) Get(deviceID string) (Result, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
value, ok := s.results[deviceID]
|
||||
return value, ok
|
||||
}
|
||||
func (s *Service) save(result Result) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.results[result.DeviceID] = result
|
||||
}
|
||||
func classify(err error) (string, string) {
|
||||
value := strings.ToLower(err.Error())
|
||||
switch {
|
||||
case strings.Contains(value, "authentication"):
|
||||
return "authentication_failed", "设备拒绝了当前凭据,请更新后重试"
|
||||
case strings.Contains(value, "deadline") || strings.Contains(value, "timeout"):
|
||||
return "timeout", "设备响应超时"
|
||||
case strings.Contains(value, "clock") || strings.Contains(value, "time"):
|
||||
return "clock_skew", "设备时间可能不准确,请校时后重试"
|
||||
default:
|
||||
return "unreachable", "无法读取设备信息,请检查地址和网络"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package admission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
"yovision.local/sense/app/sense/adapters/onvif"
|
||||
"yovision.local/sense/app/sense/adapters/rtsp"
|
||||
"yovision.local/sense/app/sense/device"
|
||||
"yovision.local/sense/app/sense/identity"
|
||||
"yovision.local/sense/internal/platform"
|
||||
)
|
||||
|
||||
type fakeONVIF struct{}
|
||||
|
||||
func (fakeONVIF) Profiles(context.Context, string, onvif.Credential) ([]onvif.Profile, error) {
|
||||
return []onvif.Profile{{Token: "main", Name: "主码流", Width: 1920, Height: 1080, Encoding: "H264", StreamURI: "rtsp://camera.invalid/main"}, {Token: "sub", Name: "子码流", Width: 640, Height: 360, Encoding: "H264", StreamURI: "rtsp://camera.invalid/sub"}}, nil
|
||||
}
|
||||
|
||||
type fakeRTSP struct{}
|
||||
|
||||
func (fakeRTSP) Verify(context.Context, string, rtsp.Credential) (rtsp.Result, error) {
|
||||
return rtsp.Result{Status: "ready"}, nil
|
||||
}
|
||||
func TestProbeProfilesWithoutCredentialURI(t *testing.T) {
|
||||
vault, err := device.NewCredentialVault(base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := device.NewMemoryStore()
|
||||
deviceService := device.NewService(store, vault)
|
||||
module := device.NewModule(deviceService)
|
||||
module.Register(platform.NewApp(platform.Config{DatabaseMode: platform.DatabaseModeMemory}, nil, nil))
|
||||
item, err := deviceService.Create(context.Background(), identity.Principal{}, "camera", "", device.ModalityVideo, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = deviceService.SetCredential(context.Background(), identity.Principal{}, item.ID, "fixture-user", "fixture-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(fakeONVIF{}, fakeRTSP{}, "")
|
||||
result, err := service.Probe(context.Background(), identity.Principal{}, item.ID, "http://camera.invalid/onvif/device_service")
|
||||
if err != nil || result.Status != "ready" || result.Profiles[0].Kind != "main" || result.Profiles[1].Kind != "sub" {
|
||||
t.Fatalf("result=%#v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package area
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
var activeService atomic.Pointer[Service]
|
||||
|
||||
// ExportCurrent is a project-internal adapter port for a future versioned
|
||||
// contract mapper. The internal Area type is not a shared Brain contract.
|
||||
func ExportCurrent(ctx context.Context, deviceID string) ([]Area, error) {
|
||||
service := activeService.Load()
|
||||
if service == nil {
|
||||
return nil, fmt.Errorf("area service is not ready")
|
||||
}
|
||||
return service.List(ctx, deviceID)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package area
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
const epsilon = 1e-9
|
||||
|
||||
func ValidateGeometry(kind, direction string, points []Point) error {
|
||||
for _, point := range points {
|
||||
if point.X < 0 || point.X > 1 || point.Y < 0 || point.Y > 1 {
|
||||
return fmt.Errorf("坐标必须位于画面范围内")
|
||||
}
|
||||
}
|
||||
switch kind {
|
||||
case KindPolygon:
|
||||
if len(points) < 3 {
|
||||
return fmt.Errorf("多边形至少需要 3 个点")
|
||||
}
|
||||
if math.Abs(polygonArea(points)) < epsilon {
|
||||
return fmt.Errorf("多边形不能退化为直线")
|
||||
}
|
||||
if selfIntersects(points, true) {
|
||||
return fmt.Errorf("多边形不能自交")
|
||||
}
|
||||
case KindLine:
|
||||
if len(points) != 2 {
|
||||
return fmt.Errorf("警戒线必须恰好包含 2 个点")
|
||||
}
|
||||
if distance(points[0], points[1]) < epsilon {
|
||||
return fmt.Errorf("警戒线两点不能重合")
|
||||
}
|
||||
if direction != DirectionAToB && direction != DirectionBToA && direction != DirectionBoth {
|
||||
return fmt.Errorf("警戒线方向不正确")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("区域类型不正确")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func polygonArea(points []Point) float64 {
|
||||
sum := 0.0
|
||||
for i := range points {
|
||||
next := (i + 1) % len(points)
|
||||
sum += points[i].X*points[next].Y - points[next].X*points[i].Y
|
||||
}
|
||||
return sum / 2
|
||||
}
|
||||
func distance(a, b Point) float64 { dx, dy := a.X-b.X, a.Y-b.Y; return dx*dx + dy*dy }
|
||||
func selfIntersects(points []Point, closed bool) bool {
|
||||
segments := len(points) - 1
|
||||
if closed {
|
||||
segments = len(points)
|
||||
}
|
||||
for i := 0; i < segments; i++ {
|
||||
a1, a2 := points[i], points[(i+1)%len(points)]
|
||||
if distance(a1, a2) < epsilon {
|
||||
return true
|
||||
}
|
||||
for j := i + 1; j < segments; j++ {
|
||||
if j == i+1 || closed && i == 0 && j == segments-1 {
|
||||
continue
|
||||
}
|
||||
b1, b2 := points[j], points[(j+1)%len(points)]
|
||||
if segmentsIntersect(a1, a2, b1, b2) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func segmentsIntersect(a, b, c, d Point) bool {
|
||||
o1, o2, o3, o4 := orientation(a, b, c), orientation(a, b, d), orientation(c, d, a), orientation(c, d, b)
|
||||
return o1*o2 < 0 && o3*o4 < 0
|
||||
}
|
||||
func orientation(a, b, c Point) float64 { return (b.X-a.X)*(c.Y-a.Y) - (b.Y-a.Y)*(c.X-a.X) }
|
||||
@@ -0,0 +1,19 @@
|
||||
package area
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateGeometry(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, kind, direction string
|
||||
points []Point
|
||||
valid bool
|
||||
}{{"polygon", KindPolygon, "", []Point{{.1, .1}, {.9, .1}, {.5, .8}}, true}, {"self intersection", KindPolygon, "", []Point{{.1, .1}, {.9, .9}, {.9, .1}, {.1, .9}}, false}, {"outside", KindPolygon, "", []Point{{-.1, .1}, {.9, .1}, {.5, .8}}, false}, {"line", KindLine, DirectionAToB, []Point{{.1, .1}, {.9, .9}}, true}, {"line direction", KindLine, "", []Point{{.1, .1}, {.9, .9}}, false}, {"line degenerate", KindLine, DirectionBoth, []Point{{.1, .1}, {.1, .1}}, false}}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := ValidateGeometry(test.kind, test.direction, test.points)
|
||||
if (err == nil) != test.valid {
|
||||
t.Fatalf("err=%v valid=%v", err, test.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package area
|
||||
|
||||
import (
|
||||
"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) {
|
||||
activeService.Store(m.service)
|
||||
app.Handle("GET /api/v1/areas", identity.Require(identity.PermissionAreaRead, http.HandlerFunc(m.list)))
|
||||
app.Handle("POST /api/v1/areas", identity.Require(identity.PermissionAreaWrite, http.HandlerFunc(m.create)))
|
||||
app.Handle("PATCH /api/v1/areas/{id}", identity.Require(identity.PermissionAreaWrite, http.HandlerFunc(m.update)))
|
||||
app.Handle("POST /api/v1/areas/{id}/recalibrate", identity.Require(identity.PermissionAreaWrite, http.HandlerFunc(m.recalibrate)))
|
||||
app.Handle("POST /api/v1/areas/{id}/disable", identity.Require(identity.PermissionAreaWrite, http.HandlerFunc(m.disable)))
|
||||
}
|
||||
|
||||
type writeRequest struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ProfileToken string `json:"profile_token"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Direction string `json:"direction"`
|
||||
Points []Point `json:"points"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
|
||||
func (m *Module) list(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := m.service.List(r.Context(), r.URL.Query().Get("device_id"))
|
||||
if err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
|
||||
}
|
||||
func (m *Module) create(w http.ResponseWriter, r *http.Request) {
|
||||
var req writeRequest
|
||||
if err := platform.DecodeJSON(r, &req); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
actor, _ := identity.PrincipalFromContext(r.Context())
|
||||
item, err := m.service.Create(r.Context(), actor, req.DeviceID, req.ProfileToken, req.Name, req.Kind, req.Direction, req.Points)
|
||||
writeResult(w, item, err, http.StatusCreated)
|
||||
}
|
||||
func (m *Module) update(w http.ResponseWriter, r *http.Request) { m.writeExisting(w, r, false) }
|
||||
func (m *Module) recalibrate(w http.ResponseWriter, r *http.Request) { m.writeExisting(w, r, true) }
|
||||
func (m *Module) writeExisting(w http.ResponseWriter, r *http.Request, recalibrate bool) {
|
||||
var req writeRequest
|
||||
if err := platform.DecodeJSON(r, &req); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
actor, _ := identity.PrincipalFromContext(r.Context())
|
||||
item, err := m.service.Update(r.Context(), actor, r.PathValue("id"), req.Name, req.Direction, req.Points, req.Version, recalibrate)
|
||||
writeResult(w, item, err, http.StatusOK)
|
||||
}
|
||||
func (m *Module) disable(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
if err := platform.DecodeJSON(r, &req); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
actor, _ := identity.PrincipalFromContext(r.Context())
|
||||
item, err := m.service.Disable(r.Context(), actor, r.PathValue("id"), req.Version)
|
||||
writeResult(w, item, err, http.StatusOK)
|
||||
}
|
||||
func writeResult(w http.ResponseWriter, item Area, err error, status int) {
|
||||
if err != nil {
|
||||
code := "area_invalid"
|
||||
httpStatus := http.StatusBadRequest
|
||||
if err == ErrConflict {
|
||||
code = "version_conflict"
|
||||
httpStatus = http.StatusConflict
|
||||
}
|
||||
if err == ErrNotFound {
|
||||
code = "area_not_found"
|
||||
httpStatus = http.StatusNotFound
|
||||
}
|
||||
platform.WriteError(w, &platform.APIError{Status: httpStatus, Code: code, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, status, item)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package area
|
||||
|
||||
const MigrationSQL = `CREATE TABLE IF NOT EXISTS sense_area_versions(id TEXT NOT NULL,version BIGINT NOT NULL,device_id TEXT NOT NULL,profile_token TEXT NOT NULL,width INTEGER NOT NULL,height INTEGER NOT NULL,name TEXT NOT NULL,kind TEXT NOT NULL,direction TEXT NOT NULL DEFAULT '',points JSONB NOT NULL,enabled BOOLEAN NOT NULL,recalibration_required BOOLEAN NOT NULL DEFAULT FALSE,created_at TIMESTAMPTZ NOT NULL,updated_at TIMESTAMPTZ NOT NULL,PRIMARY KEY(id,version));CREATE INDEX IF NOT EXISTS sense_area_device_idx ON sense_area_versions(device_id,id,version DESC);`
|
||||
@@ -0,0 +1,117 @@
|
||||
package area
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"yovision.local/sense/app/sense/admission"
|
||||
"yovision.local/sense/app/sense/identity"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
profile func(string, string) (admission.Profile, error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(store Store) *Service {
|
||||
return &Service{store: store, profile: admission.VerifiedProfile, now: time.Now}
|
||||
}
|
||||
func (s *Service) Create(ctx context.Context, actor identity.Principal, deviceID, profileToken, name, kind, direction string, points []Point) (Area, error) {
|
||||
profile, err := s.profile(deviceID, profileToken)
|
||||
if err != nil {
|
||||
return Area{}, err
|
||||
}
|
||||
if err := ValidateGeometry(kind, direction, points); err != nil {
|
||||
return Area{}, err
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return Area{}, fmt.Errorf("名称不能为空")
|
||||
}
|
||||
now := s.now().UTC()
|
||||
item := Area{ID: newID(), DeviceID: deviceID, ProfileToken: profileToken, Width: profile.Width, Height: profile.Height, Name: name, Kind: kind, Direction: direction, Points: points, Enabled: true, Version: 1, CreatedAt: now, UpdatedAt: now}
|
||||
if err := s.store.Save(ctx, item, 0); err != nil {
|
||||
return Area{}, err
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "area.create", item.ID, "success", map[string]any{"version": 1, "kind": kind})
|
||||
return item, nil
|
||||
}
|
||||
func (s *Service) Update(ctx context.Context, actor identity.Principal, id, name, direction string, points []Point, expected int64, recalibrate bool) (Area, error) {
|
||||
current, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Area{}, err
|
||||
}
|
||||
if current.Version != expected {
|
||||
return Area{}, ErrConflict
|
||||
}
|
||||
profile, err := s.profile(current.DeviceID, current.ProfileToken)
|
||||
if err != nil {
|
||||
return Area{}, err
|
||||
}
|
||||
resolutionChanged := profile.Width != current.Width || profile.Height != current.Height
|
||||
if resolutionChanged && !recalibrate {
|
||||
current.RecalibrationRequired = true
|
||||
current.Version++
|
||||
current.UpdatedAt = s.now().UTC()
|
||||
if err := s.store.Save(ctx, current, expected); err != nil {
|
||||
return Area{}, err
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
if err := ValidateGeometry(current.Kind, direction, points); err != nil {
|
||||
return Area{}, err
|
||||
}
|
||||
current.Name = strings.TrimSpace(name)
|
||||
current.Direction = direction
|
||||
current.Points = points
|
||||
current.Width = profile.Width
|
||||
current.Height = profile.Height
|
||||
current.RecalibrationRequired = false
|
||||
current.Version++
|
||||
current.UpdatedAt = s.now().UTC()
|
||||
if err := s.store.Save(ctx, current, expected); err != nil {
|
||||
return Area{}, err
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "area.publish", id, "success", map[string]any{"version": current.Version, "recalibrated": recalibrate})
|
||||
return current, nil
|
||||
}
|
||||
func (s *Service) Disable(ctx context.Context, actor identity.Principal, id string, expected int64) (Area, error) {
|
||||
item, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Area{}, err
|
||||
}
|
||||
if item.Version != expected {
|
||||
return Area{}, ErrConflict
|
||||
}
|
||||
item.Enabled = false
|
||||
item.Version++
|
||||
item.UpdatedAt = s.now().UTC()
|
||||
if err := s.store.Save(ctx, item, expected); err != nil {
|
||||
return Area{}, err
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "area.disable", id, "success", map[string]any{"version": item.Version})
|
||||
return item, nil
|
||||
}
|
||||
func (s *Service) List(ctx context.Context, deviceID string) ([]Area, error) {
|
||||
items, err := s.store.List(ctx, deviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range items {
|
||||
if profile, profileErr := s.profile(items[i].DeviceID, items[i].ProfileToken); profileErr == nil && (profile.Width != items[i].Width || profile.Height != items[i].Height) {
|
||||
items[i].RecalibrationRequired = true
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
func newID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return "area_" + hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package area
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"yovision.local/sense/app/sense/admission"
|
||||
"yovision.local/sense/app/sense/identity"
|
||||
)
|
||||
|
||||
func TestVersionsConflictAndRecalibration(t *testing.T) {
|
||||
service := NewService(NewMemoryStore())
|
||||
width := 1920
|
||||
service.profile = func(string, string) (admission.Profile, error) {
|
||||
return admission.Profile{Width: width, Height: 1080}, nil
|
||||
}
|
||||
item, err := service.Create(context.Background(), identity.Principal{}, "device", "main", "区域", KindPolygon, "", []Point{{.1, .1}, {.9, .1}, {.5, .8}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err := service.Update(context.Background(), identity.Principal{}, item.ID, "区域2", "", item.Points, item.Version, false)
|
||||
if err != nil || updated.Version != 2 {
|
||||
t.Fatalf("updated=%#v err=%v", updated, err)
|
||||
}
|
||||
if _, err := service.Update(context.Background(), identity.Principal{}, item.ID, "冲突", "", item.Points, item.Version, false); err != ErrConflict {
|
||||
t.Fatalf("conflict err=%v", err)
|
||||
}
|
||||
width = 1280
|
||||
flagged, err := service.Update(context.Background(), identity.Principal{}, item.ID, updated.Name, "", updated.Points, updated.Version, false)
|
||||
if err != nil || !flagged.RecalibrationRequired {
|
||||
t.Fatalf("flagged=%#v err=%v", flagged, err)
|
||||
}
|
||||
recalibrated, err := service.Update(context.Background(), identity.Principal{}, item.ID, updated.Name, "", updated.Points, flagged.Version, true)
|
||||
if err != nil || recalibrated.RecalibrationRequired || recalibrated.Width != 1280 {
|
||||
t.Fatalf("recalibrated=%#v err=%v", recalibrated, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package area
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("area not found")
|
||||
ErrConflict = errors.New("area version conflict")
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
Save(context.Context, Area, int64) error
|
||||
Get(context.Context, string) (Area, error)
|
||||
List(context.Context, string) ([]Area, error)
|
||||
}
|
||||
type MemoryStore struct {
|
||||
mu sync.RWMutex
|
||||
versions map[string][]Area
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore { return &MemoryStore{versions: map[string][]Area{}} }
|
||||
func (s *MemoryStore) Save(_ context.Context, item Area, expected int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
values := s.versions[item.ID]
|
||||
if len(values) == 0 {
|
||||
if expected != 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
} else if values[len(values)-1].Version != expected {
|
||||
return ErrConflict
|
||||
}
|
||||
s.versions[item.ID] = append(values, item)
|
||||
return nil
|
||||
}
|
||||
func (s *MemoryStore) Get(_ context.Context, id string) (Area, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
values := s.versions[id]
|
||||
if len(values) == 0 {
|
||||
return Area{}, ErrNotFound
|
||||
}
|
||||
return values[len(values)-1], nil
|
||||
}
|
||||
func (s *MemoryStore) List(_ context.Context, deviceID string) ([]Area, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
items := []Area{}
|
||||
for _, values := range s.versions {
|
||||
item := values[len(values)-1]
|
||||
if deviceID == "" || item.DeviceID == deviceID {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
type PostgresStore struct{ database *sql.DB }
|
||||
|
||||
func NewPostgresStore(db *sql.DB) *PostgresStore { return &PostgresStore{database: db} }
|
||||
func (s *PostgresStore) Save(ctx context.Context, item Area, expected int64) error {
|
||||
tx, err := s.database.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var current int64
|
||||
err = tx.QueryRowContext(ctx, `SELECT COALESCE(max(version),0) FROM sense_area_versions WHERE id=$1`, item.ID).Scan(¤t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current != expected {
|
||||
return ErrConflict
|
||||
}
|
||||
points, _ := json.Marshal(item.Points)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO sense_area_versions(id,version,device_id,profile_token,width,height,name,kind,direction,points,enabled,recalibration_required,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, item.ID, item.Version, item.DeviceID, item.ProfileToken, item.Width, item.Height, item.Name, item.Kind, item.Direction, points, item.Enabled, item.RecalibrationRequired, item.CreatedAt, item.UpdatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
func (s *PostgresStore) Get(ctx context.Context, id string) (Area, error) {
|
||||
return scan(s.database.QueryRowContext(ctx, `SELECT id,version,device_id,profile_token,width,height,name,kind,direction,points,enabled,recalibration_required,created_at,updated_at FROM sense_area_versions WHERE id=$1 ORDER BY version DESC LIMIT 1`, id))
|
||||
}
|
||||
|
||||
type scanner interface{ Scan(...any) error }
|
||||
|
||||
func scan(row scanner) (Area, error) {
|
||||
var item Area
|
||||
var points []byte
|
||||
err := row.Scan(&item.ID, &item.Version, &item.DeviceID, &item.ProfileToken, &item.Width, &item.Height, &item.Name, &item.Kind, &item.Direction, &points, &item.Enabled, &item.RecalibrationRequired, &item.CreatedAt, &item.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Area{}, ErrNotFound
|
||||
}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(points, &item.Points)
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
func (s *PostgresStore) List(ctx context.Context, deviceID string) ([]Area, error) {
|
||||
rows, err := s.database.QueryContext(ctx, `SELECT DISTINCT ON(id) id,version,device_id,profile_token,width,height,name,kind,direction,points,enabled,recalibration_required,created_at,updated_at FROM sense_area_versions WHERE ($1='' OR device_id=$1) ORDER BY id,version DESC`, deviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Area{}
|
||||
for rows.Next() {
|
||||
item, err := scan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package area
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
KindPolygon = "polygon"
|
||||
KindLine = "line"
|
||||
DirectionAToB = "a_to_b"
|
||||
DirectionBToA = "b_to_a"
|
||||
DirectionBoth = "both"
|
||||
)
|
||||
|
||||
type Point struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}
|
||||
type Area struct {
|
||||
ID string `json:"id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ProfileToken string `json:"profile_token"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Direction string `json:"direction,omitempty"`
|
||||
Points []Point `json:"points"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Version int64 `json:"version"`
|
||||
RecalibrationRequired bool `json:"recalibration_required"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type Credential struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
var activeService atomic.Pointer[Service]
|
||||
|
||||
// ReadCredential is an internal adapter port. Credentials must never be
|
||||
// returned from HTTP handlers, logged, or placed in a URL.
|
||||
func ReadCredential(ctx context.Context, id string) (Credential, error) {
|
||||
service := activeService.Load()
|
||||
if service == nil {
|
||||
return Credential{}, fmt.Errorf("device service is not ready")
|
||||
}
|
||||
item, err := service.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Credential{}, err
|
||||
}
|
||||
if len(item.CredentialCiphertext) == 0 {
|
||||
return Credential{}, fmt.Errorf("device credential is not configured")
|
||||
}
|
||||
username, password, err := service.vault.Decrypt(item.CredentialCiphertext)
|
||||
return Credential{Username: username, Password: password}, err
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
type CredentialVault struct{ key []byte }
|
||||
|
||||
func NewCredentialVault(encodedKey string, allowRandom bool) (*CredentialVault, error) {
|
||||
var key []byte
|
||||
if encodedKey != "" {
|
||||
decoded, err := base64.StdEncoding.DecodeString(encodedKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SENSE_CREDENTIAL_KEY must be base64 encoded")
|
||||
}
|
||||
key = decoded
|
||||
} else if allowRandom {
|
||||
key = make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return nil, fmt.Errorf("SENSE_CREDENTIAL_KEY must decode to 32 bytes")
|
||||
}
|
||||
return &CredentialVault{key: key}, nil
|
||||
}
|
||||
|
||||
func (v *CredentialVault) Encrypt(username, password string) ([]byte, error) {
|
||||
plaintext, err := json.Marshal(map[string]string{"username": username, "password": password})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, err := aes.NewCipher(v.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gcm.Seal(nonce, nonce, plaintext, []byte("sense-device-credential-v1")), nil
|
||||
}
|
||||
|
||||
func (v *CredentialVault) Decrypt(ciphertext []byte) (string, string, error) {
|
||||
block, err := aes.NewCipher(v.key)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if len(ciphertext) < gcm.NonceSize() {
|
||||
return "", "", fmt.Errorf("invalid credential ciphertext")
|
||||
}
|
||||
nonce, encrypted := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():]
|
||||
plaintext, err := gcm.Open(nil, nonce, encrypted, []byte("sense-device-credential-v1"))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("decrypt device credential: %w", err)
|
||||
}
|
||||
var value struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.Unmarshal(plaintext, &value); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return value.Username, value.Password, nil
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"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) {
|
||||
activeService.Store(m.service)
|
||||
app.Handle("GET /api/v1/devices", identity.Require(identity.PermissionDeviceRead, http.HandlerFunc(m.list)))
|
||||
app.Handle("POST /api/v1/devices", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.create)))
|
||||
app.Handle("GET /api/v1/devices/{id}", identity.Require(identity.PermissionDeviceRead, http.HandlerFunc(m.get)))
|
||||
app.Handle("PATCH /api/v1/devices/{id}", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.update)))
|
||||
app.Handle("POST /api/v1/devices/{id}/credentials", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.credential)))
|
||||
app.Handle("POST /api/v1/devices/{id}/disable", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.disable)))
|
||||
}
|
||||
func (m *Module) list(w http.ResponseWriter, r *http.Request) {
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
size, _ := strconv.Atoi(r.URL.Query().Get("page_size"))
|
||||
result, err := m.service.List(r.Context(), ListFilter{Keyword: r.URL.Query().Get("keyword"), Page: page, PageSize: size})
|
||||
if err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
func (m *Module) get(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := m.service.Get(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, item)
|
||||
}
|
||||
func (m *Module) create(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Modality string `json:"modality"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
if err := platform.DecodeJSON(r, &req); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
actor, _ := identity.PrincipalFromContext(r.Context())
|
||||
item, err := m.service.Create(r.Context(), actor, req.Name, req.Location, req.Modality, req.Capabilities)
|
||||
if err != nil {
|
||||
writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
func (m *Module) update(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
if err := platform.DecodeJSON(r, &req); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
actor, _ := identity.PrincipalFromContext(r.Context())
|
||||
item, err := m.service.Update(r.Context(), actor, r.PathValue("id"), req.Name, req.Location, req.Capabilities, req.Version)
|
||||
if err != nil {
|
||||
writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, item)
|
||||
}
|
||||
func (m *Module) credential(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := platform.DecodeJSON(r, &req); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
actor, _ := identity.PrincipalFromContext(r.Context())
|
||||
item, err := m.service.SetCredential(r.Context(), actor, r.PathValue("id"), req.Username, req.Password)
|
||||
if err != nil {
|
||||
writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, item)
|
||||
}
|
||||
func (m *Module) disable(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
if err := platform.DecodeJSON(r, &req); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
actor, _ := identity.PrincipalFromContext(r.Context())
|
||||
item, err := m.service.Disable(r.Context(), actor, r.PathValue("id"), req.Version)
|
||||
if err != nil {
|
||||
writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, item)
|
||||
}
|
||||
func writeDeviceError(w http.ResponseWriter, err error) {
|
||||
status := http.StatusBadRequest
|
||||
code := "device_invalid"
|
||||
if err == ErrNotFound {
|
||||
status = http.StatusNotFound
|
||||
code = "device_not_found"
|
||||
}
|
||||
if err == ErrConflict {
|
||||
status = http.StatusConflict
|
||||
code = "version_conflict"
|
||||
}
|
||||
platform.WriteError(w, &platform.APIError{Status: status, Code: code, Message: err.Error()})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package device
|
||||
|
||||
const MigrationSQL = `CREATE TABLE IF NOT EXISTS sense_devices(id TEXT PRIMARY KEY,name TEXT NOT NULL,location TEXT NOT NULL DEFAULT '',modality TEXT NOT NULL,capabilities TEXT NOT NULL DEFAULT '',status TEXT NOT NULL,adapter_status TEXT NOT NULL,credential_ciphertext BYTEA NULL,version BIGINT NOT NULL,created_at TIMESTAMPTZ NOT NULL,updated_at TIMESTAMPTZ NOT NULL);CREATE INDEX IF NOT EXISTS sense_devices_created_idx ON sense_devices(created_at DESC);`
|
||||
@@ -0,0 +1,126 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"yovision.local/sense/app/sense/identity"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
vault *CredentialVault
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(store Store, vault *CredentialVault) *Service {
|
||||
return &Service{store: store, vault: vault, now: time.Now}
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, actor identity.Principal, name, location, modality string, capabilities []string) (Device, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return Device{}, fmt.Errorf("设备名称不能为空")
|
||||
}
|
||||
if modality == "" {
|
||||
modality = ModalityVideo
|
||||
}
|
||||
adapter := AdapterNotReady
|
||||
if modality == ModalityVideo {
|
||||
adapter = AdapterReady
|
||||
}
|
||||
now := s.now().UTC()
|
||||
item := Device{ID: newID(), Name: name, Location: strings.TrimSpace(location), Modality: modality, Capabilities: capabilities, Status: StatusPending, AdapterStatus: adapter, Version: 1, CreatedAt: now, UpdatedAt: now}
|
||||
if err := s.store.Create(ctx, item); err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "device.create", item.ID, "success", map[string]any{"modality": modality})
|
||||
return item, nil
|
||||
}
|
||||
func (s *Service) Get(ctx context.Context, id string) (Device, error) {
|
||||
item, err := s.store.Get(ctx, id)
|
||||
if err == nil {
|
||||
item.CredentialConfigured = len(item.CredentialCiphertext) > 0
|
||||
item.CredentialCiphertext = nil
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
func (s *Service) List(ctx context.Context, filter ListFilter) (Page, error) {
|
||||
page, err := s.store.List(ctx, filter)
|
||||
for i := range page.Items {
|
||||
page.Items[i].CredentialCiphertext = nil
|
||||
}
|
||||
return page, err
|
||||
}
|
||||
func (s *Service) Update(ctx context.Context, actor identity.Principal, id, name, location string, capabilities []string, expected int64) (Device, error) {
|
||||
item, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
item.Name = strings.TrimSpace(name)
|
||||
item.Location = strings.TrimSpace(location)
|
||||
item.Capabilities = capabilities
|
||||
item.Version++
|
||||
item.UpdatedAt = s.now().UTC()
|
||||
if item.Name == "" {
|
||||
return Device{}, fmt.Errorf("设备名称不能为空")
|
||||
}
|
||||
if err := s.store.Update(ctx, item, expected); err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "device.update", id, "success", map[string]any{"version": item.Version})
|
||||
item.CredentialConfigured = len(item.CredentialCiphertext) > 0
|
||||
item.CredentialCiphertext = nil
|
||||
return item, nil
|
||||
}
|
||||
func (s *Service) SetCredential(ctx context.Context, actor identity.Principal, id, username, password string) (Device, error) {
|
||||
if strings.TrimSpace(username) == "" || password == "" {
|
||||
return Device{}, fmt.Errorf("用户名和密码不能为空")
|
||||
}
|
||||
item, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
ciphertext, err := s.vault.Encrypt(username, password)
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
expected := item.Version
|
||||
item.CredentialCiphertext = ciphertext
|
||||
item.CredentialConfigured = true
|
||||
item.Version++
|
||||
item.UpdatedAt = s.now().UTC()
|
||||
if err := s.store.Update(ctx, item, expected); err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "device.credential.update", id, "success", map[string]any{"configured": true})
|
||||
item.CredentialCiphertext = nil
|
||||
return item, nil
|
||||
}
|
||||
func (s *Service) Disable(ctx context.Context, actor identity.Principal, id string, expected int64) (Device, error) {
|
||||
item, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
item.Status = StatusDisabled
|
||||
item.Version++
|
||||
item.UpdatedAt = s.now().UTC()
|
||||
if err := s.store.Update(ctx, item, expected); err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "device.disable", id, "success", nil)
|
||||
item.CredentialConfigured = len(item.CredentialCiphertext) > 0
|
||||
item.CredentialCiphertext = nil
|
||||
return item, nil
|
||||
}
|
||||
func newID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return "dev_" + hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
"yovision.local/sense/app/sense/identity"
|
||||
)
|
||||
|
||||
func testService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
vault, err := NewCredentialVault(base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewService(NewMemoryStore(), vault)
|
||||
}
|
||||
func TestDeviceLifecycleAndCredentialNeverReturned(t *testing.T) {
|
||||
svc := testService(t)
|
||||
ctx := context.Background()
|
||||
actor := identity.Principal{UserID: "test"}
|
||||
item, err := svc.Create(ctx, actor, "东门摄像机", "东门", ModalityVideo, []string{"video"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.AdapterStatus != AdapterReady {
|
||||
t.Fatalf("adapter=%s", item.AdapterStatus)
|
||||
}
|
||||
item, err = svc.SetCredential(ctx, actor, item.ID, "camera-user", "camera-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !item.CredentialConfigured || len(item.CredentialCiphertext) != 0 {
|
||||
t.Fatal("credential leaked or not configured")
|
||||
}
|
||||
page, err := svc.List(ctx, ListFilter{Page: 1, PageSize: 20})
|
||||
if err != nil || page.Total != 1 || len(page.Items[0].CredentialCiphertext) != 0 {
|
||||
t.Fatalf("page=%#v err=%v", page, err)
|
||||
}
|
||||
}
|
||||
func TestNonVideoAdapterNotReadyAndPaginationNotCappedAt16(t *testing.T) {
|
||||
svc := testService(t)
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 20; i++ {
|
||||
item, err := svc.Create(ctx, identity.Principal{}, strings.Repeat("x", i+1), "", "radar", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.AdapterStatus != AdapterNotReady {
|
||||
t.Fatal("unsupported adapter shown ready")
|
||||
}
|
||||
}
|
||||
page, err := svc.List(ctx, ListFilter{Page: 1, PageSize: 20})
|
||||
if err != nil || len(page.Items) != 20 {
|
||||
t.Fatalf("items=%d err=%v", len(page.Items), err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("device not found")
|
||||
ErrConflict = errors.New("device version conflict")
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
Create(context.Context, Device) error
|
||||
Get(context.Context, string) (Device, error)
|
||||
Update(context.Context, Device, int64) error
|
||||
List(context.Context, ListFilter) (Page, error)
|
||||
}
|
||||
|
||||
type MemoryStore struct {
|
||||
mu sync.RWMutex
|
||||
devices map[string]Device
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore { return &MemoryStore{devices: map[string]Device{}} }
|
||||
|
||||
func (s *MemoryStore) Create(_ context.Context, item Device) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.devices[item.ID] = item
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) Get(_ context.Context, id string) (Device, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
item, ok := s.devices[id]
|
||||
if !ok {
|
||||
return Device{}, ErrNotFound
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) Update(_ context.Context, item Device, expected int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
current, ok := s.devices[item.ID]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if current.Version != expected {
|
||||
return ErrConflict
|
||||
}
|
||||
s.devices[item.ID] = item
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) List(_ context.Context, filter ListFilter) (Page, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
items := make([]Device, 0, len(s.devices))
|
||||
keyword := strings.ToLower(strings.TrimSpace(filter.Keyword))
|
||||
for _, item := range s.devices {
|
||||
if keyword == "" || strings.Contains(strings.ToLower(item.Name+" "+item.Location), keyword) {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].CreatedAt.After(items[j].CreatedAt) })
|
||||
page, size := normalizePage(filter.Page, filter.PageSize)
|
||||
total := len(items)
|
||||
start := (page - 1) * size
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + size
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
return Page{Items: append([]Device(nil), items[start:end]...), Total: total, Page: page, PageSize: size}, nil
|
||||
}
|
||||
|
||||
type PostgresStore struct{ database *sql.DB }
|
||||
|
||||
func NewPostgresStore(database *sql.DB) *PostgresStore { return &PostgresStore{database: database} }
|
||||
|
||||
func (s *PostgresStore) Create(ctx context.Context, item Device) error {
|
||||
_, err := s.database.ExecContext(ctx, `INSERT INTO sense_devices(id,name,location,modality,capabilities,status,adapter_status,credential_ciphertext,version,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, item.ID, item.Name, item.Location, item.Modality, strings.Join(item.Capabilities, ","), item.Status, item.AdapterStatus, item.CredentialCiphertext, item.Version, item.CreatedAt, item.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
func (s *PostgresStore) Get(ctx context.Context, id string) (Device, error) {
|
||||
var item Device
|
||||
var capabilities string
|
||||
err := s.database.QueryRowContext(ctx, `SELECT id,name,location,modality,capabilities,status,adapter_status,credential_ciphertext,version,created_at,updated_at FROM sense_devices WHERE id=$1`, id).Scan(&item.ID, &item.Name, &item.Location, &item.Modality, &capabilities, &item.Status, &item.AdapterStatus, &item.CredentialCiphertext, &item.Version, &item.CreatedAt, &item.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Device{}, ErrNotFound
|
||||
}
|
||||
item.Capabilities = splitCapabilities(capabilities)
|
||||
item.CredentialConfigured = len(item.CredentialCiphertext) > 0
|
||||
return item, err
|
||||
}
|
||||
func (s *PostgresStore) Update(ctx context.Context, item Device, expected int64) error {
|
||||
result, err := s.database.ExecContext(ctx, `UPDATE sense_devices SET name=$2,location=$3,modality=$4,capabilities=$5,status=$6,adapter_status=$7,credential_ciphertext=$8,version=$9,updated_at=$10 WHERE id=$1 AND version=$11`, item.ID, item.Name, item.Location, item.Modality, strings.Join(item.Capabilities, ","), item.Status, item.AdapterStatus, item.CredentialCiphertext, item.Version, item.UpdatedAt, expected)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *PostgresStore) List(ctx context.Context, filter ListFilter) (Page, error) {
|
||||
page, size := normalizePage(filter.Page, filter.PageSize)
|
||||
keyword := "%" + strings.TrimSpace(filter.Keyword) + "%"
|
||||
var total int
|
||||
if err := s.database.QueryRowContext(ctx, `SELECT count(*) FROM sense_devices WHERE name ILIKE $1 OR location ILIKE $1`, keyword).Scan(&total); err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
rows, err := s.database.QueryContext(ctx, `SELECT id,name,location,modality,capabilities,status,adapter_status,(credential_ciphertext IS NOT NULL),version,created_at,updated_at FROM sense_devices WHERE name ILIKE $1 OR location ILIKE $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`, keyword, size, (page-1)*size)
|
||||
if err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Device{}
|
||||
for rows.Next() {
|
||||
var item Device
|
||||
var caps string
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.Location, &item.Modality, &caps, &item.Status, &item.AdapterStatus, &item.CredentialConfigured, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
item.Capabilities = splitCapabilities(caps)
|
||||
items = append(items, item)
|
||||
}
|
||||
return Page{Items: items, Total: total, Page: page, PageSize: size}, rows.Err()
|
||||
}
|
||||
func normalizePage(page, size int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 {
|
||||
size = 20
|
||||
}
|
||||
if size > 100 {
|
||||
size = 100
|
||||
}
|
||||
return page, size
|
||||
}
|
||||
func splitCapabilities(value string) []string {
|
||||
if value == "" {
|
||||
return []string{}
|
||||
}
|
||||
return strings.Split(value, ",")
|
||||
}
|
||||
|
||||
var _ = time.Time{}
|
||||
@@ -0,0 +1,41 @@
|
||||
package device
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
ModalityVideo = "video"
|
||||
StatusPending = "pending"
|
||||
StatusActive = "active"
|
||||
StatusOffline = "offline"
|
||||
StatusDisabled = "disabled"
|
||||
AdapterReady = "ready"
|
||||
AdapterNotReady = "adapter_not_ready"
|
||||
)
|
||||
|
||||
type Device 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:"adapter_status"`
|
||||
CredentialConfigured bool `json:"credential_configured"`
|
||||
CredentialCiphertext []byte `json:"-"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListFilter struct {
|
||||
Keyword string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type Page struct {
|
||||
Items []Device `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package identity
|
||||
|
||||
import "context"
|
||||
|
||||
// RecordAudit is the narrow project-internal audit port used by Sense business
|
||||
// modules. It never accepts secrets and does not expose identity persistence.
|
||||
func RecordAudit(ctx context.Context, actor, action, target, outcome string, metadata map[string]any) {
|
||||
module := activeModule.Load()
|
||||
if module == nil {
|
||||
return
|
||||
}
|
||||
module.service.audit(ctx, actor, action, target, outcome, metadata)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"yovision.local/sense/internal/platform"
|
||||
)
|
||||
|
||||
const sessionCookieName = "sense_session"
|
||||
|
||||
type principalContextKey struct{}
|
||||
|
||||
type Module struct {
|
||||
service *Service
|
||||
cfg Config
|
||||
}
|
||||
|
||||
var activeModule atomic.Pointer[Module]
|
||||
|
||||
func NewModule(service *Service, cfg Config) *Module { return &Module{service: service, cfg: cfg} }
|
||||
|
||||
func (m *Module) Register(app *platform.App) {
|
||||
activeModule.Store(m)
|
||||
app.HandleFunc("POST /api/v1/identity/bootstrap", m.bootstrap)
|
||||
app.HandleFunc("POST /api/v1/identity/login", m.login)
|
||||
app.Handle("POST /api/v1/identity/logout", m.Authenticated(http.HandlerFunc(m.logout)))
|
||||
app.Handle("GET /api/v1/identity/me", m.Authenticated(http.HandlerFunc(m.me)))
|
||||
app.Handle("GET /api/v1/identity/users", m.Require(PermissionUsersRead, http.HandlerFunc(m.users)))
|
||||
app.Handle("POST /api/v1/identity/users", m.Require(PermissionUsersWrite, http.HandlerFunc(m.users)))
|
||||
app.Handle("PATCH /api/v1/identity/users/{id}", m.Require(PermissionUsersWrite, http.HandlerFunc(m.updateUser)))
|
||||
app.Handle("GET /api/v1/identity/audit", m.Require(PermissionAuditRead, http.HandlerFunc(m.audit)))
|
||||
}
|
||||
|
||||
func Require(permission string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
module := activeModule.Load()
|
||||
if module == nil {
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusServiceUnavailable, Code: "identity_not_ready", Message: "身份服务尚未就绪"})
|
||||
return
|
||||
}
|
||||
module.Require(permission, next).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func PrincipalFromContext(ctx context.Context) (Principal, bool) {
|
||||
principal, ok := ctx.Value(principalContextKey{}).(Principal)
|
||||
return principal, ok
|
||||
}
|
||||
|
||||
func (m *Module) Authenticated(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Product") != "sense" {
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "登录状态无效"})
|
||||
return
|
||||
}
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "登录状态无效"})
|
||||
return
|
||||
}
|
||||
principal, err := m.service.Authenticate(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "登录状态无效"})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), principalContextKey{}, principal)))
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Module) Require(permission string, next http.Handler) http.Handler {
|
||||
return m.Authenticated(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := PrincipalFromContext(r.Context())
|
||||
if !HasPermission(principal.Role, permission) {
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusForbidden, Code: "forbidden", Message: "没有执行此操作的权限"})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
}))
|
||||
}
|
||||
|
||||
func (m *Module) bootstrap(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := platform.DecodeJSON(r, &request); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
user, err := m.service.Bootstrap(r.Context(), r.Header.Get("X-Sense-Bootstrap-Token"), request.Username, request.DisplayName, request.Password)
|
||||
if err != nil {
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusForbidden, Code: "bootstrap_denied", Message: "安全初始化不可用或输入不符合要求"})
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusCreated, user)
|
||||
}
|
||||
|
||||
func (m *Module) login(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := platform.DecodeJSON(r, &request); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
principal, token, err := m.service.Login(r.Context(), request.Username, request.Password)
|
||||
if err != nil {
|
||||
// A small constant delay and one generic message reduce account discovery.
|
||||
time.Sleep(80*time.Millisecond + randomJitter())
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "invalid_credentials", Message: "用户名或密码不正确"})
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: token, Path: "/api/v1", HttpOnly: true, Secure: m.cfg.SecureCookie, SameSite: http.SameSiteStrictMode, MaxAge: int(m.cfg.SessionTTL.Seconds())})
|
||||
platform.WriteJSON(w, http.StatusOK, principal)
|
||||
}
|
||||
|
||||
func randomJitter() time.Duration {
|
||||
var value [1]byte
|
||||
_, _ = rand.Read(value[:])
|
||||
return time.Duration(value[0]%40) * time.Millisecond
|
||||
}
|
||||
|
||||
func (m *Module) logout(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := PrincipalFromContext(r.Context())
|
||||
if err := m.service.Logout(r.Context(), principal); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/api/v1", HttpOnly: true, Secure: m.cfg.SecureCookie, SameSite: http.SameSiteStrictMode, MaxAge: -1})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (m *Module) me(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := PrincipalFromContext(r.Context())
|
||||
platform.WriteJSON(w, http.StatusOK, principal)
|
||||
}
|
||||
|
||||
func (m *Module) users(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
users, err := m.service.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, map[string]any{"items": users, "total": len(users)})
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := platform.DecodeJSON(r, &request); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
principal, _ := PrincipalFromContext(r.Context())
|
||||
user, err := m.service.CreateUser(r.Context(), principal, request.Username, request.DisplayName, request.Role, request.Password)
|
||||
if err != nil {
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusBadRequest, Code: "user_not_created", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusCreated, user)
|
||||
}
|
||||
|
||||
func (m *Module) updateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := platform.DecodeJSON(r, &request); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
principal, _ := PrincipalFromContext(r.Context())
|
||||
user, err := m.service.UpdateUser(r.Context(), principal, r.PathValue("id"), request.DisplayName, request.Role, request.Enabled)
|
||||
if err != nil {
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusBadRequest, Code: "user_not_updated", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (m *Module) audit(w http.ResponseWriter, r *http.Request) {
|
||||
entries, err := m.service.ListAudit(r.Context())
|
||||
if err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, map[string]any{"items": entries, "total": len(entries)})
|
||||
}
|
||||
|
||||
func SigningKeyOrRandom(raw string, allowRandom bool) ([]byte, error) {
|
||||
if len(raw) >= 32 {
|
||||
return []byte(raw), nil
|
||||
}
|
||||
if !allowRandom {
|
||||
return nil, errors.New("SENSE_IDENTITY_SIGNING_KEY must contain at least 32 bytes")
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func SecureCookieFromEnvironment(value string, memoryMode bool) bool {
|
||||
if memoryMode {
|
||||
return strings.EqualFold(value, "true")
|
||||
}
|
||||
return !strings.EqualFold(value, "false")
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"yovision.local/sense/internal/platform"
|
||||
)
|
||||
|
||||
func TestHTTPLoginCookieAndProductBoundary(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
cfg := Config{SigningKey: []byte("0123456789abcdef0123456789abcdef"), BootstrapToken: "bootstrap-test", SessionTTL: time.Hour}
|
||||
service, err := NewService(store, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := platform.NewApp(platform.Config{DatabaseMode: platform.DatabaseModeMemory}, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
NewModule(service, cfg).Register(app)
|
||||
|
||||
bootstrap := httptest.NewRequest(http.MethodPost, "/api/v1/identity/bootstrap", bytes.NewBufferString(`{"username":"admin","display_name":"管理员","password":"StrongPass2026"}`))
|
||||
bootstrap.Header.Set("X-Sense-Bootstrap-Token", "bootstrap-test")
|
||||
bootstrapResult := httptest.NewRecorder()
|
||||
app.Handler().ServeHTTP(bootstrapResult, bootstrap)
|
||||
if bootstrapResult.Code != http.StatusCreated {
|
||||
t.Fatalf("bootstrap status = %d, body = %s", bootstrapResult.Code, bootstrapResult.Body.String())
|
||||
}
|
||||
|
||||
login := httptest.NewRequest(http.MethodPost, "/api/v1/identity/login", bytes.NewBufferString(`{"username":"admin","password":"StrongPass2026"}`))
|
||||
loginResult := httptest.NewRecorder()
|
||||
app.Handler().ServeHTTP(loginResult, login)
|
||||
if loginResult.Code != http.StatusOK {
|
||||
t.Fatalf("login status = %d, body = %s", loginResult.Code, loginResult.Body.String())
|
||||
}
|
||||
cookies := loginResult.Result().Cookies()
|
||||
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteStrictMode {
|
||||
t.Fatalf("unexpected session cookies: %#v", cookies)
|
||||
}
|
||||
|
||||
me := httptest.NewRequest(http.MethodGet, "/api/v1/identity/me", nil)
|
||||
me.AddCookie(cookies[0])
|
||||
me.Header.Set("X-Product", "bell")
|
||||
foreignResult := httptest.NewRecorder()
|
||||
app.Handler().ServeHTTP(foreignResult, me)
|
||||
if foreignResult.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("Bell product header status = %d", foreignResult.Code)
|
||||
}
|
||||
|
||||
me.Header.Set("X-Product", "sense")
|
||||
senseResult := httptest.NewRecorder()
|
||||
app.Handler().ServeHTTP(senseResult, me)
|
||||
if senseResult.Code != http.StatusOK {
|
||||
t.Fatalf("Sense product header status = %d", senseResult.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package identity
|
||||
|
||||
const MigrationSQL = `
|
||||
CREATE TABLE IF NOT EXISTS sense_identity_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sense_identity_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES sense_identity_users(id),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sense_identity_audit (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor_id TEXT NULL REFERENCES sense_identity_users(id),
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
outcome TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS sense_identity_audit_created_idx ON sense_identity_audit(created_at DESC);
|
||||
`
|
||||
@@ -0,0 +1,37 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func ValidatePassword(username, password string) error {
|
||||
if len([]rune(password)) < 6 {
|
||||
return fmt.Errorf("密码至少需要 6 个字符")
|
||||
}
|
||||
var upper, lower, digit bool
|
||||
for _, value := range password {
|
||||
upper = upper || unicode.IsUpper(value)
|
||||
lower = lower || unicode.IsLower(value)
|
||||
digit = digit || unicode.IsDigit(value)
|
||||
}
|
||||
if !upper || !lower || !digit {
|
||||
return fmt.Errorf("密码必须同时包含大写字母、小写字母和数字")
|
||||
}
|
||||
if username != "" && strings.Contains(strings.ToLower(password), strings.ToLower(username)) {
|
||||
return fmt.Errorf("密码不能包含用户名")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(hash), err
|
||||
}
|
||||
|
||||
func VerifyPassword(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidatePasswordLengthAndComplexity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
want string
|
||||
}{
|
||||
{name: "six characters", username: "operator", password: "Abc123"},
|
||||
{name: "five characters", username: "operator", password: "Ab123", want: "至少需要 6 个字符"},
|
||||
{name: "missing uppercase", username: "operator", password: "abc123", want: "大写字母、小写字母和数字"},
|
||||
{name: "missing lowercase", username: "operator", password: "ABC123", want: "大写字母、小写字母和数字"},
|
||||
{name: "missing digit", username: "operator", password: "Abcdef", want: "大写字母、小写字母和数字"},
|
||||
{name: "contains username", username: "admin", password: "MyAdmin1", want: "不能包含用户名"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := ValidatePassword(test.username, test.password)
|
||||
if test.want == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("ValidatePassword() error = %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("ValidatePassword() error = %v, want substring %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package identity
|
||||
|
||||
var rolePermissions = map[string][]string{
|
||||
RoleAdministrator: {
|
||||
PermissionUsersRead, PermissionUsersWrite, PermissionAuditRead,
|
||||
PermissionDeviceRead, PermissionDeviceWrite,
|
||||
PermissionMediaRead, PermissionMediaWrite,
|
||||
PermissionAreaRead, PermissionAreaWrite,
|
||||
},
|
||||
RoleOperator: {
|
||||
PermissionDeviceRead, PermissionDeviceWrite,
|
||||
PermissionMediaRead, PermissionMediaWrite,
|
||||
PermissionAreaRead, PermissionAreaWrite,
|
||||
},
|
||||
RoleSiteAdmin: {
|
||||
PermissionUsersRead, PermissionAuditRead,
|
||||
PermissionDeviceRead, PermissionDeviceWrite,
|
||||
PermissionMediaRead, PermissionAreaRead, PermissionAreaWrite,
|
||||
},
|
||||
RoleViewer: {
|
||||
PermissionDeviceRead, PermissionMediaRead, PermissionAreaRead,
|
||||
},
|
||||
}
|
||||
|
||||
func ValidRole(role string) bool {
|
||||
_, ok := rolePermissions[role]
|
||||
return ok
|
||||
}
|
||||
|
||||
func PermissionsForRole(role string) []string {
|
||||
permissions := rolePermissions[role]
|
||||
return append([]string(nil), permissions...)
|
||||
}
|
||||
|
||||
func HasPermission(role, permission string) bool {
|
||||
for _, candidate := range rolePermissions[role] {
|
||||
if candidate == permission {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PostgresStore struct{ database *sql.DB }
|
||||
|
||||
func NewPostgresStore(database *sql.DB) *PostgresStore { return &PostgresStore{database: database} }
|
||||
|
||||
func (s *PostgresStore) BootstrapUser(ctx context.Context, user User) error {
|
||||
tx, err := s.database.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM sense_identity_users`).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 0 {
|
||||
return ErrBootstrapDone
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO sense_identity_users(id, username, display_name, role, enabled, password_hash, created_at, updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, user.ID, user.Username, user.DisplayName, user.Role, user.Enabled, user.PasswordHash, user.CreatedAt, user.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateUser(ctx context.Context, user User) error {
|
||||
_, err := s.database.ExecContext(ctx, `INSERT INTO sense_identity_users(id, username, display_name, role, enabled, password_hash, created_at, updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, user.ID, user.Username, user.DisplayName, user.Role, user.Enabled, user.PasswordHash, user.CreatedAt, user.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UpdateUser(ctx context.Context, user User) error {
|
||||
result, err := s.database.ExecContext(ctx, `UPDATE sense_identity_users SET display_name=$2, role=$3, enabled=$4, password_hash=$5, updated_at=$6 WHERE id=$1`, user.ID, user.DisplayName, user.Role, user.Enabled, user.PasswordHash, user.UpdatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) FindUserByUsername(ctx context.Context, username string) (User, error) {
|
||||
return scanUser(s.database.QueryRowContext(ctx, `SELECT id,username,display_name,role,enabled,password_hash,created_at,updated_at FROM sense_identity_users WHERE lower(username)=lower($1)`, username))
|
||||
}
|
||||
|
||||
func (s *PostgresStore) FindUserByID(ctx context.Context, id string) (User, error) {
|
||||
return scanUser(s.database.QueryRowContext(ctx, `SELECT id,username,display_name,role,enabled,password_hash,created_at,updated_at FROM sense_identity_users WHERE id=$1`, id))
|
||||
}
|
||||
|
||||
type rowScanner interface{ Scan(...any) error }
|
||||
|
||||
func scanUser(row rowScanner) (User, error) {
|
||||
var user User
|
||||
err := row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Role, &user.Enabled, &user.PasswordHash, &user.CreatedAt, &user.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListUsers(ctx context.Context) ([]User, error) {
|
||||
rows, err := s.database.QueryContext(ctx, `SELECT id,username,display_name,role,enabled,password_hash,created_at,updated_at FROM sense_identity_users ORDER BY username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var users []User
|
||||
for rows.Next() {
|
||||
user, err := scanUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateSession(ctx context.Context, session Session) error {
|
||||
_, err := s.database.ExecContext(ctx, `INSERT INTO sense_identity_sessions(id,user_id,expires_at) VALUES($1,$2,$3)`, session.ID, session.UserID, session.ExpiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) SessionActive(ctx context.Context, id string, now time.Time) (bool, error) {
|
||||
var active bool
|
||||
err := s.database.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM sense_identity_sessions WHERE id=$1 AND revoked_at IS NULL AND expires_at>$2)`, id, now).Scan(&active)
|
||||
return active, err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) RevokeSession(ctx context.Context, id string, now time.Time) error {
|
||||
_, err := s.database.ExecContext(ctx, `UPDATE sense_identity_sessions SET revoked_at=$2 WHERE id=$1`, id, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) AppendAudit(ctx context.Context, entry AuditEntry) error {
|
||||
metadata, err := json.Marshal(entry.Metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.database.ExecContext(ctx, `INSERT INTO sense_identity_audit(id,actor_id,action,target,outcome,metadata,created_at) VALUES($1,NULLIF($2,''),$3,$4,$5,$6,$7)`, entry.ID, entry.ActorID, entry.Action, entry.Target, entry.Outcome, metadata, entry.CreatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListAudit(ctx context.Context, limit int) ([]AuditEntry, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.database.QueryContext(ctx, `SELECT id,COALESCE(actor_id,''),action,target,outcome,metadata,created_at FROM sense_identity_audit ORDER BY created_at DESC LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var entries []AuditEntry
|
||||
for rows.Next() {
|
||||
var entry AuditEntry
|
||||
var metadata []byte
|
||||
if err := rows.Scan(&entry.ID, &entry.ActorID, &entry.Action, &entry.Target, &entry.Outcome, &metadata, &entry.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal(metadata, &entry.Metadata)
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
SigningKey []byte
|
||||
BootstrapToken string
|
||||
SecureCookie bool
|
||||
SessionTTL time.Duration
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
cfg Config
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(store Store, cfg Config) (*Service, error) {
|
||||
if len(cfg.SigningKey) < 32 {
|
||||
return nil, fmt.Errorf("Sense identity signing key must contain at least 32 bytes")
|
||||
}
|
||||
if cfg.SessionTTL <= 0 {
|
||||
cfg.SessionTTL = 8 * time.Hour
|
||||
}
|
||||
return &Service{store: store, cfg: cfg, now: time.Now}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Bootstrap(ctx context.Context, token, username, displayName, password string) (User, error) {
|
||||
if s.cfg.BootstrapToken == "" || subtle.ConstantTimeCompare([]byte(token), []byte(s.cfg.BootstrapToken)) != 1 {
|
||||
return User{}, errors.New("bootstrap denied")
|
||||
}
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" || len(username) > 64 {
|
||||
return User{}, fmt.Errorf("用户名格式不正确")
|
||||
}
|
||||
if err := ValidatePassword(username, password); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
user := User{ID: newID("usr"), Username: username, DisplayName: strings.TrimSpace(displayName), Role: RoleAdministrator, Enabled: true, PasswordHash: hash, CreatedAt: now, UpdatedAt: now}
|
||||
if err := s.store.BootstrapUser(ctx, user); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
s.audit(ctx, user.ID, "identity.bootstrap", user.ID, "success", nil)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, username, password string) (Principal, string, error) {
|
||||
user, err := s.store.FindUserByUsername(ctx, strings.TrimSpace(username))
|
||||
if err != nil || !user.Enabled || !VerifyPassword(user.PasswordHash, password) {
|
||||
s.audit(ctx, "", "identity.login", "", "failure", map[string]any{"reason": "invalid_credentials"})
|
||||
return Principal{}, "", errors.New("invalid credentials")
|
||||
}
|
||||
now := s.now().UTC()
|
||||
session := Session{ID: newID("ses"), UserID: user.ID, ExpiresAt: now.Add(s.cfg.SessionTTL)}
|
||||
if err := s.store.CreateSession(ctx, session); err != nil {
|
||||
return Principal{}, "", err
|
||||
}
|
||||
raw, err := issueToken(s.cfg.SigningKey, user, session, now)
|
||||
if err != nil {
|
||||
return Principal{}, "", err
|
||||
}
|
||||
s.audit(ctx, user.ID, "identity.login", user.ID, "success", nil)
|
||||
return principalFor(user, session.ID), raw, nil
|
||||
}
|
||||
|
||||
func (s *Service) Authenticate(ctx context.Context, raw string) (Principal, error) {
|
||||
claims, err := parseToken(s.cfg.SigningKey, raw, s.now())
|
||||
if err != nil {
|
||||
return Principal{}, err
|
||||
}
|
||||
active, err := s.store.SessionActive(ctx, claims.ID, s.now())
|
||||
if err != nil || !active {
|
||||
return Principal{}, errors.New("inactive Sense session")
|
||||
}
|
||||
user, err := s.store.FindUserByID(ctx, claims.Subject)
|
||||
if err != nil || !user.Enabled || user.Role != claims.Role {
|
||||
return Principal{}, errors.New("invalid Sense principal")
|
||||
}
|
||||
return principalFor(user, claims.ID), nil
|
||||
}
|
||||
|
||||
func (s *Service) Logout(ctx context.Context, principal Principal) error {
|
||||
if err := s.store.RevokeSession(ctx, principal.SessionID, s.now().UTC()); err != nil && !errors.Is(err, ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
s.audit(ctx, principal.UserID, "identity.logout", principal.UserID, "success", nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ListUsers(ctx context.Context) ([]User, error) { return s.store.ListUsers(ctx) }
|
||||
func (s *Service) ListAudit(ctx context.Context) ([]AuditEntry, error) {
|
||||
return s.store.ListAudit(ctx, 100)
|
||||
}
|
||||
|
||||
func (s *Service) CreateUser(ctx context.Context, actor Principal, username, displayName, role, password string) (User, error) {
|
||||
if !ValidRole(role) {
|
||||
return User{}, fmt.Errorf("角色不正确")
|
||||
}
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" || len(username) > 64 {
|
||||
return User{}, fmt.Errorf("用户名格式不正确")
|
||||
}
|
||||
if err := ValidatePassword(username, password); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
user := User{ID: newID("usr"), Username: username, DisplayName: strings.TrimSpace(displayName), Role: role, Enabled: true, PasswordHash: hash, CreatedAt: now, UpdatedAt: now}
|
||||
if err := s.store.CreateUser(ctx, user); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
s.audit(ctx, actor.UserID, "identity.user.create", user.ID, "success", map[string]any{"role": role})
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateUser(ctx context.Context, actor Principal, id, displayName, role string, enabled bool) (User, error) {
|
||||
if !ValidRole(role) {
|
||||
return User{}, fmt.Errorf("角色不正确")
|
||||
}
|
||||
user, err := s.store.FindUserByID(ctx, id)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if actor.UserID == id && !enabled {
|
||||
return User{}, fmt.Errorf("不能停用当前登录账户")
|
||||
}
|
||||
user.DisplayName = strings.TrimSpace(displayName)
|
||||
user.Role = role
|
||||
user.Enabled = enabled
|
||||
user.UpdatedAt = s.now().UTC()
|
||||
if err := s.store.UpdateUser(ctx, user); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
s.audit(ctx, actor.UserID, "identity.user.update", user.ID, "success", map[string]any{"role": role, "enabled": enabled})
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Service) audit(ctx context.Context, actor, action, target, outcome string, metadata map[string]any) {
|
||||
_ = s.store.AppendAudit(ctx, AuditEntry{ID: newID("aud"), ActorID: actor, Action: action, Target: target, Outcome: outcome, Metadata: metadata, CreatedAt: s.now().UTC()})
|
||||
}
|
||||
|
||||
func principalFor(user User, sessionID string) Principal {
|
||||
return Principal{UserID: user.ID, Username: user.Username, DisplayName: user.DisplayName, Role: user.Role, Permissions: PermissionsForRole(user.Role), SessionID: sessionID}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestService(t *testing.T) (*Service, *MemoryStore) {
|
||||
t.Helper()
|
||||
store := NewMemoryStore()
|
||||
service, err := NewService(store, Config{SigningKey: []byte("0123456789abcdef0123456789abcdef"), BootstrapToken: "one-time-token", SessionTTL: time.Hour})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return service, store
|
||||
}
|
||||
|
||||
func TestBootstrapLoginRolesAndRevocation(t *testing.T) {
|
||||
service, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
admin, err := service.Bootstrap(ctx, "one-time-token", "admin", "管理员", "StrongPass2026")
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap() error = %v", err)
|
||||
}
|
||||
if _, err := service.Bootstrap(ctx, "one-time-token", "another", "", "SecondStrong2026"); err != ErrBootstrapDone {
|
||||
t.Fatalf("second Bootstrap() error = %v", err)
|
||||
}
|
||||
principal, raw, err := service.Login(ctx, "admin", "StrongPass2026")
|
||||
if err != nil {
|
||||
t.Fatalf("Login() error = %v", err)
|
||||
}
|
||||
if principal.Role != RoleAdministrator || !HasPermission(principal.Role, PermissionUsersWrite) {
|
||||
t.Fatalf("unexpected principal: %#v", principal)
|
||||
}
|
||||
authenticated, err := service.Authenticate(ctx, raw)
|
||||
if err != nil || authenticated.UserID != admin.ID {
|
||||
t.Fatalf("Authenticate() = %#v, %v", authenticated, err)
|
||||
}
|
||||
if err := service.Logout(ctx, authenticated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.Authenticate(ctx, raw); err == nil {
|
||||
t.Fatal("revoked token remained valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidLoginIsUniformAndAudited(t *testing.T) {
|
||||
service, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
if _, _, err := service.Login(ctx, "missing", "WrongPass2026"); err == nil {
|
||||
t.Fatal("missing user login succeeded")
|
||||
}
|
||||
entries, err := service.ListAudit(ctx)
|
||||
if err != nil || len(entries) != 1 || entries[0].Outcome != "failure" {
|
||||
t.Fatalf("audit entries = %#v, %v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBellTokenIsRejected(t *testing.T) {
|
||||
service, _ := newTestService(t)
|
||||
if _, err := service.Authenticate(context.Background(), "not-a-sense-token"); err == nil {
|
||||
t.Fatal("foreign token accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleMatrix(t *testing.T) {
|
||||
if HasPermission(RoleViewer, PermissionDeviceWrite) {
|
||||
t.Fatal("viewer received write permission")
|
||||
}
|
||||
if !HasPermission(RoleOperator, PermissionMediaWrite) {
|
||||
t.Fatal("operator missing media write permission")
|
||||
}
|
||||
if HasPermission(RoleSiteAdmin, PermissionUsersWrite) {
|
||||
t.Fatal("site admin received user write permission")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrAlreadyExists = errors.New("already exists")
|
||||
ErrBootstrapDone = errors.New("bootstrap already completed")
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
BootstrapUser(context.Context, User) error
|
||||
CreateUser(context.Context, User) error
|
||||
UpdateUser(context.Context, User) error
|
||||
FindUserByUsername(context.Context, string) (User, error)
|
||||
FindUserByID(context.Context, string) (User, error)
|
||||
ListUsers(context.Context) ([]User, error)
|
||||
CreateSession(context.Context, Session) error
|
||||
SessionActive(context.Context, string, time.Time) (bool, error)
|
||||
RevokeSession(context.Context, string, time.Time) error
|
||||
AppendAudit(context.Context, AuditEntry) error
|
||||
ListAudit(context.Context, int) ([]AuditEntry, error)
|
||||
}
|
||||
|
||||
type MemoryStore struct {
|
||||
mu sync.RWMutex
|
||||
users map[string]User
|
||||
byName map[string]string
|
||||
sessions map[string]Session
|
||||
audit []AuditEntry
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{users: map[string]User{}, byName: map[string]string{}, sessions: map[string]Session{}}
|
||||
}
|
||||
|
||||
func (s *MemoryStore) BootstrapUser(_ context.Context, user User) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.users) != 0 {
|
||||
return ErrBootstrapDone
|
||||
}
|
||||
return s.createUserLocked(user)
|
||||
}
|
||||
|
||||
func (s *MemoryStore) CreateUser(_ context.Context, user User) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.createUserLocked(user)
|
||||
}
|
||||
|
||||
func (s *MemoryStore) createUserLocked(user User) error {
|
||||
key := strings.ToLower(user.Username)
|
||||
if _, exists := s.byName[key]; exists {
|
||||
return ErrAlreadyExists
|
||||
}
|
||||
s.users[user.ID] = user
|
||||
s.byName[key] = user.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) UpdateUser(_ context.Context, user User) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
previous, exists := s.users[user.ID]
|
||||
if !exists {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(s.byName, strings.ToLower(previous.Username))
|
||||
s.users[user.ID] = user
|
||||
s.byName[strings.ToLower(user.Username)] = user.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) FindUserByUsername(_ context.Context, username string) (User, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
id, exists := s.byName[strings.ToLower(username)]
|
||||
if !exists {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
return s.users[id], nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) FindUserByID(_ context.Context, id string) (User, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
user, exists := s.users[id]
|
||||
if !exists {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListUsers(_ context.Context) ([]User, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
users := make([]User, 0, len(s.users))
|
||||
for _, user := range s.users {
|
||||
users = append(users, user)
|
||||
}
|
||||
sort.Slice(users, func(i, j int) bool { return users[i].Username < users[j].Username })
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) CreateSession(_ context.Context, session Session) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions[session.ID] = session
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) SessionActive(_ context.Context, id string, now time.Time) (bool, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
session, exists := s.sessions[id]
|
||||
return exists && session.RevokedAt == nil && now.Before(session.ExpiresAt), nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) RevokeSession(_ context.Context, id string, now time.Time) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
session, exists := s.sessions[id]
|
||||
if !exists {
|
||||
return ErrNotFound
|
||||
}
|
||||
session.RevokedAt = &now
|
||||
s.sessions[id] = session
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) AppendAudit(_ context.Context, entry AuditEntry) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.audit = append(s.audit, entry)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListAudit(_ context.Context, limit int) ([]AuditEntry, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
start := len(s.audit) - limit
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
entries := append([]AuditEntry(nil), s.audit[start:]...)
|
||||
for left, right := 0, len(entries)-1; left < right; left, right = left+1, right-1 {
|
||||
entries[left], entries[right] = entries[right], entries[left]
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func newID(prefix string) string {
|
||||
buffer := make([]byte, 16)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
panic("cryptographic random source unavailable")
|
||||
}
|
||||
return prefix + "_" + hex.EncodeToString(buffer)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
tokenIssuer = "yovision-sense"
|
||||
tokenAudience = "sense-ui"
|
||||
)
|
||||
|
||||
type sessionClaims struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func issueToken(key []byte, user User, session Session, now time.Time) (string, error) {
|
||||
claims := sessionClaims{
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: tokenIssuer,
|
||||
Subject: user.ID,
|
||||
Audience: jwt.ClaimStrings{tokenAudience},
|
||||
ExpiresAt: jwt.NewNumericDate(session.ExpiresAt),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now.Add(-time.Minute)),
|
||||
ID: session.ID,
|
||||
},
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(key)
|
||||
}
|
||||
|
||||
func parseToken(key []byte, raw string, now time.Time) (*sessionClaims, error) {
|
||||
claims := &sessionClaims{}
|
||||
token, err := jwt.ParseWithClaims(raw, claims, func(token *jwt.Token) (any, error) {
|
||||
if token.Method != jwt.SigningMethodHS256 {
|
||||
return nil, fmt.Errorf("unexpected signing method")
|
||||
}
|
||||
return key, nil
|
||||
}, jwt.WithIssuer(tokenIssuer), jwt.WithAudience(tokenAudience), jwt.WithTimeFunc(func() time.Time { return now }))
|
||||
if err != nil || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid Sense session")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package identity
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
RoleAdministrator = "administrator"
|
||||
RoleOperator = "operator"
|
||||
RoleSiteAdmin = "site_admin"
|
||||
RoleViewer = "viewer"
|
||||
)
|
||||
|
||||
const (
|
||||
PermissionUsersRead = "identity.users.read"
|
||||
PermissionUsersWrite = "identity.users.write"
|
||||
PermissionAuditRead = "identity.audit.read"
|
||||
PermissionDeviceRead = "device.read"
|
||||
PermissionDeviceWrite = "device.write"
|
||||
PermissionMediaRead = "media.read"
|
||||
PermissionMediaWrite = "media.write"
|
||||
PermissionAreaRead = "area.read"
|
||||
PermissionAreaWrite = "area.write"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
Enabled bool `json:"enabled"`
|
||||
PasswordHash string `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
ID string
|
||||
UserID string
|
||||
ExpiresAt time.Time
|
||||
RevokedAt *time.Time
|
||||
}
|
||||
|
||||
type AuditEntry struct {
|
||||
ID string `json:"id"`
|
||||
ActorID string `json:"actor_id,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Outcome string `json:"outcome"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Principal struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
SessionID string `json:"-"`
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package liveview
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"yovision.local/sense/app/sense/identity"
|
||||
"yovision.local/sense/internal/platform"
|
||||
)
|
||||
|
||||
type Module struct{ service *Service }
|
||||
|
||||
func NewModule(service *Service) *Module { return &Module{service: service} }
|
||||
func (m *Module) Register(app *platform.App) {
|
||||
app.Handle("GET /api/v1/liveview/routes", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.routes)))
|
||||
app.Handle("POST /api/v1/liveview/sessions", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.create)))
|
||||
app.Handle("GET /api/v1/liveview/sessions/{id}", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.get)))
|
||||
app.Handle("GET /api/v1/liveview/sessions/{id}/player", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.player)))
|
||||
}
|
||||
func (m *Module) routes(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := m.service.Routes(r.Context())
|
||||
if err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
func (m *Module) create(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
RouteID string `json:"route_id"`
|
||||
}
|
||||
if err := platform.DecodeJSON(r, &req); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
principal, _ := identity.PrincipalFromContext(r.Context())
|
||||
session, err := m.service.Create(r.Context(), principal.UserID, req.RouteID)
|
||||
if err != nil {
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusBadRequest, Code: "liveview_unavailable", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusCreated, session)
|
||||
}
|
||||
func (m *Module) get(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := identity.PrincipalFromContext(r.Context())
|
||||
session, err := m.service.Get(r.Context(), principal.UserID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusGone, Code: "session_expired", Message: "播放会话已过期,请重新打开"})
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, session)
|
||||
}
|
||||
|
||||
var playerTemplate = template.Must(template.New("player").Parse(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><style>html,body,iframe{width:100%;height:100%;margin:0;border:0;background:#111}</style></head><body><iframe src="{{.}}" allow="autoplay; fullscreen" title="Sense live video"></iframe></body></html>`))
|
||||
|
||||
func (m *Module) player(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := identity.PrincipalFromContext(r.Context())
|
||||
target, err := m.service.EmbedURL(r.Context(), principal.UserID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "播放会话已过期", http.StatusGone)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-src http: https:; style-src 'unsafe-inline'")
|
||||
_ = playerTemplate.Execute(w, target)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package liveview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"yovision.local/sense/app/sense/media"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
OwnerID string `json:"-"`
|
||||
RouteID string `json:"route_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ProfileToken string `json:"profile_token"`
|
||||
PlayerURL string `json:"player_url"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
type Service struct {
|
||||
base *url.URL
|
||||
ttl time.Duration
|
||||
route func(context.Context, string) (media.Route, error)
|
||||
routes func(context.Context) ([]media.Route, error)
|
||||
mu sync.RWMutex
|
||||
sessions map[string]Session
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(rawBase string, ttl time.Duration) (*Service, error) {
|
||||
parsed, err := url.Parse(strings.TrimRight(rawBase, "/"))
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil {
|
||||
return nil, fmt.Errorf("invalid MediaMTX WebRTC base URL")
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, fmt.Errorf("unsupported MediaMTX WebRTC scheme")
|
||||
}
|
||||
if ttl <= 0 || ttl > 10*time.Minute {
|
||||
ttl = 2 * time.Minute
|
||||
}
|
||||
return &Service{base: parsed, ttl: ttl, route: media.PlaybackRoute, routes: media.PlaybackRoutes, sessions: map[string]Session{}, now: time.Now}, nil
|
||||
}
|
||||
func (s *Service) Routes(ctx context.Context) ([]media.Route, error) {
|
||||
items, err := s.routes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := items[:0]
|
||||
for _, item := range items {
|
||||
if item.Desired == "running" {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (s *Service) Create(ctx context.Context, owner, routeID string) (Session, error) {
|
||||
route, err := s.route(ctx, routeID)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
if route.Desired != "running" {
|
||||
return Session{}, fmt.Errorf("media route is stopped")
|
||||
}
|
||||
id := newID()
|
||||
session := Session{ID: id, OwnerID: owner, RouteID: route.ID, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, PlayerURL: "/api/v1/liveview/sessions/" + id + "/player", Status: route.Actual, Detail: route.Detail, ExpiresAt: s.now().UTC().Add(s.ttl)}
|
||||
s.mu.Lock()
|
||||
s.sessions[id] = session
|
||||
s.mu.Unlock()
|
||||
return session, nil
|
||||
}
|
||||
func (s *Service) Get(ctx context.Context, owner, id string) (Session, error) {
|
||||
s.mu.RLock()
|
||||
session, ok := s.sessions[id]
|
||||
s.mu.RUnlock()
|
||||
if !ok || session.OwnerID != owner || !s.now().Before(session.ExpiresAt) {
|
||||
return Session{}, fmt.Errorf("playback session expired")
|
||||
}
|
||||
route, err := s.route(ctx, session.RouteID)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
session.Status = route.Actual
|
||||
session.Detail = route.Detail
|
||||
return session, nil
|
||||
}
|
||||
func (s *Service) EmbedURL(ctx context.Context, owner, id string) (string, error) {
|
||||
session, err := s.Get(ctx, owner, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
route, err := s.route(ctx, session.RouteID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
target := *s.base
|
||||
target.Path = strings.TrimRight(target.Path, "/") + "/" + route.Path
|
||||
return target.String(), nil
|
||||
}
|
||||
func newID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return "view_" + hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package liveview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"yovision.local/sense/app/sense/media"
|
||||
)
|
||||
|
||||
func TestRejectCredentialBaseURL(t *testing.T) {
|
||||
if _, err := NewService("http://user:pass@127.0.0.1:8889", time.Minute); err == nil {
|
||||
t.Fatal("credential base URL accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionIsOwnerBoundAndExpires(t *testing.T) {
|
||||
service, err := NewService("http://127.0.0.1:8889", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 12, 0, 0, 0, 0, time.UTC)
|
||||
service.now = func() time.Time { return now }
|
||||
route := media.Route{ID: "device:main", DeviceID: "device", ProfileToken: "main", Path: "sense_device_main", Desired: "running", Actual: "ready"}
|
||||
service.route = func(context.Context, string) (media.Route, error) { return route, nil }
|
||||
session, err := service.Create(context.Background(), "owner-a", route.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.Get(context.Background(), "owner-b", session.ID); err == nil {
|
||||
t.Fatal("other owner accessed session")
|
||||
}
|
||||
now = now.Add(2 * time.Minute)
|
||||
if _, err := service.Get(context.Background(), "owner-a", session.ID); err == nil {
|
||||
t.Fatal("expired session remained active")
|
||||
}
|
||||
}
|
||||
func TestTTLIsBounded(t *testing.T) {
|
||||
service, err := NewService("http://127.0.0.1:8889", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if service.ttl != 2*time.Minute {
|
||||
t.Fatalf("ttl=%v", service.ttl)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"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) {
|
||||
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)))
|
||||
app.Handle("POST /api/v1/media/routes/{id}/stop", identity.Require(identity.PermissionMediaWrite, http.HandlerFunc(m.stop)))
|
||||
app.Handle("GET /api/v1/media/process", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.process)))
|
||||
}
|
||||
func (m *Module) list(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := m.service.List(r.Context())
|
||||
if err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
|
||||
}
|
||||
func (m *Module) configure(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ProfileToken string `json:"profile_token"`
|
||||
}
|
||||
if err := platform.DecodeJSON(r, &req); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
actor, _ := identity.PrincipalFromContext(r.Context())
|
||||
item, err := m.service.Configure(r.Context(), actor, req.DeviceID, req.ProfileToken)
|
||||
if err != nil {
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusBadRequest, Code: "media_invalid", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
func (m *Module) reconcile(w http.ResponseWriter, r *http.Request) {
|
||||
actor, _ := identity.PrincipalFromContext(r.Context())
|
||||
item, err := m.service.Reconcile(r.Context(), actor, r.PathValue("id"))
|
||||
if err != nil {
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusBadRequest, Code: "media_reconcile_failed", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, item)
|
||||
}
|
||||
func (m *Module) stop(w http.ResponseWriter, r *http.Request) {
|
||||
actor, _ := identity.PrincipalFromContext(r.Context())
|
||||
item, err := m.service.Stop(r.Context(), actor, r.PathValue("id"))
|
||||
if err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, item)
|
||||
}
|
||||
func (m *Module) process(w http.ResponseWriter, _ *http.Request) {
|
||||
platform.WriteJSON(w, http.StatusOK, m.service.ProcessState())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package media
|
||||
|
||||
const MigrationSQL = `CREATE TABLE IF NOT EXISTS sense_media_routes(id TEXT PRIMARY KEY,device_id TEXT NOT NULL,profile_token TEXT NOT NULL,path TEXT NOT NULL UNIQUE,desired TEXT NOT NULL,actual TEXT NOT NULL,readers INTEGER NOT NULL DEFAULT 0,detail TEXT NOT NULL DEFAULT '',version BIGINT NOT NULL,updated_at TIMESTAMPTZ NOT NULL);`
|
||||
@@ -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,140 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"yovision.local/sense/app/sense/adapters/mediamtx"
|
||||
"yovision.local/sense/app/sense/admission"
|
||||
"yovision.local/sense/app/sense/device"
|
||||
"yovision.local/sense/app/sense/identity"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
process mediamtx.Process
|
||||
controller mediamtx.Controller
|
||||
profile func(string, string) (admission.Profile, error)
|
||||
credential func(context.Context, string) (device.Credential, error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(store Store, process mediamtx.Process, controller mediamtx.Controller) *Service {
|
||||
return &Service{store: store, process: process, controller: controller, profile: admission.VerifiedProfile, credential: device.ReadCredential, now: time.Now}
|
||||
}
|
||||
func (s *Service) Configure(ctx context.Context, actor identity.Principal, deviceID, profileToken string) (Route, error) {
|
||||
if _, err := s.profile(deviceID, profileToken); err != nil {
|
||||
return Route{}, err
|
||||
}
|
||||
id := deviceID + ":" + profileToken
|
||||
path := "sense_" + safe(deviceID) + "_" + safe(profileToken)
|
||||
now := s.now().UTC()
|
||||
route := Route{ID: id, DeviceID: deviceID, ProfileToken: profileToken, Path: path, Desired: "running", Actual: "pending", Detail: "等待状态对账", Version: 1, UpdatedAt: now}
|
||||
if previous, err := s.store.Get(ctx, id); err == nil {
|
||||
route.Version = previous.Version + 1
|
||||
}
|
||||
if err := s.store.Save(ctx, route); err != nil {
|
||||
return Route{}, err
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "media.configure", id, "success", map[string]any{"path": path})
|
||||
return route, nil
|
||||
}
|
||||
func (s *Service) Reconcile(ctx context.Context, actor identity.Principal, id string) (Route, error) {
|
||||
route, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Route{}, err
|
||||
}
|
||||
if route.Desired == "stopped" {
|
||||
route.Actual = "stopped"
|
||||
route.Detail = "已停止"
|
||||
route.Readers = 0
|
||||
route.Version++
|
||||
route.UpdatedAt = s.now().UTC()
|
||||
return route, s.store.Save(ctx, route)
|
||||
}
|
||||
if err := s.process.Start(ctx); err != nil {
|
||||
route.Actual = "process_failed"
|
||||
route.Detail = err.Error()
|
||||
route.Version++
|
||||
route.UpdatedAt = s.now().UTC()
|
||||
_ = s.store.Save(ctx, route)
|
||||
return route, nil
|
||||
}
|
||||
profile, err := s.profile(route.DeviceID, route.ProfileToken)
|
||||
if err != nil {
|
||||
return Route{}, err
|
||||
}
|
||||
credential, err := s.credential(ctx, route.DeviceID)
|
||||
if err != nil {
|
||||
return Route{}, err
|
||||
}
|
||||
source := mediamtx.Source{Path: route.Path, URI: profile.StreamURI, Username: credential.Username, Password: credential.Password}
|
||||
if err := s.controller.Apply(ctx, source); err != nil {
|
||||
route.Actual = "apply_failed"
|
||||
route.Detail = err.Error()
|
||||
route.Version++
|
||||
route.UpdatedAt = s.now().UTC()
|
||||
_ = s.store.Save(ctx, route)
|
||||
return route, nil
|
||||
}
|
||||
status, err := s.controller.Status(ctx, route.Path)
|
||||
if err != nil {
|
||||
route.Actual = "unconverged"
|
||||
route.Detail = "尚未取得媒体状态"
|
||||
} else if status.Ready {
|
||||
route.Actual = "ready"
|
||||
route.Detail = "上游拉流正常"
|
||||
route.Readers = status.Readers
|
||||
} else {
|
||||
route.Actual = "waiting"
|
||||
route.Detail = "等待上游拉流"
|
||||
route.Readers = status.Readers
|
||||
}
|
||||
route.Version++
|
||||
route.UpdatedAt = s.now().UTC()
|
||||
if err := s.store.Save(ctx, route); err != nil {
|
||||
return Route{}, err
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "media.reconcile", id, "success", map[string]any{"actual": route.Actual})
|
||||
return route, nil
|
||||
}
|
||||
func (s *Service) Stop(ctx context.Context, actor identity.Principal, id string) (Route, error) {
|
||||
route, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Route{}, err
|
||||
}
|
||||
route.Desired = "stopped"
|
||||
route.Actual = "stopped"
|
||||
route.Readers = 0
|
||||
route.Detail = "已停止"
|
||||
route.Version++
|
||||
route.UpdatedAt = s.now().UTC()
|
||||
if err := s.store.Save(ctx, route); err != nil {
|
||||
return Route{}, err
|
||||
}
|
||||
items, _ := s.store.List(ctx)
|
||||
allStopped := true
|
||||
for _, item := range items {
|
||||
if item.ID != id && item.Desired == "running" {
|
||||
allStopped = false
|
||||
}
|
||||
}
|
||||
if allStopped {
|
||||
stopCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
_ = s.process.Stop(stopCtx)
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "media.stop", id, "success", nil)
|
||||
return route, nil
|
||||
}
|
||||
func (s *Service) List(ctx context.Context) ([]Route, error) { return s.store.List(ctx) }
|
||||
func (s *Service) ProcessState() mediamtx.ProcessState { return s.process.State() }
|
||||
func safe(value string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-' {
|
||||
return r
|
||||
}
|
||||
return '_'
|
||||
}, value)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"yovision.local/sense/app/sense/adapters/mediamtx"
|
||||
"yovision.local/sense/app/sense/admission"
|
||||
"yovision.local/sense/app/sense/device"
|
||||
"yovision.local/sense/app/sense/identity"
|
||||
)
|
||||
|
||||
type fakeProcess struct {
|
||||
state mediamtx.ProcessState
|
||||
startErr error
|
||||
stopped bool
|
||||
}
|
||||
|
||||
func (p *fakeProcess) Start(context.Context) error {
|
||||
if p.startErr != nil {
|
||||
return p.startErr
|
||||
}
|
||||
p.state.Running = true
|
||||
p.state.PID = 123
|
||||
return nil
|
||||
}
|
||||
func (p *fakeProcess) Stop(context.Context) error {
|
||||
p.stopped = true
|
||||
p.state.Running = false
|
||||
p.state.PID = 0
|
||||
return nil
|
||||
}
|
||||
func (p *fakeProcess) State() mediamtx.ProcessState { return p.state }
|
||||
|
||||
type fakeController struct {
|
||||
applyErr error
|
||||
status mediamtx.PathStatus
|
||||
}
|
||||
|
||||
func (f fakeController) Apply(context.Context, mediamtx.Source) error { return f.applyErr }
|
||||
func (f fakeController) Status(context.Context, string) (mediamtx.PathStatus, error) {
|
||||
if f.status.Name == "error" {
|
||||
return mediamtx.PathStatus{}, errors.New("timeout")
|
||||
}
|
||||
if !f.status.Ready {
|
||||
return mediamtx.PathStatus{Ready: true, Readers: 2}, nil
|
||||
}
|
||||
return f.status, nil
|
||||
}
|
||||
|
||||
func TestStoreAndProcessBoundary(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
process := &fakeProcess{}
|
||||
service := NewService(store, process, fakeController{})
|
||||
route := Route{ID: "dev:main", DeviceID: "dev", ProfileToken: "main", Path: "sense_dev_main", Desired: "running", Actual: "pending", Version: 1}
|
||||
if err := store.Save(context.Background(), route); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := service.List(context.Background())
|
||||
if err != nil || len(items) != 1 {
|
||||
t.Fatalf("items=%#v err=%v", items, err)
|
||||
}
|
||||
if process.State().Running {
|
||||
t.Fatal("process started without explicit reconcile")
|
||||
}
|
||||
}
|
||||
|
||||
func preparedService(t *testing.T, process *fakeProcess, controller fakeController) (*Service, Route) {
|
||||
t.Helper()
|
||||
service := NewService(NewMemoryStore(), process, controller)
|
||||
service.profile = func(string, string) (admission.Profile, error) {
|
||||
return admission.Profile{StreamURI: "rtsp://camera.invalid/main"}, nil
|
||||
}
|
||||
service.credential = func(context.Context, string) (device.Credential, error) {
|
||||
return device.Credential{Username: "fixture", Password: "fixture"}, nil
|
||||
}
|
||||
route, err := service.Configure(context.Background(), identity.Principal{}, "device", "main")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return service, route
|
||||
}
|
||||
|
||||
func TestReconcileReadyAndStopOwnedProcess(t *testing.T) {
|
||||
process := &fakeProcess{}
|
||||
service, route := preparedService(t, process, fakeController{status: mediamtx.PathStatus{Ready: true, Readers: 2}})
|
||||
result, err := service.Reconcile(context.Background(), identity.Principal{}, route.ID)
|
||||
if err != nil || result.Actual != "ready" || result.Readers != 2 {
|
||||
t.Fatalf("result=%#v err=%v", result, err)
|
||||
}
|
||||
if _, err := service.Stop(context.Background(), identity.Principal{}, route.ID); err != nil || !process.stopped {
|
||||
t.Fatalf("stop err=%v stopped=%v", err, process.stopped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileRecordsFailureStates(t *testing.T) {
|
||||
process := &fakeProcess{startErr: errors.New("restart limit reached")}
|
||||
service, route := preparedService(t, process, fakeController{})
|
||||
result, err := service.Reconcile(context.Background(), identity.Principal{}, route.ID)
|
||||
if err != nil || result.Actual != "process_failed" {
|
||||
t.Fatalf("result=%#v err=%v", result, err)
|
||||
}
|
||||
process.startErr = nil
|
||||
service.controller = fakeController{applyErr: errors.New("control timeout")}
|
||||
result, err = service.Reconcile(context.Background(), identity.Principal{}, route.ID)
|
||||
if err != nil || result.Actual != "apply_failed" {
|
||||
t.Fatalf("result=%#v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("media route not found")
|
||||
|
||||
type Store interface {
|
||||
Save(context.Context, Route) error
|
||||
Get(context.Context, string) (Route, error)
|
||||
List(context.Context) ([]Route, error)
|
||||
}
|
||||
type MemoryStore struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]Route
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore { return &MemoryStore{items: map[string]Route{}} }
|
||||
func (s *MemoryStore) Save(_ context.Context, item Route) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.items[item.ID] = item
|
||||
return nil
|
||||
}
|
||||
func (s *MemoryStore) Get(_ context.Context, id string) (Route, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
item, ok := s.items[id]
|
||||
if !ok {
|
||||
return Route{}, ErrNotFound
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
func (s *MemoryStore) List(_ context.Context) ([]Route, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
items := make([]Route, 0, len(s.items))
|
||||
for _, item := range s.items {
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
type PostgresStore struct{ database *sql.DB }
|
||||
|
||||
func NewPostgresStore(db *sql.DB) *PostgresStore { return &PostgresStore{database: db} }
|
||||
func (s *PostgresStore) Save(ctx context.Context, item Route) error {
|
||||
_, err := s.database.ExecContext(ctx, `INSERT INTO sense_media_routes(id,device_id,profile_token,path,desired,actual,readers,detail,version,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT(id) DO UPDATE SET desired=EXCLUDED.desired,actual=EXCLUDED.actual,readers=EXCLUDED.readers,detail=EXCLUDED.detail,version=EXCLUDED.version,updated_at=EXCLUDED.updated_at`, item.ID, item.DeviceID, item.ProfileToken, item.Path, item.Desired, item.Actual, item.Readers, item.Detail, item.Version, item.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
func (s *PostgresStore) Get(ctx context.Context, id string) (Route, error) {
|
||||
var item Route
|
||||
err := s.database.QueryRowContext(ctx, `SELECT id,device_id,profile_token,path,desired,actual,readers,detail,version,updated_at FROM sense_media_routes WHERE id=$1`, id).Scan(&item.ID, &item.DeviceID, &item.ProfileToken, &item.Path, &item.Desired, &item.Actual, &item.Readers, &item.Detail, &item.Version, &item.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Route{}, ErrNotFound
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
func (s *PostgresStore) List(ctx context.Context) ([]Route, error) {
|
||||
rows, err := s.database.QueryContext(ctx, `SELECT id,device_id,profile_token,path,desired,actual,readers,detail,version,updated_at FROM sense_media_routes ORDER BY updated_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Route
|
||||
for rows.Next() {
|
||||
var item Route
|
||||
if err := rows.Scan(&item.ID, &item.DeviceID, &item.ProfileToken, &item.Path, &item.Desired, &item.Actual, &item.Readers, &item.Detail, &item.Version, &item.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package media
|
||||
|
||||
import "time"
|
||||
|
||||
type Route struct {
|
||||
ID string `json:"id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ProfileToken string `json:"profile_token"`
|
||||
Path string `json:"path"`
|
||||
Desired string `json:"desired"`
|
||||
Actual string `json:"actual"`
|
||||
Readers int `json:"readers"`
|
||||
Detail string `json:"detail"`
|
||||
Version int64 `json:"version"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package sense
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
"yovision.local/sense/app/sense/adapters/onvif"
|
||||
"yovision.local/sense/app/sense/adapters/rtsp"
|
||||
"yovision.local/sense/app/sense/admission"
|
||||
"yovision.local/sense/internal/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerModule(func(app *platform.App) error {
|
||||
service := admission.NewService(onvif.NewHTTPClient(8*time.Second), rtsp.NetVerifier{Timeout: 5 * time.Second}, os.Getenv("SENSE_ONVIF_DISCOVERY_IP"))
|
||||
admission.NewModule(service).Register(app)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package sense
|
||||
|
||||
import (
|
||||
"yovision.local/sense/app/sense/area"
|
||||
"yovision.local/sense/internal/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerModule(func(app *platform.App) error {
|
||||
var store area.Store
|
||||
if app.Config().DatabaseMode == platform.DatabaseModeMemory {
|
||||
store = area.NewMemoryStore()
|
||||
} else {
|
||||
store = area.NewPostgresStore(app.Database())
|
||||
}
|
||||
app.RegisterMigration(platform.Migration{Version: 2026081204, Name: "sense_area", SQL: area.MigrationSQL})
|
||||
area.NewModule(area.NewService(store)).Register(app)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package sense
|
||||
|
||||
import (
|
||||
"os"
|
||||
"yovision.local/sense/app/sense/device"
|
||||
"yovision.local/sense/internal/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerModule(func(app *platform.App) error {
|
||||
memory := app.Config().DatabaseMode == platform.DatabaseModeMemory
|
||||
vault, err := device.NewCredentialVault(os.Getenv("SENSE_CREDENTIAL_KEY"), memory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var store device.Store
|
||||
if memory {
|
||||
store = device.NewMemoryStore()
|
||||
} else {
|
||||
store = device.NewPostgresStore(app.Database())
|
||||
}
|
||||
app.RegisterMigration(platform.Migration{Version: 2026081202, Name: "sense_device", SQL: device.MigrationSQL})
|
||||
device.NewModule(device.NewService(store, vault)).Register(app)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package sense
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"yovision.local/sense/app/sense/identity"
|
||||
"yovision.local/sense/internal/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerModule(func(app *platform.App) error {
|
||||
memoryMode := app.Config().DatabaseMode == platform.DatabaseModeMemory
|
||||
key, err := identity.SigningKeyOrRandom(os.Getenv("SENSE_IDENTITY_SIGNING_KEY"), memoryMode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := identity.Config{
|
||||
SigningKey: key,
|
||||
BootstrapToken: os.Getenv("SENSE_BOOTSTRAP_TOKEN"),
|
||||
SecureCookie: identity.SecureCookieFromEnvironment(os.Getenv("SENSE_COOKIE_SECURE"), memoryMode),
|
||||
SessionTTL: 8 * time.Hour,
|
||||
}
|
||||
var store identity.Store
|
||||
if memoryMode {
|
||||
store = identity.NewMemoryStore()
|
||||
} else {
|
||||
if cfg.BootstrapToken == "" {
|
||||
return fmt.Errorf("SENSE_BOOTSTRAP_TOKEN is required until secure initialization is completed")
|
||||
}
|
||||
store = identity.NewPostgresStore(app.Database())
|
||||
}
|
||||
service, err := identity.NewService(store, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app.RegisterMigration(platform.Migration{Version: 2026081201, Name: "sense_identity", SQL: identity.MigrationSQL})
|
||||
identity.NewModule(service, cfg).Register(app)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package sense
|
||||
|
||||
import (
|
||||
"os"
|
||||
"yovision.local/sense/app/sense/adapters/mediamtx"
|
||||
"yovision.local/sense/app/sense/media"
|
||||
"yovision.local/sense/internal/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerModule(func(app *platform.App) error {
|
||||
var store media.Store
|
||||
if app.Config().DatabaseMode == platform.DatabaseModeMemory {
|
||||
store = media.NewMemoryStore()
|
||||
} else {
|
||||
store = media.NewPostgresStore(app.Database())
|
||||
}
|
||||
process := mediamtx.NewSupervisor(os.Getenv("SENSE_MEDIAMTX_BINARY"), os.Getenv("SENSE_MEDIAMTX_CONFIG"), 3)
|
||||
controller := mediamtx.NewHTTPController(valueOr("SENSE_MEDIAMTX_API", "http://127.0.0.1:9997"))
|
||||
app.RegisterMigration(platform.Migration{Version: 2026081203, Name: "sense_media", SQL: media.MigrationSQL})
|
||||
media.NewModule(media.NewService(store, process, controller)).Register(app)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
func valueOr(key, fallback string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package sense
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"yovision.local/sense/internal/platform"
|
||||
)
|
||||
|
||||
type moduleRegistrar func(*platform.App) error
|
||||
|
||||
var moduleRegistrars []moduleRegistrar
|
||||
|
||||
// registerModule is intentionally package-local. Feature integration files in
|
||||
// this directory append one registrar without changing the shared bootstrap.
|
||||
func registerModule(registrar moduleRegistrar) {
|
||||
moduleRegistrars = append(moduleRegistrars, registrar)
|
||||
}
|
||||
|
||||
func Run() error {
|
||||
cfg, err := platform.LoadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel()}))
|
||||
database, err := platform.OpenDatabase(context.Background(), cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if database != nil {
|
||||
defer database.Close()
|
||||
}
|
||||
|
||||
app := platform.NewApp(cfg, database, logger)
|
||||
for _, registrar := range moduleRegistrars {
|
||||
if err := registrar(app); err != nil {
|
||||
return fmt.Errorf("register Sense module: %w", err)
|
||||
}
|
||||
}
|
||||
if err := app.ApplyMigrations(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.HTTPAddress,
|
||||
Handler: app.Handler(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Info("Sense server started", "address", cfg.HTTPAddress, "database_mode", cfg.DatabaseMode)
|
||||
errCh <- server.ListenAndServe()
|
||||
}()
|
||||
|
||||
stopCh := make(chan os.Signal, 1)
|
||||
signal.Notify(stopCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
select {
|
||||
case <-stopCh:
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return server.Shutdown(ctx)
|
||||
case serveErr := <-errCh:
|
||||
if serveErr == http.ErrServerClosed {
|
||||
return nil
|
||||
}
|
||||
return serveErr
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package config
|
||||
|
||||
// Package config exists as the stable public configuration boundary for Sense.
|
||||
// Runtime parsing is implemented in internal/platform so feature packages do
|
||||
// not read process environment variables directly.
|
||||
@@ -0,0 +1,3 @@
|
||||
CREATE TABLE IF NOT EXISTS sense_area_versions(id TEXT NOT NULL,version BIGINT NOT NULL,device_id TEXT NOT NULL,profile_token TEXT NOT NULL,width INTEGER NOT NULL,height INTEGER NOT NULL,name TEXT NOT NULL,kind TEXT NOT NULL,direction TEXT NOT NULL DEFAULT '',points JSONB NOT NULL,enabled BOOLEAN NOT NULL,recalibration_required BOOLEAN NOT NULL DEFAULT FALSE,created_at TIMESTAMPTZ NOT NULL,updated_at TIMESTAMPTZ NOT NULL,PRIMARY KEY(id,version));
|
||||
CREATE INDEX IF NOT EXISTS sense_area_device_idx ON sense_area_versions(device_id,id,version DESC);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
CREATE TABLE IF NOT EXISTS sense_devices(id TEXT PRIMARY KEY,name TEXT NOT NULL,location TEXT NOT NULL DEFAULT '',modality TEXT NOT NULL,capabilities TEXT NOT NULL DEFAULT '',status TEXT NOT NULL,adapter_status TEXT NOT NULL,credential_ciphertext BYTEA NULL,version BIGINT NOT NULL,created_at TIMESTAMPTZ NOT NULL,updated_at TIMESTAMPTZ NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS sense_devices_created_idx ON sense_devices(created_at DESC);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
CREATE TABLE IF NOT EXISTS sense_identity_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sense_identity_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES sense_identity_users(id),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sense_identity_audit (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor_id TEXT NULL REFERENCES sense_identity_users(id),
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
outcome TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS sense_identity_audit_created_idx ON sense_identity_audit(created_at DESC);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE TABLE IF NOT EXISTS sense_media_routes(id TEXT PRIMARY KEY,device_id TEXT NOT NULL,profile_token TEXT NOT NULL,path TEXT NOT NULL UNIQUE,desired TEXT NOT NULL,actual TEXT NOT NULL,readers INTEGER NOT NULL DEFAULT 0,detail TEXT NOT NULL DEFAULT '',version BIGINT NOT NULL,updated_at TIMESTAMPTZ NOT NULL);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
module yovision.local/sense
|
||||
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,103 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
config Config
|
||||
database *sql.DB
|
||||
logger *slog.Logger
|
||||
mux *http.ServeMux
|
||||
migrations []Migration
|
||||
}
|
||||
|
||||
func NewApp(cfg Config, database *sql.DB, logger *slog.Logger) *App {
|
||||
app := &App{config: cfg, database: database, logger: logger, mux: http.NewServeMux()}
|
||||
app.registerPlatformRoutes()
|
||||
return app
|
||||
}
|
||||
|
||||
func (a *App) Config() Config { return a.config }
|
||||
func (a *App) Database() *sql.DB { return a.database }
|
||||
func (a *App) Logger() *slog.Logger { return a.logger }
|
||||
|
||||
func (a *App) Handle(pattern string, handler http.Handler) {
|
||||
a.mux.Handle(pattern, handler)
|
||||
}
|
||||
|
||||
func (a *App) HandleFunc(pattern string, handler http.HandlerFunc) {
|
||||
a.mux.HandleFunc(pattern, handler)
|
||||
}
|
||||
|
||||
func (a *App) Handler() http.Handler {
|
||||
return requestSecurityHeaders(a.mux)
|
||||
}
|
||||
|
||||
func (a *App) registerPlatformRoutes() {
|
||||
a.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok", "product": "sense"})
|
||||
})
|
||||
a.mux.HandleFunc("GET /readyz", a.ready)
|
||||
a.mux.HandleFunc("GET /api/v1/system/info", func(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"product": "Sense",
|
||||
"database": a.config.DatabaseMode,
|
||||
"modules": []string{},
|
||||
})
|
||||
})
|
||||
// Deliberately do not register upstream demo/code-generation routes.
|
||||
if a.config.StaticDir != "" {
|
||||
a.registerStaticFiles(a.config.StaticDir)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) ready(w http.ResponseWriter, r *http.Request) {
|
||||
if a.database == nil {
|
||||
WriteJSON(w, http.StatusOK, map[string]string{"status": "ready", "database": "memory"})
|
||||
return
|
||||
}
|
||||
ctx, cancel := timeLimitedContext(r, 2*time.Second)
|
||||
defer cancel()
|
||||
if err := a.database.PingContext(ctx); err != nil {
|
||||
WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "not_ready", "database": "unavailable"})
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, map[string]string{"status": "ready", "database": "postgres"})
|
||||
}
|
||||
|
||||
func (a *App) registerStaticFiles(directory string) {
|
||||
absolute, err := filepath.Abs(directory)
|
||||
if err != nil {
|
||||
a.logger.Warn("invalid UI static directory", "error", err)
|
||||
return
|
||||
}
|
||||
fileSystem := os.DirFS(absolute)
|
||||
a.mux.Handle("GET /assets/", http.StripPrefix("/", http.FileServer(http.FS(fileSystem))))
|
||||
a.mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(filepath.ToSlash(filepath.Clean(r.URL.Path)), "/")
|
||||
if path == "." || path == "" {
|
||||
path = "index.html"
|
||||
}
|
||||
if _, err := fs.Stat(fileSystem, path); err != nil {
|
||||
path = "index.html"
|
||||
}
|
||||
http.ServeFileFS(w, r, fileSystem, path)
|
||||
})
|
||||
}
|
||||
|
||||
func requestSecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPlatformRoutesAndDisabledDemo(t *testing.T) {
|
||||
app := NewApp(Config{DatabaseMode: DatabaseModeMemory}, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
for _, test := range []struct {
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{path: "/healthz", want: http.StatusOK},
|
||||
{path: "/readyz", want: http.StatusOK},
|
||||
{path: "/api/v1/system/info", want: http.StatusOK},
|
||||
{path: "/api/v1/demo", want: http.StatusNotFound},
|
||||
{path: "/api/v1/system/tools", want: http.StatusNotFound},
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, test.path, nil)
|
||||
res := httptest.NewRecorder()
|
||||
app.Handler().ServeHTTP(res, req)
|
||||
if res.Code != test.want {
|
||||
t.Errorf("GET %s status = %d, want %d", test.path, res.Code, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
DatabaseModePostgres = "postgres"
|
||||
DatabaseModeMemory = "memory"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTPAddress string
|
||||
DatabaseMode string
|
||||
DatabaseURL string
|
||||
StaticDir string
|
||||
LogLevelName string
|
||||
}
|
||||
|
||||
func LoadConfig() (Config, error) {
|
||||
cfg := Config{
|
||||
HTTPAddress: valueOrDefault("SENSE_HTTP_ADDRESS", "127.0.0.1:18080"),
|
||||
DatabaseMode: strings.ToLower(valueOrDefault("SENSE_DATABASE_MODE", DatabaseModePostgres)),
|
||||
DatabaseURL: strings.TrimSpace(os.Getenv("SENSE_DATABASE_URL")),
|
||||
StaticDir: strings.TrimSpace(os.Getenv("SENSE_UI_STATIC_DIR")),
|
||||
LogLevelName: strings.ToLower(valueOrDefault("SENSE_LOG_LEVEL", "info")),
|
||||
}
|
||||
|
||||
switch cfg.DatabaseMode {
|
||||
case DatabaseModePostgres:
|
||||
if cfg.DatabaseURL == "" {
|
||||
return Config{}, fmt.Errorf("SENSE_DATABASE_URL is required when SENSE_DATABASE_MODE=postgres")
|
||||
}
|
||||
case DatabaseModeMemory:
|
||||
// Explicitly limited to local smoke tests and automated tests.
|
||||
default:
|
||||
return Config{}, fmt.Errorf("unsupported SENSE_DATABASE_MODE %q", cfg.DatabaseMode)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c Config) LogLevel() slog.Level {
|
||||
switch c.LogLevelName {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
func valueOrDefault(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package platform
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLoadConfigRequiresPostgresURL(t *testing.T) {
|
||||
t.Setenv("SENSE_DATABASE_MODE", "postgres")
|
||||
t.Setenv("SENSE_DATABASE_URL", "")
|
||||
if _, err := LoadConfig(); err == nil {
|
||||
t.Fatal("expected missing PostgreSQL URL to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigAllowsExplicitMemoryMode(t *testing.T) {
|
||||
t.Setenv("SENSE_DATABASE_MODE", "memory")
|
||||
t.Setenv("SENSE_DATABASE_URL", "")
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
if cfg.DatabaseMode != DatabaseModeMemory {
|
||||
t.Fatalf("DatabaseMode = %q", cfg.DatabaseMode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func timeLimitedContext(r *http.Request, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(r.Context(), timeout)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
func OpenDatabase(ctx context.Context, cfg Config) (*sql.DB, error) {
|
||||
if cfg.DatabaseMode == DatabaseModeMemory {
|
||||
return nil, nil
|
||||
}
|
||||
database, err := sql.Open("pgx", cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open Sense PostgreSQL: %w", err)
|
||||
}
|
||||
database.SetMaxOpenConns(20)
|
||||
database.SetMaxIdleConns(5)
|
||||
database.SetConnMaxLifetime(30 * time.Minute)
|
||||
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := database.PingContext(pingCtx); err != nil {
|
||||
database.Close()
|
||||
return nil, fmt.Errorf("connect Sense PostgreSQL: %w", err)
|
||||
}
|
||||
return database, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type APIError struct {
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string { return e.Message }
|
||||
|
||||
func WriteJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func WriteError(w http.ResponseWriter, err error) {
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
WriteJSON(w, apiErr.Status, map[string]any{"error": map[string]string{"code": apiErr.Code, "message": apiErr.Message}})
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "internal_error", "message": "服务暂时不可用"}})
|
||||
}
|
||||
|
||||
func DecodeJSON(r *http.Request, target any) error {
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, (1<<20)+1))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return &APIError{Status: http.StatusBadRequest, Code: "invalid_request", Message: "请求内容格式不正确"}
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return &APIError{Status: http.StatusBadRequest, Code: "invalid_request", Message: "请求内容格式不正确"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type Migration struct {
|
||||
Version int64
|
||||
Name string
|
||||
SQL string
|
||||
}
|
||||
|
||||
func (a *App) RegisterMigration(migration Migration) {
|
||||
a.migrations = append(a.migrations, migration)
|
||||
}
|
||||
|
||||
func (a *App) ApplyMigrations(ctx context.Context) error {
|
||||
if a.database == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := a.database.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS sense_schema_migrations (
|
||||
version BIGINT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create Sense migration table: %w", err)
|
||||
}
|
||||
|
||||
sort.Slice(a.migrations, func(i, j int) bool { return a.migrations[i].Version < a.migrations[j].Version })
|
||||
for _, migration := range a.migrations {
|
||||
if err := applyMigration(ctx, a.database, migration); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyMigration(ctx context.Context, database *sql.DB, migration Migration) error {
|
||||
tx, err := database.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %d: %w", migration.Version, err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var exists bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM sense_schema_migrations WHERE version = $1)`, migration.Version).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("check migration %d: %w", migration.Version, err)
|
||||
}
|
||||
if exists {
|
||||
return tx.Commit()
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, migration.SQL); err != nil {
|
||||
return fmt.Errorf("apply migration %d (%s): %w", migration.Version, migration.Name, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO sense_schema_migrations(version, name) VALUES($1, $2)`, migration.Version, migration.Name); err != nil {
|
||||
return fmt.Errorf("record migration %d: %w", migration.Version, err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
sensecmd "yovision.local/sense/cmd/sense"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := sensecmd.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"kind":"polygon","points":[{"x":0.1,"y":0.1},{"x":0.9,"y":0.1},{"x":0.5,"y":0.8}]}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"route_id":"fixture-device:main","status":"ready","expires_in_seconds":120}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"name":"sense_fixture_main","ready":true,"bytesReceived":4096,"readers":[{"type":"webrtc"}]}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetProfilesResponse xmlns="http://www.onvif.org/ver10/media/wsdl"><Profiles token="main"><Name>主码流</Name><VideoEncoderConfiguration><Encoding>H264</Encoding><Resolution><Width>1920</Width><Height>1080</Height></Resolution></VideoEncoderConfiguration></Profiles><Profiles token="sub"><Name>子码流</Name><VideoEncoderConfiguration><Encoding>H264</Encoding><Resolution><Width>640</Width><Height>360</Height></Resolution></VideoEncoderConfiguration></Profiles></GetProfilesResponse></s:Body></s:Envelope>
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetStreamUriResponse xmlns="http://www.onvif.org/ver10/media/wsdl"><MediaUri><Uri>rtsp://camera.invalid/live/main</Uri></MediaUri></GetStreamUriResponse></s:Body></s:Envelope>
|
||||
@@ -0,0 +1,6 @@
|
||||
# Admission acceptance
|
||||
|
||||
Fixture tests cover ONVIF profile parsing, credential-free StreamUri handling,
|
||||
main/sub classification and RTSP result mapping. Controlled WS-Discovery is
|
||||
disabled until `SENSE_ONVIF_DISCOVERY_IP` names an approved local interface.
|
||||
No discovery scan or real camera access runs during automated tests.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Area acceptance
|
||||
|
||||
Tests cover normalized coordinate bounds, minimum points, polygon self-
|
||||
intersection and degeneration, directional line validation, immutable version
|
||||
increments, optimistic conflicts and explicit recalibration after a Profile
|
||||
resolution change. No customer image or production geometry is included.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Device acceptance
|
||||
|
||||
Package tests verify CRUD state, pagination beyond 16 items, unsupported
|
||||
adapter status, optimistic versions and that encrypted camera credentials are
|
||||
write-only. Fixtures use generated values only; no address or real credential
|
||||
is stored in the repository.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Identity acceptance boundary
|
||||
|
||||
Automated identity tests live with the Go package at
|
||||
`Sense/server/app/sense/identity`. They use generated, non-production values
|
||||
and never contain a real password, token, cookie or signing key.
|
||||
|
||||
The acceptance checks cover bootstrap uniqueness, password hashing, login,
|
||||
role permissions, product-specific token validation, session revocation and
|
||||
audit records. PostgreSQL migration execution remains a deployment-environment
|
||||
integration check.
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Media acceptance
|
||||
|
||||
Tests keep MediaMTX behind process and control interfaces. They verify that
|
||||
the credential-bearing MediaMTX control request is localhost-only and transient,
|
||||
never returned or persisted by Sense, and no process starts before an explicit reconcile. Real MediaMTX binary,
|
||||
camera pull, reader counts and crash recovery require the deployment lab.
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.eslintcache
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "yovision-sense-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.15.1",
|
||||
"engines": {
|
||||
"node": "22.22.1",
|
||||
"pnpm": "9.15.1"
|
||||
},
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "eslint \"src/**/*.{js,vue}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "2.3.2",
|
||||
"axios": "1.13.6",
|
||||
"element-plus": "2.14.4",
|
||||
"vue": "3.5.41",
|
||||
"vue-router": "4.6.4",
|
||||
"vuex": "4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "7.28.6",
|
||||
"@vue/cli-plugin-babel": "5.0.9",
|
||||
"@vue/cli-service": "5.0.9",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-plugin-vue": "9.33.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:vue/vue3-recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"parser": "@babel/eslint-parser",
|
||||
"requireConfigFile": false
|
||||
},
|
||||
"rules": {
|
||||
"vue/multi-word-component-names": "off"
|
||||
}
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not dead"
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+8091
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
import request from '../../../bootstrap/request'
|
||||
|
||||
export const discoverDevices = () => request.get('/admission/discover')
|
||||
export const probeDevice = (data) => request.post('/admission/probe', data)
|
||||
export const admissionResult = (deviceID) => request.get(`/admission/devices/${deviceID}`)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user