memberdir.go 12 KB

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