memberdir_handler.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. package webapi
  2. import (
  3. "fmt"
  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. // maxMemberDirTargets caps how many screen names a single memberDir/get may
  15. // resolve. Every target costs its own directory lookup, and the web client only
  16. // ever asks for one, so this bounds the fan-out an arbitrary "t" list can force.
  17. const maxMemberDirTargets = 20
  18. // dirInfoTags are the directory fields carried in both the ODir get reply and
  19. // the locate set request. SetDirectoryInfo replaces every column, so
  20. // memberDir/update re-sends all of them to preserve fields the web form (which
  21. // only edits first/last name) does not touch.
  22. var dirInfoTags = []uint16{
  23. wire.ODirTLVFirstName,
  24. wire.ODirTLVLastName,
  25. wire.ODirTLVMiddleName,
  26. wire.ODirTLVMaidenName,
  27. wire.ODirTLVCountry,
  28. wire.ODirTLVState,
  29. wire.ODirTLVCity,
  30. wire.ODirTLVNickName,
  31. wire.ODirTLVZIP,
  32. wire.ODirTLVAddress,
  33. }
  34. // MemberDirHandler handles Web AIM API member-directory endpoints
  35. // (memberDir/search, memberDir/get, and memberDir/update).
  36. type MemberDirHandler struct {
  37. DirSearchService DirSearchService
  38. LocateService LocateService
  39. Logger *slog.Logger
  40. }
  41. // MemberDirProfile is the per-result directory profile the web client consumes.
  42. // The client keys users by AimID and, for its own directory info, renders
  43. // FirstName/LastName.
  44. type MemberDirProfile struct {
  45. AimID string `json:"aimId" xml:"aimId"`
  46. DisplayID string `json:"displayId,omitempty" xml:"displayId,omitempty"`
  47. FirstName string `json:"firstName,omitempty" xml:"firstName,omitempty"`
  48. LastName string `json:"lastName,omitempty" xml:"lastName,omitempty"`
  49. State string `json:"state,omitempty" xml:"state,omitempty"`
  50. City string `json:"city,omitempty" xml:"city,omitempty"`
  51. Country string `json:"country,omitempty" xml:"country,omitempty"`
  52. }
  53. // MemberDirInfo wraps a profile in the "info" envelope the client expects:
  54. // results are read as data.results.infoArray[i].profile for search and
  55. // data.infoArray[0].profile for get.
  56. type MemberDirInfo struct {
  57. Profile MemberDirProfile `json:"profile" xml:"profile"`
  58. }
  59. // MemberDirResults wraps a directory search result set.
  60. type MemberDirResults struct {
  61. Results MemberDirInfoArray `json:"results" xml:"results"`
  62. }
  63. // MemberDirInfoArray is the list of matched profiles.
  64. type MemberDirInfoArray struct {
  65. InfoArray []MemberDirInfo `json:"infoArray" xml:"infoArray>info"`
  66. }
  67. // Search handles GET /memberDir/search. The web client sends the raw add-contact
  68. // input as a "match" parameter shaped like "keyword=<x>" or
  69. // "firstName=<x>,lastName=<y>". We translate that into an OSCAR ODir InfoQuery
  70. // and let the ODir service pick the search mode.
  71. func (h *MemberDirHandler) Search(w http.ResponseWriter, r *http.Request, session *Session) {
  72. ctx := r.Context()
  73. fields := parseMatch(r.URL.Query().Get("match"))
  74. inBody := buildDirInfoQuery(fields)
  75. reply, err := h.DirSearchService.InfoQuery(ctx, wire.SNACFrame{}, inBody)
  76. if err != nil {
  77. h.Logger.ErrorContext(ctx, "memberDir search failed", "err", err.Error())
  78. SendOK(w, r, &MemberDirResults{Results: MemberDirInfoArray{InfoArray: []MemberDirInfo{}}}, h.Logger)
  79. return
  80. }
  81. body, ok := reply.Body.(wire.SNAC_0x0F_0x03_InfoReply)
  82. if !ok || body.Status != wire.ODirSearchResponseOK {
  83. // Missing/insufficient params or an empty directory: return no results
  84. // rather than an error so the client simply shows an empty result set.
  85. SendOK(w, r, &MemberDirResults{Results: MemberDirInfoArray{InfoArray: []MemberDirInfo{}}}, h.Logger)
  86. return
  87. }
  88. limit := defaultMemberDirLimit
  89. if v := r.URL.Query().Get("nToGet"); v != "" {
  90. if n, err := strconv.Atoi(v); err == nil && n > 0 {
  91. limit = n
  92. }
  93. }
  94. self := session.ScreenName.IdentScreenName()
  95. infoArray := make([]MemberDirInfo, 0, len(body.Results.List))
  96. for _, result := range body.Results.List {
  97. profile := MemberDirProfile{}
  98. if sn, ok := result.String(wire.ODirTLVScreenName); ok && sn != "" {
  99. profile.DisplayID = sn
  100. profile.AimID = state.NewIdentScreenName(sn).String()
  101. }
  102. profile.FirstName, _ = result.String(wire.ODirTLVFirstName)
  103. profile.LastName, _ = result.String(wire.ODirTLVLastName)
  104. profile.State, _ = result.String(wire.ODirTLVState)
  105. profile.City, _ = result.String(wire.ODirTLVCity)
  106. profile.Country, _ = result.String(wire.ODirTLVCountry)
  107. // Exclude the requesting user from their own search results.
  108. if profile.AimID == self.String() {
  109. continue
  110. }
  111. infoArray = append(infoArray, MemberDirInfo{Profile: profile})
  112. if len(infoArray) >= limit {
  113. break
  114. }
  115. }
  116. h.Logger.DebugContext(ctx, "memberDir search",
  117. "aimsid", session.AimSID,
  118. "match", r.URL.Query().Get("match"),
  119. "results", len(infoArray),
  120. )
  121. SendOK(w, r, &MemberDirResults{Results: MemberDirInfoArray{InfoArray: infoArray}}, h.Logger)
  122. }
  123. // Get handles GET /memberDir/get. The "t" param names the screen names to look
  124. // up, defaulting to the caller when absent. Each returned profile carries the
  125. // identity of the target it describes.
  126. func (h *MemberDirHandler) Get(w http.ResponseWriter, r *http.Request, session *Session) {
  127. ctx := r.Context()
  128. targets := parseTargets(r.URL.Query().Get("t"))
  129. if len(targets) == 0 {
  130. targets = []string{session.ScreenName.String()}
  131. }
  132. if len(targets) > maxMemberDirTargets {
  133. h.Logger.WarnContext(ctx, "memberDir get: truncating oversized target list",
  134. "aimsid", session.AimSID,
  135. "requested", len(targets),
  136. "cap", maxMemberDirTargets,
  137. )
  138. targets = targets[:maxMemberDirTargets]
  139. }
  140. infoArray := make([]MemberDirInfo, 0, len(targets))
  141. for _, target := range targets {
  142. reply, err := h.LocateService.DirInfo(ctx, wire.SNACFrame{}, wire.SNAC_0x02_0x0B_LocateGetDirInfo{ScreenName: target})
  143. if err != nil {
  144. h.Logger.ErrorContext(ctx, "memberDir get failed", "screenName", target, "err", err.Error())
  145. continue
  146. }
  147. body, ok := reply.Body.(wire.SNAC_0x02_0x0C_LocateGetDirReply)
  148. if !ok {
  149. continue
  150. }
  151. profile := MemberDirProfile{}
  152. profile.FirstName, _ = body.String(wire.ODirTLVFirstName)
  153. profile.LastName, _ = body.String(wire.ODirTLVLastName)
  154. profile.State, _ = body.String(wire.ODirTLVState)
  155. profile.City, _ = body.String(wire.ODirTLVCity)
  156. profile.Country, _ = body.String(wire.ODirTLVCountry)
  157. profile.AimID = state.NewIdentScreenName(target).String()
  158. profile.DisplayID = target
  159. infoArray = append(infoArray, MemberDirInfo{Profile: profile})
  160. }
  161. h.Logger.DebugContext(ctx, "memberDir get",
  162. "aimsid", session.AimSID,
  163. "targets", len(targets),
  164. )
  165. SendOK(w, r, &MemberDirInfoArray{InfoArray: infoArray}, h.Logger)
  166. }
  167. // Update handles GET /memberDir/update. The "Edit Your Name" form sends repeated
  168. // "set=key=value" params — always firstName and lastName, plus a hideLevel
  169. // web-search visibility flag. We persist first/last name into the user's OSCAR
  170. // directory info; hideLevel has no directory storage, so it is ignored.
  171. //
  172. // SetDirectoryInfo replaces the whole directory record, so we read the current
  173. // info first and re-send every field, overlaying only what the form changed.
  174. func (h *MemberDirHandler) Update(w http.ResponseWriter, r *http.Request, session *Session) {
  175. ctx := r.Context()
  176. sets := parseSet(r.URL.Query()["set"])
  177. // Seed from the current record so untouched fields survive the replace. A
  178. // failed read must abort: writing a record we couldn't seed would blank
  179. // every field the form doesn't edit.
  180. reply, err := h.LocateService.DirInfo(ctx, wire.SNACFrame{}, wire.SNAC_0x02_0x0B_LocateGetDirInfo{ScreenName: session.ScreenName.String()})
  181. if err != nil {
  182. h.Logger.ErrorContext(ctx, "memberDir update: failed to read current dir info", "err", err.Error())
  183. SendEnvelopeStatus(w, r, http.StatusInternalServerError, "failed to update directory info", h.Logger)
  184. return
  185. }
  186. body, ok := reply.Body.(wire.SNAC_0x02_0x0C_LocateGetDirReply)
  187. if !ok {
  188. h.Logger.ErrorContext(ctx, "memberDir update: unexpected dir info reply", "body", fmt.Sprintf("%T", reply.Body))
  189. SendEnvelopeStatus(w, r, http.StatusInternalServerError, "failed to update directory info", h.Logger)
  190. return
  191. }
  192. values := make(map[uint16]string, len(dirInfoTags))
  193. for _, tag := range dirInfoTags {
  194. if v, ok := body.String(tag); ok {
  195. values[tag] = v
  196. }
  197. }
  198. // Overlay the form's edits. Assign unconditionally so clearing a name field
  199. // (the client sends "firstName=" with an empty value) is honored.
  200. if v, ok := sets["firstName"]; ok {
  201. values[wire.ODirTLVFirstName] = v
  202. }
  203. if v, ok := sets["lastName"]; ok {
  204. values[wire.ODirTLVLastName] = v
  205. }
  206. inBody := wire.SNAC_0x02_0x09_LocateSetDirInfo{}
  207. for _, tag := range dirInfoTags {
  208. inBody.Append(wire.NewTLVBE(tag, values[tag]))
  209. }
  210. if _, err := h.LocateService.SetDirInfo(ctx, session.OSCARSession, wire.SNACFrame{}, inBody); err != nil {
  211. h.Logger.ErrorContext(ctx, "memberDir update failed", "err", err.Error())
  212. SendEnvelopeStatus(w, r, http.StatusInternalServerError, "failed to update directory info", h.Logger)
  213. return
  214. }
  215. h.Logger.InfoContext(ctx, "memberDir update",
  216. "aimsid", session.AimSID,
  217. "firstName", values[wire.ODirTLVFirstName],
  218. "lastName", values[wire.ODirTLVLastName],
  219. )
  220. SendOK(w, r, struct{}{}, h.Logger)
  221. }
  222. // buildDirInfoQuery maps the web client's parsed "match" fields onto ODir search
  223. // TLVs. The client only ever sends two shapes: "firstName=<x>,lastName=<y>"
  224. // (input split on the last space) or "keyword=<x>" (everything else). Name
  225. // search takes precedence over interest-keyword search.
  226. func buildDirInfoQuery(fields map[string]string) wire.SNAC_0x0F_0x02_InfoQuery {
  227. inBody := wire.SNAC_0x0F_0x02_InfoQuery{}
  228. switch {
  229. case fields["firstName"] != "" || fields["lastName"] != "":
  230. if v := fields["firstName"]; v != "" {
  231. inBody.Append(wire.NewTLVBE(wire.ODirTLVFirstName, v))
  232. }
  233. if v := fields["lastName"]; v != "" {
  234. inBody.Append(wire.NewTLVBE(wire.ODirTLVLastName, v))
  235. }
  236. case fields["keyword"] != "":
  237. inBody.Append(wire.NewTLVBE(wire.ODirTLVInterest, fields["keyword"]))
  238. }
  239. return inBody
  240. }
  241. // parseMatch splits the web client's "match" value ("key=value,key=value")
  242. // into a field map.
  243. func parseMatch(match string) map[string]string {
  244. fields := make(map[string]string)
  245. for pair := range strings.SplitSeq(match, ",") {
  246. key, val, ok := strings.Cut(pair, "=")
  247. if !ok {
  248. continue
  249. }
  250. if key = strings.TrimSpace(key); key != "" {
  251. fields[key] = strings.TrimSpace(val)
  252. }
  253. }
  254. return fields
  255. }
  256. // parseSet parses the repeated "set=key=value" params the update form sends
  257. // into a field map.
  258. func parseSet(sets []string) map[string]string {
  259. fields := make(map[string]string)
  260. for _, s := range sets {
  261. key, val, ok := strings.Cut(s, "=")
  262. if !ok {
  263. continue
  264. }
  265. if key = strings.TrimSpace(key); key != "" {
  266. fields[key] = strings.TrimSpace(val)
  267. }
  268. }
  269. return fields
  270. }
  271. // parseTargets splits a comma-separated "t" screen-name list, trimming blanks.
  272. func parseTargets(t string) []string {
  273. var targets []string
  274. for name := range strings.SplitSeq(t, ",") {
  275. if name = strings.TrimSpace(name); name != "" {
  276. targets = append(targets, name)
  277. }
  278. }
  279. return targets
  280. }