Procházet zdrojové kódy

webapi: implement user directory

Mike před 2 týdny
rodič
revize
40240d27e6

+ 1 - 0
cmd/server/factory.go

@@ -590,6 +590,7 @@ func WebAPI(deps Container) *webapi.Server {
 		RecalcWarning:      deps.icbmSvc.RestoreWarningLevel,
 		LowerWarnLevel:     deps.icbmSvc.UpdateWarnLevel,
 		FeedbagService:     deps.feedbagSvc,
+		DirSearchService:   foodgroup.NewODirService(logger, deps.sqLiteUserStore),
 	}
 	// Pass SQLiteUserStore as the API key validator (it implements middleware.APIKeyValidator)
 	return webapi.NewServer(deps.cfg.WebAPIListeners, logger, handler, deps.sqLiteUserStore, deps.webAPISessionManager)

+ 1 - 0
server/webapi/handler.go

@@ -26,6 +26,7 @@ type Handler struct {
 	LowerWarnLevel     func(ctx context.Context, instance *state.SessionInstance)
 	ChatSessionManager ChatSessionManager
 	FeedbagService     FeedbagService
+	DirSearchService   DirSearchService
 }
 
 func (h Handler) GetHelloWorldHandler(w http.ResponseWriter, r *http.Request) {

+ 342 - 0
server/webapi/handlers/memberdir.go

@@ -0,0 +1,342 @@
+package handlers
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"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
+
+// DirSearchService issues OSCAR ODir directory searches. A single InfoQuery
+// dispatches to name/address, email, or interest-keyword search based on which
+// TLVs the query carries.
+type DirSearchService interface {
+	InfoQuery(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error)
+}
+
+// MemberDirLocateService reads and writes stored directory info. memberDir/get
+// reads the requested screen names' profiles via DirInfo; memberDir/update
+// writes the caller's own via SetDirInfo.
+type MemberDirLocateService interface {
+	DirInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x0B_LocateGetDirInfo) (wire.SNACMessage, error)
+	SetDirInfo(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x09_LocateSetDirInfo) (wire.SNACMessage, error)
+}
+
+// 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    MemberDirLocateService
+	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"`
+}
+
+// Search handles GET /memberDir/search. The web client sends the raw add-contact
+// input as a "match" parameter shaped like "keyword=<x>" or
+// "firstName=<x>,lastName=<y>". We translate that into an OSCAR ODir InfoQuery
+// and let the ODir service pick the search mode.
+func (h *MemberDirHandler) Search(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
+	ctx := r.Context()
+
+	fields := parseMatch(r.URL.Query().Get("match"))
+	inBody := buildDirInfoQuery(fields)
+
+	reply, err := h.DirSearchService.InfoQuery(ctx, wire.SNACFrame{}, inBody)
+	if err != nil {
+		h.Logger.ErrorContext(ctx, "memberDir search failed", "err", err.Error())
+		h.sendData(w, r, map[string]any{"results": map[string]any{"infoArray": []MemberDirInfo{}}})
+		return
+	}
+
+	body, ok := reply.Body.(wire.SNAC_0x0F_0x03_InfoReply)
+	if !ok || body.Status != wire.ODirSearchResponseOK {
+		// Missing/insufficient params or an empty directory: return no results
+		// rather than an error so the client simply shows an empty result set.
+		h.sendData(w, r, map[string]any{"results": map[string]any{"infoArray": []MemberDirInfo{}}})
+		return
+	}
+
+	limit := defaultMemberDirLimit
+	if v := r.URL.Query().Get("nToGet"); v != "" {
+		if n, err := strconv.Atoi(v); err == nil && n > 0 {
+			limit = n
+		}
+	}
+	self := session.ScreenName.IdentScreenName()
+
+	infoArray := make([]MemberDirInfo, 0, len(body.Results.List))
+	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)
+		// Exclude the requesting user from their own search results.
+		if profile.AimID == self.String() {
+			continue
+		}
+		infoArray = append(infoArray, MemberDirInfo{Profile: profile})
+		if len(infoArray) >= limit {
+			break
+		}
+	}
+
+	h.Logger.DebugContext(ctx, "memberDir search",
+		"aimsid", session.AimSID,
+		"match", r.URL.Query().Get("match"),
+		"results", len(infoArray),
+	)
+
+	h.sendData(w, r, map[string]any{"results": map[string]any{"infoArray": infoArray}})
+}
+
+// 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 *state.WebAPISession) {
+	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),
+	)
+
+	h.sendData(w, r, map[string]any{"infoArray": infoArray})
+}
+
+// Update handles GET /memberDir/update. The "Edit Your Name" form sends repeated
+// "set=key=value" params — always firstName and lastName, plus a hideLevel
+// web-search visibility flag. We persist first/last name into the user's OSCAR
+// directory info; hideLevel has no directory storage, so it is ignored.
+//
+// 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 *state.WebAPISession) {
+	ctx := r.Context()
+
+	sets := parseSet(r.URL.Query()["set"])
+
+	// 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())
+		h.sendError(w, r, http.StatusInternalServerError, "failed to update directory info")
+		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))
+		h.sendError(w, r, http.StatusInternalServerError, "failed to update directory info")
+		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())
+		h.sendError(w, r, http.StatusInternalServerError, "failed to update directory info")
+		return
+	}
+
+	h.Logger.InfoContext(ctx, "memberDir update",
+		"aimsid", session.AimSID,
+		"firstName", values[wire.ODirTLVFirstName],
+		"lastName", values[wire.ODirTLVLastName],
+	)
+
+	h.sendData(w, r, map[string]any{})
+}
+
+// sendData wraps data in the standard response envelope and sends it via
+// SendResponse, which honors the JSONP callback the web client uses.
+func (h *MemberDirHandler) sendData(w http.ResponseWriter, r *http.Request, data any) {
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = data
+	SendResponse(w, r, resp, h.Logger)
+}
+
+// sendError returns an error status in the response envelope, routed through
+// SendResponse so the JSONP callback is still honored (a raw JSON error would be
+// CORB-blocked by the client's <script> transport).
+func (h *MemberDirHandler) sendError(w http.ResponseWriter, r *http.Request, code int, msg string) {
+	resp := BaseResponse{}
+	resp.Response.StatusCode = code
+	resp.Response.StatusText = msg
+	SendResponse(w, r, resp, 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"] != "":
+		inBody.Append(wire.NewTLVBE(wire.ODirTLVInterest, fields["keyword"]))
+	}
+	return inBody
+}
+
+// parseMatch splits the web client's "match" value ("key=value,key=value")
+// into a field map.
+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 != "" {
+			fields[key] = strings.TrimSpace(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
+}

+ 302 - 0
server/webapi/handlers/memberdir_test.go

@@ -0,0 +1,302 @@
+package handlers
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/require"
+
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+)
+
+type mockDirSearchService struct{ mock.Mock }
+
+func (m *mockDirSearchService) InfoQuery(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error) {
+	args := m.Called(ctx, inFrame, inBody)
+	return args.Get(0).(wire.SNACMessage), args.Error(1)
+}
+
+type mockMemberDirLocateService struct{ mock.Mock }
+
+func (m *mockMemberDirLocateService) DirInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x0B_LocateGetDirInfo) (wire.SNACMessage, error) {
+	args := m.Called(ctx, inFrame, inBody)
+	return args.Get(0).(wire.SNACMessage), args.Error(1)
+}
+
+func (m *mockMemberDirLocateService) SetDirInfo(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x09_LocateSetDirInfo) (wire.SNACMessage, error) {
+	args := m.Called(ctx, instance, inFrame, inBody)
+	return args.Get(0).(wire.SNACMessage), args.Error(1)
+}
+
+func searchReply(status uint16, results ...wire.TLVBlock) wire.SNACMessage {
+	body := wire.SNAC_0x0F_0x03_InfoReply{Status: status}
+	body.Results.List = results
+	return wire.SNACMessage{Body: body}
+}
+
+func result(screenName, firstName, lastName string) wire.TLVBlock {
+	return wire.TLVBlock{TLVList: wire.TLVList{
+		wire.NewTLVBE(wire.ODirTLVScreenName, screenName),
+		wire.NewTLVBE(wire.ODirTLVFirstName, firstName),
+		wire.NewTLVBE(wire.ODirTLVLastName, lastName),
+	}}
+}
+
+// decodeInfoArray pulls infoArray out of the response envelope at the given
+// path ("results.infoArray" for search, "infoArray" for get).
+func decodeInfoArray(t *testing.T, body []byte, nested bool) []MemberDirInfo {
+	t.Helper()
+	var envelope struct {
+		Response struct {
+			StatusCode int `json:"statusCode"`
+			Data       struct {
+				InfoArray []MemberDirInfo `json:"infoArray"`
+				Results   struct {
+					InfoArray []MemberDirInfo `json:"infoArray"`
+				} `json:"results"`
+			} `json:"data"`
+		} `json:"response"`
+	}
+	require.NoError(t, json.Unmarshal(body, &envelope))
+	assert.Equal(t, 200, envelope.Response.StatusCode)
+	if nested {
+		return envelope.Response.Data.Results.InfoArray
+	}
+	return envelope.Response.Data.InfoArray
+}
+
+func TestMemberDirHandler_Search_Keyword(t *testing.T) {
+	dirSvc := &mockDirSearchService{}
+	// keyword=haha must map to the ODir interest TLV.
+	dirSvc.On("InfoQuery", mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x0F_0x02_InfoQuery) bool {
+		v, ok := q.String(wire.ODirTLVInterest)
+		return ok && v == "haha"
+	})).Return(searchReply(wire.ODirSearchResponseOK, result("FoundUser", "Found", "User")), nil)
+
+	h := &MemberDirHandler{DirSearchService: dirSvc, Logger: slog.Default()}
+	session := &state.WebAPISession{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
+
+	req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dhaha&nToGet=200", nil)
+	rr := httptest.NewRecorder()
+	h.Search(rr, req, session)
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+	infoArray := decodeInfoArray(t, rr.Body.Bytes(), true)
+	require.Len(t, infoArray, 1)
+	assert.Equal(t, "founduser", infoArray[0].Profile.AimID)
+	assert.Equal(t, "FoundUser", infoArray[0].Profile.DisplayID)
+	assert.Equal(t, "Found", infoArray[0].Profile.FirstName)
+	dirSvc.AssertExpectations(t)
+}
+
+func TestMemberDirHandler_Search_FirstLastName(t *testing.T) {
+	dirSvc := &mockDirSearchService{}
+	// firstName/lastName must map to the ODir name TLVs, not interest.
+	dirSvc.On("InfoQuery", mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x0F_0x02_InfoQuery) bool {
+		first, hasFirst := q.String(wire.ODirTLVFirstName)
+		last, hasLast := q.String(wire.ODirTLVLastName)
+		_, hasInterest := q.String(wire.ODirTLVInterest)
+		return hasFirst && first == "Bob" && hasLast && last == "Smith" && !hasInterest
+	})).Return(searchReply(wire.ODirSearchResponseOK, result("Bob", "Bob", "Smith")), nil)
+
+	h := &MemberDirHandler{DirSearchService: dirSvc, Logger: slog.Default()}
+	session := &state.WebAPISession{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
+
+	req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=firstName%3DBob%2ClastName%3DSmith", nil)
+	rr := httptest.NewRecorder()
+	h.Search(rr, req, session)
+
+	infoArray := decodeInfoArray(t, rr.Body.Bytes(), true)
+	require.Len(t, infoArray, 1)
+	assert.Equal(t, "bob", infoArray[0].Profile.AimID)
+	dirSvc.AssertExpectations(t)
+}
+
+func TestMemberDirHandler_Search_ExcludesSelf(t *testing.T) {
+	dirSvc := &mockDirSearchService{}
+	dirSvc.On("InfoQuery", mock.Anything, mock.Anything, mock.Anything).Return(
+		searchReply(wire.ODirSearchResponseOK,
+			result("Me", "", ""),    // caller — must be filtered out
+			result("Other", "", ""), // kept
+		), nil)
+
+	h := &MemberDirHandler{DirSearchService: dirSvc, Logger: slog.Default()}
+	session := &state.WebAPISession{AimSID: "sid", ScreenName: state.DisplayScreenName("M E")}
+
+	req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dx", nil)
+	rr := httptest.NewRecorder()
+	h.Search(rr, req, session)
+
+	infoArray := decodeInfoArray(t, rr.Body.Bytes(), true)
+	require.Len(t, infoArray, 1)
+	assert.Equal(t, "other", infoArray[0].Profile.AimID)
+}
+
+func TestMemberDirHandler_Search_RespectsJSONPCallback(t *testing.T) {
+	dirSvc := &mockDirSearchService{}
+	dirSvc.On("InfoQuery", mock.Anything, mock.Anything, mock.Anything).Return(
+		searchReply(wire.ODirSearchResponseOK), nil)
+
+	h := &MemberDirHandler{DirSearchService: dirSvc, Logger: slog.Default()}
+	session := &state.WebAPISession{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
+
+	req := httptest.NewRequest("GET", "/memberDir/search?aimsid=sid&match=keyword%3Dx&c=_callbacks_._abc", nil)
+	rr := httptest.NewRecorder()
+	h.Search(rr, req, session)
+
+	// The web client loads this via a <script> tag, so the response must be
+	// JavaScript (JSONP), not application/json — otherwise the browser CORB-blocks it.
+	assert.Equal(t, "application/javascript", rr.Header().Get("Content-Type"))
+	assert.Contains(t, rr.Body.String(), "_callbacks_._abc(")
+}
+
+func TestMemberDirHandler_Get_Self(t *testing.T) {
+	reply := wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}
+	reply.Append(wire.NewTLVBE(wire.ODirTLVFirstName, "Me"))
+	reply.Append(wire.NewTLVBE(wire.ODirTLVLastName, "Myself"))
+
+	locSvc := &mockMemberDirLocateService{}
+	locSvc.On("DirInfo", mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x02_0x0B_LocateGetDirInfo) bool {
+		return q.ScreenName == "me"
+	})).Return(wire.SNACMessage{Body: reply}, nil)
+
+	h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
+	session := &state.WebAPISession{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
+
+	// No "t" param: defaults to self.
+	req := httptest.NewRequest("GET", "/memberDir/get?aimsid=sid", nil)
+	rr := httptest.NewRecorder()
+	h.Get(rr, req, session)
+
+	infoArray := decodeInfoArray(t, rr.Body.Bytes(), false)
+	require.Len(t, infoArray, 1)
+	assert.Equal(t, "me", infoArray[0].Profile.AimID)
+	assert.Equal(t, "Me", infoArray[0].Profile.FirstName)
+	assert.Equal(t, "Myself", infoArray[0].Profile.LastName)
+	locSvc.AssertExpectations(t)
+}
+
+func TestMemberDirHandler_Get_LabelsEachTargetWithOwnIdentity(t *testing.T) {
+	dirReply := func(firstName string) wire.SNACMessage {
+		reply := wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}
+		reply.Append(wire.NewTLVBE(wire.ODirTLVFirstName, firstName))
+		return wire.SNACMessage{Body: reply}
+	}
+
+	locSvc := &mockMemberDirLocateService{}
+	locSvc.On("DirInfo", mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x02_0x0B_LocateGetDirInfo) bool {
+		return q.ScreenName == "Bob Smith"
+	})).Return(dirReply("Bob"), nil)
+	locSvc.On("DirInfo", mock.Anything, mock.Anything, mock.MatchedBy(func(q wire.SNAC_0x02_0x0B_LocateGetDirInfo) bool {
+		return q.ScreenName == "alice"
+	})).Return(dirReply("Alice"), nil)
+
+	h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
+	session := &state.WebAPISession{AimSID: "sid", ScreenName: state.DisplayScreenName("Bob Smith")}
+
+	req := httptest.NewRequest("GET", "/memberDir/get?aimsid=sid&t=Bob+Smith,alice", nil)
+	rr := httptest.NewRecorder()
+	h.Get(rr, req, session)
+
+	// Each result carries the identity of the target it describes, not the
+	// caller's — the client keys users by aimId.
+	infoArray := decodeInfoArray(t, rr.Body.Bytes(), false)
+	require.Len(t, infoArray, 2)
+	assert.Equal(t, "bobsmith", infoArray[0].Profile.AimID)
+	assert.Equal(t, "Bob Smith", infoArray[0].Profile.DisplayID)
+	assert.Equal(t, "Bob", infoArray[0].Profile.FirstName)
+	assert.Equal(t, "alice", infoArray[1].Profile.AimID)
+	assert.Equal(t, "alice", infoArray[1].Profile.DisplayID)
+	assert.Equal(t, "Alice", infoArray[1].Profile.FirstName)
+	locSvc.AssertExpectations(t)
+}
+
+func TestMemberDirHandler_Get_CapsTargetFanOut(t *testing.T) {
+	locSvc := &mockMemberDirLocateService{}
+	locSvc.On("DirInfo", mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}}, nil)
+
+	h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
+	session := &state.WebAPISession{AimSID: "sid", ScreenName: state.DisplayScreenName("me")}
+
+	// Every target costs a directory lookup, so an arbitrarily long "t" list
+	// must not translate into an unbounded number of them.
+	targets := make([]string, maxMemberDirTargets+50)
+	for i := range targets {
+		targets[i] = fmt.Sprintf("user%d", i)
+	}
+	req := httptest.NewRequest("GET", "/memberDir/get?aimsid=sid&t="+strings.Join(targets, ","), nil)
+	rr := httptest.NewRecorder()
+	h.Get(rr, req, session)
+
+	infoArray := decodeInfoArray(t, rr.Body.Bytes(), false)
+	assert.Len(t, infoArray, maxMemberDirTargets)
+	locSvc.AssertNumberOfCalls(t, "DirInfo", maxMemberDirTargets)
+}
+
+func TestMemberDirHandler_Update_PersistsNameAndPreservesOtherFields(t *testing.T) {
+	// Current directory record has a city set that the name form must not wipe.
+	current := wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}
+	current.Append(wire.NewTLVBE(wire.ODirTLVFirstName, "Old"))
+	current.Append(wire.NewTLVBE(wire.ODirTLVLastName, "Name"))
+	current.Append(wire.NewTLVBE(wire.ODirTLVCity, "Reno"))
+
+	locSvc := &mockMemberDirLocateService{}
+	locSvc.On("DirInfo", mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{Body: current}, nil)
+	// The set request must carry the new name AND the preserved city.
+	locSvc.On("SetDirInfo", mock.Anything, mock.Anything, mock.Anything,
+		mock.MatchedBy(func(b wire.SNAC_0x02_0x09_LocateSetDirInfo) bool {
+			first, _ := b.String(wire.ODirTLVFirstName)
+			last, _ := b.String(wire.ODirTLVLastName)
+			city, _ := b.String(wire.ODirTLVCity)
+			return first == "Mike" && last == "K" && city == "Reno"
+		})).Return(wire.SNACMessage{}, nil)
+
+	h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
+	session := &state.WebAPISession{AimSID: "sid", ScreenName: state.DisplayScreenName("mike")}
+
+	req := httptest.NewRequest("GET",
+		"/memberDir/update?aimsid=sid&set=firstName%3DMike&set=lastName%3DK&set=hideLevel%3DemailsAndCellular", nil)
+	rr := httptest.NewRecorder()
+	h.Update(rr, req, session)
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+	locSvc.AssertExpectations(t)
+}
+
+func TestMemberDirHandler_Update_AbortsWhenCurrentInfoUnreadable(t *testing.T) {
+	locSvc := &mockMemberDirLocateService{}
+	locSvc.On("DirInfo", mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{}, io.ErrUnexpectedEOF)
+
+	h := &MemberDirHandler{LocateService: locSvc, Logger: slog.Default()}
+	session := &state.WebAPISession{AimSID: "sid", ScreenName: state.DisplayScreenName("mike")}
+
+	req := httptest.NewRequest("GET", "/memberDir/update?aimsid=sid&set=firstName%3DMike&set=lastName%3DK", nil)
+	rr := httptest.NewRecorder()
+	h.Update(rr, req, session)
+
+	// SetDirectoryInfo replaces every column, so writing a record we couldn't
+	// seed would blank the fields this form doesn't edit. Report the failure
+	// instead.
+	locSvc.AssertNotCalled(t, "SetDirInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
+
+	var envelope struct {
+		Response struct {
+			StatusCode int `json:"statusCode"`
+		} `json:"response"`
+	}
+	require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &envelope))
+	assert.Equal(t, http.StatusInternalServerError, envelope.Response.StatusCode)
+}

+ 18 - 0
server/webapi/server.go

@@ -68,6 +68,12 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		Logger:         logger,
 	}
 
+	memberDirHandler := &handlers.MemberDirHandler{
+		DirSearchService: handler.DirSearchService,
+		LocateService:    handler.LocateService,
+		Logger:           logger,
+	}
+
 	oscarBridgeHandler := &handlers.OSCARBridgeHandler{
 		SessionManager:   sessionManager,
 		OSCARAuthService: handler.AuthService,
@@ -219,6 +225,18 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 
 		mux.HandleFunc("GET /presence/icon", presenceHandler.Icon)
 
+		// Member directory search and self directory-info retrieval. Both use
+		// aimsid-based auth, so we use flexible auth.
+		mux.Handle("GET /memberDir/search", authMiddleware.AuthenticateFlexible(
+			authMiddleware.CORSMiddleware(
+				authMiddleware.RequireSession(sessionManager, memberDirHandler.Search))))
+		mux.Handle("GET /memberDir/get", authMiddleware.AuthenticateFlexible(
+			authMiddleware.CORSMiddleware(
+				authMiddleware.RequireSession(sessionManager, memberDirHandler.Get))))
+		mux.Handle("GET /memberDir/update", authMiddleware.AuthenticateFlexible(
+			authMiddleware.CORSMiddleware(
+				authMiddleware.RequireSession(sessionManager, memberDirHandler.Update))))
+
 		// These endpoints support aimsid-based auth, so we use a flexible auth approach
 		mux.Handle("GET /preference/set", authMiddleware.AuthenticateFlexible(
 			authMiddleware.CORSMiddleware(

+ 6 - 0
server/webapi/types.go

@@ -43,6 +43,12 @@ type LocateService interface {
 	DirInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x0B_LocateGetDirInfo) (wire.SNACMessage, error)
 }
 
+// DirSearchService issues OSCAR ODir directory searches on behalf of the Web
+// AIM member-directory endpoints.
+type DirSearchService interface {
+	InfoQuery(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error)
+}
+
 // BuddyListRegistry is the interface for keeping track of users with active
 // buddy lists. Once registered, a user becomes visible to other users' buddy
 // lists and vice versa.