| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414 |
- package webapi
- import (
- "context"
- "fmt"
- "log/slog"
- "net/http"
- "net/url"
- "strconv"
- "strings"
- "github.com/mk6i/open-oscar-server/state"
- "github.com/mk6i/open-oscar-server/wire"
- )
- // defaultMemberDirLimit caps how many directory search results are returned
- // when the client does not specify nToGet.
- const defaultMemberDirLimit = 100
- // maxMemberDirTargets caps how many screen names a single memberDir/get may
- // resolve. Every target costs its own directory lookup, and the web client only
- // ever asks for one, so this bounds the fan-out an arbitrary "t" list can force.
- const maxMemberDirTargets = 20
- // dirInfoTags are the directory fields carried in both the ODir get reply and
- // the locate set request. SetDirectoryInfo replaces every column, so
- // memberDir/update re-sends all of them to preserve fields the web form (which
- // only edits first/last name) does not touch.
- var dirInfoTags = []uint16{
- wire.ODirTLVFirstName,
- wire.ODirTLVLastName,
- wire.ODirTLVMiddleName,
- wire.ODirTLVMaidenName,
- wire.ODirTLVCountry,
- wire.ODirTLVState,
- wire.ODirTLVCity,
- wire.ODirTLVNickName,
- wire.ODirTLVZIP,
- wire.ODirTLVAddress,
- }
- // MemberDirHandler handles Web AIM API member-directory endpoints
- // (memberDir/search, memberDir/get, and memberDir/update).
- type MemberDirHandler struct {
- DirSearchService DirSearchService
- LocateService LocateService
- Logger *slog.Logger
- }
- // MemberDirProfile is the per-result directory profile the web client consumes.
- // The client keys users by AimID and, for its own directory info, renders
- // FirstName/LastName.
- type MemberDirProfile struct {
- AimID string `json:"aimId" xml:"aimId"`
- DisplayID string `json:"displayId,omitempty" xml:"displayId,omitempty"`
- FirstName string `json:"firstName,omitempty" xml:"firstName,omitempty"`
- LastName string `json:"lastName,omitempty" xml:"lastName,omitempty"`
- State string `json:"state,omitempty" xml:"state,omitempty"`
- City string `json:"city,omitempty" xml:"city,omitempty"`
- Country string `json:"country,omitempty" xml:"country,omitempty"`
- }
- // MemberDirInfo wraps a profile in the "info" envelope the client expects:
- // results are read as data.results.infoArray[i].profile for search and
- // data.infoArray[0].profile for get.
- type MemberDirInfo struct {
- Profile MemberDirProfile `json:"profile" xml:"profile"`
- }
- // MemberDirResults wraps a directory search result set.
- type MemberDirResults struct {
- Results MemberDirSearchResults `json:"results" xml:"results"`
- }
- // MemberDirSearchResults is the matched profile list plus its counters.
- type MemberDirSearchResults struct {
- NTotal int `json:"nTotal" xml:"nTotal"`
- NSkipped int `json:"nSkipped" xml:"nSkipped"`
- NProfiles int `json:"nProfiles" xml:"nProfiles"`
- InfoArray []MemberDirInfo `json:"infoArray" xml:"infoArray>info"`
- }
- // MemberDirInfoArray is the list of matched profiles.
- type MemberDirInfoArray struct {
- InfoArray []MemberDirInfo `json:"infoArray" xml:"infoArray>info"`
- }
- // Search handles GET /memberDir/search.
- func (h *MemberDirHandler) Search(w http.ResponseWriter, r *http.Request, session *Session) {
- ctx := r.Context()
- fields := parseMatch(r.URL.Query().Get("match"))
- self := session.ScreenName.IdentScreenName()
- // ODir matches interests, names and email but never the screen name, so an
- // identity lookup runs alongside the directory search.
- profiles := make([]MemberDirProfile, 0, 8)
- seen := make(map[string]bool)
- addProfile := func(profile MemberDirProfile) {
- // Exclude the requesting user from their own search results.
- if profile.AimID == "" || profile.AimID == self.String() || seen[profile.AimID] {
- return
- }
- seen[profile.AimID] = true
- profiles = append(profiles, profile)
- }
- named, found, err := h.exactScreenNameMatch(ctx, fields["keyword"])
- if err != nil {
- h.Logger.ErrorContext(ctx, "memberDir search: screen name lookup failed",
- "screenName", fields["keyword"], "err", err.Error())
- SendEnvelopeStatus(w, r, http.StatusInternalServerError, "internal server error", h.Logger)
- return
- }
- if found {
- addProfile(named)
- }
- reply, err := h.DirSearchService.InfoQuery(ctx, wire.SNACFrame{}, buildDirInfoQuery(fields))
- if err != nil {
- h.Logger.ErrorContext(ctx, "memberDir search failed", "err", err.Error())
- SendEnvelopeStatus(w, r, http.StatusInternalServerError, "internal server error", h.Logger)
- return
- }
- if body, ok := reply.Body.(wire.SNAC_0x0F_0x03_InfoReply); ok && body.Status == wire.ODirSearchResponseOK {
- for _, result := range body.Results.List {
- profile := MemberDirProfile{}
- if sn, ok := result.String(wire.ODirTLVScreenName); ok && sn != "" {
- profile.DisplayID = sn
- profile.AimID = state.NewIdentScreenName(sn).String()
- }
- profile.FirstName, _ = result.String(wire.ODirTLVFirstName)
- profile.LastName, _ = result.String(wire.ODirTLVLastName)
- profile.State, _ = result.String(wire.ODirTLVState)
- profile.City, _ = result.String(wire.ODirTLVCity)
- profile.Country, _ = result.String(wire.ODirTLVCountry)
- addProfile(profile)
- }
- }
- limit := defaultMemberDirLimit
- if v := r.URL.Query().Get("nToGet"); v != "" {
- if n, err := strconv.Atoi(v); err == nil && n > 0 {
- limit = n
- }
- }
- skip := 0
- if v := r.URL.Query().Get("nToSkip"); v != "" {
- if n, err := strconv.Atoi(v); err == nil && n > 0 {
- skip = n
- }
- }
- // matched counts every profile the query matched; infoArray holds the page of
- // them this response carries, after nToSkip and nToGet are applied.
- matched := 0
- infoArray := make([]MemberDirInfo, 0, len(profiles))
- for _, profile := range profiles {
- matched++
- if matched <= skip {
- continue
- }
- if len(infoArray) >= limit {
- continue
- }
- infoArray = append(infoArray, MemberDirInfo{Profile: profile})
- }
- h.Logger.DebugContext(ctx, "memberDir search",
- "aimsid", session.AimSID,
- "match", r.URL.Query().Get("match"),
- "results", len(infoArray),
- )
- SendOK(w, r, &MemberDirResults{Results: MemberDirSearchResults{
- NTotal: matched,
- NSkipped: skip,
- NProfiles: len(infoArray),
- InfoArray: infoArray,
- }}, h.Logger)
- }
- // exactScreenNameMatch resolves query as a screen name, reporting whether a user
- // by that name exists along with their directory profile.
- func (h *MemberDirHandler) exactScreenNameMatch(ctx context.Context, query string) (MemberDirProfile, bool, error) {
- query = strings.TrimSpace(query)
- if query == "" {
- return MemberDirProfile{}, false, nil
- }
- reply, err := h.LocateService.DirInfo(ctx, wire.SNACFrame{}, wire.SNAC_0x02_0x0B_LocateGetDirInfo{ScreenName: query})
- if err != nil {
- return MemberDirProfile{}, false, fmt.Errorf("DirInfo: %w", err)
- }
- body, ok := reply.Body.(wire.SNAC_0x02_0x0C_LocateGetDirReply)
- if !ok {
- return MemberDirProfile{}, false, nil
- }
- // DirInfo answers for any name, appending directory TLVs only for a user that
- // exists, so their presence — not their values — is the existence test.
- if !body.HasTag(wire.ODirTLVFirstName) {
- return MemberDirProfile{}, false, nil
- }
- profile := MemberDirProfile{
- AimID: state.NewIdentScreenName(query).String(),
- DisplayID: query,
- }
- profile.FirstName, _ = body.String(wire.ODirTLVFirstName)
- profile.LastName, _ = body.String(wire.ODirTLVLastName)
- profile.State, _ = body.String(wire.ODirTLVState)
- profile.City, _ = body.String(wire.ODirTLVCity)
- profile.Country, _ = body.String(wire.ODirTLVCountry)
- return profile, true, nil
- }
- // Get handles GET /memberDir/get. The "t" param names the screen names to look
- // up, defaulting to the caller when absent. Each returned profile carries the
- // identity of the target it describes.
- func (h *MemberDirHandler) Get(w http.ResponseWriter, r *http.Request, session *Session) {
- ctx := r.Context()
- targets := parseTargets(r.URL.Query().Get("t"))
- if len(targets) == 0 {
- targets = []string{session.ScreenName.String()}
- }
- if len(targets) > maxMemberDirTargets {
- h.Logger.WarnContext(ctx, "memberDir get: truncating oversized target list",
- "aimsid", session.AimSID,
- "requested", len(targets),
- "cap", maxMemberDirTargets,
- )
- targets = targets[:maxMemberDirTargets]
- }
- infoArray := make([]MemberDirInfo, 0, len(targets))
- for _, target := range targets {
- reply, err := h.LocateService.DirInfo(ctx, wire.SNACFrame{}, wire.SNAC_0x02_0x0B_LocateGetDirInfo{ScreenName: target})
- if err != nil {
- h.Logger.ErrorContext(ctx, "memberDir get failed", "screenName", target, "err", err.Error())
- continue
- }
- body, ok := reply.Body.(wire.SNAC_0x02_0x0C_LocateGetDirReply)
- if !ok {
- continue
- }
- profile := MemberDirProfile{}
- profile.FirstName, _ = body.String(wire.ODirTLVFirstName)
- profile.LastName, _ = body.String(wire.ODirTLVLastName)
- profile.State, _ = body.String(wire.ODirTLVState)
- profile.City, _ = body.String(wire.ODirTLVCity)
- profile.Country, _ = body.String(wire.ODirTLVCountry)
- profile.AimID = state.NewIdentScreenName(target).String()
- profile.DisplayID = target
- infoArray = append(infoArray, MemberDirInfo{Profile: profile})
- }
- h.Logger.DebugContext(ctx, "memberDir get",
- "aimsid", session.AimSID,
- "targets", len(targets),
- )
- SendOK(w, r, &MemberDirInfoArray{InfoArray: infoArray}, h.Logger)
- }
- // Update handles /memberDir/update over GET and POST. Clients send repeated
- // "set=key=value" params; first and last name are persisted to the directory
- // record and every other field is ignored, having nowhere to be stored.
- //
- // SetDirectoryInfo replaces the whole directory record, so we read the current
- // info first and re-send every field, overlaying only what the form changed.
- func (h *MemberDirHandler) Update(w http.ResponseWriter, r *http.Request, session *Session) {
- ctx := r.Context()
- sets := memberDirSets(r)
- // Seed from the current record so untouched fields survive the replace. A
- // failed read must abort: writing a record we couldn't seed would blank
- // every field the form doesn't edit.
- reply, err := h.LocateService.DirInfo(ctx, wire.SNACFrame{}, wire.SNAC_0x02_0x0B_LocateGetDirInfo{ScreenName: session.ScreenName.String()})
- if err != nil {
- h.Logger.ErrorContext(ctx, "memberDir update: failed to read current dir info", "err", err.Error())
- SendEnvelopeStatus(w, r, http.StatusInternalServerError, "failed to update directory info", h.Logger)
- return
- }
- body, ok := reply.Body.(wire.SNAC_0x02_0x0C_LocateGetDirReply)
- if !ok {
- h.Logger.ErrorContext(ctx, "memberDir update: unexpected dir info reply", "body", fmt.Sprintf("%T", reply.Body))
- SendEnvelopeStatus(w, r, http.StatusInternalServerError, "failed to update directory info", h.Logger)
- return
- }
- values := make(map[uint16]string, len(dirInfoTags))
- for _, tag := range dirInfoTags {
- if v, ok := body.String(tag); ok {
- values[tag] = v
- }
- }
- // Overlay the form's edits. Assign unconditionally so clearing a name field
- // (the client sends "firstName=" with an empty value) is honored.
- if v, ok := sets["firstName"]; ok {
- values[wire.ODirTLVFirstName] = v
- }
- if v, ok := sets["lastName"]; ok {
- values[wire.ODirTLVLastName] = v
- }
- inBody := wire.SNAC_0x02_0x09_LocateSetDirInfo{}
- for _, tag := range dirInfoTags {
- inBody.Append(wire.NewTLVBE(tag, values[tag]))
- }
- if _, err := h.LocateService.SetDirInfo(ctx, session.OSCARSession, wire.SNACFrame{}, inBody); err != nil {
- h.Logger.ErrorContext(ctx, "memberDir update failed", "err", err.Error())
- SendEnvelopeStatus(w, r, http.StatusInternalServerError, "failed to update directory info", h.Logger)
- return
- }
- h.Logger.InfoContext(ctx, "memberDir update",
- "aimsid", session.AimSID,
- "firstName", values[wire.ODirTLVFirstName],
- "lastName", values[wire.ODirTLVLastName],
- )
- SendOK(w, r, struct{}{}, h.Logger)
- }
- // buildDirInfoQuery maps the web client's parsed "match" fields onto ODir search
- // TLVs. The client only ever sends two shapes: "firstName=<x>,lastName=<y>"
- // (input split on the last space) or "keyword=<x>" (everything else). Name
- // search takes precedence over interest-keyword search.
- func buildDirInfoQuery(fields map[string]string) wire.SNAC_0x0F_0x02_InfoQuery {
- inBody := wire.SNAC_0x0F_0x02_InfoQuery{}
- switch {
- case fields["firstName"] != "" || fields["lastName"] != "":
- if v := fields["firstName"]; v != "" {
- inBody.Append(wire.NewTLVBE(wire.ODirTLVFirstName, v))
- }
- if v := fields["lastName"]; v != "" {
- inBody.Append(wire.NewTLVBE(wire.ODirTLVLastName, v))
- }
- case fields["keyword"] != "":
- // "keyword" carries either an interest or an identifier. An address can
- // only be the latter, and ODir searches email directly.
- if kw := fields["keyword"]; strings.Contains(kw, "@") {
- inBody.Append(wire.NewTLVBE(wire.ODirTLVEmailAddress, kw))
- } else {
- inBody.Append(wire.NewTLVBE(wire.ODirTLVInterest, kw))
- }
- }
- return inBody
- }
- // parseMatch splits a client's "match" value ("key=value,key=value") into a field
- // map. Values may carry a second layer of escaping, so they are unescaped
- // after the split, the separators never being escaped themselves.
- func parseMatch(match string) map[string]string {
- fields := make(map[string]string)
- for pair := range strings.SplitSeq(match, ",") {
- key, val, ok := strings.Cut(pair, "=")
- if !ok {
- continue
- }
- if key = strings.TrimSpace(key); key == "" {
- continue
- }
- if unescaped, err := url.PathUnescape(val); err == nil {
- val = unescaped
- }
- fields[key] = strings.TrimSpace(val)
- }
- return fields
- }
- // memberDirSets reads the repeated "set" params into a field map. Body values arrive
- // doubly encoded and need a second unescape; query values are already decoded, and
- // unescaping those again would corrupt a literal '%'.
- func memberDirSets(r *http.Request) map[string]string {
- fields := parseSet(r.URL.Query()["set"])
- for key, val := range parseSet(bodyValues(r, "set")) {
- if decoded, err := url.QueryUnescape(val); err == nil {
- val = decoded
- }
- fields[key] = val
- }
- return fields
- }
- // parseSet parses the repeated "set=key=value" params the update form sends
- // into a field map.
- func parseSet(sets []string) map[string]string {
- fields := make(map[string]string)
- for _, s := range sets {
- key, val, ok := strings.Cut(s, "=")
- if !ok {
- continue
- }
- if key = strings.TrimSpace(key); key != "" {
- fields[key] = strings.TrimSpace(val)
- }
- }
- return fields
- }
- // parseTargets splits a comma-separated "t" screen-name list, trimming blanks.
- func parseTargets(t string) []string {
- var targets []string
- for name := range strings.SplitSeq(t, ",") {
- if name = strings.TrimSpace(name); name != "" {
- targets = append(targets, name)
- }
- }
- return targets
- }
|