Przeglądaj źródła

Additional admin functions

 * setting email address
 * setting reg status preference
 * confirming account
 * set userinfo flags for unconfirmed account
 * database migration for new tables
 * added consts for userinfo flags
Josh Knight 2 lat temu
rodzic
commit
27e1983c7a

+ 7 - 7
cmd/server/main.go

@@ -52,7 +52,7 @@ func main() {
 	}()
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "BOS")
-		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager, feedbagStore)
 		bartService := foodgroup.NewBARTService(logger, feedbagStore, sessionManager, feedbagStore, adjListBuddyListStore)
 		buddyService := foodgroup.NewBuddyService(sessionManager, feedbagStore, adjListBuddyListStore)
 		chatNavService := foodgroup.NewChatNavService(logger, feedbagStore, state.NewChatRoom)
@@ -85,7 +85,7 @@ func main() {
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "CHAT")
 		sessionManager := state.NewInMemorySessionManager(logger)
-		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager, feedbagStore)
 		chatService := foodgroup.NewChatService(chatSessionManager)
 		oServiceService := foodgroup.NewOServiceServiceForChat(cfg, logger, sessionManager, adjListBuddyListStore, feedbagStore, feedbagStore, chatSessionManager)
 
@@ -104,7 +104,7 @@ func main() {
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "CHAT_NAV")
 		sessionManager := state.NewInMemorySessionManager(logger)
-		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager, feedbagStore)
 		chatNavService := foodgroup.NewChatNavService(logger, feedbagStore, state.NewChatRoom)
 		oServiceService := foodgroup.NewOServiceServiceForChatNav(cfg, logger, sessionManager, adjListBuddyListStore, feedbagStore)
 
@@ -124,7 +124,7 @@ func main() {
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "ALERT")
 		sessionManager := state.NewInMemorySessionManager(logger)
-		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager, feedbagStore)
 		oServiceService := foodgroup.NewOServiceServiceForAlert(cfg, logger, sessionManager, adjListBuddyListStore, feedbagStore)
 
 		oscar.BOSServer{
@@ -144,7 +144,7 @@ func main() {
 		logger = logger.With("svc", "ADMIN")
 		buddyService := foodgroup.NewBuddyService(sessionManager, feedbagStore, adjListBuddyListStore)
 		adminService := foodgroup.NewAdminService(sessionManager, feedbagStore, buddyService, sessionManager)
-		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager, feedbagStore)
 		oServiceService := foodgroup.NewOServiceServiceForAdmin(cfg, logger, buddyService)
 
 		oscar.AdminServer{
@@ -164,7 +164,7 @@ func main() {
 		logger = logger.With("svc", "BART")
 		sessionManager := state.NewInMemorySessionManager(logger)
 		bartService := foodgroup.NewBARTService(logger, feedbagStore, sessionManager, feedbagStore, adjListBuddyListStore)
-		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager, feedbagStore)
 		oServiceService := foodgroup.NewOServiceServiceForBART(cfg, logger, sessionManager, adjListBuddyListStore, feedbagStore)
 
 		oscar.BOSServer{
@@ -182,7 +182,7 @@ func main() {
 	}(logger)
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "AUTH")
-		authHandler := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, nil, nil, chatSessionManager)
+		authHandler := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, nil, nil, chatSessionManager, feedbagStore)
 
 		oscar.AuthServer{
 			AuthService: authHandler,

+ 141 - 48
foodgroup/admin.go

@@ -2,6 +2,8 @@ package foodgroup
 
 import (
 	"context"
+	"errors"
+	"net/mail"
 
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
@@ -32,25 +34,50 @@ type AdminService struct {
 	messageRelayer         MessageRelayer
 }
 
-// ConfirmRequest returns the ScreenName account status. It returns SNAC
-// wire.AdminConfirmReply. The values in the return SNAC are
-// flag, URL, length
-func (s AdminService) ConfirmRequest(_ context.Context, frame wire.SNACFrame) (wire.SNACMessage, error) {
-	return wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Admin,
-			SubGroup:  wire.AdminAcctConfirmReply,
-			RequestID: frame.RequestID,
-		},
-		Body: wire.SNAC_0x07_0x07_AdminConfirmReply{
-			Status: wire.AdminAcctConfirmStatusEmailSent, // todo: get from session/db
-		},
-	}, nil
+// ConfirmRequest will mark the user account as confirmed if the user has an email address set
+func (s AdminService) ConfirmRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame) (wire.SNACMessage, error) {
+	// getAdminInfoReply returns an AdminAcctConfirmReply SNAC
+	var getAdminConfirmReply = func(status uint16) wire.SNACMessage {
+		return wire.SNACMessage{
+			Frame: wire.SNACFrame{
+				FoodGroup: wire.Admin,
+				SubGroup:  wire.AdminAcctConfirmReply,
+				RequestID: frame.RequestID,
+			},
+			Body: wire.SNAC_0x07_0x07_AdminConfirmReply{
+				Status: status,
+			},
+		}
+	}
+
+	_, err := s.accountManager.EmailAddressByName(sess.IdentScreenName())
+	if errors.Is(err, state.ErrNoEmailAddress) {
+		return getAdminConfirmReply(wire.AdminAcctConfirmStatusServerError), nil
+	} else if err != nil {
+		return wire.SNACMessage{}, err
+	}
+
+	accountConfirmed, err := s.accountManager.ConfirmStatusByName(sess.IdentScreenName())
+	if err != nil {
+		return wire.SNACMessage{}, err
+	}
+	if accountConfirmed {
+		return getAdminConfirmReply(wire.AdminAcctConfirmStatusAlreadyConfirmed), nil
+	}
+	if err := s.accountManager.UpdateConfirmStatus(true, sess.IdentScreenName()); err != nil {
+		return wire.SNACMessage{}, err
+	}
+	sess.ClearUserInfoFlag(wire.OServiceUserFlagUnconfirmed)
+	if err := s.buddyUpdateBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
+		return wire.SNACMessage{}, err
+	}
+	return getAdminConfirmReply(wire.AdminAcctConfirmStatusEmailSent), nil
 }
 
 // InfoQuery returns the requested information about the account
 func (s AdminService) InfoQuery(_ context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x07_0x02_AdminInfoQuery) (wire.SNACMessage, error) {
-	var getAdminInfoReply = func(tag uint16, val any) wire.SNACMessage {
+	// getAdminInfoReply returns an AdminInfoReply SNAC
+	var getAdminInfoReply = func(tlvList wire.TLVList) wire.SNACMessage {
 		return wire.SNACMessage{
 			Frame: wire.SNACFrame{
 				FoodGroup: wire.Admin,
@@ -60,28 +87,38 @@ func (s AdminService) InfoQuery(_ context.Context, sess *state.Session, frame wi
 			Body: wire.SNAC_0x07_0x03_AdminInfoReply{
 				Permissions: wire.AdminInfoPermissionsReadWrite, // todo: what does this actually control?
 				TLVBlock: wire.TLVBlock{
-					TLVList: wire.TLVList{
-						wire.NewTLV(tag, val),
-					},
+					TLVList: tlvList,
 				},
 			},
 		}
 	}
 
-	// wire.AdminTLVRegistrationStatus is used in the AIM Preferences > Privacy panel to control
-	// Allow users who know my e-mail address to find...
-	//	o Nothing about me - wire.AdminInfoRegStatusNoDisclosure
-	//	o Only that I have an account - wire.AdminInfoRegStatusLimitDisclosure
-	//	o My screen name - wire.AdminInfoRegStatusFullDisclosure
-	if _, hasRegStatus := body.TLVRestBlock.Slice(wire.AdminTLVRegistrationStatus); hasRegStatus {
-		return getAdminInfoReply(wire.AdminTLVRegistrationStatus, wire.AdminInfoRegStatusFullDisclosure), nil // todo: get from session/db
+	tlvList := wire.TLVList{}
 
-	} else if _, hasEmail := body.TLVRestBlock.Slice(wire.AdminTLVEmailAddress); hasEmail {
-		return getAdminInfoReply(wire.AdminTLVEmailAddress, sess.IdentScreenName().String()+"@aol.com"), nil // todo: get from session/db
+	if _, hasRegStatus := body.TLVRestBlock.Slice(wire.AdminTLVRegistrationStatus); hasRegStatus {
+		regStatus, err := s.accountManager.RegStatusByName(sess.IdentScreenName())
+		if err != nil {
+			return wire.SNACMessage{}, err
+		}
+		tlvList.Append(wire.NewTLV(wire.AdminTLVRegistrationStatus, regStatus))
+		return getAdminInfoReply(tlvList), nil
+	}
 
-	} else if _, hasNickName := body.TLVRestBlock.Slice(wire.AdminTLVScreenNameFormatted); hasNickName {
-		return getAdminInfoReply(wire.AdminTLVScreenNameFormatted, sess.DisplayScreenName().String()), nil
+	if _, hasEmail := body.TLVRestBlock.Slice(wire.AdminTLVEmailAddress); hasEmail {
+		e, err := s.accountManager.EmailAddressByName(sess.IdentScreenName())
+		if errors.Is(err, state.ErrNoEmailAddress) {
+			tlvList.Append(wire.NewTLV(wire.AdminTLVEmailAddress, ""))
+		} else if err != nil {
+			return wire.SNACMessage{}, err
+		} else {
+			tlvList.Append(wire.NewTLV(wire.AdminTLVEmailAddress, e.Address))
+		}
+		return getAdminInfoReply(tlvList), nil
+	}
 
+	if _, hasNickName := body.TLVRestBlock.Slice(wire.AdminTLVScreenNameFormatted); hasNickName {
+		tlvList.Append(wire.NewTLV(wire.AdminTLVScreenNameFormatted, sess.DisplayScreenName().String()))
+		return getAdminInfoReply(tlvList), nil
 	}
 
 	return wire.SNACMessage{
@@ -96,8 +133,10 @@ func (s AdminService) InfoQuery(_ context.Context, sess *state.Session, frame wi
 	}, nil
 }
 
+// InfoChangeRequest handles the user changing account information
 func (s AdminService) InfoChangeRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x07_0x04_AdminInfoChangeRequest) (wire.SNACMessage, error) {
-	var replyMessage = func(tag uint16, val any) wire.SNACMessage {
+	// replyMessage builds and returns an AdminChangeReply SNAC
+	var getAdminChangeReply = func(tlvList wire.TLVList) wire.SNACMessage {
 		return wire.SNACMessage{
 			Frame: wire.SNACFrame{
 				FoodGroup: wire.Admin,
@@ -107,39 +146,59 @@ func (s AdminService) InfoChangeRequest(ctx context.Context, sess *state.Session
 			Body: wire.SNAC_0x07_0x05_AdminChangeReply{
 				Permissions: wire.AdminInfoPermissionsReadWrite,
 				TLVBlock: wire.TLVBlock{
-					TLVList: wire.TLVList{
-						wire.NewTLV(tag, val),
-					},
+					TLVList: tlvList,
 				},
 			},
 		}
 	}
 
+	// validateProposedName ensures that the name is valid
 	var validateProposedName = func(name state.DisplayScreenName) (ok bool, errorCode uint16) {
+		// proposed name is too long
 		if len(name) > 16 {
-			// proposed name is too long
-			// todo: 16 should be defined elsewhere
 			return false, wire.AdminInfoErrorInvalidNickNameLength
-		} else if name.IdentScreenName() != sess.IdentScreenName() {
-			// proposed name does not match session name (e.g. malicious client)
+		}
+		// proposed name does not match session name (e.g. malicious client)
+		if name.IdentScreenName() != sess.IdentScreenName() {
+			return false, wire.AdminInfoErrorValidateNickName
+		}
+		// proposed name ends in a space
+		if name[len(name)-1] == 32 {
 			return false, wire.AdminInfoErrorInvalidNickName
 		}
 		return true, 0
 	}
 
+	// validateProposedEmailAddress ensures that the email address is valid
+	var validateProposedEmailAddress = func(emailAddress []byte) (e *mail.Address, errorCode uint16) {
+		/*
+			todo: pidgin/libpurple will show 'unknown error: 0xNNNN' for these error codes.
+			We could do a client check here and send wire.AdminInfoErrorDNSFail so pidgin
+			will show "given email address is invalid" instead.
+		*/
+
+		e, err := mail.ParseAddress(string(emailAddress))
+
+		// rfc 5322 basic validation
+		if err != nil {
+			return nil, wire.AdminInfoErrorInvalidEmail
+		}
+		// rfc 5521 length - local-part (64) + @ (1) + domain (255)
+		if len(e.Address) > 320 {
+			return nil, wire.AdminInfoErrorInvalidEmailLength
+		}
+		// todo: wire.AdminInfoErrorDNSFail could be sent here for an invalid domain name
+		return e, 0
+	}
+
+	tlvList := wire.TLVList{}
+
 	if sn, hasScreenNameFormatted := body.TLVRestBlock.Slice(wire.AdminTLVScreenNameFormatted); hasScreenNameFormatted {
 		proposedName := state.DisplayScreenName(sn)
 		if ok, errorCode := validateProposedName(proposedName); !ok {
-			return wire.SNACMessage{
-				Frame: wire.SNACFrame{
-					FoodGroup: wire.Admin,
-					SubGroup:  wire.AdminErr,
-					RequestID: frame.RequestID,
-				},
-				Body: wire.SNACError{
-					Code: errorCode,
-				},
-			}, nil
+			tlvList.Append(wire.NewTLV(wire.AdminTLVErrorCode, errorCode))
+			tlvList.Append(wire.NewTLV(wire.AdminTLVUrl, ""))
+			return getAdminChangeReply(tlvList), nil
 		}
 		if err := s.accountManager.UpdateDisplayScreenName(proposedName); err != nil {
 			return wire.SNACMessage{}, err
@@ -157,8 +216,42 @@ func (s AdminService) InfoChangeRequest(ctx context.Context, sess *state.Session
 				TLVUserInfo: sess.TLVUserInfo(),
 			},
 		})
-		return replyMessage(wire.AdminTLVScreenNameFormatted, proposedName.String()), nil
+		tlvList.Append(wire.NewTLV(wire.AdminTLVScreenNameFormatted, proposedName.String()))
+		return getAdminChangeReply(tlvList), nil
+	}
+
+	if emailAddress, hasEmailAddress := body.TLVRestBlock.Slice(wire.AdminTLVEmailAddress); hasEmailAddress {
+		e, errorCode := validateProposedEmailAddress(emailAddress)
+		if errorCode != 0 {
+			tlvList.Append(wire.NewTLV(wire.AdminTLVErrorCode, errorCode))
+			tlvList.Append(wire.NewTLV(wire.AdminTLVUrl, ""))
+			return getAdminChangeReply(tlvList), nil
+
+		}
+		if err := s.accountManager.UpdateEmailAddress(e, sess.IdentScreenName()); err != nil {
+			return wire.SNACMessage{}, err
+		}
+		tlvList.Append(wire.NewTLV(wire.AdminTLVEmailAddress, e.Address))
+		return getAdminChangeReply(tlvList), nil
+	}
+
+	if regStatus, hasRegStatus := body.TLVRestBlock.Uint16(wire.AdminTLVRegistrationStatus); hasRegStatus {
+		switch regStatus {
+		case
+			wire.AdminInfoRegStatusFullDisclosure,
+			wire.AdminInfoRegStatusLimitDisclosure,
+			wire.AdminInfoRegStatusNoDisclosure:
+			if err := s.accountManager.UpdateRegStatus(regStatus, sess.IdentScreenName()); err != nil {
+				return wire.SNACMessage{}, err
+			}
+			tlvList.Append(wire.NewTLV(wire.AdminTLVRegistrationStatus, regStatus))
+			return getAdminChangeReply(tlvList), nil
+		}
+		tlvList.Append(wire.NewTLV(wire.AdminTLVErrorCode, wire.AdminInfoErrorInvalidRegistrationPreference))
+		tlvList.Append(wire.NewTLV(wire.AdminTLVUrl, ""))
+		return getAdminChangeReply(tlvList), nil
 	}
+
 	return wire.SNACMessage{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.Admin,

+ 12 - 1
foodgroup/auth.go

@@ -25,6 +25,7 @@ func NewAuthService(
 	messageRelayer MessageRelayer,
 	feedbagManager FeedbagManager,
 	chatMessageRelayer ChatMessageRelayer,
+	accountManager AccountManager,
 ) *AuthService {
 	return &AuthService{
 		buddyUpdateBroadcaster: NewBuddyService(messageRelayer, feedbagManager, legacyBuddyListManager),
@@ -35,6 +36,7 @@ func NewAuthService(
 		sessionManager:         sessionManager,
 		userManager:            userManager,
 		chatMessageRelayer:     chatMessageRelayer,
+		accountManager:         accountManager,
 	}
 }
 
@@ -51,6 +53,7 @@ type AuthService struct {
 	sessionManager         SessionManager
 	userManager            UserManager
 	chatRoomManager        ChatRoomRegistry
+	accountManager         AccountManager
 }
 
 // RegisterChatSession adds a user to a chat room. The authCookie param is an
@@ -87,7 +90,15 @@ func (s AuthService) RegisterBOSSession(authCookie []byte) (*state.Session, erro
 		return nil, fmt.Errorf("user not found")
 	}
 
-	return s.sessionManager.AddSession(u.DisplayScreenName), nil
+	sess := s.sessionManager.AddSession(u.DisplayScreenName)
+	// Set the unconfirmed user info flag if this account is unconfirmed
+	if confirmed, err := s.accountManager.ConfirmStatusByName(sess.IdentScreenName()); err != nil {
+		return nil, fmt.Errorf("error setting unconfirmed user flag: %w", err)
+	} else if !confirmed {
+		sess.SetUserInfoFlag(wire.OServiceUserFlagUnconfirmed)
+	}
+
+	return sess, nil
 }
 
 // RetrieveBOSSession returns a user's existing session

+ 7 - 0
foodgroup/types.go

@@ -35,6 +35,7 @@ package foodgroup
 
 import (
 	"context"
+	"net/mail"
 	"time"
 
 	"github.com/mk6i/retro-aim-server/state"
@@ -164,4 +165,10 @@ type buddyBroadcaster interface {
 
 type AccountManager interface {
 	UpdateDisplayScreenName(displayScreenName state.DisplayScreenName) error
+	UpdateEmailAddress(emailAddress *mail.Address, screenName state.IdentScreenName) error
+	EmailAddressByName(screenName state.IdentScreenName) (*mail.Address, error)
+	UpdateRegStatus(regStatus uint16, screenName state.IdentScreenName) error
+	RegStatusByName(screenName state.IdentScreenName) (uint16, error)
+	UpdateConfirmStatus(confirmStatus bool, screenName state.IdentScreenName) error
+	ConfirmStatusByName(screnName state.IdentScreenName) (bool, error)
 }

+ 3 - 3
server/oscar/handler/admin.go

@@ -27,13 +27,13 @@ type AdminHandler struct {
 }
 
 type AdminService interface {
-	ConfirmRequest(_ context.Context, frame wire.SNACFrame) (wire.SNACMessage, error)
+	ConfirmRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame) (wire.SNACMessage, error)
 	InfoQuery(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x07_0x02_AdminInfoQuery) (wire.SNACMessage, error)
 	InfoChangeRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x07_0x04_AdminInfoChangeRequest) (wire.SNACMessage, error)
 }
 
-func (rt AdminHandler) ConfirmRequest(ctx context.Context, _ *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw oscar.ResponseWriter) error {
-	outSNAC, err := rt.AdminService.ConfirmRequest(ctx, inFrame)
+func (rt AdminHandler) ConfirmRequest(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, _ io.Reader, rw oscar.ResponseWriter) error {
+	outSNAC, err := rt.AdminService.ConfirmRequest(ctx, sess, inFrame)
 	if err != nil {
 		return err
 	}

+ 17 - 0
state/migrations/0005_user_settings.down.sql

@@ -0,0 +1,17 @@
+CREATE TABLE users_backup
+(
+    identScreenName   VARCHAR(16) PRIMARY KEY,
+    displayScreenName TEXT,
+    authKey           TEXT,
+    strongMD5Pass     TEXT,
+    weakMD5Pass       TEXT
+);
+
+INSERT INTO users_backup (identScreenName, displayScreenName, authKey, strongMD5Pass, weakMD5Pass)
+SELECT dentScreenName, displayScreenName, authKey, strongMD5Pass, weakMD5Pass
+FROM users;
+
+DROP TABLE users;
+
+ALTER TABLE users_backup
+    RENAME TO users;

+ 6 - 0
state/migrations/0005_user_settings.up.sql

@@ -0,0 +1,6 @@
+ALTER TABLE users
+    ADD COLUMN confirmStatus     BOOL DEFAULT FALSE;
+ALTER TABLE users
+    ADD COLUMN emailAddress      VARCHAR(320) NOT NULL DEFAULT '';
+ALTER TABLE users
+    ADD COLUMN regStatus         INT NOT NULL DEFAULT 3;

+ 40 - 12
state/session.go

@@ -39,20 +39,45 @@ type Session struct {
 	signonTime        time.Time
 	stopCh            chan struct{}
 	warning           uint16
+	userInfoFlags     uint16
 }
 
 // NewSession returns a new instance of Session. By default, the user may have
 // up to 1000 pending messages before blocking.
 func NewSession() *Session {
 	return &Session{
-		msgCh:      make(chan wire.SNACMessage, 1000),
-		nowFn:      time.Now,
-		stopCh:     make(chan struct{}),
-		signonTime: time.Now(),
-		caps:       make([][16]byte, 0),
+		msgCh:         make(chan wire.SNACMessage, 1000),
+		nowFn:         time.Now,
+		stopCh:        make(chan struct{}),
+		signonTime:    time.Now(),
+		caps:          make([][16]byte, 0),
+		userInfoFlags: wire.OServiceUserFlagOSCARFree,
 	}
 }
 
+// SetUserInfoFlag sets a flag to and returns UserInfoFlags
+func (s *Session) SetUserInfoFlag(flag uint16) (flags uint16) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	s.userInfoFlags |= flag
+	return s.userInfoFlags
+}
+
+// ClearUserInfoFlag clear a flag from and returns UserInfoFlags
+func (s *Session) ClearUserInfoFlag(flag uint16) (flags uint16) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	s.userInfoFlags &^= flag
+	return s.userInfoFlags
+}
+
+// UserInfoFlags returns UserInfoFlags
+func (s *Session) UserInfoFlags() (flags uint16) {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.userInfoFlags
+}
+
 // IncrementWarning increments the user's warning level. To decrease, pass a
 // negative increment value.
 func (s *Session) IncrementWarning(incr uint16) {
@@ -126,10 +151,17 @@ func (s *Session) UnsetIdle() {
 	s.idle = false
 }
 
-// SetAwayMessage sets the user's away message.
+// SetAwayMessage sets the user's away message, and
+// sets or clears the OServiceUserFlagUnavailable flag
 func (s *Session) SetAwayMessage(awayMessage string) {
 	s.mutex.Lock()
 	defer s.mutex.Unlock()
+	if awayMessage != "" {
+		s.userInfoFlags |= wire.OServiceUserFlagUnavailable
+	} else {
+		s.userInfoFlags &^= wire.OServiceUserFlagUnavailable
+	}
+
 	s.awayMessage = awayMessage
 }
 
@@ -188,12 +220,8 @@ func (s *Session) userInfo() wire.TLVList {
 	// sign-in timestamp
 	tlvs.Append(wire.NewTLV(wire.OServiceUserInfoSignonTOD, uint32(s.signonTime.Unix())))
 
-	// away message status
-	if s.awayMessage != "" {
-		tlvs.Append(wire.NewTLV(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagOSCARFree|wire.OServiceUserFlagUnavailable))
-	} else {
-		tlvs.Append(wire.NewTLV(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagOSCARFree))
-	}
+	// user info flags
+	tlvs.Append(wire.NewTLV(wire.OServiceUserInfoUserFlags, s.UserInfoFlags()))
 
 	// reflects invisibility toggle status back to toggling client
 	if s.invisible {

+ 2 - 0
state/user.go

@@ -18,6 +18,8 @@ var (
 	ErrDupUser = errors.New("user already exists")
 	// ErrNoUser indicates that a user does not exist.
 	ErrNoUser = errors.New("user does not exist")
+	// ErrNoEmail indicates that a user has not set an email address.
+	ErrNoEmailAddress = errors.New("user has no email address")
 )
 
 const (

+ 86 - 0
state/user_store.go

@@ -8,6 +8,7 @@ import (
 	"fmt"
 	"io/fs"
 	"net/http"
+	"net/mail"
 	"time"
 
 	"github.com/mk6i/retro-aim-server/wire"
@@ -598,3 +599,88 @@ func (f SQLiteUserStore) UpdateDisplayScreenName(displayScreenName DisplayScreen
 	_, err := f.db.Exec(q, displayScreenName.String(), displayScreenName.IdentScreenName().String())
 	return err
 }
+
+// UpdateEmailAddress updates the user's EmailAddress
+func (f SQLiteUserStore) UpdateEmailAddress(emailAddress *mail.Address, screenName IdentScreenName) error {
+	q := `
+		UPDATE users
+		SET emailAddress = ?
+		WHERE identScreenName = ?
+	`
+	_, err := f.db.Exec(q, emailAddress.Address, screenName.String())
+	return err
+}
+
+// EmailAddressByName retrieves the user's EmailAddress
+func (f SQLiteUserStore) EmailAddressByName(screenName IdentScreenName) (*mail.Address, error) {
+	q := `
+		SELECT emailAddress
+		FROM users
+		WHERE identScreenName = ?
+	`
+	var emailAddress string
+	err := f.db.QueryRow(q, screenName.String()).Scan(&emailAddress)
+	// username isn't found for some reason
+	if err != nil && !errors.Is(err, sql.ErrNoRows) {
+		return nil, err
+	}
+	e, err := mail.ParseAddress(emailAddress)
+	if err != nil {
+		return nil, fmt.Errorf("%w: %w", ErrNoEmailAddress, err)
+	}
+	return e, nil
+}
+
+// UpdateRegStatus updates the user's registration status preference
+func (f SQLiteUserStore) UpdateRegStatus(regStatus uint16, screenName IdentScreenName) error {
+	q := `
+		UPDATE users
+		SET regStatus = ?
+		WHERE identScreenName = ?
+	`
+	_, err := f.db.Exec(q, regStatus, screenName.String())
+	return err
+}
+
+// RegStatusByName retrieves the user's registration status preference
+func (f SQLiteUserStore) RegStatusByName(screenName IdentScreenName) (uint16, error) {
+	q := `
+		SELECT regStatus
+		FROM users
+		WHERE identScreenName = ?
+	`
+	var regStatus uint16
+	err := f.db.QueryRow(q, screenName.String()).Scan(&regStatus)
+	// username isn't found for some reason
+	if err != nil && !errors.Is(err, sql.ErrNoRows) {
+		return 0, err
+	}
+	return regStatus, nil
+}
+
+// UpdateConfirmStatus updates the user's confirmation status
+func (f SQLiteUserStore) UpdateConfirmStatus(confirmStatus bool, screenName IdentScreenName) error {
+	q := `
+		UPDATE users
+		SET confirmStatus = ?
+		WHERE identScreenName = ?
+	`
+	_, err := f.db.Exec(q, confirmStatus, screenName.String())
+	return err
+}
+
+// ConfirmStatusByName retrieves the user's confirmation status
+func (f SQLiteUserStore) ConfirmStatusByName(screenName IdentScreenName) (bool, error) {
+	q := `
+		SELECT confirmStatus
+		FROM users
+		WHERE identScreenName = ?
+	`
+	var confirmStatus bool
+	err := f.db.QueryRow(q, screenName.String()).Scan(&confirmStatus)
+	// username isn't found for some reason
+	if err != nil && !errors.Is(err, sql.ErrNoRows) {
+		return false, err
+	}
+	return confirmStatus, nil
+}

+ 26 - 8
wire/snacs.go

@@ -134,12 +134,13 @@ const (
 	OServiceBartQuery2        uint16 = 0x0022
 	OServiceBartReply2        uint16 = 0x0023
 
-	OServiceUserInfoUserFlags uint16 = 0x01
-	OServiceUserInfoSignonTOD uint16 = 0x03
-	OServiceUserInfoIdleTime  uint16 = 0x04
-	OServiceUserInfoStatus    uint16 = 0x06
-	OServiceUserInfoOscarCaps uint16 = 0x0D
-	OServiceUserInfoBARTInfo  uint16 = 0x1D
+	OServiceUserInfoUserFlags  uint16 = 0x01
+	OServiceUserInfoSignonTOD  uint16 = 0x03
+	OServiceUserInfoIdleTime   uint16 = 0x04
+	OServiceUserInfoStatus     uint16 = 0x06
+	OServiceUserInfoOscarCaps  uint16 = 0x0D
+	OServiceUserInfoBARTInfo   uint16 = 0x1D
+	OServiceUserInfoUserFlags2 uint16 = 0x1F
 
 	OServiceUserStatusAvailable         uint32 = 0x00000000 // user is available
 	OServiceUserStatusAway              uint32 = 0x00000001 // user is away
@@ -154,8 +155,25 @@ const (
 	OServiceUserStatusICQHomePage       uint32 = 0x00200000
 	OServiceUserStatusDirectRequireAuth uint32 = 0x10000000
 
-	OServiceUserFlagOSCARFree   uint16 = 0x0010 // AIM (not AOL) account
-	OServiceUserFlagUnavailable uint16 = 0x0020 // user is away
+	OServiceUserFlagUnconfirmed    uint16 = 0x0001 // Unconfirmed account
+	OServiceUserFlagAdministrator  uint16 = 0x0002 // Server Administrator
+	OServiceUserFlagAOL            uint16 = 0x0004 // AOL (staff?) account
+	OServiceUserFlagOSCARPay       uint16 = 0x0008 // Commercial account
+	OServiceUserFlagOSCARFree      uint16 = 0x0010 // AIM (not AOL) account
+	OServiceUserFlagUnavailable    uint16 = 0x0020 // user is away
+	OServiceUserFlagICQ            uint16 = 0x0040 // ICQ user (OServiceUserFlagOSCARFree should also be set)
+	OServiceUserFlagWireless       uint16 = 0x0080 // On mobile device
+	OServiceUserFlagInternal       uint16 = 0x0100 // Internal account
+	OServiceUserFlagFish           uint16 = 0x0200 // IM forwarding enabled
+	OServiceUserFlagBot            uint16 = 0x0400 // Bot account
+	OServiceUserFlagBeast          uint16 = 0x0800 // Unknown
+	OServiceUserFlagOneWayWireless uint16 = 0x1000 // On one way mobile device
+	OServiceUserFlagOfficial       uint16 = 0x2000 // Unknown
+
+	OServiceUserFlag2BuddyMatchDirect   uint32 = 0x00010000 // Unknown
+	OServiceUserFlag2BuddyMatchIndirect uint32 = 0x00020000 // Unknown
+	OServiceUserFlag2NoKnockKnock       uint32 = 0x00040000 // Sender is safe
+	OServiceUserFlag2ForwardMobile      uint32 = 0x00080000 // Forward to mobile if no acive session
 
 	OServicePrivacyFlagIdle   uint32 = 0x00000001
 	OServicePrivacyFlagMember uint32 = 0x00000002