package server import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "net/http" "strings" "sync" "time" "vocat/internal/store" ) // websheetSession is a short-lived, token-authenticated E911 address // provisioning session. The operator/carrier-hosted websheet VoHive embeds // cannot be reproduced, so the backend self-hosts a minimal address form and // relays the result into the device's VoWiFi record. type websheetSession struct { id string token string deviceID string createdAt time.Time expiresAt time.Time address map[string]string done bool } type websheetManager struct { mu sync.Mutex sessions map[string]*websheetSession } func newWebsheetManager() *websheetManager { return &websheetManager{sessions: make(map[string]*websheetSession)} } func websheetToken() string { var b [16]byte _, _ = rand.Read(b[:]) return hex.EncodeToString(b[:]) } func (m *websheetManager) create(deviceID string) *websheetSession { now := time.Now().UTC() session := &websheetSession{ id: websheetToken()[:16], token: websheetToken(), deviceID: deviceID, createdAt: now, expiresAt: now.Add(15 * time.Minute), } m.mu.Lock() m.sessions[session.id] = session // Opportunistically drop expired sessions. for id, value := range m.sessions { if now.After(value.expiresAt) { delete(m.sessions, id) } } m.mu.Unlock() return session } func (m *websheetManager) get(id string) *websheetSession { m.mu.Lock() defer m.mu.Unlock() session := m.sessions[id] if session == nil || time.Now().UTC().After(session.expiresAt) { return nil } return session } // handleE911Websheet creates a self-hosted E911 websheet session for a device // and returns the embeddable form URL (VoHive: POST /devices/{id}/vowifi/e911/websheet). func (s *Server) handleE911Websheet( w http.ResponseWriter, r *http.Request, config store.Device, ) bool { if !requireMethod(w, r, http.MethodPost) { return true } session := s.websheets.create(config.ID) embedURL := fmt.Sprintf("/websheets/%s?token=%s", session.id, session.token) writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "id": session.id, "token": session.token, "embed_url": embedURL, "expires_at": session.expiresAt, }, }) return true } // handleWebsheet serves and drives the self-hosted E911 address form. These // paths are token-authenticated (the token in the URL is the credential), so // they live outside the session-gated /api tree. // // GET /websheets/{id}?token=... -> the address form // POST /websheets/{id}/callback -> relay the entered address // POST /websheets/{id}/done -> complete the session func (s *Server) handleWebsheet(w http.ResponseWriter, r *http.Request) { rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/websheets/"), "/") segments := splitAPIPath(rest) if len(segments) == 0 || segments[0] == "" { writeError(w, http.StatusNotFound, "not_found", "websheet was not found") return } session := s.websheets.get(segments[0]) if session == nil { writeError(w, http.StatusNotFound, "websheet_not_found", "websheet session was not found or has expired") return } action := "" if len(segments) > 1 { action = segments[1] } switch { case action == "" && r.Method == http.MethodGet: s.serveWebsheetForm(w, r, session) case action == "callback" && r.Method == http.MethodPost: s.handleWebsheetCallback(w, r, session) case action == "done" && r.Method == http.MethodPost: s.handleWebsheetDone(w, r, session) default: w.Header().Set("Allow", "GET, POST") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") } } func (s *Server) websheetTokenOK(r *http.Request, session *websheetSession) bool { token := r.URL.Query().Get("token") if token == "" { token = r.Header.Get("X-Websheet-Token") } return token != "" && token == session.token } func (s *Server) serveWebsheetForm(w http.ResponseWriter, r *http.Request, session *websheetSession) { if !s.websheetTokenOK(r, session) { writeError(w, http.StatusForbidden, "invalid_token", "websheet token is invalid") return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) _, _ = fmt.Fprint(w, websheetFormHTML(session)) } func (s *Server) handleWebsheetCallback(w http.ResponseWriter, r *http.Request, session *websheetSession) { if !s.websheetTokenOK(r, session) { writeError(w, http.StatusForbidden, "invalid_token", "websheet token is invalid") return } var address map[string]string if err := s.decodeJSON(w, r, &address); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return } clean := make(map[string]string, len(address)) for key, value := range address { trimmed := strings.TrimSpace(value) if trimmed != "" && len(trimmed) <= 256 { clean[key] = trimmed } } s.websheets.mu.Lock() session.address = clean s.websheets.mu.Unlock() s.persistE911Address(r, session) writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"received": true}}) } func (s *Server) handleWebsheetDone(w http.ResponseWriter, r *http.Request, session *websheetSession) { if !s.websheetTokenOK(r, session) { writeError(w, http.StatusForbidden, "invalid_token", "websheet token is invalid") return } s.websheets.mu.Lock() session.done = true s.websheets.mu.Unlock() writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"done": true}}) } // persistE911Address stores the provisioned E911 address against the device so // the VoWiFi IMS registration can reference it later. func (s *Server) persistE911Address(r *http.Request, session *websheetSession) { payload, err := json.Marshal(session.address) if err != nil { return } if err := s.store.UpsertAppSetting(r.Context(), store.AppSetting{ Key: "e911_address:" + session.deviceID, Value: payload, }); err != nil { s.logger.Warn("persist e911 address failed", "device_id", session.deviceID, "error", err) } } // websheetFormHTML renders the self-contained E911 address form embedded by the // frontend. On submit it relays the address to the callback, notifies the // parent frame (vohive-websheet-callback), and completes the session. func websheetFormHTML(session *websheetSession) string { callbackURL := fmt.Sprintf("/websheets/%s/callback?token=%s", session.id, session.token) doneURL := fmt.Sprintf("/websheets/%s/done?token=%s", session.id, session.token) callbackJSON, _ := json.Marshal(callbackURL) doneJSON, _ := json.Marshal(doneURL) return ` E911 Address

E911 紧急地址登记

为 VoWiFi 服务登记紧急呼叫地址(Emergency Address)。

` }