فهرست منبع

Mgmt api improvements

Allow for fetching additional account, session, and buddy icon data using the mgmt api

Centralize fetching of buddy icon BART items
Josh Knight 1 سال پیش
والد
کامیت
d369eb4420
8فایلهای تغییر یافته به همراه407 افزوده شده و 143 حذف شده
  1. 116 0
      api.yml
  2. 1 1
      cmd/server/main.go
  3. 2 59
      foodgroup/buddy.go
  4. 1 0
      foodgroup/types.go
  5. 183 81
      server/http/mgmt_api.go
  6. 41 2
      server/http/types.go
  7. 21 0
      state/session.go
  8. 42 0
      state/user_store.go

+ 116 - 0
api.yml

@@ -72,6 +72,66 @@ paths:
         '404':
           description: User not found.
 
+  /user/{screenname}/account:
+    get:
+      summary: Get account details for a specific screen name.
+      description: Retrieve account details for a specific screen name.
+      parameters:
+      - name: screenname
+        in: path
+        description: User's AIM screen name or ICQ UIN.
+        required: true
+        type: string
+      responses:
+        '200':
+          description: Successful response containing account details
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                    id:
+                      type: string
+                      description: User's unique identifier.
+                    screen_name:
+                      type: string
+                      description: User's AIM screen name or ICQ UIN.
+                    profile:
+                      type: string
+                      description: User's AIM profile HTML.
+                    email_address:
+                      type: string
+                      description: User's email address
+                    confirmed:
+                      type: bool
+                      description: User's account confirmation status
+                    is_icq:
+                      type: boolean
+                      description: If true, indicates an ICQ user instead of an AIM user.
+        '404':
+          description: User not found.
+
+  /user/{screenname}/icon:
+    get:
+      summary: Get AIM buddy icon for a screen name
+      description: Retrieve account buddy icon for a specific screen name.
+      parameters:
+      - name: screenname
+        in: path
+        description: User's AIM screen name or ICQ UIN.
+        required: true
+        type: string
+      responses:
+        '200':
+          description: Successful response containing buddy icon bytes
+          content:
+            image/gif:
+              schema:
+                type: string
+                format: binary
+        '404':
+          description: User not found, or user has no buddy icon
+
   /session:
     get:
       summary: Get active sessions
@@ -98,10 +158,66 @@ paths:
                         screen_name:
                           type: string
                           description: User's AIM screen name or ICQ UIN.
+                        online_seconds:
+                          type: float
+                          description: Number of seconds this user session has been online.
+                        away_message:
+                          type: string
+                          description: User's AIM away message HTML. Empty if the user is not away.
+                        idle_seconds:
+                          type: float
+                          description: Number of seconds this user session has been idle. 0 if not idle.
                         is_icq:
                           type: boolean
                           description: If true, indicates an ICQ user instead of an AIM user.
 
+  /session/{screenname}:
+    get:
+      summary: Get active sessions for a given screen name or UIN.
+      description: Retrieve a list of active sessions of a specific logged in user.
+      parameters:
+      - name: screenname
+        in: path
+        description: User's AIM screen name or ICQ UIN.
+        required: true
+        type: string
+      responses:
+        '200':
+          description: Successful response containing a list of active sessions for the given screen name
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  count:
+                    type: integer
+                    description: The number of active sessions.
+                  sessions:
+                    type: array
+                    items:
+                      type: object
+                      properties:
+                        id:
+                          type: string
+                          description: User's unique identifier.
+                        screen_name:
+                          type: string
+                          description: User's AIM screen name or ICQ UIN.
+                        online_seconds:
+                          type: float
+                          description: Number of seconds this user session has been online.
+                        away_message:
+                          type: string
+                          description: User's AIM away message HTML. Empty if the user is not away.
+                        idle_seconds:
+                          type: float
+                          description: Number of seconds this user session has been idle. 0 if not idle.
+                        is_icq:
+                          type: boolean
+                          description: If true, indicates an ICQ user instead of an AIM user.
+        '404':
+          description: User not found.
+
   /user/password:
     put:
       summary: Set a user's password

+ 1 - 1
cmd/server/main.go

@@ -47,7 +47,7 @@ func main() {
 	wg.Add(7)
 
 	go func() {
-		http.StartManagementAPI(cfg, feedbagStore, sessionManager, feedbagStore, feedbagStore, chatSessionManager, sessionManager, logger)
+		http.StartManagementAPI(cfg, feedbagStore, sessionManager, feedbagStore, feedbagStore, chatSessionManager, sessionManager, feedbagStore, feedbagStore, feedbagStore, feedbagStore, logger)
 		wg.Done()
 	}()
 	go func(logger *slog.Logger) {

+ 2 - 59
foodgroup/buddy.go

@@ -1,10 +1,7 @@
 package foodgroup
 
 import (
-	"bytes"
 	"context"
-	"errors"
-	"strconv"
 
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
@@ -87,7 +84,7 @@ func (s BuddyService) DelBuddies(_ context.Context, sess *state.Session, inBody
 // buddy icons, warning levels, invisibility status, etc.
 func (s BuddyService) UnicastBuddyArrived(ctx context.Context, from *state.Session, to *state.Session) error {
 	userInfo := from.TLVUserInfo()
-	icon, err := getBuddyIconRefFromFeedbag(from, s.feedbagManager)
+	icon, err := s.feedbagManager.BuddyIconRefByName(from.IdentScreenName())
 	switch {
 	case err != nil:
 		return err
@@ -122,7 +119,7 @@ func (s BuddyService) BroadcastBuddyArrived(ctx context.Context, sess *state.Ses
 	recipients = append(recipients, legacyUsers...)
 
 	userInfo := sess.TLVUserInfo()
-	icon, err := getBuddyIconRefFromFeedbag(sess, s.feedbagManager)
+	icon, err := s.feedbagManager.BuddyIconRefByName(sess.IdentScreenName())
 	switch {
 	case err != nil:
 		return err
@@ -143,60 +140,6 @@ func (s BuddyService) BroadcastBuddyArrived(ctx context.Context, sess *state.Ses
 	return nil
 }
 
-// getBuddyIconRefFromFeedbag retrieves a reference to the user's buddy icon
-// from their feedbag. If it exists, the buddy icon is the feedbag item of
-// class wire.FeedbagClassIdBart with BART type wire.BARTTypesBuddyIcon.
-func getBuddyIconRefFromFeedbag(sess *state.Session, feedbagManager FeedbagManager) (*wire.BARTID, error) {
-	items, err := feedbagManager.Feedbag(sess.IdentScreenName())
-	if err != nil {
-		return nil, err
-	}
-
-	for _, item := range items {
-		if item.ClassID != wire.FeedbagClassIdBart {
-			continue
-		}
-		bartType, err := extractBARTItemType(item)
-		if err != nil {
-			return nil, err
-		}
-		if bartType != wire.BARTTypesBuddyIcon {
-			continue
-		}
-		b, hasBuf := item.Slice(wire.FeedbagAttributesBartInfo)
-		if !hasBuf {
-			return nil, errors.New("unable to extract icon payload")
-		}
-		bartInfo := wire.BARTInfo{}
-		if err := wire.UnmarshalBE(&bartInfo, bytes.NewBuffer(b)); err != nil {
-			return nil, err
-		}
-		return &wire.BARTID{
-			Type: bartType,
-			BARTInfo: wire.BARTInfo{
-				Flags: bartInfo.Flags,
-				Hash:  bartInfo.Hash,
-			},
-		}, nil
-	}
-
-	return nil, nil
-}
-
-// extractBARTItemType gets the BART type for item, which is stored in the
-// "name" field.
-func extractBARTItemType(item wire.FeedbagItem) (uint16, error) {
-	var bartType uint16
-	// Feedbag items of type wire.FeedbagClassIdBart store the BART type in the
-	// name field.
-	if bt, err := strconv.ParseUint(item.Name, 10, 16); err != nil {
-		return 0, err
-	} else {
-		bartType = uint16(bt)
-	}
-	return bartType, nil
-}
-
 func (s BuddyService) BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error {
 	recipients, err := s.feedbagManager.AdjacentUsers(sess.IdentScreenName())
 	if err != nil {

+ 1 - 0
foodgroup/types.go

@@ -50,6 +50,7 @@ type FeedbagManager interface {
 	FeedbagLastModified(screenName state.IdentScreenName) (time.Time, error)
 	Feedbag(screenName state.IdentScreenName) ([]wire.FeedbagItem, error)
 	FeedbagUpsert(screenName state.IdentScreenName, items []wire.FeedbagItem) error
+	BuddyIconRefByName(screenName state.IdentScreenName) (*wire.BARTID, error)
 }
 
 // LegacyBuddyListManager defines operations for tracking user relationships

+ 183 - 81
server/http/mgmt_api.go

@@ -11,6 +11,7 @@ import (
 	"net/http"
 	"os"
 	"strings"
+	"time"
 
 	"github.com/google/uuid"
 
@@ -27,30 +28,72 @@ func StartManagementAPI(
 	chatRoomCreator ChatRoomCreator,
 	chatSessionRetriever ChatSessionRetriever,
 	messageRelayer MessageRelayer,
+	bartRetriever BARTRetriever,
+	feedbagRetriever FeedBagRetriever,
+	accountRetriever AccountRetriever,
+	profileRetriever ProfileRetriever,
 	logger *slog.Logger,
 ) {
 
 	mux := http.NewServeMux()
-	mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) {
-		userHandler(w, r, userManager, uuid.New, logger)
+
+	// Handlers for '/user' route
+	mux.HandleFunc("DELETE /user", func(w http.ResponseWriter, r *http.Request) {
+		deleteUserHandler(w, r, userManager, logger)
+	})
+	mux.HandleFunc("GET /user", func(w http.ResponseWriter, r *http.Request) {
+		getUserHandler(w, userManager, logger)
+	})
+	mux.HandleFunc("POST /user", func(w http.ResponseWriter, r *http.Request) {
+		postUserHandler(w, r, userManager, uuid.New, logger)
+	})
+
+	// Handlers for '/user/password' route
+	mux.HandleFunc("PUT /user/password", func(w http.ResponseWriter, r *http.Request) {
+		putUserPasswordHandler(w, r, userManager, logger)
+	})
+
+	// Handlers for '/user/login' route
+	mux.HandleFunc("GET /user/login", func(w http.ResponseWriter, r *http.Request) {
+		getUserLoginHandler(w, r, userManager, logger)
+	})
+
+	// Handlers for '/user/{screenname}/account' route
+	mux.HandleFunc("GET /user/{screenname}/account", func(w http.ResponseWriter, r *http.Request) {
+		getUserAccountHandler(w, r, userManager, accountRetriever, profileRetriever, logger)
+	})
+
+	// Handlers for '/user/{screenname}/icon' route
+	mux.HandleFunc("GET /user/{screenname}/icon", func(w http.ResponseWriter, r *http.Request) {
+		getUserBuddyIconHandler(w, r, userManager, feedbagRetriever, bartRetriever, logger)
 	})
-	mux.HandleFunc("/user/password", func(w http.ResponseWriter, r *http.Request) {
-		userPasswordHandler(w, r, userManager, logger)
+
+	// Handlers for '/session' route
+	mux.HandleFunc("GET /session", func(w http.ResponseWriter, r *http.Request) {
+		getSessionHandler(w, r, sessionRetriever, time.Since)
 	})
-	mux.HandleFunc("/user/login", func(w http.ResponseWriter, r *http.Request) {
-		loginHandler(w, r, userManager, logger)
+
+	// Handlers for '/session/{screenname}' route
+	mux.HandleFunc("GET /session/{screenname}", func(w http.ResponseWriter, r *http.Request) {
+		getSessionHandler(w, r, sessionRetriever, time.Since)
 	})
-	mux.HandleFunc("/session", func(w http.ResponseWriter, r *http.Request) {
-		sessionHandler(w, r, sessionRetriever)
+
+	// Handlers for '/chat/room/public' route
+	mux.HandleFunc("GET /chat/room/public", func(w http.ResponseWriter, r *http.Request) {
+		getPublicChatHandler(w, r, chatRoomRetriever, chatSessionRetriever, logger)
 	})
-	mux.HandleFunc("/chat/room/public", func(w http.ResponseWriter, r *http.Request) {
-		publicChatHandler(w, r, chatRoomRetriever, chatRoomCreator, chatSessionRetriever, logger)
+	mux.HandleFunc("POST /chat/room/public", func(w http.ResponseWriter, r *http.Request) {
+		postPublicChatHandler(w, r, chatRoomCreator, logger)
 	})
-	mux.HandleFunc("/chat/room/private", func(w http.ResponseWriter, r *http.Request) {
-		privateChatHandler(w, r, chatRoomRetriever, chatSessionRetriever, logger)
+
+	// Handlers for '/chat/room/private' route
+	mux.HandleFunc("GET /chat/room/private", func(w http.ResponseWriter, r *http.Request) {
+		getPrivateChatHandler(w, r, chatRoomRetriever, chatSessionRetriever, logger)
 	})
-	mux.HandleFunc("/instant-message", func(w http.ResponseWriter, r *http.Request) {
-		instantMessageHandler(w, r, messageRelayer, logger)
+
+	// Handlers for '/instant-message' route
+	mux.HandleFunc("POST /instant-message", func(w http.ResponseWriter, r *http.Request) {
+		postInstantMessageHandler(w, r, messageRelayer, logger)
 	})
 
 	addr := net.JoinHostPort(cfg.ApiHost, cfg.ApiPort)
@@ -61,19 +104,7 @@ func StartManagementAPI(
 	}
 }
 
-func userHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, newUUID func() uuid.UUID, logger *slog.Logger) {
-	switch r.Method {
-	case http.MethodDelete:
-		deleteUserHandler(w, r, userManager, logger)
-	case http.MethodGet:
-		getUserHandler(w, r, userManager, logger)
-	case http.MethodPost:
-		postUserHandler(w, r, userManager, newUUID, logger)
-	default:
-		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-	}
-}
-
+// deleteUserHandler handles the DELETE /user endpoint.
 func deleteUserHandler(w http.ResponseWriter, r *http.Request, manager UserManager, logger *slog.Logger) {
 	user, err := userFromBody(r)
 	if err != nil {
@@ -96,15 +127,6 @@ func deleteUserHandler(w http.ResponseWriter, r *http.Request, manager UserManag
 	_, _ = fmt.Fprintln(w, "User account successfully deleted.")
 }
 
-func userPasswordHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, logger *slog.Logger) {
-	switch r.Method {
-	case http.MethodPut:
-		putUserPasswordHandler(w, r, userManager, logger)
-	default:
-		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-	}
-}
-
 // putUserPasswordHandler handles the PUT /user/password endpoint.
 func putUserPasswordHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, logger *slog.Logger) {
 	input, err := userFromBody(r)
@@ -123,7 +145,7 @@ func putUserPasswordHandler(w http.ResponseWriter, r *http.Request, userManager
 		case errors.Is(err, state.ErrPasswordInvalid):
 			http.Error(w, err.Error(), http.StatusBadRequest)
 			return
-		case err != nil:
+		default:
 			logger.Error("error updating user password PUT /user/password", "err", err.Error())
 			http.Error(w, "internal server error", http.StatusInternalServerError)
 			return
@@ -134,26 +156,43 @@ func putUserPasswordHandler(w http.ResponseWriter, r *http.Request, userManager
 	_, _ = fmt.Fprintln(w, "Password successfully reset.")
 }
 
-// sessionHandler handles GET /session
-func sessionHandler(w http.ResponseWriter, r *http.Request, sessionRetriever SessionRetriever) {
+// getSessionHandler handles GET /session
+func getSessionHandler(w http.ResponseWriter, r *http.Request, sessionRetriever SessionRetriever, funcTimeSince func(t time.Time) time.Duration) {
 	w.Header().Set("Content-Type", "application/json")
-	if r.Method != http.MethodGet {
-		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-		return
-	}
 
-	allUsers := sessionRetriever.AllSessions()
+	var allUsers []*state.Session
+
+	if screenName := r.PathValue("screenname"); screenName != "" {
+		session := sessionRetriever.RetrieveByScreenName(state.NewIdentScreenName(screenName))
+		if session == nil {
+			http.Error(w, "session not found", http.StatusNotFound)
+			return
+		}
+		allUsers = append(allUsers, session)
+	} else {
+		allUsers = sessionRetriever.AllSessions()
+	}
 
 	ou := onlineUsers{
 		Count:    len(allUsers),
-		Sessions: make([]userHandle, len(allUsers)),
+		Sessions: make([]sessionHandle, len(allUsers)),
 	}
 
 	for i, s := range allUsers {
-		ou.Sessions[i] = userHandle{
-			ID:         s.IdentScreenName().String(),
-			ScreenName: s.DisplayScreenName().String(),
-			IsICQ:      s.UIN() > 0,
+		// report 0 if the user is not idle
+		idleSeconds := funcTimeSince(s.IdleTime()).Seconds()
+		if !s.Idle() {
+			idleSeconds = 0
+		}
+		onlineSeconds := funcTimeSince(s.SignonTime()).Seconds()
+
+		ou.Sessions[i] = sessionHandle{
+			ID:            s.IdentScreenName().String(),
+			ScreenName:    s.DisplayScreenName().String(),
+			OnlineSeconds: onlineSeconds,
+			AwayMessage:   s.AwayMessage(),
+			IdleSeconds:   idleSeconds,
+			IsICQ:         s.UIN() > 0,
 		}
 	}
 
@@ -164,7 +203,7 @@ func sessionHandler(w http.ResponseWriter, r *http.Request, sessionRetriever Ses
 }
 
 // getUserHandler handles the GET /user endpoint.
-func getUserHandler(w http.ResponseWriter, _ *http.Request, userManager UserManager, logger *slog.Logger) {
+func getUserHandler(w http.ResponseWriter, userManager UserManager, logger *slog.Logger) {
 	w.Header().Set("Content-Type", "application/json")
 
 	users, err := userManager.AllUsers()
@@ -246,9 +285,9 @@ func userFromBody(r *http.Request) (userWithPassword, error) {
 	return user, nil
 }
 
-// loginHandler is a temporary endpoint for validating user credentials for
+// getUserLoginHandler is a temporary endpoint for validating user credentials for
 // chivanet. do not rely on this endpoint, as it will be eventually removed.
-func loginHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, logger *slog.Logger) {
+func getUserLoginHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, logger *slog.Logger) {
 	authHeader := r.Header.Get("Authorization")
 	if authHeader == "" {
 		// No authentication header found
@@ -299,26 +338,6 @@ func loginHandler(w http.ResponseWriter, r *http.Request, userManager UserManage
 	_, _ = w.Write([]byte("200 OK: Successfully Authenticated\n"))
 }
 
-func publicChatHandler(w http.ResponseWriter, r *http.Request, chatRoomRetriever ChatRoomRetriever, chatRoomCreator ChatRoomCreator, chatSessionRetriever ChatSessionRetriever, logger *slog.Logger) {
-	switch r.Method {
-	case http.MethodGet:
-		getPublicChatHandler(w, r, chatRoomRetriever, chatSessionRetriever, logger)
-	case http.MethodPost:
-		postPublicChatHandler(w, r, chatRoomCreator, logger)
-	default:
-		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-	}
-}
-
-func privateChatHandler(w http.ResponseWriter, r *http.Request, chatRoomRetriever ChatRoomRetriever, chatSessionRetriever ChatSessionRetriever, logger *slog.Logger) {
-	switch r.Method {
-	case http.MethodGet:
-		getPrivateChatHandler(w, r, chatRoomRetriever, chatSessionRetriever, logger)
-	default:
-		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-	}
-}
-
 // getPublicChatHandler handles the GET /chat/room/public endpoint.
 func getPublicChatHandler(w http.ResponseWriter, _ *http.Request, chatRoomRetriever ChatRoomRetriever, chatSessionRetriever ChatSessionRetriever, logger *slog.Logger) {
 	w.Header().Set("Content-Type", "application/json")
@@ -421,15 +440,6 @@ func getPrivateChatHandler(w http.ResponseWriter, _ *http.Request, chatRoomRetri
 	}
 }
 
-func instantMessageHandler(w http.ResponseWriter, r *http.Request, messageRelayer MessageRelayer, logger *slog.Logger) {
-	switch r.Method {
-	case http.MethodPost:
-		postInstantMessageHandler(w, r, messageRelayer, logger)
-	default:
-		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-	}
-}
-
 // postIMHandler handles the POST /instant-message endpoint.
 func postInstantMessageHandler(w http.ResponseWriter, r *http.Request, messageRelayer MessageRelayer, logger *slog.Logger) {
 	input := instantMessage{}
@@ -467,3 +477,95 @@ func postInstantMessageHandler(w http.ResponseWriter, r *http.Request, messageRe
 	w.WriteHeader(http.StatusOK)
 	_, _ = fmt.Fprintln(w, "Message sent successfully.")
 }
+
+// getUserBuddyIconHandler handles the GET /user/{screenname}/icon endpoint.
+func getUserBuddyIconHandler(w http.ResponseWriter, r *http.Request, u UserManager, f FeedBagRetriever, b BARTRetriever, logger *slog.Logger) {
+	w.Header().Set("Content-Type", "image/gif")
+
+	screenName := state.NewIdentScreenName(r.PathValue("screenname"))
+	user, err := u.User(screenName)
+	if err != nil {
+		logger.Error("error retrieving user", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	if user == nil {
+		http.Error(w, "user not found", http.StatusNotFound)
+		return
+	}
+	iconRef, err := f.BuddyIconRefByName(screenName)
+	if err != nil {
+		logger.Error("error retrieving buddy icon ref", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	if iconRef == nil || iconRef.HasClearIconHash() {
+		http.Error(w, "icon not found", http.StatusNotFound)
+		return
+	}
+	icon, err := b.BARTRetrieve(iconRef.Hash)
+	if err != nil {
+		logger.Error("error retrieving buddy icon bart item", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	w.Write(icon)
+}
+
+// getUserAccountHandler handles the GET /user/{screenname}/account endpoint.
+func getUserAccountHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, a AccountRetriever, p ProfileRetriever, logger *slog.Logger) {
+	w.Header().Set("Content-Type", "application/json")
+
+	screenName := r.PathValue("screenname")
+	user, err := userManager.User(state.NewIdentScreenName(screenName))
+	if err != nil {
+		logger.Error("error in GET /user/{screenname}/account", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	if user == nil {
+		http.Error(w, "user not found", http.StatusNotFound)
+		return
+	}
+
+	emailAddress := ""
+	email, err := a.EmailAddressByName(user.IdentScreenName)
+	if err != nil {
+		emailAddress = ""
+	} else {
+		emailAddress = email.String()
+	}
+	regStatus, err := a.RegStatusByName(user.IdentScreenName)
+	if err != nil {
+		logger.Error("error in GET /user/*/account RegStatus", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	confirmStatus, err := a.ConfirmStatusByName(user.IdentScreenName)
+	if err != nil {
+		logger.Error("error in GET /user/*/account ConfirmStatus", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	profile, err := p.Profile(user.IdentScreenName)
+	if err != nil {
+		logger.Error("error in GET /user/*/account Profile", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	out := userAccountHandle{
+		ID:           user.IdentScreenName.String(),
+		ScreenName:   user.DisplayScreenName.String(),
+		EmailAddress: emailAddress,
+		RegStatus:    regStatus,
+		Confirmed:    confirmStatus,
+		Profile:      profile,
+		IsICQ:        user.IsICQ,
+	}
+
+	if err := json.NewEncoder(w).Encode(out); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}

+ 41 - 2
server/http/types.go

@@ -2,6 +2,7 @@ package http
 
 import (
 	"context"
+	"net/mail"
 	"time"
 
 	"github.com/mk6i/retro-aim-server/state"
@@ -22,6 +23,7 @@ type ChatSessionRetriever interface {
 
 type SessionRetriever interface {
 	AllSessions() []*state.Session
+	RetrieveByScreenName(screenName state.IdentScreenName) *state.Session
 }
 
 type UserManager interface {
@@ -36,14 +38,28 @@ type MessageRelayer interface {
 	RelayToScreenName(ctx context.Context, screenName state.IdentScreenName, msg wire.SNACMessage)
 }
 
+type AccountRetriever interface {
+	EmailAddressByName(screenName state.IdentScreenName) (*mail.Address, error)
+	RegStatusByName(screenName state.IdentScreenName) (uint16, error)
+	ConfirmStatusByName(screnName state.IdentScreenName) (bool, error)
+}
+
+type BARTRetriever interface {
+	BARTRetrieve(itemHash []byte) ([]byte, error)
+}
+
+type FeedBagRetriever interface {
+	BuddyIconRefByName(screenName state.IdentScreenName) (*wire.BARTID, error)
+}
+
 type userWithPassword struct {
 	ScreenName string `json:"screen_name"`
 	Password   string `json:"password,omitempty"`
 }
 
 type onlineUsers struct {
-	Count    int          `json:"count"`
-	Sessions []userHandle `json:"sessions"`
+	Count    int             `json:"count"`
+	Sessions []sessionHandle `json:"sessions"`
 }
 
 type userHandle struct {
@@ -57,6 +73,29 @@ type aimChatUserHandle struct {
 	ScreenName string `json:"screen_name"`
 }
 
+type userAccountHandle struct {
+	ID           string `json:"id"`
+	ScreenName   string `json:"screen_name"`
+	Profile      string `json:"profile"`
+	EmailAddress string `json:"email_address"`
+	RegStatus    uint16 `json:"reg_status"`
+	Confirmed    bool   `json:"confirmed"`
+	IsICQ        bool   `json:"is_icq"`
+}
+
+type sessionHandle struct {
+	ID            string  `json:"id"`
+	ScreenName    string  `json:"screen_name"`
+	OnlineSeconds float64 `json:"online_seconds"`
+	AwayMessage   string  `json:"away_message"`
+	IdleSeconds   float64 `json:"idle_seconds"`
+	IsICQ         bool    `json:"is_icq"`
+}
+
+type ProfileRetriever interface {
+	Profile(screenName state.IdentScreenName) (string, error)
+}
+
 type chatRoomCreate struct {
 	Name string `json:"name"`
 }

+ 21 - 0
state/session.go

@@ -138,6 +138,27 @@ func (s *Session) SetSignonTime(t time.Time) {
 	s.signonTime = t
 }
 
+// SignonTime reports when the user signed on
+func (s *Session) SignonTime() time.Time {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.signonTime
+}
+
+// Idle reports the user's idle state.
+func (s *Session) Idle() bool {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.idle
+}
+
+// IdleTime reports when the user went idle
+func (s *Session) IdleTime() time.Time {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.idleTime
+}
+
 // SetIdle sets the user's idle state.
 func (s *Session) SetIdle(dur time.Duration) {
 	s.mutex.Lock()

+ 42 - 0
state/user_store.go

@@ -1281,3 +1281,45 @@ func (f SQLiteUserStore) DeleteMessages(recip IdentScreenName) error {
 	_, err := f.db.Exec(q, recip.String())
 	return err
 }
+
+// BuddyIconRefByName retrieves the buddy icon reference for a given user
+func (f SQLiteUserStore) BuddyIconRefByName(screenName IdentScreenName) (*wire.BARTID, error) {
+	q := `
+		SELECT
+			groupID,
+			itemID,
+			classID,
+			name,
+			attributes
+		FROM feedBag
+		WHERE screenname = ? AND name = ? AND classID = ?
+	`
+	var item wire.FeedbagItem
+	var attrs []byte
+	err := f.db.QueryRow(q, screenName.String(), wire.BARTTypesBuddyIcon, wire.FeedbagClassIdBart).Scan(&item.GroupID, &item.ItemID, &item.ClassID, &item.Name, &attrs)
+	if errors.Is(err, sql.ErrNoRows) {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	if err := wire.UnmarshalBE(&item.TLVLBlock, bytes.NewBuffer(attrs)); err != nil {
+		return nil, err
+	}
+	b, hasBuf := item.Slice(wire.FeedbagAttributesBartInfo)
+	if !hasBuf {
+		return nil, errors.New("unable to extract icon payload")
+	}
+	bartInfo := wire.BARTInfo{}
+	if err := wire.UnmarshalBE(&bartInfo, bytes.NewBuffer(b)); err != nil {
+		return nil, err
+	}
+	return &wire.BARTID{
+		Type: wire.BARTTypesBuddyIcon,
+		BARTInfo: wire.BARTInfo{
+			Flags: bartInfo.Flags,
+			Hash:  bartInfo.Hash,
+		},
+	}, nil
+
+}