feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
+167
-7
@@ -1,10 +1,14 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"data-terminal-cloud/internal/devicefleet"
|
||||
"data-terminal-cloud/internal/devicereset"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"io"
|
||||
"net"
|
||||
@@ -16,6 +20,12 @@ import (
|
||||
)
|
||||
|
||||
func (s *Service) cookieName(account bool) string {
|
||||
if s.operatorLogin {
|
||||
if s.options.AllowLocalHTTP {
|
||||
return "platform-operator"
|
||||
}
|
||||
return "__Host-platform-operator"
|
||||
}
|
||||
n := "__Host-onboarding"
|
||||
if account {
|
||||
n = "__Host-platform-onboarding"
|
||||
@@ -42,8 +52,9 @@ func (s *Service) secure(r *http.Request) bool {
|
||||
}
|
||||
for _, cidr := range s.options.TrustedProxyCIDRs {
|
||||
prefix, err := netip.ParsePrefix(cidr)
|
||||
if err == nil && prefix.Contains(ip) && r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
return true
|
||||
if err == nil && prefix.Contains(ip) {
|
||||
proto := r.Header.Get("X-Forwarded-Proto")
|
||||
return proto == "https" || (s.options.AllowLocalHTTP && proto == "http")
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -53,16 +64,56 @@ func (s *Service) sameOrigin(r *http.Request) bool {
|
||||
return r.Header.Get("Origin") == entry.Scheme+"://"+entry.Host
|
||||
}
|
||||
func (s *Service) RegisterRoutes(mux *http.ServeMux) {
|
||||
s.mount(mux, "GET /api/v1/ops/login-captcha-policy", "captcha-policy")
|
||||
s.mount(mux, "POST /api/v1/ops/login-captcha-policy", "captcha-policy")
|
||||
for _, name := range []string{"login/session", "login/captcha", "login/password", "login/sms/send", "login/sms/verify", "login/wechat", "login/wechat-link", "logout", "ble-session", "ble-device-state", "ble-bind-tokens", "ble-bind-status", "device-subject"} {
|
||||
s.mount(mux, "POST /api/v1/mini/"+name, "mini")
|
||||
}
|
||||
s.mount(mux, "GET /api/v1/mini/account", "mini")
|
||||
for _, name := range []string{"ble-bind-tokens", "ble-bind-status", "ble-device-state", "device-subject"} {
|
||||
s.mount(mux, "POST /api/v1/web/"+name, "ble-web")
|
||||
}
|
||||
s.mount(mux, "POST /api/v1/device/ble-bind", "device")
|
||||
s.mount(mux, "POST /api/v1/device/mqtt-config", "device")
|
||||
s.mount(mux, "POST /api/v1/device/control/sync", "device")
|
||||
s.mount(mux, "POST /api/v1/device/control/ack", "device")
|
||||
s.mount(mux, "POST /api/v1/device/factory-reset/state", "device")
|
||||
s.mount(mux, "POST /api/v1/device/factory-reset", "device")
|
||||
s.mount(mux, "POST /api/v1/device/ota/report", "device")
|
||||
s.mount(mux, "POST /api/v1/device/ota/manifest", "device")
|
||||
s.mount(mux, "POST /api/v1/device/ble-state", "device")
|
||||
s.mount(mux, "POST /api/v1/device/voice-runtime", "device")
|
||||
s.mount(mux, "POST /api/v1/device/voice-config", "device")
|
||||
s.mount(mux, "POST /api/v1/device/voice-credentials", "device")
|
||||
for _, name := range []string{"session", "captcha", "captcha/verify", "password", "sms/send", "sms/verify"} {
|
||||
method := "POST"
|
||||
if name == "session" {
|
||||
method = "GET"
|
||||
}
|
||||
s.mount(mux, method+" /api/v1/web/operator/login/"+name, "operator-login")
|
||||
}
|
||||
s.mount(mux, "GET /api/v1/web/operator/account/session", "operator-session")
|
||||
s.mount(mux, "GET /api/v1/web/operator/account/password", "operator-password")
|
||||
s.mount(mux, "POST /api/v1/web/operator/account/password", "operator-password")
|
||||
s.mount(mux, "POST /api/v1/web/operator/auth/logout", "operator-logout")
|
||||
for _, name := range []string{"time-challenges", "register", "setup-sessions", "handoffs", "poll", "claim-confirmations", "state-acks", "activation-attempts", "activation-results", "activation-acks", "setup-close"} {
|
||||
s.mount(mux, "POST /api/v1/device/"+name, "device")
|
||||
}
|
||||
for _, name := range []string{"handoffs/exchange", "auth/sms/send", "auth/sms/verify", "setup/auth-link", "claims", "setup/status", "setup/ack", "claims/cancel", "profiles", "profiles/create"} {
|
||||
for _, name := range []string{"handoffs/exchange", "auth/captcha", "auth/captcha/verify", "auth/sms/send", "auth/sms/verify", "setup/auth-link", "claims", "setup/status", "setup/ack", "claims/cancel", "profiles", "profiles/create"} {
|
||||
s.mount(mux, "POST /api/v1/web/"+name, "web")
|
||||
}
|
||||
s.mount(mux, "GET /api/v1/web/setup/session", "bootstrap")
|
||||
s.mount(mux, "GET /api/v1/web/onboarding/devices", "account")
|
||||
s.mount(mux, "GET /api/v1/web/account/session", "account-session")
|
||||
s.mount(mux, "POST /api/v1/web/auth/logout", "logout")
|
||||
s.mount(mux, "GET /api/v1/web/login/session", "customer-login")
|
||||
s.mount(mux, "POST /api/v1/web/login/sms/send", "customer-login")
|
||||
s.mount(mux, "POST /api/v1/web/login/sms/verify", "customer-login")
|
||||
s.mount(mux, "POST /api/v1/web/login/captcha", "customer-login")
|
||||
s.mount(mux, "POST /api/v1/web/login/captcha/verify", "customer-login")
|
||||
s.mount(mux, "POST /api/v1/web/login/password", "customer-login")
|
||||
s.mount(mux, "GET /api/v1/web/account/password", "account-password")
|
||||
s.mount(mux, "POST /api/v1/web/account/password", "account-password")
|
||||
s.mount(mux, "POST /api/v1/ops/device-inventory", "ops")
|
||||
s.mount(mux, "GET /api/v1/ops/device-inventory", "ops")
|
||||
for _, name := range []string{"credential-deliveries", "release", "credential-rotations"} {
|
||||
@@ -90,7 +141,12 @@ func (s *Service) serve(w http.ResponseWriter, r *http.Request, kind string) {
|
||||
s.write(w, id, nil, fail(400, "HTTPS_REQUIRED", "此接口必须通过HTTPS访问"))
|
||||
return
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
client := "saas"
|
||||
if kind == "mini" {
|
||||
client = "mini_program"
|
||||
}
|
||||
host := s.clientIP(r)
|
||||
r = r.WithContext(context.WithValue(r.Context(), captchaRequestKey{}, captchaRequestInfo{client: client, ip: host}))
|
||||
if host == "" {
|
||||
host = "unknown"
|
||||
}
|
||||
@@ -98,15 +154,35 @@ func (s *Service) serve(w http.ResponseWriter, r *http.Request, kind string) {
|
||||
s.write(w, id, nil, err)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(r.URL.Path, "/auth/sms/send") {
|
||||
if strings.HasSuffix(r.URL.Path, "/sms/send") {
|
||||
if err := s.limit(r.Context(), "sms:ip:"+host, 20, time.Hour); err != nil {
|
||||
s.write(w, id, nil, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(r.URL.Path, "/login/password") || strings.HasSuffix(r.URL.Path, "/login/wechat") || strings.HasSuffix(r.URL.Path, "/login/wechat-link") || strings.HasSuffix(r.URL.Path, "/captcha") || strings.HasSuffix(r.URL.Path, "/captcha/verify") {
|
||||
if err := s.limit(r.Context(), "password:ip:"+host, 120, 10*time.Minute); err != nil {
|
||||
s.write(w, id, nil, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
var data any
|
||||
var err error
|
||||
switch kind {
|
||||
case "captcha-policy":
|
||||
data, err = s.captchaPolicyHTTP(r, id)
|
||||
case "mini":
|
||||
data, err = s.miniHTTP(r, id)
|
||||
case "ble-web":
|
||||
data, err = s.bleWebHTTP(r)
|
||||
case "operator-login":
|
||||
data, err = s.operatorView().customerLoginHTTP(w, r, id)
|
||||
case "operator-session":
|
||||
data, err = s.operatorView().accountSessionHTTP(r)
|
||||
case "operator-password":
|
||||
data, err = s.operatorView().accountPasswordHTTP(r)
|
||||
case "operator-logout":
|
||||
data, err = s.operatorView().logoutHTTP(w, r)
|
||||
case "device":
|
||||
data, err = s.deviceHTTP(r, id)
|
||||
case "web":
|
||||
@@ -123,8 +199,12 @@ func (s *Service) serve(w http.ResponseWriter, r *http.Request, kind string) {
|
||||
}
|
||||
case "account-session":
|
||||
data, err = s.accountSessionHTTP(r)
|
||||
case "account-password":
|
||||
data, err = s.accountPasswordHTTP(r)
|
||||
case "logout":
|
||||
data, err = s.logoutHTTP(w, r)
|
||||
case "customer-login":
|
||||
data, err = s.customerLoginHTTP(w, r, id)
|
||||
}
|
||||
if r.URL.Path == "/api/v1/device/time-challenges" && err == nil {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
@@ -219,6 +299,63 @@ func (s *Service) deviceHTTP(r *http.Request, id string) (any, error) {
|
||||
return nil, fail(415, "JSON_REQUIRED", "请使用application/json")
|
||||
}
|
||||
switch path {
|
||||
case "factory-reset/state":
|
||||
return decodeCall(raw, func(in struct{}) (map[string]any, error) { return s.DeviceResetState(ctx, d) })
|
||||
case "factory-reset":
|
||||
var in devicereset.Input
|
||||
if err := StrictJSON(raw, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.DeviceFactoryReset(ctx, d, id, raw, in)
|
||||
case "control/sync":
|
||||
return decodeCall(raw, func(in DeviceControlReport) (map[string]any, error) { return s.DeviceControlSync(ctx, d, in) })
|
||||
case "control/ack":
|
||||
return decodeCall(raw, func(in DeviceControlAck) (map[string]any, error) { return s.DeviceControlAcknowledge(ctx, d, in) })
|
||||
case "mqtt-config", "ota/report", "ota/manifest":
|
||||
if s.Fleet == nil {
|
||||
return nil, fail(503, "MQTT_UNAVAILABLE", "MQTT服务未配置")
|
||||
}
|
||||
if !deviceAllowed(d) {
|
||||
return nil, fail(409, "DEVICE_UNAVAILABLE", "设备状态不允许此操作")
|
||||
}
|
||||
var result map[string]any
|
||||
var fleetErr error
|
||||
if path == "mqtt-config" {
|
||||
if err := StrictJSON(raw, &struct{}{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, fleetErr = s.Fleet.Config(ctx, d.ID, d.FactoryID, d.Key)
|
||||
} else if path == "ota/manifest" {
|
||||
var in struct {
|
||||
TaskID string `json:"task_id"`
|
||||
}
|
||||
if err := StrictJSON(raw, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, fleetErr = s.Fleet.Manifest(ctx, d.ID, in.TaskID)
|
||||
} else {
|
||||
var in devicefleet.Report
|
||||
if err := StrictJSON(raw, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fleetErr = s.Fleet.Report(ctx, d.ID, in)
|
||||
result = map[string]any{"status": "ok"}
|
||||
}
|
||||
var fe *devicefleet.Error
|
||||
if errors.As(fleetErr, &fe) {
|
||||
return nil, fail(409, fe.Code, fe.Message)
|
||||
}
|
||||
return result, fleetErr
|
||||
case "voice-config":
|
||||
return decodeCall(raw, func(in struct{}) (map[string]any, error) { return s.VoiceConfig(ctx, d) })
|
||||
case "voice-credentials":
|
||||
return decodeCall(raw, func(in DeviceVoiceCredential) (map[string]any, error) { return s.ImportVoiceCredential(ctx, d, in) })
|
||||
case "voice-runtime":
|
||||
return decodeDeviceCall(raw, func(in VoiceRuntimeInput) (map[string]any, error) { return s.VoiceRuntime(ctx, d, in) })
|
||||
case "ble-state":
|
||||
return decodeCall(raw, func(in struct{}) (map[string]any, error) { return s.DeviceBLEState(ctx, d) })
|
||||
case "ble-bind":
|
||||
return decodeCall(raw, func(in BLEBindInput) (map[string]any, error) { return s.BindBLE(ctx, d, id, in) })
|
||||
case "register":
|
||||
return decodeDeviceCall(raw, func(in RegisterInput) (map[string]any, error) { return s.Register(ctx, d, id, raw, in) })
|
||||
case "setup-sessions":
|
||||
@@ -253,6 +390,9 @@ func (s *Service) browserSession(r *http.Request) (WebSession, error) {
|
||||
return s.webSession(r.Context(), s.pool, digest([]byte(c.Value)))
|
||||
}
|
||||
func (s *Service) issueCookies(w http.ResponseWriter, out BrowserCredentials) {
|
||||
if out.TrustedDeviceToken != "" {
|
||||
http.SetCookie(w, &http.Cookie{Name: s.trustedLoginCookieName(), Value: out.TrustedDeviceToken, Path: "/", Secure: !s.options.AllowLocalHTTP, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int(trustedLoginLifetime.Seconds())})
|
||||
}
|
||||
for _, item := range []struct {
|
||||
value string
|
||||
account bool
|
||||
@@ -264,6 +404,9 @@ func (s *Service) issueCookies(w http.ResponseWriter, out BrowserCredentials) {
|
||||
}
|
||||
}
|
||||
func (s *Service) bearer(r *http.Request) (identity.Principal, error) {
|
||||
if strings.HasPrefix(r.Header.Get("Authorization"), "Mini ") {
|
||||
return s.miniPrincipal(r)
|
||||
}
|
||||
if s.options.TokenVerifier == nil || !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
|
||||
return identity.Principal{}, fail(401, "LOGIN_REQUIRED", "请先登录")
|
||||
}
|
||||
@@ -338,7 +481,9 @@ func (s *Service) webHTTP(w http.ResponseWriter, r *http.Request, id string) (an
|
||||
if err = StrictJSON(raw, &struct{}{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, e := s.bearer(r)
|
||||
// Same-origin and setup CSRF were verified above. The account cookie
|
||||
// supplies identity when returning from the standalone SMS login page.
|
||||
p, e := s.browserPrincipal(r, false)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
@@ -347,6 +492,21 @@ func (s *Service) webHTTP(w http.ResponseWriter, r *http.Request, id string) (an
|
||||
s.issueCookies(w, out)
|
||||
}
|
||||
return out, e
|
||||
case "auth/captcha":
|
||||
if err = StrictJSON(raw, &struct{}{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.webCaptcha(ctx, ws)
|
||||
case "auth/captcha/verify":
|
||||
return decodeCall(raw, func(in SlideCaptchaVerifyInput) (map[string]any, error) {
|
||||
return s.verifySlideCaptcha(ctx, ws.Hash, in, func(tx pgx.Tx) error {
|
||||
d, e := readDevice(ctx, tx, ws.Device.ID, true)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return s.checkWebActor(ctx, tx, ws.Hash, d)
|
||||
})
|
||||
})
|
||||
case "auth/sms/send":
|
||||
return decodeCall(raw, func(in SMSSendInput) (map[string]any, error) { return s.SendSMS(ctx, ws, id, in) })
|
||||
case "auth/sms/verify":
|
||||
@@ -403,7 +563,7 @@ func (s *Service) bootstrapHTTP(r *http.Request) (any, error) {
|
||||
return map[string]any{"csrf_token": token, "data": data}, nil
|
||||
}
|
||||
func (s *Service) opsHTTP(r *http.Request, id string) (any, error) {
|
||||
p, err := s.bearer(r)
|
||||
p, err := s.operatorView().browserPrincipal(r, r.Method != http.MethodGet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user