Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef85943ef4 | ||
|
|
6bbaa07962 | ||
|
|
5a178986e5 | ||
|
|
5e5aa48cf9 | ||
|
|
21624f6c55 | ||
|
|
c4a799c99c | ||
|
|
62ed5cc1ce | ||
|
|
048a6ec969 | ||
|
|
cb3015a5b1 | ||
|
|
245d38d471 | ||
|
|
88dcebc3fb | ||
|
|
8091fbb89c | ||
|
|
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,110 @@
|
||||
@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 ("%OUTPUT_ROOT%\.sense.env.preserve") do set "PRESERVED_ENV=%%~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
|
||||
)
|
||||
if /I not "%PRESERVED_ENV%"=="%SENSE_ROOT%\dist\.sense.env.preserve" (
|
||||
echo [ERROR] Unsafe preserved configuration path: %PRESERVED_ENV%
|
||||
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 "%PRESERVED_ENV%" (
|
||||
echo [ERROR] Preserved configuration already exists: %PRESERVED_ENV%
|
||||
echo Move it back to config\sense.env or remove it after confirming it is obsolete.
|
||||
exit /b 1
|
||||
)
|
||||
if exist "%PACKAGE_DIR%\config\sense.env" (
|
||||
copy /y "%PACKAGE_DIR%\config\sense.env" "%PRESERVED_ENV%" >nul || goto :failed
|
||||
)
|
||||
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\start-sense.ps1" "%PACKAGE_DIR%\start-sense.ps1" >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
|
||||
if exist "%PRESERVED_ENV%" (
|
||||
move /y "%PRESERVED_ENV%" "%PACKAGE_DIR%\config\sense.env" >nul || 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,38 @@
|
||||
# Sense Windows 运行包
|
||||
|
||||
此目录同时包含 Sense 后端和已经构建好的前端静态页面。Brain、Bell 无需启动。
|
||||
|
||||
## 快速查看
|
||||
|
||||
在命令提示符中运行:
|
||||
|
||||
```bat
|
||||
start-sense.bat demo
|
||||
```
|
||||
|
||||
然后访问 <http://127.0.0.1:18080>。`demo` 使用内存数据,进程退出后数据会丢失,仅用于本机查看,不能用于生产。
|
||||
|
||||
## 生产启动
|
||||
|
||||
生产环境必须先安装并准备独立 PostgreSQL。把配置写入运行目录的 `config\sense.env`,或在 Windows 进程环境中提供;已存在的非空进程环境变量优先于文件:
|
||||
|
||||
- `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
|
||||
```
|
||||
|
||||
启动前可只检查配置,不连接数据库也不启动服务:
|
||||
|
||||
```bat
|
||||
start-sense.bat check
|
||||
```
|
||||
|
||||
完整变量名可参考 `config\sense.env.example`。复制为 `config\sense.env` 后填写真实值;该文件不会进入 ZIP 或 Git。本机在同一运行目录重新打包时会保留该文件,但交付 ZIP 始终不包含它。启动器只读取 `SENSE_*` 键,忽略空行与 `#` 注释,且不会打印配置值。生产环境仍建议使用 Windows 环境变量或外部秘密管理工具注入真实值。
|
||||
|
||||
健康检查为 `GET /healthz` 和 `GET /readyz`。MediaMTX 仍是独立程序,本运行包不会安装 PostgreSQL、MediaMTX 或 Windows 服务。
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0start-sense.ps1" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
@@ -0,0 +1,91 @@
|
||||
param(
|
||||
[ValidateSet('production', 'demo', 'check')]
|
||||
[string]$Mode = 'production'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Import-SenseEnvironment {
|
||||
param([string]$Path)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||||
return
|
||||
}
|
||||
|
||||
$lineNumber = 0
|
||||
foreach ($line in Get-Content -LiteralPath $Path -Encoding UTF8) {
|
||||
$lineNumber++
|
||||
$trimmed = $line.Trim()
|
||||
if ($trimmed.Length -eq 0 -or $trimmed.StartsWith('#')) {
|
||||
continue
|
||||
}
|
||||
if ($line -notmatch '^\s*(SENSE_[A-Z0-9_]+)\s*=(.*)$') {
|
||||
throw "config\sense.env line $lineNumber must use SENSE_NAME=value format"
|
||||
}
|
||||
|
||||
$name = $Matches[1]
|
||||
$value = $Matches[2].Trim()
|
||||
if ($value.Length -ge 2) {
|
||||
$first = $value[0]
|
||||
$last = $value[$value.Length - 1]
|
||||
if (($first -eq '"' -and $last -eq '"') -or ($first -eq "'" -and $last -eq "'")) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
}
|
||||
|
||||
$current = [Environment]::GetEnvironmentVariable($name, 'Process')
|
||||
if ([string]::IsNullOrEmpty($current)) {
|
||||
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-RequiredEnvironment {
|
||||
param([string[]]$Names)
|
||||
|
||||
foreach ($name in $Names) {
|
||||
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name, 'Process'))) {
|
||||
throw "$name is required in production mode"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$runtimeRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Import-SenseEnvironment -Path (Join-Path $runtimeRoot 'config\sense.env')
|
||||
|
||||
[Environment]::SetEnvironmentVariable('SENSE_UI_STATIC_DIR', (Join-Path $runtimeRoot 'ui'), 'Process')
|
||||
if ([string]::IsNullOrWhiteSpace($env:SENSE_HTTP_ADDRESS)) {
|
||||
$env:SENSE_HTTP_ADDRESS = '127.0.0.1:18080'
|
||||
}
|
||||
|
||||
if ($Mode -eq 'demo') {
|
||||
$env:SENSE_DATABASE_MODE = 'memory'
|
||||
Write-Host "Starting Sense in temporary demo mode at http://$($env:SENSE_HTTP_ADDRESS) ..."
|
||||
Write-Host 'Demo data is discarded when the process stops. Do not use this mode in production.'
|
||||
} else {
|
||||
$env:SENSE_DATABASE_MODE = 'postgres'
|
||||
Assert-RequiredEnvironment -Names @(
|
||||
'SENSE_DATABASE_URL',
|
||||
'SENSE_IDENTITY_SIGNING_KEY',
|
||||
'SENSE_BOOTSTRAP_TOKEN',
|
||||
'SENSE_CREDENTIAL_KEY'
|
||||
)
|
||||
if ($Mode -eq 'check') {
|
||||
Write-Host 'Sense production configuration check passed.'
|
||||
exit 0
|
||||
}
|
||||
Write-Host "Starting Sense in production mode at $($env:SENSE_HTTP_ADDRESS) ..."
|
||||
}
|
||||
|
||||
$server = Join-Path $runtimeRoot 'sense-server.exe'
|
||||
if (-not (Test-Path -LiteralPath $server -PathType Leaf)) {
|
||||
throw 'sense-server.exe was not found beside the start script'
|
||||
}
|
||||
& $server
|
||||
exit $LASTEXITCODE
|
||||
} catch {
|
||||
Write-Host "[ERROR] $($_.Exception.Message)"
|
||||
Write-Host 'See README-WINDOWS.md and config\sense.env.example.'
|
||||
exit 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,23 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func ValidatePassword(_ string, password string) error {
|
||||
if len([]rune(password)) < 6 {
|
||||
return fmt.Errorf("密码至少需要 6 个字符")
|
||||
}
|
||||
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,35 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidatePasswordLengthAndComplexity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
want string
|
||||
}{
|
||||
{name: "six lowercase characters", username: "operator", password: "abcdef"},
|
||||
{name: "contains username", username: "admin", password: "admin123"},
|
||||
{name: "six unicode characters", username: "operator", password: "密码密码密码"},
|
||||
{name: "five characters", username: "operator", password: "Ab123", want: "至少需要 6 个字符"},
|
||||
}
|
||||
|
||||
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}\"",
|
||||
"test:navigation": "node --test tests/navigation.test.cjs"
|
||||
},
|
||||
"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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user