4
0

memberdir_handler.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. package webapi
  2. import (
  3. "context"
  4. "fmt"
  5. "log/slog"
  6. "net/http"
  7. "net/url"
  8. "strconv"
  9. "strings"
  10. "github.com/mk6i/open-oscar-server/state"
  11. "github.com/mk6i/open-oscar-server/wire"
  12. )
  13. // defaultMemberDirLimit caps how many directory search results are returned
  14. // when the client does not specify nToGet.
  15. const defaultMemberDirLimit = 100
  16. // maxMemberDirTargets caps how many screen names a single memberDir/get may
  17. // resolve. Every target costs its own directory lookup, and the web client only
  18. // ever asks for one, so this bounds the fan-out an arbitrary "t" list can force.
  19. const maxMemberDirTargets = 20
  20. // dirInfoTags are the directory fields carried in both the ODir get reply and
  21. // the locate set request. SetDirectoryInfo replaces every column, so
  22. // memberDir/update re-sends all of them to preserve fields the web form (which
  23. // only edits first/last name) does not touch.
  24. var dirInfoTags = []uint16{
  25. wire.ODirTLVFirstName,
  26. wire.ODirTLVLastName,
  27. wire.ODirTLVMiddleName,
  28. wire.ODirTLVMaidenName,
  29. wire.ODirTLVCountry,
  30. wire.ODirTLVState,
  31. wire.ODirTLVCity,
  32. wire.ODirTLVNickName,
  33. wire.ODirTLVZIP,
  34. wire.ODirTLVAddress,
  35. }
  36. // MemberDirHandler handles Web AIM API member-directory endpoints
  37. // (memberDir/search, memberDir/get, and memberDir/update).
  38. type MemberDirHandler struct {
  39. DirSearchService DirSearchService
  40. LocateService LocateService
  41. Logger *slog.Logger
  42. }
  43. // MemberDirProfile is the per-result directory profile the web client consumes.
  44. // The client keys users by AimID and, for its own directory info, renders
  45. // FirstName/LastName.
  46. type MemberDirProfile struct {
  47. AimID string `json:"aimId" xml:"aimId"`
  48. DisplayID string `json:"displayId,omitempty" xml:"displayId,omitempty"`
  49. FirstName string `json:"firstName,omitempty" xml:"firstName,omitempty"`
  50. LastName string `json:"lastName,omitempty" xml:"lastName,omitempty"`
  51. State string `json:"state,omitempty" xml:"state,omitempty"`
  52. City string `json:"city,omitempty" xml:"city,omitempty"`
  53. Country string `json:"country,omitempty" xml:"country,omitempty"`
  54. }
  55. // MemberDirInfo wraps a profile in the "info" envelope the client expects:
  56. // results are read as data.results.infoArray[i].profile for search and
  57. // data.infoArray[0].profile for get.
  58. type MemberDirInfo struct {
  59. Profile MemberDirProfile `json:"profile" xml:"profile"`
  60. }
  61. // MemberDirResults wraps a directory search result set.
  62. type MemberDirResults struct {
  63. Results MemberDirSearchResults `json:"results" xml:"results"`
  64. }
  65. // MemberDirSearchResults is the matched profile list plus its counters.
  66. type MemberDirSearchResults struct {
  67. NTotal int `json:"nTotal" xml:"nTotal"`
  68. NSkipped int `json:"nSkipped" xml:"nSkipped"`
  69. NProfiles int `json:"nProfiles" xml:"nProfiles"`
  70. InfoArray []MemberDirInfo `json:"infoArray" xml:"infoArray>info"`
  71. }
  72. // MemberDirInfoArray is the list of matched profiles.
  73. type MemberDirInfoArray struct {
  74. InfoArray []MemberDirInfo `json:"infoArray" xml:"infoArray>info"`
  75. }
  76. // Search handles GET /memberDir/search.
  77. func (h *MemberDirHandler) Search(w http.ResponseWriter, r *http.Request, session *Session) {
  78. ctx := r.Context()
  79. fields := parseMatch(r.URL.Query().Get("match"))
  80. self := session.ScreenName.IdentScreenName()
  81. // ODir matches interests, names and email but never the screen name, so an
  82. // identity lookup runs alongside the directory search.
  83. profiles := make([]MemberDirProfile, 0, 8)
  84. seen := make(map[string]bool)
  85. addProfile := func(profile MemberDirProfile) {
  86. // Exclude the requesting user from their own search results.
  87. if profile.AimID == "" || profile.AimID == self.String() || seen[profile.AimID] {
  88. return
  89. }
  90. seen[profile.AimID] = true
  91. profiles = append(profiles, profile)
  92. }
  93. named, found, err := h.exactScreenNameMatch(ctx, fields["keyword"])
  94. if err != nil {
  95. h.Logger.ErrorContext(ctx, "memberDir search: screen name lookup failed",
  96. "screenName", fields["keyword"], "err", err.Error())
  97. SendEnvelopeStatus(w, r, http.StatusInternalServerError, "internal server error", h.Logger)
  98. return
  99. }
  100. if found {
  101. addProfile(named)
  102. }
  103. reply, err := h.DirSearchService.InfoQuery(ctx, wire.SNACFrame{}, buildDirInfoQuery(fields))
  104. if err != nil {
  105. h.Logger.ErrorContext(ctx, "memberDir search failed", "err", err.Error())
  106. SendEnvelopeStatus(w, r, http.StatusInternalServerError, "internal server error", h.Logger)
  107. return
  108. }
  109. if body, ok := reply.Body.(wire.SNAC_0x0F_0x03_InfoReply); ok && body.Status == wire.ODirSearchResponseOK {
  110. for _, result := range body.Results.List {
  111. profile := MemberDirProfile{}
  112. if sn, ok := result.String(wire.ODirTLVScreenName); ok && sn != "" {
  113. profile.DisplayID = sn
  114. profile.AimID = state.NewIdentScreenName(sn).String()
  115. }
  116. profile.FirstName, _ = result.String(wire.ODirTLVFirstName)
  117. profile.LastName, _ = result.String(wire.ODirTLVLastName)
  118. profile.State, _ = result.String(wire.ODirTLVState)
  119. profile.City, _ = result.String(wire.ODirTLVCity)
  120. profile.Country, _ = result.String(wire.ODirTLVCountry)
  121. addProfile(profile)
  122. }
  123. }
  124. limit := defaultMemberDirLimit
  125. if v := r.URL.Query().Get("nToGet"); v != "" {
  126. if n, err := strconv.Atoi(v); err == nil && n > 0 {
  127. limit = n
  128. }
  129. }
  130. skip := 0
  131. if v := r.URL.Query().Get("nToSkip"); v != "" {
  132. if n, err := strconv.Atoi(v); err == nil && n > 0 {
  133. skip = n
  134. }
  135. }
  136. // matched counts every profile the query matched; infoArray holds the page of
  137. // them this response carries, after nToSkip and nToGet are applied.
  138. matched := 0
  139. infoArray := make([]MemberDirInfo, 0, len(profiles))
  140. for _, profile := range profiles {
  141. matched++
  142. if matched <= skip {
  143. continue
  144. }
  145. if len(infoArray) >= limit {
  146. continue
  147. }
  148. infoArray = append(infoArray, MemberDirInfo{Profile: profile})
  149. }
  150. h.Logger.DebugContext(ctx, "memberDir search",
  151. "aimsid", session.AimSID,
  152. "match", r.URL.Query().Get("match"),
  153. "results", len(infoArray),
  154. )
  155. SendOK(w, r, &MemberDirResults{Results: MemberDirSearchResults{
  156. NTotal: matched,
  157. NSkipped: skip,
  158. NProfiles: len(infoArray),
  159. InfoArray: infoArray,
  160. }}, h.Logger)
  161. }
  162. // exactScreenNameMatch resolves query as a screen name, reporting whether a user
  163. // by that name exists along with their directory profile.
  164. func (h *MemberDirHandler) exactScreenNameMatch(ctx context.Context, query string) (MemberDirProfile, bool, error) {
  165. query = strings.TrimSpace(query)
  166. if query == "" {
  167. return MemberDirProfile{}, false, nil
  168. }
  169. reply, err := h.LocateService.DirInfo(ctx, wire.SNACFrame{}, wire.SNAC_0x02_0x0B_LocateGetDirInfo{ScreenName: query})
  170. if err != nil {
  171. return MemberDirProfile{}, false, fmt.Errorf("DirInfo: %w", err)
  172. }
  173. body, ok := reply.Body.(wire.SNAC_0x02_0x0C_LocateGetDirReply)
  174. if !ok {
  175. return MemberDirProfile{}, false, nil
  176. }
  177. // DirInfo answers for any name, appending directory TLVs only for a user that
  178. // exists, so their presence — not their values — is the existence test.
  179. if !body.HasTag(wire.ODirTLVFirstName) {
  180. return MemberDirProfile{}, false, nil
  181. }
  182. profile := MemberDirProfile{
  183. AimID: state.NewIdentScreenName(query).String(),
  184. DisplayID: query,
  185. }
  186. profile.FirstName, _ = body.String(wire.ODirTLVFirstName)
  187. profile.LastName, _ = body.String(wire.ODirTLVLastName)
  188. profile.State, _ = body.String(wire.ODirTLVState)
  189. profile.City, _ = body.String(wire.ODirTLVCity)
  190. profile.Country, _ = body.String(wire.ODirTLVCountry)
  191. return profile, true, nil
  192. }
  193. // Get handles GET /memberDir/get. The "t" param names the screen names to look
  194. // up, defaulting to the caller when absent. Each returned profile carries the
  195. // identity of the target it describes.
  196. func (h *MemberDirHandler) Get(w http.ResponseWriter, r *http.Request, session *Session) {
  197. ctx := r.Context()
  198. targets := parseTargets(r.URL.Query().Get("t"))
  199. if len(targets) == 0 {
  200. targets = []string{session.ScreenName.String()}
  201. }
  202. if len(targets) > maxMemberDirTargets {
  203. h.Logger.WarnContext(ctx, "memberDir get: truncating oversized target list",
  204. "aimsid", session.AimSID,
  205. "requested", len(targets),
  206. "cap", maxMemberDirTargets,
  207. )
  208. targets = targets[:maxMemberDirTargets]
  209. }
  210. infoArray := make([]MemberDirInfo, 0, len(targets))
  211. for _, target := range targets {
  212. reply, err := h.LocateService.DirInfo(ctx, wire.SNACFrame{}, wire.SNAC_0x02_0x0B_LocateGetDirInfo{ScreenName: target})
  213. if err != nil {
  214. h.Logger.ErrorContext(ctx, "memberDir get failed", "screenName", target, "err", err.Error())
  215. continue
  216. }
  217. body, ok := reply.Body.(wire.SNAC_0x02_0x0C_LocateGetDirReply)
  218. if !ok {
  219. continue
  220. }
  221. profile := MemberDirProfile{}
  222. profile.FirstName, _ = body.String(wire.ODirTLVFirstName)
  223. profile.LastName, _ = body.String(wire.ODirTLVLastName)
  224. profile.State, _ = body.String(wire.ODirTLVState)
  225. profile.City, _ = body.String(wire.ODirTLVCity)
  226. profile.Country, _ = body.String(wire.ODirTLVCountry)
  227. profile.AimID = state.NewIdentScreenName(target).String()
  228. profile.DisplayID = target
  229. infoArray = append(infoArray, MemberDirInfo{Profile: profile})
  230. }
  231. h.Logger.DebugContext(ctx, "memberDir get",
  232. "aimsid", session.AimSID,
  233. "targets", len(targets),
  234. )
  235. SendOK(w, r, &MemberDirInfoArray{InfoArray: infoArray}, h.Logger)
  236. }
  237. // Update handles /memberDir/update over GET and POST. Clients send repeated
  238. // "set=key=value" params; first and last name are persisted to the directory
  239. // record and every other field is ignored, having nowhere to be stored.
  240. //
  241. // SetDirectoryInfo replaces the whole directory record, so we read the current
  242. // info first and re-send every field, overlaying only what the form changed.
  243. func (h *MemberDirHandler) Update(w http.ResponseWriter, r *http.Request, session *Session) {
  244. ctx := r.Context()
  245. sets := memberDirSets(r)
  246. // Seed from the current record so untouched fields survive the replace. A
  247. // failed read must abort: writing a record we couldn't seed would blank
  248. // every field the form doesn't edit.
  249. reply, err := h.LocateService.DirInfo(ctx, wire.SNACFrame{}, wire.SNAC_0x02_0x0B_LocateGetDirInfo{ScreenName: session.ScreenName.String()})
  250. if err != nil {
  251. h.Logger.ErrorContext(ctx, "memberDir update: failed to read current dir info", "err", err.Error())
  252. SendEnvelopeStatus(w, r, http.StatusInternalServerError, "failed to update directory info", h.Logger)
  253. return
  254. }
  255. body, ok := reply.Body.(wire.SNAC_0x02_0x0C_LocateGetDirReply)
  256. if !ok {
  257. h.Logger.ErrorContext(ctx, "memberDir update: unexpected dir info reply", "body", fmt.Sprintf("%T", reply.Body))
  258. SendEnvelopeStatus(w, r, http.StatusInternalServerError, "failed to update directory info", h.Logger)
  259. return
  260. }
  261. values := make(map[uint16]string, len(dirInfoTags))
  262. for _, tag := range dirInfoTags {
  263. if v, ok := body.String(tag); ok {
  264. values[tag] = v
  265. }
  266. }
  267. // Overlay the form's edits. Assign unconditionally so clearing a name field
  268. // (the client sends "firstName=" with an empty value) is honored.
  269. if v, ok := sets["firstName"]; ok {
  270. values[wire.ODirTLVFirstName] = v
  271. }
  272. if v, ok := sets["lastName"]; ok {
  273. values[wire.ODirTLVLastName] = v
  274. }
  275. inBody := wire.SNAC_0x02_0x09_LocateSetDirInfo{}
  276. for _, tag := range dirInfoTags {
  277. inBody.Append(wire.NewTLVBE(tag, values[tag]))
  278. }
  279. if _, err := h.LocateService.SetDirInfo(ctx, session.OSCARSession, wire.SNACFrame{}, inBody); err != nil {
  280. h.Logger.ErrorContext(ctx, "memberDir update failed", "err", err.Error())
  281. SendEnvelopeStatus(w, r, http.StatusInternalServerError, "failed to update directory info", h.Logger)
  282. return
  283. }
  284. h.Logger.InfoContext(ctx, "memberDir update",
  285. "aimsid", session.AimSID,
  286. "firstName", values[wire.ODirTLVFirstName],
  287. "lastName", values[wire.ODirTLVLastName],
  288. )
  289. SendOK(w, r, struct{}{}, h.Logger)
  290. }
  291. // buildDirInfoQuery maps the web client's parsed "match" fields onto ODir search
  292. // TLVs. The client only ever sends two shapes: "firstName=<x>,lastName=<y>"
  293. // (input split on the last space) or "keyword=<x>" (everything else). Name
  294. // search takes precedence over interest-keyword search.
  295. func buildDirInfoQuery(fields map[string]string) wire.SNAC_0x0F_0x02_InfoQuery {
  296. inBody := wire.SNAC_0x0F_0x02_InfoQuery{}
  297. switch {
  298. case fields["firstName"] != "" || fields["lastName"] != "":
  299. if v := fields["firstName"]; v != "" {
  300. inBody.Append(wire.NewTLVBE(wire.ODirTLVFirstName, v))
  301. }
  302. if v := fields["lastName"]; v != "" {
  303. inBody.Append(wire.NewTLVBE(wire.ODirTLVLastName, v))
  304. }
  305. case fields["keyword"] != "":
  306. // "keyword" carries either an interest or an identifier. An address can
  307. // only be the latter, and ODir searches email directly.
  308. if kw := fields["keyword"]; strings.Contains(kw, "@") {
  309. inBody.Append(wire.NewTLVBE(wire.ODirTLVEmailAddress, kw))
  310. } else {
  311. inBody.Append(wire.NewTLVBE(wire.ODirTLVInterest, kw))
  312. }
  313. }
  314. return inBody
  315. }
  316. // parseMatch splits a client's "match" value ("key=value,key=value") into a field
  317. // map. Values may carry a second layer of escaping, so they are unescaped
  318. // after the split, the separators never being escaped themselves.
  319. func parseMatch(match string) map[string]string {
  320. fields := make(map[string]string)
  321. for pair := range strings.SplitSeq(match, ",") {
  322. key, val, ok := strings.Cut(pair, "=")
  323. if !ok {
  324. continue
  325. }
  326. if key = strings.TrimSpace(key); key == "" {
  327. continue
  328. }
  329. if unescaped, err := url.PathUnescape(val); err == nil {
  330. val = unescaped
  331. }
  332. fields[key] = strings.TrimSpace(val)
  333. }
  334. return fields
  335. }
  336. // memberDirSets reads the repeated "set" params into a field map. Body values arrive
  337. // doubly encoded and need a second unescape; query values are already decoded, and
  338. // unescaping those again would corrupt a literal '%'.
  339. func memberDirSets(r *http.Request) map[string]string {
  340. fields := parseSet(r.URL.Query()["set"])
  341. for key, val := range parseSet(bodyValues(r, "set")) {
  342. if decoded, err := url.QueryUnescape(val); err == nil {
  343. val = decoded
  344. }
  345. fields[key] = val
  346. }
  347. return fields
  348. }
  349. // parseSet parses the repeated "set=key=value" params the update form sends
  350. // into a field map.
  351. func parseSet(sets []string) map[string]string {
  352. fields := make(map[string]string)
  353. for _, s := range sets {
  354. key, val, ok := strings.Cut(s, "=")
  355. if !ok {
  356. continue
  357. }
  358. if key = strings.TrimSpace(key); key != "" {
  359. fields[key] = strings.TrimSpace(val)
  360. }
  361. }
  362. return fields
  363. }
  364. // parseTargets splits a comma-separated "t" screen-name list, trimming blanks.
  365. func parseTargets(t string) []string {
  366. var targets []string
  367. for name := range strings.SplitSeq(t, ",") {
  368. if name = strings.TrimSpace(name); name != "" {
  369. targets = append(targets, name)
  370. }
  371. }
  372. return targets
  373. }