memberdir.go 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. package handlers
  2. import (
  3. "context"
  4. "log/slog"
  5. "net/http"
  6. "strconv"
  7. "strings"
  8. "github.com/mk6i/open-oscar-server/state"
  9. "github.com/mk6i/open-oscar-server/wire"
  10. )
  11. // defaultMemberDirLimit caps how many directory search results are returned
  12. // when the client does not specify nToGet.
  13. const defaultMemberDirLimit = 100
  14. // DirSearchService issues OSCAR ODir directory searches. A single InfoQuery
  15. // dispatches to name/address, email, or interest-keyword search based on which
  16. // TLVs the query carries.
  17. type DirSearchService interface {
  18. InfoQuery(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error)
  19. }
  20. // MemberDirLocateService retrieves a user's stored directory info by screen
  21. // name. memberDir/get uses this to return the caller's own directory profile.
  22. type MemberDirLocateService interface {
  23. DirInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x0B_LocateGetDirInfo) (wire.SNACMessage, error)
  24. }
  25. // MemberDirHandler handles Web AIM API member-directory endpoints
  26. // (memberDir/search and memberDir/get).
  27. type MemberDirHandler struct {
  28. DirSearchService DirSearchService
  29. LocateService MemberDirLocateService
  30. Logger *slog.Logger
  31. }
  32. // MemberDirProfile is the per-result directory profile the web client consumes.
  33. // The client keys users by AimID and, for its own directory info, renders
  34. // FirstName/LastName.
  35. type MemberDirProfile struct {
  36. AimID string `json:"aimId" xml:"aimId"`
  37. DisplayID string `json:"displayId,omitempty" xml:"displayId,omitempty"`
  38. FirstName string `json:"firstName,omitempty" xml:"firstName,omitempty"`
  39. LastName string `json:"lastName,omitempty" xml:"lastName,omitempty"`
  40. State string `json:"state,omitempty" xml:"state,omitempty"`
  41. City string `json:"city,omitempty" xml:"city,omitempty"`
  42. Country string `json:"country,omitempty" xml:"country,omitempty"`
  43. }
  44. // MemberDirInfo wraps a profile in the "info" envelope the client expects:
  45. // results are read as data.results.infoArray[i].profile for search and
  46. // data.infoArray[0].profile for get.
  47. type MemberDirInfo struct {
  48. Profile MemberDirProfile `json:"profile" xml:"profile"`
  49. }
  50. // Search handles GET /memberDir/search. The web client sends the raw add-contact
  51. // input as a "match" parameter shaped like "keyword=<x>" or
  52. // "firstName=<x>,lastName=<y>". We translate that into an OSCAR ODir InfoQuery
  53. // and let the ODir service pick the search mode.
  54. func (h *MemberDirHandler) Search(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
  55. ctx := r.Context()
  56. fields := parseMatch(r.URL.Query().Get("match"))
  57. inBody := buildDirInfoQuery(fields)
  58. reply, err := h.DirSearchService.InfoQuery(ctx, wire.SNACFrame{}, inBody)
  59. if err != nil {
  60. h.Logger.ErrorContext(ctx, "memberDir search failed", "err", err.Error())
  61. h.sendData(w, r, map[string]any{"results": map[string]any{"infoArray": []MemberDirInfo{}}})
  62. return
  63. }
  64. body, ok := reply.Body.(wire.SNAC_0x0F_0x03_InfoReply)
  65. if !ok || body.Status != wire.ODirSearchResponseOK {
  66. // Missing/insufficient params or an empty directory: return no results
  67. // rather than an error so the client simply shows an empty result set.
  68. h.sendData(w, r, map[string]any{"results": map[string]any{"infoArray": []MemberDirInfo{}}})
  69. return
  70. }
  71. limit := defaultMemberDirLimit
  72. if v := r.URL.Query().Get("nToGet"); v != "" {
  73. if n, err := strconv.Atoi(v); err == nil && n > 0 {
  74. limit = n
  75. }
  76. }
  77. self := session.ScreenName.IdentScreenName()
  78. infoArray := make([]MemberDirInfo, 0, len(body.Results.List))
  79. for _, result := range body.Results.List {
  80. profile := MemberDirProfile{}
  81. if sn, ok := result.String(wire.ODirTLVScreenName); ok && sn != "" {
  82. profile.DisplayID = sn
  83. profile.AimID = state.NewIdentScreenName(sn).String()
  84. }
  85. profile.FirstName, _ = result.String(wire.ODirTLVFirstName)
  86. profile.LastName, _ = result.String(wire.ODirTLVLastName)
  87. profile.State, _ = result.String(wire.ODirTLVState)
  88. profile.City, _ = result.String(wire.ODirTLVCity)
  89. profile.Country, _ = result.String(wire.ODirTLVCountry)
  90. // Exclude the requesting user from their own search results.
  91. if state.NewIdentScreenName(profile.DisplayID).String() == self.String() {
  92. continue
  93. }
  94. infoArray = append(infoArray, MemberDirInfo{Profile: profile})
  95. if len(infoArray) >= limit {
  96. break
  97. }
  98. }
  99. h.Logger.DebugContext(ctx, "memberDir search",
  100. "aimsid", session.AimSID,
  101. "match", r.URL.Query().Get("match"),
  102. "results", len(infoArray),
  103. )
  104. h.sendData(w, r, map[string]any{"results": map[string]any{"infoArray": infoArray}})
  105. }
  106. // Get handles GET /memberDir/get.
  107. func (h *MemberDirHandler) Get(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
  108. ctx := r.Context()
  109. targets := parseTargets(r.URL.Query().Get("t"))
  110. if len(targets) == 0 {
  111. targets = []string{session.ScreenName.String()}
  112. }
  113. infoArray := make([]MemberDirInfo, 0, len(targets))
  114. for _, target := range targets {
  115. reply, err := h.LocateService.DirInfo(ctx, wire.SNACFrame{},
  116. wire.SNAC_0x02_0x0B_LocateGetDirInfo{ScreenName: target})
  117. if err != nil {
  118. h.Logger.ErrorContext(ctx, "memberDir get failed", "screenName", target, "err", err.Error())
  119. continue
  120. }
  121. body, ok := reply.Body.(wire.SNAC_0x02_0x0C_LocateGetDirReply)
  122. if !ok {
  123. continue
  124. }
  125. profile := MemberDirProfile{}
  126. profile.FirstName, _ = body.String(wire.ODirTLVFirstName)
  127. profile.LastName, _ = body.String(wire.ODirTLVLastName)
  128. profile.State, _ = body.String(wire.ODirTLVState)
  129. profile.City, _ = body.String(wire.ODirTLVCity)
  130. profile.Country, _ = body.String(wire.ODirTLVCountry)
  131. profile.AimID = session.ScreenName.IdentScreenName().String()
  132. profile.DisplayID = session.ScreenName.String()
  133. infoArray = append(infoArray, MemberDirInfo{Profile: profile})
  134. }
  135. h.Logger.DebugContext(ctx, "memberDir get",
  136. "aimsid", session.AimSID,
  137. "targets", len(targets),
  138. )
  139. h.sendData(w, r, map[string]any{"infoArray": infoArray})
  140. }
  141. // sendData wraps data in the standard response envelope and sends it via
  142. // SendResponse, which honors the JSONP callback the web client uses.
  143. func (h *MemberDirHandler) sendData(w http.ResponseWriter, r *http.Request, data any) {
  144. resp := BaseResponse{}
  145. resp.Response.StatusCode = 200
  146. resp.Response.StatusText = "OK"
  147. resp.Response.Data = data
  148. SendResponse(w, r, resp, h.Logger)
  149. }
  150. // buildDirInfoQuery maps the web client's parsed "match" fields onto ODir search
  151. // TLVs. The client only ever sends two shapes: "firstName=<x>,lastName=<y>"
  152. // (input split on the first space) or "keyword=<x>" (everything else, including
  153. // a typed email). Name search takes precedence over interest-keyword search.
  154. func buildDirInfoQuery(fields map[string]string) wire.SNAC_0x0F_0x02_InfoQuery {
  155. inBody := wire.SNAC_0x0F_0x02_InfoQuery{}
  156. switch {
  157. case fields["firstName"] != "" || fields["lastName"] != "":
  158. if v := fields["firstName"]; v != "" {
  159. inBody.Append(wire.NewTLVBE(wire.ODirTLVFirstName, v))
  160. }
  161. if v := fields["lastName"]; v != "" {
  162. inBody.Append(wire.NewTLVBE(wire.ODirTLVLastName, v))
  163. }
  164. case fields["keyword"] != "":
  165. inBody.Append(wire.NewTLVBE(wire.ODirTLVInterest, fields["keyword"]))
  166. }
  167. return inBody
  168. }
  169. // parseMatch splits the web client's "match" value ("key=value,key=value")
  170. // into a field map.
  171. func parseMatch(match string) map[string]string {
  172. fields := make(map[string]string)
  173. for pair := range strings.SplitSeq(match, ",") {
  174. key, val, ok := strings.Cut(pair, "=")
  175. if !ok {
  176. continue
  177. }
  178. if key = strings.TrimSpace(key); key != "" {
  179. fields[key] = strings.TrimSpace(val)
  180. }
  181. }
  182. return fields
  183. }
  184. // parseTargets splits a comma-separated "t" screen-name list, trimming blanks.
  185. func parseTargets(t string) []string {
  186. var targets []string
  187. for name := range strings.SplitSeq(t, ",") {
  188. if name = strings.TrimSpace(name); name != "" {
  189. targets = append(targets, name)
  190. }
  191. }
  192. return targets
  193. }