From 00c2f20caf8885fc44389d9f8eb8d211c29ac2ec Mon Sep 17 00:00:00 2001 From: Artem Titoulenko Date: Sun, 19 Dec 2021 01:14:25 -0500 Subject: [PATCH] buddy list management service --- 0x01_generic_service_controls.go | 71 ++++++++++++-- 0x03_buddy_list_management.go | 102 +++++++++++++++++++++ 0x04_ICBM.go | 11 ++- 0x17_authorization_registration_service.go | 2 +- aimerror/errors.go | 9 ++ main.go | 39 ++++++-- message_delivery_routine.go | 12 ++- models/Buddy.go | 12 +++ models/Message.go | 15 +-- models/User.go | 9 +- models/fixtures.yml | 12 ++- online_routine.go | 96 +++++++++++++++++++ service.go | 3 +- 13 files changed, 357 insertions(+), 36 deletions(-) create mode 100644 0x03_buddy_list_management.go create mode 100644 aimerror/errors.go create mode 100644 models/Buddy.go create mode 100644 online_routine.go diff --git a/0x01_generic_service_controls.go b/0x01_generic_service_controls.go index 6dd5fcc..8f05a37 100644 --- a/0x01_generic_service_controls.go +++ b/0x01_generic_service_controls.go @@ -1,6 +1,7 @@ package main import ( + "aim-oscar/aimerror" "aim-oscar/models" "aim-oscar/oscar" "aim-oscar/util" @@ -17,15 +18,73 @@ var versions map[uint16]uint16 func init() { versions = make(map[uint16]uint16) versions[1] = 3 + versions[3] = 1 versions[4] = 1 + versions[17] = 1 } -type GenericServiceControls struct{} +type GenericServiceControls struct { + OnlineCh chan *models.User +} -func (g *GenericServiceControls) HandleSNAC(ctx context.Context, db *bun.DB, snac *oscar.SNAC, comm chan *models.Message) (context.Context, error) { +func (g *GenericServiceControls) HandleSNAC(ctx context.Context, db *bun.DB, snac *oscar.SNAC) (context.Context, error) { session, _ := oscar.SessionFromContext(ctx) switch snac.Header.Subtype { + + // Client is ONLINE and READY + case 0x02: + user := models.UserFromContext(ctx) + if user != nil { + user.Status = models.UserStatusActive + if err := user.Update(ctx, db); err != nil { + return ctx, errors.Wrap(err, "could not set user as active") + } + + g.OnlineCh <- user + + // Find all of the buddies that are online and tell the user + var buddies []*models.Buddy + err := db.NewSelect().Model(&buddies).Where("with_uin = ?", user.UIN).Relation("Source").Scan(context.Background(), &buddies) + if err != nil { + return ctx, errors.Wrapf(err, "could not find user's buddies: %s", err.Error()) + } + + for _, buddy := range buddies { + if buddy.Source.Status != models.UserStatusActive { + continue + } + + onlineSnac := oscar.NewSNAC(3, 0xb) + onlineSnac.Data.WriteLPString(buddy.Source.Username) + onlineSnac.Data.WriteUint16(0) // TODO: user warning level + + tlvs := []*oscar.TLV{ + oscar.NewTLV(1, util.Word(0x80)), // TODO: user class + oscar.NewTLV(0x06, util.Dword(0x0001|0x0100)), // TODO: User Status + oscar.NewTLV(0x0a, util.Dword(binary.BigEndian.Uint32([]byte(SRV_HOST)))), // External IP + oscar.NewTLV(0x0f, util.Dword(uint32(time.Since(user.LastActivityAt).Seconds()))), // Idle Time + oscar.NewTLV(0x03, util.Dword(uint32(time.Now().Unix()))), // Client Signon Time + oscar.NewTLV(0x05, util.Dword(uint32(user.CreatedAt.Unix()))), // Member since + } + + onlineSnac.Data.WriteUint16(uint16(len(tlvs))) + for _, tlv := range tlvs { + onlineSnac.Data.WriteBinary(tlv) + } + + onlineFlap := oscar.NewFLAP(2) + onlineFlap.Data.WriteBinary(onlineSnac) + if err := session.Send(onlineFlap); err != nil { + return ctx, errors.Wrapf(err, "could not tell %s that %s is online", buddy.Source.Username, buddy.Target.Username) + } + } + + return models.NewContextWithUser(ctx, user), nil + } + + return ctx, nil + // Client wants to know the rate limits for all services case 0x06: rateSnac := oscar.NewSNAC(1, 7) @@ -51,7 +110,7 @@ func (g *GenericServiceControls) HandleSNAC(ctx context.Context, db *bun.DB, sna // TODO: make actual rate groups instead of this hack. I can't tell which subtypes are supported so // make it set rate limits for everything family for all subtypes under 0x21. - rg.WriteUint16(2 * 0x21) // Number of rate groups + rg.WriteUint16(3 * 0x21) // Number of rate groups for family := range versions { for subtype := 0; subtype < 0x21; subtype++ { rg.WriteUint16(family) @@ -68,7 +127,7 @@ func (g *GenericServiceControls) HandleSNAC(ctx context.Context, db *bun.DB, sna case 0x0e: user := models.UserFromContext(ctx) if user == nil { - return ctx, errors.New("expecting user in context") + return ctx, aimerror.NoUserInSession } onlineSnac := oscar.NewSNAC(1, 0xf) @@ -76,7 +135,7 @@ func (g *GenericServiceControls) HandleSNAC(ctx context.Context, db *bun.DB, sna onlineSnac.Data.WriteString(user.Username) onlineSnac.Data.WriteUint16(0) // warning level - user.Status = "active" + user.Status = models.UserStatusActive if err := user.Update(ctx, db); err != nil { return ctx, errors.Wrap(err, "could not set user as active") } @@ -98,7 +157,7 @@ func (g *GenericServiceControls) HandleSNAC(ctx context.Context, db *bun.DB, sna onlineFlap := oscar.NewFLAP(2) onlineFlap.Data.WriteBinary(onlineSnac) - return ctx, session.Send(onlineFlap) + return models.NewContextWithUser(ctx, user), session.Send(onlineFlap) // Client wants to know the versions of all of the services offered case 0x17: diff --git a/0x03_buddy_list_management.go b/0x03_buddy_list_management.go new file mode 100644 index 0000000..c411f66 --- /dev/null +++ b/0x03_buddy_list_management.go @@ -0,0 +1,102 @@ +package main + +import ( + "aim-oscar/aimerror" + "aim-oscar/models" + "aim-oscar/oscar" + "aim-oscar/util" + "context" + "log" + + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +type BuddyListManagement struct{} + +func (b *BuddyListManagement) HandleSNAC(ctx context.Context, db *bun.DB, snac *oscar.SNAC) (context.Context, error) { + session, _ := oscar.SessionFromContext(ctx) + + switch snac.Header.Subtype { + + // Client wants to know the buddy list params + limitations + case 0x2: + limitSnac := oscar.NewSNAC(3, 3) + limitSnac.Data.WriteBinary(oscar.NewTLV(1, util.Word(500))) // Max buddy list size + limitSnac.Data.WriteBinary(oscar.NewTLV(2, util.Word(750))) // Max list watchers + limitSnac.Data.WriteBinary(oscar.NewTLV(3, util.Word(512))) // Max online notifications ? + + limitFlap := oscar.NewFLAP(2) + limitFlap.Data.WriteBinary(limitSnac) + return ctx, session.Send(limitFlap) + + // Add buddy + case 0x4: + user := models.UserFromContext(ctx) + if user == nil { + return ctx, aimerror.NoUserInSession + } + + for len(snac.Data.Bytes()) > 0 { + buddyScreename, err := snac.Data.ReadLPString() + if err != nil { + return ctx, errors.Wrap(err, "expecting more buddies in list") + } + + buddy, err := models.UserByUsername(ctx, db, buddyScreename) + if err != nil { + return ctx, errors.Wrap(err, "error looking for User") + } + if buddy == nil { + return ctx, aimerror.UserNotFound(buddyScreename) + } + + rel := &models.Buddy{ + SourceUIN: user.UIN, + WithUIN: buddy.UIN, + } + + _, err = db.NewInsert().Model(rel).Exec(ctx) + if err != nil { + return ctx, err + } + + log.Printf("%s added buddy %s to buddy list", user.Username, buddyScreename) + } + + return ctx, nil + + // Remove buddies from user list + case 0x5: + user := models.UserFromContext(ctx) + if user == nil { + return ctx, aimerror.NoUserInSession + } + + for len(snac.Data.Bytes()) > 0 { + buddyScreename, err := snac.Data.ReadLPString() + if err != nil { + return ctx, errors.Wrap(err, "expecting more buddies in list") + } + + buddy, err := models.UserByUsername(ctx, db, buddyScreename) + if err != nil { + return ctx, errors.Wrap(err, "error looking for User") + } + if buddy == nil { + return ctx, aimerror.UserNotFound(buddyScreename) + } + + _, err = db.NewDelete().Model((*models.Buddy)(nil)).Where("uin = ?", user.UIN).Where("with_uin = ?", buddy.UIN).Exec(ctx) + if err != nil { + return ctx, err + } + + log.Printf("%s removed buddy %s from buddy list", user.Username, buddyScreename) + } + + return ctx, nil + } + + return ctx, nil +} diff --git a/0x04_ICBM.go b/0x04_ICBM.go index d62774f..12c0ad0 100644 --- a/0x04_ICBM.go +++ b/0x04_ICBM.go @@ -1,6 +1,7 @@ package main import ( + "aim-oscar/aimerror" "aim-oscar/models" "aim-oscar/oscar" "bytes" @@ -12,7 +13,9 @@ import ( "github.com/uptrace/bun" ) -type ICBM struct{} +type ICBM struct { + CommCh chan *models.Message +} type icbmKey string @@ -46,7 +49,7 @@ type channel struct { Unknown uint16 } -func (icbm *ICBM) HandleSNAC(ctx context.Context, db *bun.DB, snac *oscar.SNAC, comm chan *models.Message) (context.Context, error) { +func (icbm *ICBM) HandleSNAC(ctx context.Context, db *bun.DB, snac *oscar.SNAC) (context.Context, error) { session, _ := oscar.SessionFromContext(ctx) switch snac.Header.Subtype { @@ -93,7 +96,7 @@ func (icbm *ICBM) HandleSNAC(ctx context.Context, db *bun.DB, snac *oscar.SNAC, case 0x06: user := models.UserFromContext(ctx) if user == nil { - return ctx, errors.New("context should have User") + return ctx, aimerror.NoUserInSession } msgID, _ := snac.Data.ReadUint64() @@ -179,7 +182,7 @@ func (icbm *ICBM) HandleSNAC(ctx context.Context, db *bun.DB, snac *oscar.SNAC, } // Fire the message off into the communication channel to get delivered - comm <- message + icbm.CommCh <- message // The Client usually wants a response that the server got the message. It checks that the message // back has the same message ID that was sent and the user it was sent to. diff --git a/0x17_authorization_registration_service.go b/0x17_authorization_registration_service.go index 9016757..b177315 100644 --- a/0x17_authorization_registration_service.go +++ b/0x17_authorization_registration_service.go @@ -74,7 +74,7 @@ func (a *AuthorizationRegistrationService) GenerateCipher() string { return base32.StdEncoding.EncodeToString(randomBytes)[:CIPHER_LENGTH] } -func (a *AuthorizationRegistrationService) HandleSNAC(ctx context.Context, db *bun.DB, snac *oscar.SNAC, comm chan *models.Message) (context.Context, error) { +func (a *AuthorizationRegistrationService) HandleSNAC(ctx context.Context, db *bun.DB, snac *oscar.SNAC) (context.Context, error) { session, err := oscar.SessionFromContext(ctx) if err != nil { util.PanicIfError(err) diff --git a/aimerror/errors.go b/aimerror/errors.go new file mode 100644 index 0000000..1cdfe71 --- /dev/null +++ b/aimerror/errors.go @@ -0,0 +1,9 @@ +package aimerror + +import "github.com/pkg/errors" + +func UserNotFound(screenname string) error { + return errors.Errorf("no user with UIN %s", screenname) +} + +var NoUserInSession = errors.New("no user in session") diff --git a/main.go b/main.go index 838e2f3..064ad05 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "net" "os" "os/signal" + "sync" "syscall" "time" @@ -33,6 +34,18 @@ var services map[uint16]Service // Map username to session var sessions map[string]*oscar.Session +var sessionsMutex = &sync.RWMutex{} + +func getSession(username string) *oscar.Session { + sessionsMutex.RLock() + s, ok := sessions[username] + sessionsMutex.RUnlock() + + if ok { + return s + } + return nil +} func init() { services = make(map[uint16]Service) @@ -57,7 +70,7 @@ func main() { db.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true))) // Register our DB models - db.RegisterModel((*models.User)(nil), (*models.Message)(nil)) + db.RegisterModel((*models.User)(nil), (*models.Message)(nil), (*models.Buddy)(nil)) // dev: load in fixtures to test against fixture := dbfixture.New(db, dbfixture.WithRecreateTables()) @@ -77,15 +90,21 @@ func main() { commCh, messageRoutine := MessageDelivery() go messageRoutine(db) + // Goroutine that listens for users who change their online status and notifies their buddies + onlineCh, onlineRoutine := OnlineNotification() + go onlineRoutine(db) + handleCloseFn := func(ctx context.Context, session *oscar.Session) { log.Printf("%v disconnected", session.RemoteAddr()) user := models.UserFromContext(ctx) if user != nil { - user.Status = "offline" + user.Status = models.UserStatusInactive if err := user.Update(ctx, db); err != nil { - log.Print(errors.Wrap(err, "could not set user as active")) + log.Print(errors.Wrap(err, "could not set user as inactive")) } + + onlineCh <- user } } @@ -119,8 +138,10 @@ func main() { // Send available services servicesSnac := oscar.NewSNAC(1, 3) - servicesSnac.Data.WriteUint16(0x1) - servicesSnac.Data.WriteUint16(0x4) + for family := range versions { + servicesSnac.Data.WriteUint16(family) + } + servicesFlap := oscar.NewFLAP(2) servicesFlap.Data.WriteBinary(servicesSnac) session.Send(servicesFlap) @@ -141,7 +162,7 @@ func main() { } if service, ok := services[snac.Header.Family]; ok { - newCtx, err := service.HandleSNAC(ctx, db, snac, commCh) + newCtx, err := service.HandleSNAC(ctx, db, snac) util.PanicIfError(err) return newCtx } @@ -156,14 +177,16 @@ func main() { handler := oscar.NewHandler(handleFn, handleCloseFn) RegisterService(0x17, &AuthorizationRegistrationService{}) - RegisterService(0x01, &GenericServiceControls{}) - RegisterService(0x04, &ICBM{}) + RegisterService(0x01, &GenericServiceControls{OnlineCh: onlineCh}) + RegisterService(0x03, &BuddyListManagement{}) + RegisterService(0x04, &ICBM{CommCh: commCh}) exitChan := make(chan os.Signal, 1) signal.Notify(exitChan, os.Interrupt, syscall.SIGINT, syscall.SIGTERM, syscall.SIGABRT) go func() { <-exitChan close(commCh) + close(onlineCh) fmt.Println("Shutting down") os.Exit(1) }() diff --git a/message_delivery_routine.go b/message_delivery_routine.go index 1e8e153..0ce75d7 100644 --- a/message_delivery_routine.go +++ b/message_delivery_routine.go @@ -16,6 +16,8 @@ func MessageDelivery() (chan *models.Message, routineFn) { commCh := make(chan *models.Message, 1) routine := func(db *bun.DB) { + log.Printf("message delivery routine started") + for { message, more := <-commCh if !more { @@ -24,9 +26,9 @@ func MessageDelivery() (chan *models.Message, routineFn) { } log.Printf("got a message: %s", message) - if s, ok := sessions[message.To]; ok { + if s := getSession(message.To); s != nil { messageSnac := oscar.NewSNAC(4, 7) - messageSnac.Data.WriteUint64(message.MessageID) + messageSnac.Data.WriteUint64(message.Cookie) messageSnac.Data.WriteUint16(1) messageSnac.Data.WriteLPString(message.From) messageSnac.Data.WriteUint16(0) // TODO: sender's warning level @@ -60,14 +62,14 @@ func MessageDelivery() (chan *models.Message, routineFn) { messageFlap := oscar.NewFLAP(2) messageFlap.Data.WriteBinary(messageSnac) if err := s.Send(messageFlap); err != nil { - log.Panicf("could not deliver message %d: %s", message.MessageID, err.Error()) + log.Panicf("could not deliver message %d: %s", message.Cookie, err.Error()) continue } else { - log.Printf("sent message %d to user %s at %s", message.MessageID, message.To, s.RemoteAddr()) + log.Printf("sent message %d to user %s at %s", message.Cookie, message.To, s.RemoteAddr()) } if err := message.MarkDelivered(context.Background(), db); err != nil { - log.Panicf("could not mark message %d as delivered: %s", message.MessageID, err.Error()) + log.Panicf("could not mark message %d as delivered: %s", message.Cookie, err.Error()) } } else { log.Printf("could not find session for user %s", message.To) diff --git a/models/Buddy.go b/models/Buddy.go new file mode 100644 index 0000000..36105df --- /dev/null +++ b/models/Buddy.go @@ -0,0 +1,12 @@ +package models + +import "github.com/uptrace/bun" + +type Buddy struct { + bun.BaseModel `bun:"table:buddies"` + ID int `bun:",pk"` + SourceUIN int64 `bun:",notnull"` + Source *User `bun:"rel:has-one,join:source_uin=uin"` + WithUIN int64 `bun:",notnull"` + Target *User `bun:"rel:has-one,join:with_uin=uin"` +} diff --git a/models/Message.go b/models/Message.go index 9547ace..a452d27 100644 --- a/models/Message.go +++ b/models/Message.go @@ -11,7 +11,8 @@ import ( type Message struct { bun.BaseModel `bun:"table:messages"` - MessageID uint64 `bun:",pk,notnull,unique"` + ID int `bun:",pk"` + Cookie uint64 `bun:",notnull"` From string To string Contents string @@ -19,12 +20,12 @@ type Message struct { DeliveredAt time.Time `bun:",nullzero"` } -func InsertMessage(ctx context.Context, db *bun.DB, messageId uint64, from string, to string, contents string) (*Message, error) { +func InsertMessage(ctx context.Context, db *bun.DB, cookie uint64, from string, to string, contents string) (*Message, error) { msg := &Message{ - MessageID: messageId, - From: from, - To: to, - Contents: contents, + Cookie: cookie, + From: from, + To: to, + Contents: contents, } if _, err := db.NewInsert().Model(msg).Exec(ctx, msg); err != nil { return nil, errors.Wrap(err, "could not update user") @@ -39,7 +40,7 @@ func (m *Message) String() string { func (m *Message) MarkDelivered(ctx context.Context, db *bun.DB) error { m.DeliveredAt = time.Now() - if _, err := db.NewUpdate().Model(m).Where("message_id = ?", m.MessageID).Exec(ctx); err != nil { + if _, err := db.NewUpdate().Model(m).Where("cookie = ?", m.Cookie).Exec(ctx); err != nil { return errors.Wrap(err, "could not mark message as updated") } diff --git a/models/User.go b/models/User.go index a1ad05c..bcd2795 100644 --- a/models/User.go +++ b/models/User.go @@ -9,6 +9,13 @@ import ( "github.com/uptrace/bun" ) +type UserStatus int16 + +const ( + UserStatusInactive UserStatus = iota + UserStatusActive +) + type User struct { bun.BaseModel `bun:"table:users"` UIN int64 `bun:",pk,autoincrement"` @@ -18,7 +25,7 @@ type User struct { Cipher string CreatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"` UpdatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"` - Status string + Status UserStatus LastActivityAt time.Time `bin:"-"` } diff --git a/models/fixtures.yml b/models/fixtures.yml index 13d487b..88646c2 100644 --- a/models/fixtures.yml +++ b/models/fixtures.yml @@ -1,10 +1,18 @@ - model: User rows: - - username: toof + - uin: 1 + username: toof password: bar email: toof@plot.club - - username: artem + - uin: 2 + username: artem password: bar email: artem@plot.club - model: Message rows: [] +- model: Buddy + rows: + - source_uin: 1 + with_uin: 2 + - source_uin: 2 + with_uin: 1 diff --git a/online_routine.go b/online_routine.go new file mode 100644 index 0000000..0542135 --- /dev/null +++ b/online_routine.go @@ -0,0 +1,96 @@ +package main + +import ( + "aim-oscar/models" + "aim-oscar/oscar" + "aim-oscar/util" + "context" + "encoding/binary" + "fmt" + "log" + "time" + + "github.com/uptrace/bun" +) + +func OnlineNotification() (chan *models.User, routineFn) { + commCh := make(chan *models.User, 1) + + routine := func(db *bun.DB) { + log.Printf("online notification starting up") + + for { + user, more := <-commCh + if !more { + log.Printf("online notification routine shutting down") + return + } + + if user.Status == models.UserStatusActive { + fmt.Printf("%s is online", user.Username) + + var buddies []*models.Buddy + err := db.NewSelect().Model(&buddies).Where("with_uin = ?", user.UIN).Relation("Source").Relation("Target").Scan(context.Background(), &buddies) + if err != nil { + log.Printf("could not find user's buddies: %s", err.Error()) + return + } + + for _, buddy := range buddies { + if s := getSession(buddy.Source.Username); s != nil { + onlineSnac := oscar.NewSNAC(3, 0xb) + onlineSnac.Data.WriteLPString(user.Username) + onlineSnac.Data.WriteUint16(0) // TODO: user warning level + + tlvs := []*oscar.TLV{ + oscar.NewTLV(1, util.Word(0x80)), // TODO: user class + oscar.NewTLV(0x06, util.Dword(0x0001|0x0100)), // TODO: User Status + oscar.NewTLV(0x0a, util.Dword(binary.BigEndian.Uint32([]byte(SRV_HOST)))), // External IP + oscar.NewTLV(0x0f, util.Dword(uint32(time.Since(user.LastActivityAt).Seconds()))), // Idle Time + oscar.NewTLV(0x03, util.Dword(uint32(time.Now().Unix()))), // Client Signon Time + oscar.NewTLV(0x05, util.Dword(uint32(user.CreatedAt.Unix()))), // Member since + } + + onlineSnac.Data.WriteUint16(uint16(len(tlvs))) + for _, tlv := range tlvs { + onlineSnac.Data.WriteBinary(tlv) + } + + onlineFlap := oscar.NewFLAP(2) + onlineFlap.Data.WriteBinary(onlineSnac) + if err := s.Send(onlineFlap); err != nil { + log.Printf("could not tell %s that %s is online", buddy.Source.Username, buddy.Target.Username) + } + } + } + } + + if user.Status == models.UserStatusInactive { + var buddies []*models.Buddy + err := db.NewSelect().Model(&buddies).Where("with_uin = ?", user.UIN).Relation("Source").Relation("Target").Scan(context.Background(), &buddies) + if err != nil { + log.Printf("could not find user's buddies: %s", err.Error()) + return + } + + for _, buddy := range buddies { + if s := getSession(buddy.Source.Username); s != nil { + offlineSnac := oscar.NewSNAC(3, 0xb) + offlineSnac.Data.WriteLPString(user.Username) + offlineSnac.Data.WriteUint16(0) // TODO: user warning level + offlineSnac.Data.WriteUint16(1) + offlineSnac.Data.WriteBinary(oscar.NewTLV(1, util.Dword(0x80))) + + offlineFlap := oscar.NewFLAP(2) + offlineFlap.Data.WriteBinary(offlineSnac) + if err := s.Send(offlineFlap); err != nil { + log.Printf("could not tell %s that %s is offline", buddy.Source.Username, buddy.Target.Username) + } + } + } + } + } + } + + return commCh, routine +} diff --git a/service.go b/service.go index d8e0a5d..35d38bd 100644 --- a/service.go +++ b/service.go @@ -1,7 +1,6 @@ package main import ( - "aim-oscar/models" "aim-oscar/oscar" "context" @@ -9,5 +8,5 @@ import ( ) type Service interface { - HandleSNAC(context.Context, *bun.DB, *oscar.SNAC, chan *models.Message) (context.Context, error) + HandleSNAC(context.Context, *bun.DB, *oscar.SNAC) (context.Context, error) }