e911_api.go 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. package server
  2. import (
  3. "crypto/rand"
  4. "encoding/hex"
  5. "encoding/json"
  6. "fmt"
  7. "net/http"
  8. "strings"
  9. "sync"
  10. "time"
  11. "vocat/internal/store"
  12. )
  13. // websheetSession is a short-lived, token-authenticated E911 address
  14. // provisioning session. The operator/carrier-hosted websheet VoHive embeds
  15. // cannot be reproduced, so the backend self-hosts a minimal address form and
  16. // relays the result into the device's VoWiFi record.
  17. type websheetSession struct {
  18. id string
  19. token string
  20. deviceID string
  21. createdAt time.Time
  22. expiresAt time.Time
  23. address map[string]string
  24. done bool
  25. }
  26. type websheetManager struct {
  27. mu sync.Mutex
  28. sessions map[string]*websheetSession
  29. }
  30. func newWebsheetManager() *websheetManager {
  31. return &websheetManager{sessions: make(map[string]*websheetSession)}
  32. }
  33. func websheetToken() string {
  34. var b [16]byte
  35. _, _ = rand.Read(b[:])
  36. return hex.EncodeToString(b[:])
  37. }
  38. func (m *websheetManager) create(deviceID string) *websheetSession {
  39. now := time.Now().UTC()
  40. session := &websheetSession{
  41. id: websheetToken()[:16],
  42. token: websheetToken(),
  43. deviceID: deviceID,
  44. createdAt: now,
  45. expiresAt: now.Add(15 * time.Minute),
  46. }
  47. m.mu.Lock()
  48. m.sessions[session.id] = session
  49. // Opportunistically drop expired sessions.
  50. for id, value := range m.sessions {
  51. if now.After(value.expiresAt) {
  52. delete(m.sessions, id)
  53. }
  54. }
  55. m.mu.Unlock()
  56. return session
  57. }
  58. func (m *websheetManager) get(id string) *websheetSession {
  59. m.mu.Lock()
  60. defer m.mu.Unlock()
  61. session := m.sessions[id]
  62. if session == nil || time.Now().UTC().After(session.expiresAt) {
  63. return nil
  64. }
  65. return session
  66. }
  67. // handleE911Websheet creates a self-hosted E911 websheet session for a device
  68. // and returns the embeddable form URL (VoHive: POST /devices/{id}/vowifi/e911/websheet).
  69. func (s *Server) handleE911Websheet(
  70. w http.ResponseWriter,
  71. r *http.Request,
  72. config store.Device,
  73. ) bool {
  74. if !requireMethod(w, r, http.MethodPost) {
  75. return true
  76. }
  77. session := s.websheets.create(config.ID)
  78. embedURL := fmt.Sprintf("/websheets/%s?token=%s", session.id, session.token)
  79. writeJSON(w, http.StatusOK, map[string]any{
  80. "data": map[string]any{
  81. "id": session.id,
  82. "token": session.token,
  83. "embed_url": embedURL,
  84. "expires_at": session.expiresAt,
  85. },
  86. })
  87. return true
  88. }
  89. // handleWebsheet serves and drives the self-hosted E911 address form. These
  90. // paths are token-authenticated (the token in the URL is the credential), so
  91. // they live outside the session-gated /api tree.
  92. //
  93. // GET /websheets/{id}?token=... -> the address form
  94. // POST /websheets/{id}/callback -> relay the entered address
  95. // POST /websheets/{id}/done -> complete the session
  96. func (s *Server) handleWebsheet(w http.ResponseWriter, r *http.Request) {
  97. rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/websheets/"), "/")
  98. segments := splitAPIPath(rest)
  99. if len(segments) == 0 || segments[0] == "" {
  100. writeError(w, http.StatusNotFound, "not_found", "websheet was not found")
  101. return
  102. }
  103. session := s.websheets.get(segments[0])
  104. if session == nil {
  105. writeError(w, http.StatusNotFound, "websheet_not_found", "websheet session was not found or has expired")
  106. return
  107. }
  108. action := ""
  109. if len(segments) > 1 {
  110. action = segments[1]
  111. }
  112. switch {
  113. case action == "" && r.Method == http.MethodGet:
  114. s.serveWebsheetForm(w, r, session)
  115. case action == "callback" && r.Method == http.MethodPost:
  116. s.handleWebsheetCallback(w, r, session)
  117. case action == "done" && r.Method == http.MethodPost:
  118. s.handleWebsheetDone(w, r, session)
  119. default:
  120. w.Header().Set("Allow", "GET, POST")
  121. writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
  122. }
  123. }
  124. func (s *Server) websheetTokenOK(r *http.Request, session *websheetSession) bool {
  125. token := r.URL.Query().Get("token")
  126. if token == "" {
  127. token = r.Header.Get("X-Websheet-Token")
  128. }
  129. return token != "" && token == session.token
  130. }
  131. func (s *Server) serveWebsheetForm(w http.ResponseWriter, r *http.Request, session *websheetSession) {
  132. if !s.websheetTokenOK(r, session) {
  133. writeError(w, http.StatusForbidden, "invalid_token", "websheet token is invalid")
  134. return
  135. }
  136. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  137. w.WriteHeader(http.StatusOK)
  138. _, _ = fmt.Fprint(w, websheetFormHTML(session))
  139. }
  140. func (s *Server) handleWebsheetCallback(w http.ResponseWriter, r *http.Request, session *websheetSession) {
  141. if !s.websheetTokenOK(r, session) {
  142. writeError(w, http.StatusForbidden, "invalid_token", "websheet token is invalid")
  143. return
  144. }
  145. var address map[string]string
  146. if err := s.decodeJSON(w, r, &address); err != nil {
  147. writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
  148. return
  149. }
  150. clean := make(map[string]string, len(address))
  151. for key, value := range address {
  152. trimmed := strings.TrimSpace(value)
  153. if trimmed != "" && len(trimmed) <= 256 {
  154. clean[key] = trimmed
  155. }
  156. }
  157. s.websheets.mu.Lock()
  158. session.address = clean
  159. s.websheets.mu.Unlock()
  160. s.persistE911Address(r, session)
  161. writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"received": true}})
  162. }
  163. func (s *Server) handleWebsheetDone(w http.ResponseWriter, r *http.Request, session *websheetSession) {
  164. if !s.websheetTokenOK(r, session) {
  165. writeError(w, http.StatusForbidden, "invalid_token", "websheet token is invalid")
  166. return
  167. }
  168. s.websheets.mu.Lock()
  169. session.done = true
  170. s.websheets.mu.Unlock()
  171. writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"done": true}})
  172. }
  173. // persistE911Address stores the provisioned E911 address against the device so
  174. // the VoWiFi IMS registration can reference it later.
  175. func (s *Server) persistE911Address(r *http.Request, session *websheetSession) {
  176. payload, err := json.Marshal(session.address)
  177. if err != nil {
  178. return
  179. }
  180. if err := s.store.UpsertAppSetting(r.Context(), store.AppSetting{
  181. Key: "e911_address:" + session.deviceID,
  182. Value: payload,
  183. }); err != nil {
  184. s.logger.Warn("persist e911 address failed", "device_id", session.deviceID, "error", err)
  185. }
  186. }
  187. // websheetFormHTML renders the self-contained E911 address form embedded by the
  188. // frontend. On submit it relays the address to the callback, notifies the
  189. // parent frame (vohive-websheet-callback), and completes the session.
  190. func websheetFormHTML(session *websheetSession) string {
  191. callbackURL := fmt.Sprintf("/websheets/%s/callback?token=%s", session.id, session.token)
  192. doneURL := fmt.Sprintf("/websheets/%s/done?token=%s", session.id, session.token)
  193. callbackJSON, _ := json.Marshal(callbackURL)
  194. doneJSON, _ := json.Marshal(doneURL)
  195. return `<!doctype html>
  196. <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
  197. <title>E911 Address</title>
  198. <style>
  199. body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;background:#f6f7f9;margin:0;padding:20px;color:#111}
  200. .card{max-width:430px;margin:0 auto;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px}
  201. h1{font-size:16px;margin:0 0 4px}p.sub{font-size:12px;color:#6b7280;margin:0 0 16px}
  202. label{display:block;font-size:12px;color:#4b5563;margin:10px 0 4px}
  203. input{width:100%;height:34px;padding:0 10px;border:1px solid #dcdfe6;border-radius:8px;font-size:13px;box-sizing:border-box}
  204. input:focus{outline:none;border-color:#5b5bd6;box-shadow:0 0 0 3px rgba(91,91,214,.18)}
  205. button{margin-top:16px;width:100%;height:38px;border:0;border-radius:8px;background:#5b5bd6;color:#fff;font-size:14px;font-weight:600;cursor:pointer}
  206. button:disabled{opacity:.6;cursor:not-allowed}.row{display:flex;gap:10px}.row>div{flex:1}
  207. .msg{margin-top:12px;font-size:13px;text-align:center}.ok{color:#16a34a}.err{color:#dc2626}
  208. </style></head><body>
  209. <div class="card">
  210. <h1>E911 紧急地址登记</h1>
  211. <p class="sub">为 VoWiFi 服务登记紧急呼叫地址(Emergency Address)。</p>
  212. <form id="f">
  213. <label>姓名 / Name</label><input name="name" autocomplete="name">
  214. <label>街道地址 / Street</label><input name="street" required autocomplete="street-address">
  215. <label>城市 / City</label><input name="city" required>
  216. <div class="row"><div><label>州/省 / State</label><input name="state"></div>
  217. <div><label>邮编 / ZIP</label><input name="zip"></div></div>
  218. <label>国家 / Country</label><input name="country" required>
  219. <button type="submit" id="btn">提交地址</button>
  220. <div class="msg" id="msg"></div>
  221. </form></div>
  222. <script>
  223. var CALLBACK=` + string(callbackJSON) + `,DONE=` + string(doneJSON) + `;
  224. document.getElementById('f').addEventListener('submit',async function(e){
  225. e.preventDefault();var btn=document.getElementById('btn'),msg=document.getElementById('msg');
  226. btn.disabled=true;msg.textContent='';var data={};
  227. new FormData(e.target).forEach(function(v,k){data[k]=v});
  228. try{
  229. var r=await fetch(CALLBACK,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
  230. if(!r.ok)throw new Error('callback '+r.status);
  231. try{if(window.parent)window.parent.postMessage({type:'vohive-websheet-callback',address:data},'*')}catch(_){}
  232. await fetch(DONE,{method:'POST'}).catch(function(){});
  233. msg.className='msg ok';msg.textContent='地址已提交 / Address saved';
  234. }catch(err){btn.disabled=false;msg.className='msg err';msg.textContent='提交失败,请重试 / Submit failed';}
  235. });
  236. </script></body></html>`
  237. }