From: Jeff Halter Date: Wed, 10 Jul 2024 04:36:27 +0000 (-0700) Subject: Extensive refactor and clean up X-Git-Url: https://git.r.bdr.sh/rbdr/mobius/commitdiff_plain/d9bc63a10d0978d9a5222cf7be74044e55f409b7 Extensive refactor and clean up --- diff --git a/cmd/mobius-hotline-server/main.go b/cmd/mobius-hotline-server/main.go index 1e9b5b6..878b425 100644 --- a/cmd/mobius-hotline-server/main.go +++ b/cmd/mobius-hotline-server/main.go @@ -7,14 +7,17 @@ import ( "flag" "fmt" "github.com/jhalter/mobius/hotline" + "github.com/jhalter/mobius/internal/mobius" "gopkg.in/natefinch/lumberjack.v2" "io" "log" "log/slog" "net/http" "os" + "os/signal" "path" "runtime" + "syscall" ) //go:embed mobius/config @@ -36,28 +39,16 @@ var ( ) func main() { - ctx, _ := context.WithCancel(context.Background()) - - // TODO: implement graceful shutdown by closing context - //c := make(chan os.Signal, 1) - //signal.Notify(c, os.Interrupt) - //defer func() { - // signal.Stop(c) - // cancel() - //}() - //go func() { - // select { - // case <-c: - // cancel() - // case <-ctx.Done(): - // } - //}() + ctx, cancel := context.WithCancel(context.Background()) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGINT, os.Interrupt) netInterface := flag.String("interface", "", "IP addr of interface to listen on. Defaults to all interfaces.") basePort := flag.Int("bind", defaultPort, "Base Hotline server port. File transfer port is base port + 1.") statsPort := flag.String("stats-port", "", "Enable stats HTTP endpoint on address and port") configDir := flag.String("config", defaultConfigPath(), "Path to config root") - printVersion := flag.Bool("version", false, "print version and exit") + printVersion := flag.Bool("version", false, "Print version and exit") logLevel := flag.String("log-level", "info", "Log level") logFile := flag.String("log-file", "", "Path to log file") @@ -105,12 +96,36 @@ func main() { os.Exit(1) } - srv, err := hotline.NewServer(*configDir, *netInterface, *basePort, slogger, &hotline.OSFileStore{}) + config, err := mobius.LoadConfig(path.Join(*configDir, "config.yaml")) + if err != nil { + slogger.Error(fmt.Sprintf("Error loading config: %v", err)) + os.Exit(1) + } + + srv, err := hotline.NewServer(*config, *configDir, *netInterface, *basePort, slogger, &hotline.OSFileStore{}) if err != nil { slogger.Error(fmt.Sprintf("Error starting server: %s", err)) os.Exit(1) } + srv.MessageBoard, err = mobius.NewFlatNews(path.Join(*configDir, "MessageBoard.txt")) + if err != nil { + slogger.Error(fmt.Sprintf("Error loading message board: %v", err)) + os.Exit(1) + } + + srv.BanList, err = mobius.NewBanFile(path.Join(*configDir, "Banlist.yaml")) + if err != nil { + slogger.Error(fmt.Sprintf("Error loading ban list: %v", err)) + os.Exit(1) + } + + srv.ThreadedNewsMgr, err = mobius.NewThreadedNewsYAML(path.Join(*configDir, "ThreadedNews.yaml")) + if err != nil { + slogger.Error(fmt.Sprintf("Error loading news: %v", err)) + os.Exit(1) + } + sh := statHandler{hlServer: srv} if *statsPort != "" { http.HandleFunc("/", sh.RenderStats) @@ -124,6 +139,33 @@ func main() { }(srv) } + go func() { + for { + sig := <-sigChan + switch sig { + case syscall.SIGHUP: + slogger.Info("SIGHUP received. Reloading configuration.") + + if err := srv.MessageBoard.(*mobius.FlatNews).Reload(); err != nil { + slogger.Error("Error reloading news", "err", err) + } + + if err := srv.BanList.(*mobius.BanFile).Load(); err != nil { + slogger.Error("Error reloading ban list", "err", err) + } + + if err := srv.ThreadedNewsMgr.(*mobius.ThreadedNewsYAML).Load(); err != nil { + slogger.Error("Error reloading threaded news list", "err", err) + } + default: + signal.Stop(sigChan) + cancel() + os.Exit(0) + } + + } + }() + slogger.Info("Hotline server started", "version", version, "API port", fmt.Sprintf("%s:%v", *netInterface, *basePort), @@ -168,24 +210,6 @@ func defaultConfigPath() string { return cfgPath } -// copyFile copies a file from src to dst. If dst does not exist, it is created. -func copyFile(src, dst string) error { - sourceFile, err := os.Open(src) - if err != nil { - return err - } - defer sourceFile.Close() - - destinationFile, err := os.Create(dst) - if err != nil { - return err - } - defer destinationFile.Close() - - _, err = io.Copy(destinationFile, sourceFile) - return err -} - // copyDir recursively copies a directory tree, attempting to preserve permissions. func copyDir(src, dst string) error { entries, err := cfgTemplate.ReadDir(src) diff --git a/hotline/access.go b/hotline/access.go index 90740fc..179941f 100644 --- a/hotline/access.go +++ b/hotline/access.go @@ -1,44 +1,44 @@ package hotline const ( - accessDeleteFile = 0 // File System Maintenance: Can Delete Files - accessUploadFile = 1 // File System Maintenance: Can Upload Files - accessDownloadFile = 2 // File System Maintenance: Can Download Files - accessRenameFile = 3 // File System Maintenance: Can Rename Files - accessMoveFile = 4 // File System Maintenance: Can Move Files - accessCreateFolder = 5 // File System Maintenance: Can Create Folders - accessDeleteFolder = 6 // File System Maintenance: Can Delete Folders - accessRenameFolder = 7 // File System Maintenance: Can Rename Folders - accessMoveFolder = 8 // File System Maintenance: Can Move Folders - accessReadChat = 9 // Chat: Can Read Chat - accessSendChat = 10 // Chat: Can Send Chat - accessOpenChat = 11 // Chat: Can Initial Private Chat - // accessCloseChat = 12 // Present in the Hotline 1.9 protocol documentation, but seemingly unused - // accessShowInList = 13 // Present in the Hotline 1.9 protocol documentation, but seemingly unused - accessCreateUser = 14 // User Maintenance: Can Create Accounts - accessDeleteUser = 15 // User Maintenance: Can Delete Accounts - accessOpenUser = 16 // User Maintenance: Can Read Accounts - accessModifyUser = 17 // User Maintenance: Can Modify Accounts - // accessChangeOwnPass = 18 // Present in the Hotline 1.9 protocol documentation, but seemingly unused - accessNewsReadArt = 20 // News: Can Read Articles - accessNewsPostArt = 21 // News: Can Post Articles - accessDisconUser = 22 // User Maintenance: Can Disconnect Users (Note: Turns username red in user list) - accessCannotBeDiscon = 23 // User Maintenance: Cannot be Disconnected - accessGetClientInfo = 24 // User Maintenance: Can Get User Info - accessUploadAnywhere = 25 // File System Maintenance: Can Upload Anywhere - accessAnyName = 26 // Miscellaneous: Can User Any Name - accessNoAgreement = 27 // Miscellaneous: Don't Show Agreement - accessSetFileComment = 28 // File System Maintenance: Can Comment Files - accessSetFolderComment = 29 // File System Maintenance: Can Comment Folders - accessViewDropBoxes = 30 // File System Maintenance: Can View Drop Boxes - accessMakeAlias = 31 // File System Maintenance: Can Make Aliases - accessBroadcast = 32 // Messaging: Can Broadcast - accessNewsDeleteArt = 33 // News: Can Delete Articles - accessNewsCreateCat = 34 // News: Can Create Categories - accessNewsDeleteCat = 35 // News: Can Delete Categories - accessNewsCreateFldr = 36 // News: Can Create News Bundles - accessNewsDeleteFldr = 37 // News: Can Delete News Bundles - accessSendPrivMsg = 40 // Messaging: Can Send Messages (Note: 1.9 protocol doc incorrectly says this is bit 19) + AccessDeleteFile = 0 // File System Maintenance: Can Delete Files + AccessUploadFile = 1 // File System Maintenance: Can Upload Files + AccessDownloadFile = 2 // File System Maintenance: Can Download Files + AccessRenameFile = 3 // File System Maintenance: Can Rename Files + AccessMoveFile = 4 // File System Maintenance: Can Move Files + AccessCreateFolder = 5 // File System Maintenance: Can Create Folders + AccessDeleteFolder = 6 // File System Maintenance: Can Delete Folders + AccessRenameFolder = 7 // File System Maintenance: Can Rename Folders + AccessMoveFolder = 8 // File System Maintenance: Can Move Folders + AccessReadChat = 9 // Chat: Can Read Chat + AccessSendChat = 10 // Chat: Can Send Chat + AccessOpenChat = 11 // Chat: Can Initial Private Chat + AccessCloseChat = 12 // Present in the Hotline 1.9 protocol documentation, but seemingly unused + AccessShowInList = 13 // Present in the Hotline 1.9 protocol documentation, but seemingly unused + AccessCreateUser = 14 // User Maintenance: Can Create Accounts + AccessDeleteUser = 15 // User Maintenance: Can Delete Accounts + AccessOpenUser = 16 // User Maintenance: Can Read Accounts + AccessModifyUser = 17 // User Maintenance: Can Modify Accounts + AccessChangeOwnPass = 18 // Present in the Hotline 1.9 protocol documentation, but seemingly unused + AccessNewsReadArt = 20 // News: Can Read Articles + AccessNewsPostArt = 21 // News: Can Post Articles + AccessDisconUser = 22 // User Maintenance: Can Disconnect Users (Note: Turns username red in user list) + AccessCannotBeDiscon = 23 // User Maintenance: Cannot be Disconnected + AccessGetClientInfo = 24 // User Maintenance: Can Get User Info + AccessUploadAnywhere = 25 // File System Maintenance: Can Upload Anywhere + AccessAnyName = 26 // Miscellaneous: Can User Any Name + AccessNoAgreement = 27 // Miscellaneous: Don't Show Agreement + AccessSetFileComment = 28 // File System Maintenance: Can Comment Files + AccessSetFolderComment = 29 // File System Maintenance: Can Comment Folders + AccessViewDropBoxes = 30 // File System Maintenance: Can View Drop Boxes + AccessMakeAlias = 31 // File System Maintenance: Can Make Aliases + AccessBroadcast = 32 // Messaging: Can Broadcast + AccessNewsDeleteArt = 33 // News: Can Delete Articles + AccessNewsCreateCat = 34 // News: Can Create Categories + AccessNewsDeleteCat = 35 // News: Can Delete Categories + AccessNewsCreateFldr = 36 // News: Can Create News Bundles + AccessNewsDeleteFldr = 37 // News: Can Delete News Bundles + AccessSendPrivMsg = 40 // Messaging: Can Send Messages (Note: 1.9 protocol doc incorrectly says this is bit 19) ) type accessBitmap [8]byte diff --git a/hotline/account_manager.go b/hotline/account_manager.go new file mode 100644 index 0000000..769d12c --- /dev/null +++ b/hotline/account_manager.go @@ -0,0 +1,188 @@ +package hotline + +import ( + "fmt" + "github.com/stretchr/testify/mock" + "gopkg.in/yaml.v3" + "os" + "path" + "path/filepath" + "sync" +) + +type AccountManager interface { + Create(account Account) error + Update(account Account, newLogin string) error + Get(login string) *Account + List() []Account + Delete(login string) error +} + +type YAMLAccountManager struct { + accounts map[string]Account + accountDir string + + mu sync.Mutex +} + +func NewYAMLAccountManager(accountDir string) (*YAMLAccountManager, error) { + accountMgr := YAMLAccountManager{ + accountDir: accountDir, + accounts: make(map[string]Account), + } + + matches, err := filepath.Glob(filepath.Join(accountDir, "*.yaml")) + if err != nil { + return nil, err + } + + if len(matches) == 0 { + return nil, fmt.Errorf("no accounts found in directory: %s", accountDir) + } + + for _, file := range matches { + var account Account + if err = loadFromYAMLFile(file, &account); err != nil { + return nil, fmt.Errorf("error loading account %s: %w", file, err) + } + + accountMgr.accounts[account.Login] = account + } + + return &accountMgr, nil +} + +func (am *YAMLAccountManager) Create(account Account) error { + am.mu.Lock() + defer am.mu.Unlock() + + // Create account file, returning an error if one already exists. + file, err := os.OpenFile( + filepath.Join(am.accountDir, path.Join("/", account.Login+".yaml")), + os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644, + ) + if err != nil { + return fmt.Errorf("error creating account file: %w", err) + } + defer file.Close() + + b, err := yaml.Marshal(account) + if err != nil { + return fmt.Errorf("marshal account to YAML: %v", err) + } + + _, err = file.Write(b) + if err != nil { + return fmt.Errorf("write account file: %w", err) + } + + am.accounts[account.Login] = account + + return nil +} + +func (am *YAMLAccountManager) Update(account Account, newLogin string) error { + am.mu.Lock() + defer am.mu.Unlock() + + // If the login has changed, rename the account file. + if account.Login != newLogin { + err := os.Rename( + filepath.Join(am.accountDir, path.Join("/", account.Login)+".yaml"), + filepath.Join(am.accountDir, path.Join("/", newLogin)+".yaml"), + ) + if err != nil { + return fmt.Errorf("error renaming account file: %w", err) + } + + account.Login = newLogin + am.accounts[newLogin] = account + + delete(am.accounts, account.Login) + } + + out, err := yaml.Marshal(&account) + if err != nil { + return err + } + + if err := os.WriteFile(filepath.Join(am.accountDir, newLogin+".yaml"), out, 0644); err != nil { + return fmt.Errorf("error writing account file: %w", err) + } + + am.accounts[account.Login] = account + + return nil +} + +func (am *YAMLAccountManager) Get(login string) *Account { + am.mu.Lock() + defer am.mu.Unlock() + + account, ok := am.accounts[login] + if !ok { + return nil + } + + return &account +} + +func (am *YAMLAccountManager) List() []Account { + am.mu.Lock() + defer am.mu.Unlock() + + var accounts []Account + for _, account := range am.accounts { + accounts = append(accounts, account) + } + + return accounts +} + +func (am *YAMLAccountManager) Delete(login string) error { + am.mu.Lock() + defer am.mu.Unlock() + + err := os.Remove(filepath.Join(am.accountDir, path.Join("/", login+".yaml"))) + if err != nil { + return fmt.Errorf("delete account file: %v", err) + } + + delete(am.accounts, login) + + return nil +} + +type MockAccountManager struct { + mock.Mock +} + +func (m *MockAccountManager) Create(account Account) error { + args := m.Called(account) + + return args.Error(0) +} + +func (m *MockAccountManager) Update(account Account, newLogin string) error { + args := m.Called(account, newLogin) + + return args.Error(0) +} + +func (m *MockAccountManager) Get(login string) *Account { + args := m.Called(login) + + return args.Get(0).(*Account) +} + +func (m *MockAccountManager) List() []Account { + args := m.Called() + + return args.Get(0).([]Account) +} + +func (m *MockAccountManager) Delete(login string) error { + args := m.Called(login) + + return args.Error(0) +} diff --git a/hotline/ban.go b/hotline/ban.go index 0b75e8e..14f41b9 100644 --- a/hotline/ban.go +++ b/hotline/ban.go @@ -3,3 +3,8 @@ package hotline import "time" const tempBanDuration = 30 * time.Minute + +type BanMgr interface { + Add(ip string, until *time.Time) error + IsBanned(ip string) (bool, *time.Time) +} diff --git a/hotline/chat.go b/hotline/chat.go new file mode 100644 index 0000000..6f2a4dd --- /dev/null +++ b/hotline/chat.go @@ -0,0 +1,137 @@ +package hotline + +import ( + "crypto/rand" + "github.com/stretchr/testify/mock" + "slices" + "sync" +) + +type PrivateChat struct { + Subject string + ClientConn map[[2]byte]*ClientConn +} + +type ChatID [4]byte + +type ChatManager interface { + New(cc *ClientConn) ChatID + GetSubject(id ChatID) string + Join(id ChatID, cc *ClientConn) + Leave(id ChatID, clientID [2]byte) + SetSubject(id ChatID, subject string) + Members(id ChatID) []*ClientConn +} + +type MockChatManager struct { + mock.Mock +} + +func (m *MockChatManager) New(cc *ClientConn) ChatID { + args := m.Called(cc) + + return args.Get(0).(ChatID) +} + +func (m *MockChatManager) GetSubject(id ChatID) string { + args := m.Called(id) + + return args.String(0) +} + +func (m *MockChatManager) Join(id ChatID, cc *ClientConn) { + m.Called(id, cc) +} + +func (m *MockChatManager) Leave(id ChatID, clientID [2]byte) { + m.Called(id, clientID) +} + +func (m *MockChatManager) SetSubject(id ChatID, subject string) { + m.Called(id, subject) + +} + +func (m *MockChatManager) Members(id ChatID) []*ClientConn { + args := m.Called(id) + + return args.Get(0).([]*ClientConn) +} + +type MemChatManager struct { + chats map[ChatID]*PrivateChat + + mu sync.Mutex +} + +func NewMemChatManager() *MemChatManager { + return &MemChatManager{ + chats: make(map[ChatID]*PrivateChat), + } +} + +func (cm *MemChatManager) New(cc *ClientConn) ChatID { + cm.mu.Lock() + defer cm.mu.Unlock() + + var randID [4]byte + _, _ = rand.Read(randID[:]) + + cm.chats[randID] = &PrivateChat{ClientConn: make(map[[2]byte]*ClientConn)} + + cm.chats[randID].ClientConn[cc.ID] = cc + + return randID +} + +func (cm *MemChatManager) Join(id ChatID, cc *ClientConn) { + cm.mu.Lock() + defer cm.mu.Unlock() + + chat := cm.chats[id] + chat.ClientConn[cc.ID] = cc +} + +func (cm *MemChatManager) Leave(id ChatID, clientID [2]byte) { + cm.mu.Lock() + defer cm.mu.Unlock() + + privChat, ok := cm.chats[id] + if !ok { + return + } + + delete(privChat.ClientConn, clientID) +} + +func (cm *MemChatManager) GetSubject(id ChatID) string { + cm.mu.Lock() + defer cm.mu.Unlock() + + return cm.chats[id].Subject +} + +func (cm *MemChatManager) Members(id ChatID) []*ClientConn { + cm.mu.Lock() + defer cm.mu.Unlock() + + chat := cm.chats[id] + + var members []*ClientConn + for _, cc := range chat.ClientConn { + members = append(members, cc) + } + + slices.SortFunc(members, clientConnSortFunc) + + return members +} + +func (cm *MemChatManager) SetSubject(id ChatID, subject string) { + cm.mu.Lock() + defer cm.mu.Unlock() + + chat := cm.chats[id] + + chat.Subject = subject +} diff --git a/hotline/chat_test.go b/hotline/chat_test.go new file mode 100644 index 0000000..0bacc09 --- /dev/null +++ b/hotline/chat_test.go @@ -0,0 +1,67 @@ +package hotline + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestMemChatManager(t *testing.T) { + cc1 := &ClientConn{ID: [2]byte{1}} + cc2 := &ClientConn{ID: [2]byte{2}} + + cm := NewMemChatManager() + + // Create a new chat with cc1 as initial member. + randChatID := cm.New(cc1) + assert.Equalf(t, []*ClientConn{cc1}, cm.Members(randChatID), "Initial ChatMembers") + + // Second client joins. + cm.Join(randChatID, cc2) + assert.Equalf(t, []*ClientConn{cc1, cc2}, cm.Members(randChatID), "ChatMembers") + + // Initial subject is blank. + assert.Equalf(t, "", cm.GetSubject(randChatID), "ChatMembers") + + // Update subject. + cm.SetSubject(randChatID, "test") + assert.Equalf(t, "test", cm.GetSubject(randChatID), "ChatMembers") + + // Second client leaves. + cm.Leave(randChatID, cc2.ID) + assert.Equalf(t, []*ClientConn{cc1}, cm.Members(randChatID), "ChatMembers") + + // + //type fields struct { + // chats map[ChatID]*PrivateChat + //} + //type args struct { + // cc *ClientConn + //} + //tests := []struct { + // name string + // fields fields + // args args + // want ChatID + //}{ + // { + // name: "creates new chat", + // fields: fields{ + // chats: make(map[ChatID]*PrivateChat), + // }, + // args: args{ + // cc: &ClientConn{ID: [2]byte{1}}, + // }, + // //want: , + // }, + //} + //for _, tt := range tests { + // t.Run(tt.name, func(t *testing.T) { + // cm := &MemChatManager{ + // chats: tt.fields.chats, + // mu: sync.Mutex{}, + // } + // + // assert.Equalf(t, tt.want, cm.New(tt.args.cc), "New(%v)", tt.args.cc) + // }) + //} +} diff --git a/hotline/client.go b/hotline/client.go index 2389ee5..e75f77b 100644 --- a/hotline/client.go +++ b/hotline/client.go @@ -119,8 +119,8 @@ var ServerHandshake = []byte{ } func (c *Client) Handshake() error { - // Protocol ID 4 ‘TRTP’ 0x54 52 54 50 - // Sub-protocol ID 4 User defined + // Protocol Type 4 ‘TRTP’ 0x54 52 54 50 + // Sub-protocol Type 4 User defined // Version 2 1 Currently 1 // Sub-version 2 User defined if _, err := c.Connection.Write(ClientHandshake); err != nil { diff --git a/hotline/client_conn.go b/hotline/client_conn.go index e527eba..dc58996 100644 --- a/hotline/client_conn.go +++ b/hotline/client_conn.go @@ -7,64 +7,117 @@ import ( "golang.org/x/crypto/bcrypt" "io" "log/slog" - "slices" "strings" "sync" ) +var clientConnSortFunc = func(a, b *ClientConn) int { + return cmp.Compare( + binary.BigEndian.Uint16(a.ID[:]), + binary.BigEndian.Uint16(b.ID[:]), + ) +} + // ClientConn represents a client connected to a Server type ClientConn struct { Connection io.ReadWriteCloser RemoteAddr string - ID [2]byte - Icon []byte - flagsMU sync.Mutex - Flags UserFlags - UserName []byte - Account *Account - IdleTime int - Server *Server - Version []byte - Idle bool - AutoReply []byte - - transfersMU sync.Mutex - transfers map[int]map[[4]byte]*FileTransfer + ID ClientID + Icon []byte // TODO: make fixed size of 2 + Version []byte // TODO: make fixed size of 2 + + flagsMU sync.Mutex // TODO: move into UserFlags struct + Flags UserFlags + + UserName []byte + Account *Account + IdleTime int + Server *Server // TODO: consider adding methods to interact with server + AutoReply []byte + + ClientFileTransferMgr ClientFileTransferMgr logger *slog.Logger - sync.Mutex + mu sync.RWMutex +} + +type ClientFileTransferMgr struct { + transfers map[FileTransferType]map[FileTransferID]*FileTransfer + + mu sync.RWMutex +} + +func NewClientFileTransferMgr() ClientFileTransferMgr { + return ClientFileTransferMgr{ + transfers: map[FileTransferType]map[FileTransferID]*FileTransfer{ + FileDownload: {}, + FileUpload: {}, + FolderDownload: {}, + FolderUpload: {}, + BannerDownload: {}, + }, + } +} + +func (cftm *ClientFileTransferMgr) Add(ftType FileTransferType, ft *FileTransfer) { + cftm.mu.Lock() + defer cftm.mu.Unlock() + + cftm.transfers[ftType][ft.refNum] = ft +} + +func (cftm *ClientFileTransferMgr) Get(ftType FileTransferType) []FileTransfer { + cftm.mu.Lock() + defer cftm.mu.Unlock() + + fts := cftm.transfers[ftType] + + var transfers []FileTransfer + for _, ft := range fts { + transfers = append(transfers, *ft) + } + + return transfers } -func (cc *ClientConn) sendAll(t [2]byte, fields ...Field) { - for _, c := range cc.Server.Clients { +func (cftm *ClientFileTransferMgr) Delete(ftType FileTransferType, id FileTransferID) { + cftm.mu.Lock() + defer cftm.mu.Unlock() + + delete(cftm.transfers[ftType], id) +} + +func (cc *ClientConn) SendAll(t [2]byte, fields ...Field) { + for _, c := range cc.Server.ClientMgr.List() { cc.Server.outbox <- NewTransaction(t, c.ID, fields...) } } func (cc *ClientConn) handleTransaction(transaction Transaction) { if handler, ok := TransactionHandlers[transaction.Type]; ok { - cc.logger.Debug("Received Transaction", "RequestType", transaction.Type) + if transaction.Type != TranKeepAlive { + cc.logger.Info(tranTypeNames[transaction.Type]) + } for _, t := range handler(cc, &transaction) { cc.Server.outbox <- t } } - cc.Server.mux.Lock() - defer cc.Server.mux.Unlock() - if transaction.Type != TranKeepAlive { + cc.mu.Lock() + defer cc.mu.Unlock() + // reset the user idle timer cc.IdleTime = 0 // if user was previously idle, mark as not idle and notify other connected clients that // the user is no longer away - if cc.Idle { + if cc.Flags.IsSet(UserFlagAway) { cc.Flags.Set(UserFlagAway, 0) - cc.Idle = false - cc.sendAll( + cc.SendAll( TranNotifyChangeUser, NewField(FieldUserID, cc.ID[:]), NewField(FieldUserFlags, cc.Flags[:]), @@ -76,7 +129,7 @@ func (cc *ClientConn) handleTransaction(transaction Transaction) { } func (cc *ClientConn) Authenticate(login string, password []byte) bool { - if account, ok := cc.Server.Accounts[login]; ok { + if account := cc.Server.AccountManager.Get(login); account != nil { return bcrypt.CompareHashAndPassword([]byte(account.Password), password) == nil } @@ -85,8 +138,6 @@ func (cc *ClientConn) Authenticate(login string, password []byte) bool { // Authorize checks if the user account has the specified permission func (cc *ClientConn) Authorize(access int) bool { - cc.Lock() - defer cc.Unlock() if cc.Account == nil { return false } @@ -95,11 +146,9 @@ func (cc *ClientConn) Authorize(access int) bool { // Disconnect notifies other clients that a client has disconnected func (cc *ClientConn) Disconnect() { - cc.Server.mux.Lock() - delete(cc.Server.Clients, cc.ID) - cc.Server.mux.Unlock() + cc.Server.ClientMgr.Delete(cc.ID) - for _, t := range cc.notifyOthers(NewTransaction(TranNotifyDeleteUser, [2]byte{}, NewField(FieldUserID, cc.ID[:]))) { + for _, t := range cc.NotifyOthers(NewTransaction(TranNotifyDeleteUser, [2]byte{}, NewField(FieldUserID, cc.ID[:]))) { cc.Server.outbox <- t } @@ -108,11 +157,9 @@ func (cc *ClientConn) Disconnect() { } } -// notifyOthers sends transaction t to other clients connected to the server -func (cc *ClientConn) notifyOthers(t Transaction) (trans []Transaction) { - cc.Server.mux.Lock() - defer cc.Server.mux.Unlock() - for _, c := range cc.Server.Clients { +// NotifyOthers sends transaction t to other clients connected to the server +func (cc *ClientConn) NotifyOthers(t Transaction) (trans []Transaction) { + for _, c := range cc.Server.ClientMgr.List() { if c.ID != cc.ID { t.clientID = c.ID trans = append(trans, t) @@ -146,25 +193,6 @@ func (cc *ClientConn) NewErrReply(t *Transaction, errMsg string) []Transaction { } } -var clientSortFunc = func(a, b *ClientConn) int { - return cmp.Compare( - binary.BigEndian.Uint16(a.ID[:]), - binary.BigEndian.Uint16(b.ID[:]), - ) -} - -// sortedClients is a utility function that takes a map of *ClientConn and returns a sorted slice of the values. -// The purpose of this is to ensure that the ordering of client connections is deterministic so that test assertions work. -func sortedClients(unsortedClients map[[2]byte]*ClientConn) (clients []*ClientConn) { - for _, c := range unsortedClients { - clients = append(clients, c) - } - - slices.SortFunc(clients, clientSortFunc) - - return clients -} - const userInfoTemplate = `Nickname: %s Name: %s Account: %s @@ -187,7 +215,7 @@ Address: %s %s ` -func formatDownloadList(fts map[[4]byte]*FileTransfer) (s string) { +func formatDownloadList(fts []FileTransfer) (s string) { if len(fts) == 0 { return "None.\n" } @@ -200,18 +228,16 @@ func formatDownloadList(fts map[[4]byte]*FileTransfer) (s string) { } func (cc *ClientConn) String() string { - cc.transfersMU.Lock() - defer cc.transfersMU.Unlock() template := fmt.Sprintf( userInfoTemplate, cc.UserName, cc.Account.Name, cc.Account.Login, cc.RemoteAddr, - formatDownloadList(cc.transfers[FileDownload]), - formatDownloadList(cc.transfers[FolderDownload]), - formatDownloadList(cc.transfers[FileUpload]), - formatDownloadList(cc.transfers[FolderUpload]), + formatDownloadList(cc.ClientFileTransferMgr.Get(FileDownload)), + formatDownloadList(cc.ClientFileTransferMgr.Get(FolderDownload)), + formatDownloadList(cc.ClientFileTransferMgr.Get(FileUpload)), + formatDownloadList(cc.ClientFileTransferMgr.Get(FolderUpload)), "None.\n", ) diff --git a/hotline/client_manager.go b/hotline/client_manager.go new file mode 100644 index 0000000..ab6372c --- /dev/null +++ b/hotline/client_manager.go @@ -0,0 +1,99 @@ +package hotline + +import ( + "cmp" + "encoding/binary" + "github.com/stretchr/testify/mock" + "slices" + "sync" + "sync/atomic" +) + +type ClientID [2]byte + +type ClientManager interface { + List() []*ClientConn // Returns list of sorted clients + Get(id ClientID) *ClientConn + Add(cc *ClientConn) + Delete(id ClientID) +} + +type MockClientMgr struct { + mock.Mock +} + +func (m *MockClientMgr) List() []*ClientConn { + args := m.Called() + + return args.Get(0).([]*ClientConn) +} + +func (m *MockClientMgr) Get(id ClientID) *ClientConn { + args := m.Called(id) + + return args.Get(0).(*ClientConn) +} + +func (m *MockClientMgr) Add(cc *ClientConn) { + m.Called(cc) +} +func (m *MockClientMgr) Delete(id ClientID) { + m.Called(id) +} + +type MemClientMgr struct { + clients map[ClientID]*ClientConn + + mu sync.Mutex + nextClientID atomic.Uint32 +} + +func NewMemClientMgr() *MemClientMgr { + return &MemClientMgr{ + clients: make(map[ClientID]*ClientConn), + } +} + +// List returns slice of sorted clients. +func (cm *MemClientMgr) List() []*ClientConn { + cm.mu.Lock() + defer cm.mu.Unlock() + + var clients []*ClientConn + for _, client := range cm.clients { + clients = append(clients, client) + } + + slices.SortFunc(clients, func(a, b *ClientConn) int { + return cmp.Compare( + binary.BigEndian.Uint16(a.ID[:]), + binary.BigEndian.Uint16(b.ID[:]), + ) + }) + + return clients +} + +func (cm *MemClientMgr) Get(id ClientID) *ClientConn { + cm.mu.Lock() + defer cm.mu.Unlock() + + return cm.clients[id] +} + +func (cm *MemClientMgr) Add(cc *ClientConn) { + cm.mu.Lock() + defer cm.mu.Unlock() + + cm.nextClientID.Add(1) + binary.BigEndian.PutUint16(cc.ID[:], uint16(cm.nextClientID.Load())) + + cm.clients[cc.ID] = cc +} + +func (cm *MemClientMgr) Delete(id ClientID) { + cm.mu.Lock() + defer cm.mu.Unlock() + + delete(cm.clients, id) +} diff --git a/hotline/field.go b/hotline/field.go index 7bba0d7..4c760d5 100644 --- a/hotline/field.go +++ b/hotline/field.go @@ -1,6 +1,8 @@ package hotline import ( + "bufio" + "bytes" "encoding/binary" "errors" "io" @@ -62,24 +64,24 @@ var ( FieldNewsArtData = [2]byte{0x01, 0x4D} // 333 FieldNewsArtParentArt = [2]byte{0x01, 0x4F} // 335 FieldNewsArt1stChildArt = [2]byte{0x01, 0x50} // 336 + FieldNewsArtRecurseDel = [2]byte{0x01, 0x51} // 337 // These fields are documented, but seemingly unused. // FieldUserAlias = [2]byte{0x00, 0x6F} // 111 // FieldNewsArtFlags = [2]byte{0x01, 0x4E} // 334 - // FieldNewsArtRecurseDel = [2]byte{0x01, 0x51} // 337 ) type Field struct { - ID [2]byte // Type of field - FieldSize [2]byte // Size of the data part - Data []byte // Actual field content + Type [2]byte // Type of field + FieldSize [2]byte // Size of the data field + Data []byte // Field data readOffset int // Internal offset to track read progress } -func NewField(id [2]byte, data []byte) Field { +func NewField(fieldType [2]byte, data []byte) Field { f := Field{ - ID: id, + Type: fieldType, Data: make([]byte, len(data)), } @@ -105,9 +107,53 @@ func fieldScanner(data []byte, _ bool) (advance int, token []byte, err error) { return neededSize, data[0:neededSize], nil } +// DecodeInt decodes the field bytes to an int. +// The official Hotline clients will send uint32s as 2 bytes if possible, but +// some third party clients such as Frogblast and Heildrun will always send 4 bytes +func (f *Field) DecodeInt() (int, error) { + switch len(f.Data) { + case 2: + return int(binary.BigEndian.Uint16(f.Data)), nil + case 4: + return int(binary.BigEndian.Uint32(f.Data)), nil + } + + return 0, errors.New("unknown byte length") +} + +func (f *Field) DecodeObfuscatedString() string { + return string(encodeString(f.Data)) +} + +// DecodeNewsPath decodes the field data to a news path. +// Example News Path data for a Category nested under two Bundles: +// 00000000 00 03 00 00 10 54 6f 70 20 4c 65 76 65 6c 20 42 |.....Top Level B| +// 00000010 75 6e 64 6c 65 00 00 13 53 65 63 6f 6e 64 20 4c |undle...Second L| +// 00000020 65 76 65 6c 20 42 75 6e 64 6c 65 00 00 0f 4e 65 |evel Bundle...Ne| +// 00000030 73 74 65 64 20 43 61 74 65 67 6f 72 79 |sted Category| +func (f *Field) DecodeNewsPath() ([]string, error) { + if len(f.Data) == 0 { + return []string{}, nil + } + + pathCount := binary.BigEndian.Uint16(f.Data[0:2]) + + scanner := bufio.NewScanner(bytes.NewReader(f.Data[2:])) + scanner.Split(newsPathScanner) + + var paths []string + + for i := uint16(0); i < pathCount; i++ { + scanner.Scan() + paths = append(paths, scanner.Text()) + } + + return paths, nil +} + // Read implements io.Reader for Field func (f *Field) Read(p []byte) (int, error) { - buf := slices.Concat(f.ID[:], f.FieldSize[:], f.Data) + buf := slices.Concat(f.Type[:], f.FieldSize[:], f.Data) if f.readOffset >= len(buf) { return 0, io.EOF // All bytes have been read @@ -125,7 +171,7 @@ func (f *Field) Write(p []byte) (int, error) { return 0, errors.New("input slice too short") } - copy(f.ID[:], p[0:2]) + copy(f.Type[:], p[0:2]) copy(f.FieldSize[:], p[2:4]) dataSize := int(binary.BigEndian.Uint16(f.FieldSize[:])) @@ -141,7 +187,7 @@ func (f *Field) Write(p []byte) (int, error) { func getField(id [2]byte, fields *[]Field) *Field { for _, field := range *fields { - if id == field.ID { + if id == field.Type { return &field } } diff --git a/hotline/field_test.go b/hotline/field_test.go index ecae9c7..abb4d44 100644 --- a/hotline/field_test.go +++ b/hotline/field_test.go @@ -172,7 +172,7 @@ func TestField_Read(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { f := &Field{ - ID: tt.fields.ID, + Type: tt.fields.ID, FieldSize: tt.fields.FieldSize, Data: tt.fields.Data, readOffset: tt.fields.readOffset, @@ -186,3 +186,46 @@ func TestField_Read(t *testing.T) { }) } } + +func TestField_DecodeInt(t *testing.T) { + type fields struct { + Data []byte + } + tests := []struct { + name string + fields fields + want int + wantErr assert.ErrorAssertionFunc + }{ + { + name: "with 2 bytes of input", + fields: fields{Data: []byte{0, 1}}, + want: 1, + wantErr: assert.NoError, + }, + { + name: "with 4 bytes of input", + fields: fields{Data: []byte{0, 1, 0, 0}}, + want: 65536, + wantErr: assert.NoError, + }, + { + name: "with invalid number of bytes of input", + fields: fields{Data: []byte{1, 0, 0, 0, 0, 0, 0, 0}}, + want: 0, + wantErr: assert.Error, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &Field{ + Data: tt.fields.Data, + } + got, err := f.DecodeInt() + if !tt.wantErr(t, err, fmt.Sprintf("DecodeInt()")) { + return + } + assert.Equalf(t, tt.want, got, "DecodeInt()") + }) + } +} diff --git a/hotline/file_transfer.go b/hotline/file_transfer.go index c883822..b567b18 100644 --- a/hotline/file_transfer.go +++ b/hotline/file_transfer.go @@ -16,20 +16,87 @@ import ( "sync" ) +// Folder download actions. Send by the client to indicate the next action the server should take +// for a folder download. +const ( + DlFldrActionSendFile = 1 + DlFldrActionResumeFile = 2 + DlFldrActionNextFile = 3 +) + // File transfer types +type FileTransferType uint8 + const ( - FileDownload = iota - FileUpload - FolderDownload - FolderUpload - bannerDownload + FileDownload = FileTransferType(0) + FileUpload = FileTransferType(1) + FolderDownload = FileTransferType(2) + FolderUpload = FileTransferType(3) + BannerDownload = FileTransferType(4) ) +type FileTransferID [4]byte + +type FileTransferMgr interface { + Add(ft *FileTransfer) + Get(id FileTransferID) *FileTransfer + Delete(id FileTransferID) +} + +type MemFileTransferMgr struct { + fileTransfers map[FileTransferID]*FileTransfer + + mu sync.Mutex +} + +func NewMemFileTransferMgr() *MemFileTransferMgr { + return &MemFileTransferMgr{ + fileTransfers: make(map[FileTransferID]*FileTransfer), + } +} + +func (ftm *MemFileTransferMgr) Add(ft *FileTransfer) { + ftm.mu.Lock() + defer ftm.mu.Unlock() + + _, _ = rand.Read(ft.refNum[:]) + + ftm.fileTransfers[ft.refNum] = ft + + ft.ClientConn.ClientFileTransferMgr.Add(ft.Type, ft) + + //ft.ClientConn.transfersMU.Lock() + //ft.ClientConn.transfers[ft.Type] = ft + //ft.ClientConn.transfersMU.Unlock() +} + +func (ftm *MemFileTransferMgr) Get(id FileTransferID) *FileTransfer { + ftm.mu.Lock() + defer ftm.mu.Unlock() + + return ftm.fileTransfers[id] +} + +func (ftm *MemFileTransferMgr) Delete(id FileTransferID) { + ftm.mu.Lock() + defer ftm.mu.Unlock() + + ft := ftm.fileTransfers[id] + + //ft.ClientConn.transfersMU.Lock() + //delete(ft.ClientConn.transfers[ft.Type], ft.refNum) + //ft.ClientConn.transfersMU.Unlock() + ft.ClientConn.ClientFileTransferMgr.Delete(ft.Type, id) + + delete(ftm.fileTransfers, id) + +} + type FileTransfer struct { FileName []byte FilePath []byte refNum [4]byte - Type int + Type FileTransferType TransferSize []byte FolderItemCount []byte fileResumeData *FileResumeData @@ -55,7 +122,7 @@ func (wc *WriteCounter) Write(p []byte) (int, error) { return n, nil } -func (cc *ClientConn) newFileTransfer(transferType int, fileName, filePath, size []byte) *FileTransfer { +func (cc *ClientConn) newFileTransfer(transferType FileTransferType, fileName, filePath, size []byte) *FileTransfer { ft := &FileTransfer{ FileName: fileName, FilePath: filePath, @@ -64,16 +131,18 @@ func (cc *ClientConn) newFileTransfer(transferType int, fileName, filePath, size ClientConn: cc, bytesSentCounter: &WriteCounter{}, } + // + //_, _ = rand.Read(ft.refNum[:]) + // + //cc.transfersMU.Lock() + //defer cc.transfersMU.Unlock() + //cc.transfers[transferType][ft.refNum] = ft - _, _ = rand.Read(ft.refNum[:]) - - cc.transfersMU.Lock() - defer cc.transfersMU.Unlock() - cc.transfers[transferType][ft.refNum] = ft + cc.Server.FileTransferMgr.Add(ft) - cc.Server.mux.Lock() - defer cc.Server.mux.Unlock() - cc.Server.fileTransfers[ft.refNum] = ft + //cc.Server.mux.Lock() + //defer cc.Server.mux.Unlock() + //cc.Server.fileTransfers[ft.refNum] = ft return ft } @@ -148,7 +217,7 @@ func (fu *folderUpload) FormattedPath() string { return filepath.Join(pathSegments...) } -func DownloadHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileTransfer, fs FileStore, rLogger *slog.Logger, preserveForks bool) error { +func DownloadHandler(w io.Writer, fullPath string, fileTransfer *FileTransfer, fs FileStore, rLogger *slog.Logger, preserveForks bool) error { //s.Stats.DownloadCounter += 1 //s.Stats.DownloadsInProgress += 1 //defer func() { @@ -162,48 +231,48 @@ func DownloadHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileTrans fw, err := newFileWrapper(fs, fullPath, 0) if err != nil { - //return err + return fmt.Errorf("reading file header: %v", err) } - rLogger.Info("File download started", "filePath", fullPath) + rLogger.Info("Download file", "filePath", fullPath) - // if file transfer options are included, that means this is a "quick preview" request from a 1.5+ client + // If file transfer options are included, that means this is a "quick preview" request. In this case skip sending + // the flat file info and proceed directly to sending the file data. if fileTransfer.options == nil { - _, err = io.Copy(rwc, fw.ffo) - if err != nil { - //return err + if _, err = io.Copy(w, fw.ffo); err != nil { + return fmt.Errorf("send flat file object: %v", err) } } file, err := fw.dataForkReader() if err != nil { - //return err + return fmt.Errorf("open data fork reader: %v", err) } br := bufio.NewReader(file) if _, err := br.Discard(int(dataOffset)); err != nil { - //return err + return fmt.Errorf("seek to resume offsent: %v", err) } - if _, err = io.Copy(rwc, io.TeeReader(br, fileTransfer.bytesSentCounter)); err != nil { - return err + if _, err = io.Copy(w, io.TeeReader(br, fileTransfer.bytesSentCounter)); err != nil { + return fmt.Errorf("send data fork: %v", err) } - // if the client requested to resume transfer, do not send the resource fork header, or it will be appended into the fileWrapper data + // If the client requested to resume transfer, do not send the resource fork header. if fileTransfer.fileResumeData == nil { - err = binary.Write(rwc, binary.BigEndian, fw.rsrcForkHeader()) + err = binary.Write(w, binary.BigEndian, fw.rsrcForkHeader()) if err != nil { - return err + return fmt.Errorf("send resource fork header: %v", err) } } rFile, err := fw.rsrcForkFile() if err != nil { - return nil + // return fmt.Errorf("open resource fork file: %v", err) } - if _, err = io.Copy(rwc, io.TeeReader(rFile, fileTransfer.bytesSentCounter)); err != nil { - return err + if _, err = io.Copy(w, io.TeeReader(rFile, fileTransfer.bytesSentCounter)); err != nil { + // return fmt.Errorf("send resource fork data: %v", err) } return nil @@ -347,7 +416,7 @@ func DownloadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *Fil var dataOffset int64 switch nextAction[1] { - case dlFldrActionResumeFile: + case DlFldrActionResumeFile: // get size of resumeData resumeDataByteLen := make([]byte, 2) if _, err := io.ReadFull(rwc, resumeDataByteLen); err != nil { @@ -365,7 +434,7 @@ func DownloadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *Fil return err } dataOffset = int64(binary.BigEndian.Uint32(frd.ForkInfoList[0].DataSize[:])) - case dlFldrActionNextFile: + case DlFldrActionNextFile: // client asked to skip this file return nil } @@ -442,7 +511,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT } // Begin the folder upload flow by sending the "next file action" to client - if _, err := rwc.Write([]byte{0, dlFldrActionNextFile}); err != nil { + if _, err := rwc.Write([]byte{0, DlFldrActionNextFile}); err != nil { return err } @@ -475,11 +544,11 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT } // Tell client to send next file - if _, err := rwc.Write([]byte{0, dlFldrActionNextFile}); err != nil { + if _, err := rwc.Write([]byte{0, DlFldrActionNextFile}); err != nil { return err } } else { - nextAction := dlFldrActionSendFile + nextAction := DlFldrActionSendFile // Check if we have the full file already. If so, send dlFldrAction_NextFile to client to skip. _, err := os.Stat(filepath.Join(fullPath, fu.FormattedPath())) @@ -487,7 +556,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT return err } if err == nil { - nextAction = dlFldrActionNextFile + nextAction = DlFldrActionNextFile } // Check if we have a partial file already. If so, send dlFldrAction_ResumeFile to client to resume upload. @@ -496,7 +565,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT return err } if err == nil { - nextAction = dlFldrActionResumeFile + nextAction = DlFldrActionResumeFile } if _, err := rwc.Write([]byte{0, uint8(nextAction)}); err != nil { @@ -504,9 +573,9 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT } switch nextAction { - case dlFldrActionNextFile: + case DlFldrActionNextFile: continue - case dlFldrActionResumeFile: + case DlFldrActionResumeFile: offset := make([]byte, 4) binary.BigEndian.PutUint32(offset, uint32(incompleteFile.Size())) @@ -539,7 +608,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT return err } - case dlFldrActionSendFile: + case DlFldrActionSendFile: if _, err := io.ReadFull(rwc, fileSize); err != nil { return err } @@ -581,7 +650,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT } // Tell client to send next fileWrapper - if _, err := rwc.Write([]byte{0, dlFldrActionNextFile}); err != nil { + if _, err := rwc.Write([]byte{0, DlFldrActionNextFile}); err != nil { return err } } diff --git a/hotline/file_transfer_test.go b/hotline/file_transfer_test.go index 213cb9a..d12f29a 100644 --- a/hotline/file_transfer_test.go +++ b/hotline/file_transfer_test.go @@ -11,7 +11,7 @@ func TestFileTransfer_String(t *testing.T) { FileName []byte FilePath []byte refNum [4]byte - Type int + Type FileTransferType TransferSize []byte FolderItemCount []byte fileResumeData *FileResumeData diff --git a/hotline/handshake.go b/hotline/handshake.go index c54359e..55b074d 100644 --- a/hotline/handshake.go +++ b/hotline/handshake.go @@ -19,14 +19,14 @@ import ( // // The following information is sent to the server: // Description Size Data Note -// Protocol ID 4 TRTP 0x54525450 -// Sub-protocol ID 4 HOTL User defined +// Protocol Type 4 TRTP 0x54525450 +// Sub-protocol Type 4 HOTL User defined // VERSION 2 1 Currently 1 // Sub-version 2 2 User defined // // The server replies with the following: // Description Size Data Note -// Protocol ID 4 TRTP +// Protocol Type 4 TRTP // Error code 4 Error code returned by the server (0 = no error) type handshake struct { @@ -53,10 +53,10 @@ func (h *handshake) Valid() bool { } var ( - // trtp represents the Protocol ID "TRTP" in hex + // trtp represents the Protocol Type "TRTP" in hex trtp = [4]byte{0x54, 0x52, 0x54, 0x50} - // hotl represents the Sub-protocol ID "HOTL" in hex + // hotl represents the Sub-protocol Type "HOTL" in hex hotl = [4]byte{0x48, 0x4F, 0x54, 0x4C} // handshakeResponse represents the server's response after a successful handshake diff --git a/hotline/message_board.go b/hotline/message_board.go new file mode 100644 index 0000000..e5022a4 --- /dev/null +++ b/hotline/message_board.go @@ -0,0 +1,9 @@ +package hotline + +const defaultNewsDateFormat = "Jan02 15:04" // Jun23 20:49 + +const defaultNewsTemplate = `From %s (%s): + +%s + +__________________________________________________________` diff --git a/hotline/message_board_test.go b/hotline/message_board_test.go new file mode 100644 index 0000000..64db714 --- /dev/null +++ b/hotline/message_board_test.go @@ -0,0 +1,25 @@ +package hotline + +import "github.com/stretchr/testify/mock" + +type mockReadWriteSeeker struct { + mock.Mock +} + +func (m *mockReadWriteSeeker) Read(p []byte) (int, error) { + args := m.Called(p) + + return args.Int(0), args.Error(1) +} + +func (m *mockReadWriteSeeker) Write(p []byte) (int, error) { + args := m.Called(p) + + return args.Int(0), args.Error(1) +} + +func (m *mockReadWriteSeeker) Seek(offset int64, whence int) (int64, error) { + args := m.Called(offset, whence) + + return args.Get(0).(int64), args.Error(1) +} diff --git a/hotline/news.go b/hotline/news.go index 6e89388..0d0400c 100644 --- a/hotline/news.go +++ b/hotline/news.go @@ -1,21 +1,29 @@ package hotline import ( + "cmp" "encoding/binary" "io" "slices" - "sort" ) -const defaultNewsDateFormat = "Jan02 15:04" // Jun23 20:49 - -const defaultNewsTemplate = `From %s (%s): - -%s +var ( + NewsBundle = [2]byte{0, 2} + NewsCategory = [2]byte{0, 3} +) -__________________________________________________________` +type ThreadedNewsMgr interface { + ListArticles(newsPath []string) NewsArtListData + GetArticle(newsPath []string, articleID uint32) *NewsArtData + DeleteArticle(newsPath []string, articleID uint32, recursive bool) error + PostArticle(newsPath []string, parentArticleID uint32, article NewsArtData) error + CreateGrouping(newsPath []string, name string, t [2]byte) error + GetCategories(paths []string) []NewsCategoryListData15 + NewsItem(newsPath []string) NewsCategoryListData15 + DeleteNewsItem(newsPath []string) error +} -// ThreadedNews is the top level struct containing all threaded news categories, bundles, and articles +// ThreadedNews contains the top level of threaded news categories, bundles, and articles. type ThreadedNews struct { Categories map[string]NewsCategoryListData15 `yaml:"Categories"` } @@ -40,19 +48,23 @@ func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData { id := make([]byte, 4) binary.BigEndian.PutUint32(id, i) - newArt := NewsArtList{ + newsArts = append(newsArts, NewsArtList{ ID: [4]byte(id), TimeStamp: art.Date, ParentID: art.ParentArt, Title: []byte(art.Title), Poster: []byte(art.Poster), ArticleSize: art.DataSize(), - } - - newsArts = append(newsArts, newArt) + }) } - sort.Sort(byID(newsArts)) + // Sort the articles by ID. This is important for displaying the message threading correctly on the client side. + slices.SortFunc(newsArts, func(a, b NewsArtList) int { + return cmp.Compare( + binary.BigEndian.Uint32(a.ID[:]), + binary.BigEndian.Uint32(b.ID[:]), + ) + }) for _, v := range newsArts { b, err := io.ReadAll(&v) @@ -71,7 +83,7 @@ func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData { } } -// NewsArtData represents single news article +// NewsArtData represents an individual news article. type NewsArtData struct { Title string `yaml:"Title"` Poster string `yaml:"Poster"` @@ -80,19 +92,19 @@ type NewsArtData struct { NextArt [4]byte `yaml:"NextArt,flow"` ParentArt [4]byte `yaml:"ParentArt,flow"` FirstChildArt [4]byte `yaml:"FirstChildArtArt,flow"` - DataFlav []byte `yaml:"-"` // "text/plain" + DataFlav []byte `yaml:"-"` // MIME type string. Always "text/plain". Data string `yaml:"Data"` } -func (art *NewsArtData) DataSize() []byte { +func (art *NewsArtData) DataSize() [2]byte { dataLen := make([]byte, 2) binary.BigEndian.PutUint16(dataLen, uint16(len(art.Data))) - return dataLen + return [2]byte(dataLen) } type NewsArtListData struct { - ID [4]byte `yaml:"ID"` + ID [4]byte `yaml:"Type"` Name []byte `yaml:"Name"` Description []byte `yaml:"Description"` // not used? NewsArtList []byte // List of articles Optional (if article count > 0) @@ -138,23 +150,11 @@ type NewsArtList struct { Poster []byte FlavorList []NewsFlavorList // Flavor list… Optional (if flavor count > 0) - ArticleSize []byte // Size 2 + ArticleSize [2]byte // Size 2 readOffset int // Internal offset to track read progress } -type byID []NewsArtList - -func (s byID) Len() int { - return len(s) -} -func (s byID) Swap(i, j int) { - s[i], s[j] = s[j], s[i] -} -func (s byID) Less(i, j int) bool { - return binary.BigEndian.Uint32(s[i].ID[:]) < binary.BigEndian.Uint32(s[j].ID[:]) -} - var ( NewsFlavorLen = []byte{0x0a} NewsFlavor = []byte("text/plain") @@ -173,7 +173,7 @@ func (nal *NewsArtList) Read(p []byte) (int, error) { nal.Poster, NewsFlavorLen, NewsFlavor, - nal.ArticleSize, + nal.ArticleSize[:], ) if nal.readOffset >= len(out) { @@ -200,22 +200,24 @@ func (newscat *NewsCategoryListData15) Read(p []byte) (int, error) { newscat.Type[:], count, ) - - // If type is category - if newscat.Type == [2]byte{0, 3} { - out = append(out, newscat.GUID[:]...) - out = append(out, newscat.AddSN[:]...) - out = append(out, newscat.DeleteSN[:]...) + if newscat.Type == NewsCategory { + out = slices.Concat(out, + newscat.GUID[:], + newscat.AddSN[:], + newscat.DeleteSN[:], + ) } - - out = append(out, newscat.nameLen()...) - out = append(out, []byte(newscat.Name)...) + out = slices.Concat(out, + newscat.nameLen(), + []byte(newscat.Name), + ) if newscat.readOffset >= len(out) { return 0, io.EOF // All bytes have been read } n := copy(p, out) + newscat.readOffset = n return n, nil @@ -225,30 +227,12 @@ func (newscat *NewsCategoryListData15) nameLen() []byte { return []byte{uint8(len(newscat.Name))} } -// TODO: re-implement as bufio.Scanner interface -func ReadNewsPath(newsPath []byte) []string { - if len(newsPath) == 0 { - return []string{} +// newsPathScanner implements bufio.SplitFunc for parsing incoming byte slices into complete tokens +func newsPathScanner(data []byte, _ bool) (advance int, token []byte, err error) { + if len(data) < 3 { + return 0, nil, nil } - pathCount := binary.BigEndian.Uint16(newsPath[0:2]) - - pathData := newsPath[2:] - var paths []string - - for i := uint16(0); i < pathCount; i++ { - pathLen := pathData[2] - paths = append(paths, string(pathData[3:3+pathLen])) - pathData = pathData[pathLen+3:] - } - - return paths -} - -func (s *Server) GetNewsCatByPath(paths []string) map[string]NewsCategoryListData15 { - cats := s.ThreadedNews.Categories - for _, path := range paths { - cats = cats[path].SubCats - } - return cats + advance = 3 + int(data[2]) + return advance, data[3:advance], nil } diff --git a/hotline/news_test.go b/hotline/news_test.go index d1b043e..588c979 100644 --- a/hotline/news_test.go +++ b/hotline/news_test.go @@ -2,10 +2,61 @@ package hotline import ( "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "io" "testing" ) +type mockThreadNewsMgr struct { + mock.Mock +} + +func (m *mockThreadNewsMgr) ListArticles(newsPath []string) NewsArtListData { + args := m.Called(newsPath) + + return args.Get(0).(NewsArtListData) +} + +func (m *mockThreadNewsMgr) GetArticle(newsPath []string, articleID uint32) *NewsArtData { + args := m.Called(newsPath, articleID) + + return args.Get(0).(*NewsArtData) +} +func (m *mockThreadNewsMgr) DeleteArticle(newsPath []string, articleID uint32, recursive bool) error { + args := m.Called(newsPath, articleID, recursive) + + return args.Error(0) +} + +func (m *mockThreadNewsMgr) PostArticle(newsPath []string, parentArticleID uint32, article NewsArtData) error { + args := m.Called(newsPath, parentArticleID, article) + + return args.Error(0) +} +func (m *mockThreadNewsMgr) CreateGrouping(newsPath []string, name string, itemType [2]byte) error { + args := m.Called(newsPath, name, itemType) + + return args.Error(0) +} + +func (m *mockThreadNewsMgr) GetCategories(paths []string) []NewsCategoryListData15 { + args := m.Called(paths) + + return args.Get(0).([]NewsCategoryListData15) +} + +func (m *mockThreadNewsMgr) NewsItem(newsPath []string) NewsCategoryListData15 { + args := m.Called(newsPath) + + return args.Get(0).(NewsCategoryListData15) +} + +func (m *mockThreadNewsMgr) DeleteNewsItem(newsPath []string) error { + args := m.Called(newsPath) + + return args.Error(0) +} + func TestNewsCategoryListData15_MarshalBinary(t *testing.T) { type fields struct { Type [2]byte diff --git a/hotline/server.go b/hotline/server.go index 0ee2dd7..6232253 100644 --- a/hotline/server.go +++ b/hotline/server.go @@ -8,7 +8,6 @@ import ( "encoding/binary" "errors" "fmt" - "github.com/go-playground/validator/v10" "golang.org/x/text/encoding/charmap" "gopkg.in/yaml.v3" "io" @@ -16,11 +15,9 @@ import ( "log/slog" "net" "os" - "path" "path/filepath" "strings" "sync" - "sync/atomic" "time" ) @@ -41,54 +38,94 @@ var txtEncoder = charmap.Macintosh.NewEncoder() type Server struct { NetInterface string Port int - Accounts map[string]*Account - Agreement []byte - Clients map[[2]byte]*ClientConn - fileTransfers map[[4]byte]*FileTransfer - - Config *Config + Config Config ConfigDir string Logger *slog.Logger - banner []byte - PrivateChatsMu sync.Mutex - PrivateChats map[[4]byte]*PrivateChat - - nextClientID atomic.Uint32 TrackerPassID [4]byte - statsMu sync.Mutex - Stats *Stats + Stats Counter FS FileStore // Storage backend to use for File storage outbox chan Transaction - mux sync.Mutex - threadedNewsMux sync.Mutex - ThreadedNews *ThreadedNews + // TODO + Agreement []byte + banner []byte + // END TODO - flatNewsMux sync.Mutex - FlatNews []byte + FileTransferMgr FileTransferMgr + ChatMgr ChatManager + ClientMgr ClientManager + AccountManager AccountManager + ThreadedNewsMgr ThreadedNewsMgr + BanList BanMgr - banListMU sync.Mutex - banList map[string]*time.Time + MessageBoard io.ReadWriteSeeker } -func (s *Server) CurrentStats() Stats { - s.statsMu.Lock() - defer s.statsMu.Unlock() +// NewServer constructs a new Server from a config dir +func NewServer(config Config, configDir, netInterface string, netPort int, logger *slog.Logger, fs FileStore) (*Server, error) { + server := Server{ + NetInterface: netInterface, + Port: netPort, + Config: config, + ConfigDir: configDir, + Logger: logger, + outbox: make(chan Transaction), + Stats: NewStats(), + FS: fs, + ChatMgr: NewMemChatManager(), + ClientMgr: NewMemClientMgr(), + FileTransferMgr: NewMemFileTransferMgr(), + } + + // generate a new random passID for tracker registration + _, err := rand.Read(server.TrackerPassID[:]) + if err != nil { + return nil, err + } + + server.Agreement, err = os.ReadFile(filepath.Join(configDir, agreementFile)) + if err != nil { + return nil, err + } + + server.AccountManager, err = NewYAMLAccountManager(filepath.Join(configDir, "Users/")) + if err != nil { + return nil, fmt.Errorf("error loading accounts: %w", err) + } + + // If the FileRoot is an absolute path, use it, otherwise treat as a relative path to the config dir. + if !filepath.IsAbs(server.Config.FileRoot) { + server.Config.FileRoot = filepath.Join(configDir, server.Config.FileRoot) + } + + server.banner, err = os.ReadFile(filepath.Join(server.ConfigDir, server.Config.BannerFile)) + if err != nil { + return nil, fmt.Errorf("error opening banner: %w", err) + } + + if server.Config.EnableTrackerRegistration { + server.Logger.Info( + "Tracker registration enabled", + "frequency", fmt.Sprintf("%vs", trackerUpdateFrequency), + "trackers", server.Config.Trackers, + ) + + go server.registerWithTrackers() + } - stats := s.Stats - stats.CurrentlyConnected = len(s.Clients) + // Start Client Keepalive go routine + go server.keepaliveHandler() - return *stats + return &server, nil } -type PrivateChat struct { - Subject string - ClientConn map[[2]byte]*ClientConn +func (s *Server) CurrentStats() map[string]interface{} { + return s.Stats.Values() } func (s *Server) ListenAndServe(ctx context.Context) error { @@ -142,11 +179,9 @@ func (s *Server) ServeFileTransfers(ctx context.Context, ln net.Listener) error } func (s *Server) sendTransaction(t Transaction) error { - s.mux.Lock() - client, ok := s.Clients[t.clientID] - s.mux.Unlock() + client := s.ClientMgr.Get(t.clientID) - if !ok || client == nil { + if client == nil { return nil } @@ -200,126 +235,40 @@ const ( agreementFile = "Agreement.txt" ) -// NewServer constructs a new Server from a config dir -// TODO: move config file reads out of this function -func NewServer(configDir, netInterface string, netPort int, logger *slog.Logger, fs FileStore) (*Server, error) { - server := Server{ - NetInterface: netInterface, - Port: netPort, - Accounts: make(map[string]*Account), - Config: new(Config), - Clients: make(map[[2]byte]*ClientConn), - fileTransfers: make(map[[4]byte]*FileTransfer), - PrivateChats: make(map[[4]byte]*PrivateChat), - ConfigDir: configDir, - Logger: logger, - outbox: make(chan Transaction), - Stats: &Stats{Since: time.Now()}, - ThreadedNews: &ThreadedNews{}, - FS: fs, - banList: make(map[string]*time.Time), - } - - var err error - - // generate a new random passID for tracker registration - if _, err := rand.Read(server.TrackerPassID[:]); err != nil { - return nil, err - } - - server.Agreement, err = os.ReadFile(filepath.Join(configDir, agreementFile)) - if err != nil { - return nil, err - } - - if server.FlatNews, err = os.ReadFile(filepath.Join(configDir, "MessageBoard.txt")); err != nil { - return nil, err - } - - // try to load the ban list, but ignore errors as this file may not be present or may be empty - //_ = server.loadBanList(filepath.Join(configDir, "Banlist.yaml")) - - _ = loadFromYAMLFile(filepath.Join(configDir, "Banlist.yaml"), &server.banList) - - err = loadFromYAMLFile(filepath.Join(configDir, "ThreadedNews.yaml"), &server.ThreadedNews) - if err != nil { - return nil, fmt.Errorf("error loading threaded news: %w", err) - } - - err = server.loadConfig(filepath.Join(configDir, "config.yaml")) - if err != nil { - return nil, fmt.Errorf("error loading config: %w", err) - } - - if err := server.loadAccounts(filepath.Join(configDir, "Users/")); err != nil { - return nil, err - } - - // If the FileRoot is an absolute path, use it, otherwise treat as a relative path to the config dir. - if !filepath.IsAbs(server.Config.FileRoot) { - server.Config.FileRoot = filepath.Join(configDir, server.Config.FileRoot) - } - - server.banner, err = os.ReadFile(filepath.Join(server.ConfigDir, server.Config.BannerFile)) - if err != nil { - return nil, fmt.Errorf("error opening banner: %w", err) - } - - if server.Config.EnableTrackerRegistration { - server.Logger.Info( - "Tracker registration enabled", - "frequency", fmt.Sprintf("%vs", trackerUpdateFrequency), - "trackers", server.Config.Trackers, - ) - - go func() { - for { - tr := &TrackerRegistration{ - UserCount: server.userCount(), - PassID: server.TrackerPassID, - Name: server.Config.Name, - Description: server.Config.Description, - } - binary.BigEndian.PutUint16(tr.Port[:], uint16(server.Port)) - for _, t := range server.Config.Trackers { - if err := register(&RealDialer{}, t, tr); err != nil { - server.Logger.Error("unable to register with tracker %v", "error", err) - } - server.Logger.Debug("Sent Tracker registration", "addr", t) - } - - time.Sleep(trackerUpdateFrequency * time.Second) +func (s *Server) registerWithTrackers() { + for { + tr := &TrackerRegistration{ + UserCount: len(s.ClientMgr.List()), + PassID: s.TrackerPassID, + Name: s.Config.Name, + Description: s.Config.Description, + } + binary.BigEndian.PutUint16(tr.Port[:], uint16(s.Port)) + for _, t := range s.Config.Trackers { + if err := register(&RealDialer{}, t, tr); err != nil { + s.Logger.Error(fmt.Sprintf("unable to register with tracker %v", t), "error", err) } - }() - } - - // Start Client Keepalive go routine - go server.keepaliveHandler() - - return &server, nil -} - -func (s *Server) userCount() int { - s.mux.Lock() - defer s.mux.Unlock() + } - return len(s.Clients) + time.Sleep(trackerUpdateFrequency * time.Second) + } } +// keepaliveHandler func (s *Server) keepaliveHandler() { for { time.Sleep(idleCheckInterval * time.Second) - s.mux.Lock() - for _, c := range s.Clients { + for _, c := range s.ClientMgr.List() { + c.mu.Lock() + c.IdleTime += idleCheckInterval - if c.IdleTime > userIdleSeconds && !c.Idle { - c.Idle = true - c.flagsMU.Lock() + // Check if the user + if c.IdleTime > userIdleSeconds && !c.Flags.IsSet(UserFlagAway) { c.Flags.Set(UserFlagAway, 1) - c.flagsMU.Unlock() - c.sendAll( + + c.SendAll( TranNotifyChangeUser, NewField(FieldUserID, c.ID[:]), NewField(FieldUserFlags, c.Flags[:]), @@ -327,169 +276,24 @@ func (s *Server) keepaliveHandler() { NewField(FieldUserIconID, c.Icon), ) } + c.mu.Unlock() } - s.mux.Unlock() - } -} - -func (s *Server) writeBanList() error { - s.banListMU.Lock() - defer s.banListMU.Unlock() - - out, err := yaml.Marshal(s.banList) - if err != nil { - return err } - err = os.WriteFile( - filepath.Join(s.ConfigDir, "Banlist.yaml"), - out, - 0666, - ) - return err -} - -func (s *Server) writeThreadedNews() error { - s.threadedNewsMux.Lock() - defer s.threadedNewsMux.Unlock() - - out, err := yaml.Marshal(s.ThreadedNews) - if err != nil { - return err - } - err = s.FS.WriteFile( - filepath.Join(s.ConfigDir, "ThreadedNews.yaml"), - out, - 0666, - ) - return err } func (s *Server) NewClientConn(conn io.ReadWriteCloser, remoteAddr string) *ClientConn { - s.mux.Lock() - defer s.mux.Unlock() - clientConn := &ClientConn{ Icon: []byte{0, 0}, // TODO: make array type Connection: conn, Server: s, RemoteAddr: remoteAddr, - transfers: map[int]map[[4]byte]*FileTransfer{ - FileDownload: {}, - FileUpload: {}, - FolderDownload: {}, - FolderUpload: {}, - bannerDownload: {}, - }, - } - - s.nextClientID.Add(1) - - binary.BigEndian.PutUint16(clientConn.ID[:], uint16(s.nextClientID.Load())) - s.Clients[clientConn.ID] = clientConn - - return clientConn -} - -// NewUser creates a new user account entry in the server map and config file -func (s *Server) NewUser(login, name, password string, access accessBitmap) error { - s.mux.Lock() - defer s.mux.Unlock() - - account := NewAccount(login, name, password, access) - - // Create account file, returning an error if one already exists. - file, err := os.OpenFile( - filepath.Join(s.ConfigDir, "Users", path.Join("/", login)+".yaml"), - os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644, - ) - if err != nil { - return fmt.Errorf("error creating account file: %w", err) - } - defer file.Close() - - b, err := yaml.Marshal(account) - if err != nil { - return err - } - - _, err = file.Write(b) - if err != nil { - return fmt.Errorf("error writing account file: %w", err) - } - - s.Accounts[login] = account - - return nil -} - -func (s *Server) UpdateUser(login, newLogin, name, password string, access accessBitmap) error { - s.mux.Lock() - defer s.mux.Unlock() - - // If the login has changed, rename the account file. - if login != newLogin { - err := os.Rename( - filepath.Join(s.ConfigDir, "Users", path.Join("/", login)+".yaml"), - filepath.Join(s.ConfigDir, "Users", path.Join("/", newLogin)+".yaml"), - ) - if err != nil { - return fmt.Errorf("error renaming account file: %w", err) - } - s.Accounts[newLogin] = s.Accounts[login] - s.Accounts[newLogin].Login = newLogin - delete(s.Accounts, login) - } - - account := s.Accounts[newLogin] - account.Access = access - account.Name = name - account.Password = password - - out, err := yaml.Marshal(&account) - if err != nil { - return err - } - - if err := os.WriteFile(filepath.Join(s.ConfigDir, "Users", newLogin+".yaml"), out, 0666); err != nil { - return fmt.Errorf("error writing account file: %w", err) - } - - return nil -} -// DeleteUser deletes the user account -func (s *Server) DeleteUser(login string) error { - s.mux.Lock() - defer s.mux.Unlock() - - err := s.FS.Remove(filepath.Join(s.ConfigDir, "Users", path.Join("/", login)+".yaml")) - if err != nil { - return err + ClientFileTransferMgr: NewClientFileTransferMgr(), } - delete(s.Accounts, login) + s.ClientMgr.Add(clientConn) - return nil -} - -func (s *Server) connectedUsers() []Field { - //s.mux.Lock() - //defer s.mux.Unlock() - - var connectedUsers []Field - for _, c := range sortedClients(s.Clients) { - b, err := io.ReadAll(&User{ - ID: c.ID, - Icon: c.Icon, - Flags: c.Flags[:], - Name: string(c.UserName), - }) - if err != nil { - return nil - } - connectedUsers = append(connectedUsers, NewField(FieldUsernameWithInfo, b)) - } - return connectedUsers + return clientConn } // loadFromYAMLFile loads data from a YAML file into the provided data structure. @@ -504,48 +308,6 @@ func loadFromYAMLFile(path string, data interface{}) error { return decoder.Decode(data) } -// loadAccounts loads account data from disk -func (s *Server) loadAccounts(userDir string) error { - matches, err := filepath.Glob(filepath.Join(userDir, "*.yaml")) - if err != nil { - return err - } - - if len(matches) == 0 { - return fmt.Errorf("no accounts found in directory: %s", userDir) - } - - for _, file := range matches { - var account Account - if err = loadFromYAMLFile(file, &account); err != nil { - return fmt.Errorf("error loading account %s: %w", file, err) - } - - s.Accounts[account.Login] = &account - } - return nil -} - -func (s *Server) loadConfig(path string) error { - fh, err := s.FS.Open(path) - if err != nil { - return err - } - - decoder := yaml.NewDecoder(fh) - err = decoder.Decode(s.Config) - if err != nil { - return err - } - - validate := validator.New() - err = validate.Struct(s.Config) - if err != nil { - return err - } - return nil -} - func sendBanMessage(rwc io.Writer, message string) { t := NewTransaction( TranServerMsg, @@ -563,7 +325,7 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser // Check if remoteAddr is present in the ban list ipAddr := strings.Split(remoteAddr, ":")[0] - if banUntil, ok := s.banList[ipAddr]; ok { + if isBanned, banUntil := s.BanList.IsBanned(ipAddr); isBanned { // permaban if banUntil == nil { sendBanMessage(rwc, "You are permanently banned on this server") @@ -605,12 +367,12 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser encodedPassword := clientLogin.GetField(FieldUserPassword).Data c.Version = clientLogin.GetField(FieldVersion).Data - login := string(encodeString(clientLogin.GetField(FieldUserLogin).Data)) + login := clientLogin.GetField(FieldUserLogin).DecodeObfuscatedString() if login == "" { login = GuestAccount } - c.logger = s.Logger.With("remoteAddr", remoteAddr, "login", login) + c.logger = s.Logger.With("ip", ipAddr, "login", login) // If authentication fails, send error reply and close connection if !c.Authenticate(login, encodedPassword) { @@ -630,19 +392,20 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser c.Icon = clientLogin.GetField(FieldUserIconID).Data } - c.Lock() - c.Account = c.Server.Accounts[login] - c.Unlock() + c.Account = c.Server.AccountManager.Get(login) + if c.Account == nil { + return nil + } if clientLogin.GetField(FieldUserName).Data != nil { - if c.Authorize(accessAnyName) { + if c.Authorize(AccessAnyName) { c.UserName = clientLogin.GetField(FieldUserName).Data } else { c.UserName = []byte(c.Account.Name) } } - if c.Authorize(accessDisconUser) { + if c.Authorize(AccessDisconUser) { c.Flags.Set(UserFlagAdmin, 1) } @@ -655,10 +418,10 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser // Send user access privs so client UI knows how to behave c.Server.outbox <- NewTransaction(TranUserAccess, c.ID, NewField(FieldUserAccess, c.Account.Access[:])) - // Accounts with accessNoAgreement do not receive the server agreement on login. The behavior is different between + // Accounts with AccessNoAgreement do not receive the server agreement on login. The behavior is different between // client versions. For 1.2.3 client, we do not send TranShowAgreement. For other client versions, we send // TranShowAgreement but with the NoServerAgreement field set to 1. - if c.Authorize(accessNoAgreement) { + if c.Authorize(AccessNoAgreement) { // If client version is nil, then the client uses the 1.2.3 login behavior if c.Version != nil { c.Server.outbox <- NewTransaction(TranShowAgreement, c.ID, NewField(FieldNoServerAgreement, []byte{1})) @@ -677,7 +440,7 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser // Notify other clients on the server that the new user has logged in. For 1.5+ clients we don't have this // information yet, so we do it in TranAgreed instead - for _, t := range c.notifyOthers( + for _, t := range c.NotifyOthers( NewTransaction( TranNotifyChangeUser, [2]byte{0, 0}, NewField(FieldUserName, c.UserName), @@ -690,12 +453,12 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser } } - c.Server.mux.Lock() - c.Server.Stats.ConnectionCounter += 1 - if len(s.Clients) > c.Server.Stats.ConnectionPeak { - c.Server.Stats.ConnectionPeak = len(s.Clients) + c.Server.Stats.Increment(StatConnectionCounter, StatCurrentlyConnected) + defer c.Server.Stats.Decrement(StatCurrentlyConnected) + + if len(s.ClientMgr.List()) > c.Server.Stats.Get(StatConnectionPeak) { + c.Server.Stats.Set(StatConnectionPeak, len(s.ClientMgr.List())) } - c.Server.mux.Unlock() // Scan for new transactions and handle them as they come in. for scanner.Scan() { @@ -713,25 +476,6 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser return nil } -func (s *Server) NewPrivateChat(cc *ClientConn) [4]byte { - s.PrivateChatsMu.Lock() - defer s.PrivateChatsMu.Unlock() - - var randID [4]byte - _, _ = rand.Read(randID[:]) - - s.PrivateChats[randID] = &PrivateChat{ - ClientConn: make(map[[2]byte]*ClientConn), - } - s.PrivateChats[randID].ClientConn[cc.ID] = cc - - return randID -} - -const dlFldrActionSendFile = 1 -const dlFldrActionResumeFile = 2 -const dlFldrActionNextFile = 3 - // handleFileTransfer receives a client net.Conn from the file transfer server, performs the requested transfer type, then closes the connection func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) error { defer dontPanic(s.Logger) @@ -742,10 +486,13 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro return fmt.Errorf("error reading file transfer: %w", err) } + fileTransfer := s.FileTransferMgr.Get(t.ReferenceNumber) + if fileTransfer == nil { + return errors.New("invalid transaction ID") + } + defer func() { - s.mux.Lock() - delete(s.fileTransfers, t.ReferenceNumber) - s.mux.Unlock() + s.FileTransferMgr.Delete(t.ReferenceNumber) // Wait a few seconds before closing the connection: this is a workaround for problems // observed with Windows clients where the client must initiate close of the TCP connection before @@ -753,19 +500,6 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro time.Sleep(3 * time.Second) }() - s.mux.Lock() - fileTransfer, ok := s.fileTransfers[t.ReferenceNumber] - s.mux.Unlock() - if !ok { - return errors.New("invalid transaction ID") - } - - defer func() { - fileTransfer.ClientConn.transfersMU.Lock() - delete(fileTransfer.ClientConn.transfers[fileTransfer.Type], t.ReferenceNumber) - fileTransfer.ClientConn.transfersMU.Unlock() - }() - rLogger := s.Logger.With( "remoteAddr", ctx.Value(contextKeyReq).(requestCtx).remoteAddr, "login", fileTransfer.ClientConn.Account.Login, @@ -778,26 +512,26 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro } switch fileTransfer.Type { - case bannerDownload: + case BannerDownload: if _, err := io.Copy(rwc, bytes.NewBuffer(s.banner)); err != nil { return fmt.Errorf("error sending banner: %w", err) } case FileDownload: - s.Stats.DownloadCounter += 1 - s.Stats.DownloadsInProgress += 1 + s.Stats.Increment(StatDownloadCounter, StatDownloadsInProgress) defer func() { - s.Stats.DownloadsInProgress -= 1 + s.Stats.Decrement(StatDownloadsInProgress) }() err = DownloadHandler(rwc, fullPath, fileTransfer, s.FS, rLogger, true) if err != nil { - return fmt.Errorf("file download error: %w", err) + return fmt.Errorf("file download: %w", err) } case FileUpload: - s.Stats.UploadCounter += 1 - s.Stats.UploadsInProgress += 1 - defer func() { s.Stats.UploadsInProgress -= 1 }() + s.Stats.Increment(StatUploadCounter, StatUploadsInProgress) + defer func() { + s.Stats.Decrement(StatUploadsInProgress) + }() err = UploadHandler(rwc, fullPath, fileTransfer, s.FS, rLogger, s.Config.PreserveResourceForks) if err != nil { @@ -805,9 +539,10 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro } case FolderDownload: - s.Stats.DownloadCounter += 1 - s.Stats.DownloadsInProgress += 1 - defer func() { s.Stats.DownloadsInProgress -= 1 }() + s.Stats.Increment(StatDownloadCounter, StatDownloadsInProgress) + defer func() { + s.Stats.Decrement(StatDownloadsInProgress) + }() err = DownloadFolderHandler(rwc, fullPath, fileTransfer, s.FS, rLogger, s.Config.PreserveResourceForks) if err != nil { @@ -815,9 +550,11 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro } case FolderUpload: - s.Stats.UploadCounter += 1 - s.Stats.UploadsInProgress += 1 - defer func() { s.Stats.UploadsInProgress -= 1 }() + s.Stats.Increment(StatUploadCounter, StatUploadsInProgress) + defer func() { + s.Stats.Decrement(StatUploadsInProgress) + }() + rLogger.Info( "Folder upload started", "dstPath", fullPath, diff --git a/hotline/server_blackbox_test.go b/hotline/server_blackbox_test.go index 06e7771..45d32d4 100644 --- a/hotline/server_blackbox_test.go +++ b/hotline/server_blackbox_test.go @@ -25,11 +25,11 @@ func assertTransferBytesEqual(t *testing.T, wantHexDump string, got []byte) bool return true } - var clean []byte - clean = append(clean, got[:92]...) // keep the first 92 bytes - clean = append(clean, make([]byte, 16)...) // replace the next 16 bytes for create/modify timestamps - clean = append(clean, got[108:]...) // keep the rest - + clean := slices.Concat( + got[:92], + make([]byte, 16), + got[108:], + ) return assert.Equal(t, wantHexDump, hex.Dump(clean)) } @@ -40,7 +40,7 @@ var tranSortFunc = func(a, b Transaction) int { ) } -// tranAssertEqual compares equality of transactions slices after stripping out the random transaction ID +// tranAssertEqual compares equality of transactions slices after stripping out the random transaction Type func tranAssertEqual(t *testing.T, tran1, tran2 []Transaction) bool { var newT1 []Transaction var newT2 []Transaction @@ -49,12 +49,13 @@ func tranAssertEqual(t *testing.T, tran1, tran2 []Transaction) bool { trans.ID = [4]byte{0, 0, 0, 0} var fs []Field for _, field := range trans.Fields { - if field.ID == [2]byte{0x00, 0x6b} { // FieldRefNum + if field.Type == FieldRefNum { // FieldRefNum continue } - if field.ID == [2]byte{0x00, 0x72} { // FieldChatID + if field.Type == FieldChatID { // FieldChatID continue } + fs = append(fs, field) } trans.Fields = fs @@ -65,12 +66,13 @@ func tranAssertEqual(t *testing.T, tran1, tran2 []Transaction) bool { trans.ID = [4]byte{0, 0, 0, 0} var fs []Field for _, field := range trans.Fields { - if field.ID == [2]byte{0x00, 0x6b} { // FieldRefNum + if field.Type == FieldRefNum { // FieldRefNum continue } - if field.ID == [2]byte{0x00, 0x72} { // FieldChatID + if field.Type == FieldChatID { // FieldChatID continue } + fs = append(fs, field) } trans.Fields = fs diff --git a/hotline/server_test.go b/hotline/server_test.go index a29a4f8..0487d2e 100644 --- a/hotline/server_test.go +++ b/hotline/server_test.go @@ -8,7 +8,6 @@ import ( "io" "log/slog" "os" - "sync" "testing" ) @@ -27,21 +26,13 @@ func (mrw mockReadWriter) Write(p []byte) (n int, err error) { func TestServer_handleFileTransfer(t *testing.T) { type fields struct { - Port int - Accounts map[string]*Account - Agreement []byte - Clients map[[2]byte]*ClientConn - ThreadedNews *ThreadedNews - fileTransfers map[[4]byte]*FileTransfer - Config *Config - ConfigDir string - Logger *slog.Logger - PrivateChats map[uint32]*PrivateChat - NextGuestID *uint16 - TrackerPassID [4]byte - Stats *Stats - FS FileStore - FlatNews []byte + ThreadedNews *ThreadedNews + FileTransferMgr FileTransferMgr + Config Config + ConfigDir string + Stats *Stats + Logger *slog.Logger + FS FileStore } type args struct { ctx context.Context @@ -79,7 +70,10 @@ func TestServer_handleFileTransfer(t *testing.T) { wantErr: assert.Error, }, { - name: "with invalid transfer ID", + name: "with invalid transfer Type", + fields: fields{ + FileTransferMgr: NewMemFileTransferMgr(), + }, args: args{ ctx: func() context.Context { ctx := context.Background() @@ -106,31 +100,34 @@ func TestServer_handleFileTransfer(t *testing.T) { name: "file download", fields: fields{ FS: &OSFileStore{}, - Config: &Config{ + Config: Config{ FileRoot: func() string { path, _ := os.Getwd() return path + "/test/config/Files" }()}, Logger: NewTestLogger(), - Stats: &Stats{}, - fileTransfers: map[[4]byte]*FileTransfer{ - {0, 0, 0, 5}: { - refNum: [4]byte{0, 0, 0, 5}, - Type: FileDownload, - FileName: []byte("testfile-8b"), - FilePath: []byte{}, - ClientConn: &ClientConn{ - Account: &Account{ - Login: "foo", - }, - transfersMU: sync.Mutex{}, - transfers: map[int]map[[4]byte]*FileTransfer{ - FileDownload: { - [4]byte{0, 0, 0, 5}: &FileTransfer{}, + Stats: NewStats(), + FileTransferMgr: &MemFileTransferMgr{ + fileTransfers: map[FileTransferID]*FileTransfer{ + {0, 0, 0, 5}: { + refNum: [4]byte{0, 0, 0, 5}, + Type: FileDownload, + FileName: []byte("testfile-8b"), + FilePath: []byte{}, + ClientConn: &ClientConn{ + Account: &Account{ + Login: "foo", + }, + ClientFileTransferMgr: ClientFileTransferMgr{ + transfers: map[FileTransferType]map[FileTransferID]*FileTransfer{ + FileDownload: { + [4]byte{0, 0, 0, 5}: &FileTransfer{}, + }, + }, }, }, + bytesSentCounter: &WriteCounter{}, }, - bytesSentCounter: &WriteCounter{}, }, }, }, @@ -172,18 +169,14 @@ func TestServer_handleFileTransfer(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := &Server{ - Port: tt.fields.Port, - Accounts: tt.fields.Accounts, - Agreement: tt.fields.Agreement, - Clients: tt.fields.Clients, - ThreadedNews: tt.fields.ThreadedNews, - fileTransfers: tt.fields.fileTransfers, - Config: tt.fields.Config, - ConfigDir: tt.fields.ConfigDir, - Logger: tt.fields.Logger, - Stats: tt.fields.Stats, - FS: tt.fields.FS, + FileTransferMgr: tt.fields.FileTransferMgr, + Config: tt.fields.Config, + ConfigDir: tt.fields.ConfigDir, + Logger: tt.fields.Logger, + Stats: tt.fields.Stats, + FS: tt.fields.FS, } + tt.wantErr(t, s.handleFileTransfer(tt.args.ctx, tt.args.rwc), fmt.Sprintf("handleFileTransfer(%v, %v)", tt.args.ctx, tt.args.rwc)) assertTransferBytesEqual(t, tt.wantDump, tt.args.rwc.(mockReadWriter).WBuf.Bytes()) diff --git a/hotline/stats.go b/hotline/stats.go index d1a3c5f..316a67d 100644 --- a/hotline/stats.go +++ b/hotline/stats.go @@ -5,16 +5,92 @@ import ( "time" ) +// Stat counter keys +const ( + StatCurrentlyConnected = iota + StatDownloadsInProgress + StatUploadsInProgress + StatWaitingDownloads + StatConnectionPeak + StatConnectionCounter + StatDownloadCounter + StatUploadCounter +) + +type Counter interface { + Increment(keys ...int) + Decrement(key int) + Set(key, val int) + Get(key int) int + Values() map[string]interface{} +} + type Stats struct { - CurrentlyConnected int - DownloadsInProgress int - UploadsInProgress int - WaitingDownloads int - ConnectionPeak int - ConnectionCounter int - DownloadCounter int - UploadCounter int - Since time.Time - - sync.Mutex + stats map[int]int + since time.Time + + mu sync.RWMutex +} + +func NewStats() *Stats { + return &Stats{ + since: time.Now(), + stats: map[int]int{ + StatCurrentlyConnected: 0, + StatDownloadsInProgress: 0, + StatUploadsInProgress: 0, + StatWaitingDownloads: 0, + StatConnectionPeak: 0, + StatDownloadCounter: 0, + StatUploadCounter: 0, + StatConnectionCounter: 0, + }, + } +} + +func (s *Stats) Increment(keys ...int) { + s.mu.Lock() + defer s.mu.Unlock() + + for _, key := range keys { + s.stats[key]++ + } +} + +func (s *Stats) Decrement(key int) { + s.mu.Lock() + defer s.mu.Unlock() + + s.stats[key]-- +} + +func (s *Stats) Set(key, val int) { + s.mu.Lock() + defer s.mu.Unlock() + + s.stats[key] = val +} + +func (s *Stats) Get(key int) int { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.stats[key] +} + +func (s *Stats) Values() map[string]interface{} { + s.mu.RLock() + defer s.mu.RUnlock() + + return map[string]interface{}{ + "CurrentlyConnected": s.stats[StatCurrentlyConnected], + "DownloadsInProgress": s.stats[StatDownloadsInProgress], + "UploadsInProgress": s.stats[StatUploadsInProgress], + "WaitingDownloads": s.stats[StatWaitingDownloads], + "ConnectionPeak": s.stats[StatConnectionPeak], + "ConnectionCounter": s.stats[StatConnectionCounter], + "DownloadCounter": s.stats[StatDownloadCounter], + "UploadCounter": s.stats[StatUploadCounter], + "Since": s.since, + } } diff --git a/hotline/tracker.go b/hotline/tracker.go index d0ccff4..2d655d9 100644 --- a/hotline/tracker.go +++ b/hotline/tracker.go @@ -64,7 +64,7 @@ func (d *RealDialer) Dial(network, address string) (net.Conn, error) { func register(dialer Dialer, tracker string, tr io.Reader) error { conn, err := dialer.Dial("udp", tracker) if err != nil { - return fmt.Errorf("failed to dial tracker: %w", err) + return fmt.Errorf("failed to dial tracker: %v", err) } defer conn.Close() diff --git a/hotline/transaction.go b/hotline/transaction.go index f8c7dfd..7a456a5 100644 --- a/hotline/transaction.go +++ b/hotline/transaction.go @@ -7,70 +7,71 @@ import ( "errors" "fmt" "io" - "log/slog" "math/rand" "slices" ) +type TranType [2]byte + var ( - TranError = [2]byte{0x00, 0x00} // 0 - TranGetMsgs = [2]byte{0x00, 0x65} // 101 - TranNewMsg = [2]byte{0x00, 0x66} // 102 - TranOldPostNews = [2]byte{0x00, 0x67} // 103 - TranServerMsg = [2]byte{0x00, 0x68} // 104 - TranChatSend = [2]byte{0x00, 0x69} // 105 - TranChatMsg = [2]byte{0x00, 0x6A} // 106 - TranLogin = [2]byte{0x00, 0x6B} // 107 - TranSendInstantMsg = [2]byte{0x00, 0x6C} // 108 - TranShowAgreement = [2]byte{0x00, 0x6D} // 109 - TranDisconnectUser = [2]byte{0x00, 0x6E} // 110 - TranDisconnectMsg = [2]byte{0x00, 0x6F} // 111 - TranInviteNewChat = [2]byte{0x00, 0x70} // 112 - TranInviteToChat = [2]byte{0x00, 0x71} // 113 - TranRejectChatInvite = [2]byte{0x00, 0x72} // 114 - TranJoinChat = [2]byte{0x00, 0x73} // 115 - TranLeaveChat = [2]byte{0x00, 0x74} // 116 - TranNotifyChatChangeUser = [2]byte{0x00, 0x75} // 117 - TranNotifyChatDeleteUser = [2]byte{0x00, 0x76} // 118 - TranNotifyChatSubject = [2]byte{0x00, 0x77} // 119 - TranSetChatSubject = [2]byte{0x00, 0x78} // 120 - TranAgreed = [2]byte{0x00, 0x79} // 121 - TranServerBanner = [2]byte{0x00, 0x7A} // 122 - TranGetFileNameList = [2]byte{0x00, 0xC8} // 200 - TranDownloadFile = [2]byte{0x00, 0xCA} // 202 - TranUploadFile = [2]byte{0x00, 0xCB} // 203 - TranNewFolder = [2]byte{0x00, 0xCD} // 205 - TranDeleteFile = [2]byte{0x00, 0xCC} // 204 - TranGetFileInfo = [2]byte{0x00, 0xCE} // 206 - TranSetFileInfo = [2]byte{0x00, 0xCF} // 207 - TranMoveFile = [2]byte{0x00, 0xD0} // 208 - TranMakeFileAlias = [2]byte{0x00, 0xD1} // 209 - TranDownloadFldr = [2]byte{0x00, 0xD2} // 210 - TranDownloadInfo = [2]byte{0x00, 0xD3} // 211 - TranDownloadBanner = [2]byte{0x00, 0xD4} // 212 - TranUploadFldr = [2]byte{0x00, 0xD5} // 213 - TranGetUserNameList = [2]byte{0x01, 0x2C} // 300 - TranNotifyChangeUser = [2]byte{0x01, 0x2D} // 301 - TranNotifyDeleteUser = [2]byte{0x01, 0x2E} // 302 - TranGetClientInfoText = [2]byte{0x01, 0x2F} // 303 - TranSetClientUserInfo = [2]byte{0x01, 0x30} // 304 - TranListUsers = [2]byte{0x01, 0x5C} // 348 - TranUpdateUser = [2]byte{0x01, 0x5D} // 349 - TranNewUser = [2]byte{0x01, 0x5E} // 350 - TranDeleteUser = [2]byte{0x01, 0x5F} // 351 - TranGetUser = [2]byte{0x01, 0x60} // 352 - TranSetUser = [2]byte{0x01, 0x61} // 353 - TranUserAccess = [2]byte{0x01, 0x62} // 354 - TranUserBroadcast = [2]byte{0x01, 0x63} // 355 - TranGetNewsCatNameList = [2]byte{0x01, 0x72} // 370 - TranGetNewsArtNameList = [2]byte{0x01, 0x73} // 371 - TranDelNewsItem = [2]byte{0x01, 0x7C} // 380 - TranNewNewsFldr = [2]byte{0x01, 0x7D} // 381 - TranNewNewsCat = [2]byte{0x01, 0x7E} // 382 - TranGetNewsArtData = [2]byte{0x01, 0x90} // 400 - TranPostNewsArt = [2]byte{0x01, 0x9A} // 410 - TranDelNewsArt = [2]byte{0x01, 0x9B} // 411 - TranKeepAlive = [2]byte{0x01, 0xF4} // 500 + TranError = TranType{0x00, 0x00} // 0 + TranGetMsgs = TranType{0x00, 0x65} // 101 + TranNewMsg = TranType{0x00, 0x66} // 102 + TranOldPostNews = TranType{0x00, 0x67} // 103 + TranServerMsg = TranType{0x00, 0x68} // 104 + TranChatSend = TranType{0x00, 0x69} // 105 + TranChatMsg = TranType{0x00, 0x6A} // 106 + TranLogin = TranType{0x00, 0x6B} // 107 + TranSendInstantMsg = TranType{0x00, 0x6C} // 108 + TranShowAgreement = TranType{0x00, 0x6D} // 109 + TranDisconnectUser = TranType{0x00, 0x6E} // 110 + TranDisconnectMsg = TranType{0x00, 0x6F} // 111 + TranInviteNewChat = TranType{0x00, 0x70} // 112 + TranInviteToChat = TranType{0x00, 0x71} // 113 + TranRejectChatInvite = TranType{0x00, 0x72} // 114 + TranJoinChat = TranType{0x00, 0x73} // 115 + TranLeaveChat = TranType{0x00, 0x74} // 116 + TranNotifyChatChangeUser = TranType{0x00, 0x75} // 117 + TranNotifyChatDeleteUser = TranType{0x00, 0x76} // 118 + TranNotifyChatSubject = TranType{0x00, 0x77} // 119 + TranSetChatSubject = TranType{0x00, 0x78} // 120 + TranAgreed = TranType{0x00, 0x79} // 121 + TranServerBanner = TranType{0x00, 0x7A} // 122 + TranGetFileNameList = TranType{0x00, 0xC8} // 200 + TranDownloadFile = TranType{0x00, 0xCA} // 202 + TranUploadFile = TranType{0x00, 0xCB} // 203 + TranNewFolder = TranType{0x00, 0xCD} // 205 + TranDeleteFile = TranType{0x00, 0xCC} // 204 + TranGetFileInfo = TranType{0x00, 0xCE} // 206 + TranSetFileInfo = TranType{0x00, 0xCF} // 207 + TranMoveFile = TranType{0x00, 0xD0} // 208 + TranMakeFileAlias = TranType{0x00, 0xD1} // 209 + TranDownloadFldr = TranType{0x00, 0xD2} // 210 + TranDownloadInfo = TranType{0x00, 0xD3} // 211 + TranDownloadBanner = TranType{0x00, 0xD4} // 212 + TranUploadFldr = TranType{0x00, 0xD5} // 213 + TranGetUserNameList = TranType{0x01, 0x2C} // 300 + TranNotifyChangeUser = TranType{0x01, 0x2D} // 301 + TranNotifyDeleteUser = TranType{0x01, 0x2E} // 302 + TranGetClientInfoText = TranType{0x01, 0x2F} // 303 + TranSetClientUserInfo = TranType{0x01, 0x30} // 304 + TranListUsers = TranType{0x01, 0x5C} // 348 + TranUpdateUser = TranType{0x01, 0x5D} // 349 + TranNewUser = TranType{0x01, 0x5E} // 350 + TranDeleteUser = TranType{0x01, 0x5F} // 351 + TranGetUser = TranType{0x01, 0x60} // 352 + TranSetUser = TranType{0x01, 0x61} // 353 + TranUserAccess = TranType{0x01, 0x62} // 354 + TranUserBroadcast = TranType{0x01, 0x63} // 355 + TranGetNewsCatNameList = TranType{0x01, 0x72} // 370 + TranGetNewsArtNameList = TranType{0x01, 0x73} // 371 + TranDelNewsItem = TranType{0x01, 0x7C} // 380 + TranNewNewsFldr = TranType{0x01, 0x7D} // 381 + TranNewNewsCat = TranType{0x01, 0x7E} // 382 + TranGetNewsArtData = TranType{0x01, 0x90} // 400 + TranPostNewsArt = TranType{0x01, 0x9A} // 410 + TranDelNewsArt = TranType{0x01, 0x9B} // 411 + TranKeepAlive = TranType{0x01, 0xF4} // 500 ) type Transaction struct { @@ -88,66 +89,64 @@ type Transaction struct { readOffset int // Internal offset to track read progress } -type TranType [2]byte - var tranTypeNames = map[TranType]string{ - TranChatMsg: "Receive Chat", - TranNotifyChangeUser: "TranNotifyChangeUser", - TranError: "TranError", - TranShowAgreement: "TranShowAgreement", - TranUserAccess: "TranUserAccess", - TranNotifyDeleteUser: "TranNotifyDeleteUser", + TranChatMsg: "Receive chat", + TranNotifyChangeUser: "User change", + TranError: "Error", + TranShowAgreement: "Show Agreement", + TranUserAccess: "User access", + TranNotifyDeleteUser: "User left", TranAgreed: "TranAgreed", - TranChatSend: "Send Chat", - TranDelNewsArt: "TranDelNewsArt", - TranDelNewsItem: "TranDelNewsItem", - TranDeleteFile: "TranDeleteFile", - TranDeleteUser: "TranDeleteUser", - TranDisconnectUser: "TranDisconnectUser", - TranDownloadFile: "TranDownloadFile", - TranDownloadFldr: "TranDownloadFldr", - TranGetClientInfoText: "TranGetClientInfoText", - TranGetFileInfo: "TranGetFileInfo", - TranGetFileNameList: "TranGetFileNameList", - TranGetMsgs: "TranGetMsgs", - TranGetNewsArtData: "TranGetNewsArtData", - TranGetNewsArtNameList: "TranGetNewsArtNameList", - TranGetNewsCatNameList: "TranGetNewsCatNameList", - TranGetUser: "TranGetUser", - TranGetUserNameList: "tranHandleGetUserNameList", - TranInviteNewChat: "TranInviteNewChat", - TranInviteToChat: "TranInviteToChat", - TranJoinChat: "TranJoinChat", - TranKeepAlive: "TranKeepAlive", - TranLeaveChat: "TranJoinChat", - TranListUsers: "TranListUsers", - TranMoveFile: "TranMoveFile", - TranNewFolder: "TranNewFolder", - TranNewNewsCat: "TranNewNewsCat", - TranNewNewsFldr: "TranNewNewsFldr", - TranNewUser: "TranNewUser", - TranUpdateUser: "TranUpdateUser", - TranOldPostNews: "TranOldPostNews", - TranPostNewsArt: "TranPostNewsArt", - TranRejectChatInvite: "TranRejectChatInvite", - TranSendInstantMsg: "TranSendInstantMsg", - TranSetChatSubject: "TranSetChatSubject", - TranMakeFileAlias: "TranMakeFileAlias", - TranSetClientUserInfo: "TranSetClientUserInfo", - TranSetFileInfo: "TranSetFileInfo", - TranSetUser: "TranSetUser", - TranUploadFile: "TranUploadFile", - TranUploadFldr: "TranUploadFldr", - TranUserBroadcast: "TranUserBroadcast", - TranDownloadBanner: "TranDownloadBanner", + TranChatSend: "Send chat", + TranDelNewsArt: "Delete news article", + TranDelNewsItem: "Delete news item", + TranDeleteFile: "Delete file", + TranDeleteUser: "Delete user", + TranDisconnectUser: "Disconnect user", + TranDownloadFile: "Download file", + TranDownloadFldr: "Download folder", + TranGetClientInfoText: "Get client info", + TranGetFileInfo: "Get file info", + TranGetFileNameList: "Get file list", + TranGetMsgs: "Get messages", + TranGetNewsArtData: "Get news article", + TranGetNewsArtNameList: "Get news article list", + TranGetNewsCatNameList: "Get news categories", + TranGetUser: "Get user", + TranGetUserNameList: "Get user list", + TranInviteNewChat: "Invite to new chat", + TranInviteToChat: "Invite to chat", + TranJoinChat: "Join chat", + TranKeepAlive: "Keepalive", + TranLeaveChat: "Leave chat", + TranListUsers: "List user accounts", + TranMoveFile: "Move file", + TranNewFolder: "Create folder", + TranNewNewsCat: "Create news category", + TranNewNewsFldr: "Create news bundle", + TranNewUser: "Create user account", + TranUpdateUser: "Update user account", + TranOldPostNews: "Post to message board", + TranPostNewsArt: "Create news article", + TranRejectChatInvite: "Decline chat invite", + TranSendInstantMsg: "Send message", + TranSetChatSubject: "Set chat subject", + TranMakeFileAlias: "Make file alias", + TranSetClientUserInfo: "Set client user info", + TranSetFileInfo: "Set file info", + TranSetUser: "Set user", + TranUploadFile: "Upload file", + TranUploadFldr: "Upload folder", + TranUserBroadcast: "Send broadcast", + TranDownloadBanner: "Download banner", } -func (t TranType) LogValue() slog.Value { - return slog.StringValue(tranTypeNames[t]) -} +//func (t TranType) LogValue() slog.Value { +// return slog.StringValue(tranTypeNames[t]) +//} -// NewTransaction creates a new Transaction with the specified type, client ID, and optional fields. -func NewTransaction(t, clientID [2]byte, fields ...Field) Transaction { +// NewTransaction creates a new Transaction with the specified type, client Type, and optional fields. +func NewTransaction(t TranType, clientID [2]byte, fields ...Field) Transaction { transaction := Transaction{ Type: t, clientID: clientID, @@ -227,45 +226,6 @@ func transactionScanner(data []byte, _ bool) (advance int, token []byte, err err const minFieldLen = 4 -func ReadFields(paramCount []byte, buf []byte) ([]Field, error) { - paramCountInt := int(binary.BigEndian.Uint16(paramCount)) - if paramCountInt > 0 && len(buf) < minFieldLen { - return []Field{}, fmt.Errorf("invalid field length %v", len(buf)) - } - - // A Field consists of: - // ID: 2 bytes - // Size: 2 bytes - // Data: FieldSize number of bytes - var fields []Field - for i := 0; i < paramCountInt; i++ { - if len(buf) < minFieldLen { - return []Field{}, fmt.Errorf("invalid field length %v", len(buf)) - } - fieldID := buf[0:2] - fieldSize := buf[2:4] - fieldSizeInt := int(binary.BigEndian.Uint16(buf[2:4])) - expectedLen := minFieldLen + fieldSizeInt - if len(buf) < expectedLen { - return []Field{}, fmt.Errorf("field length too short") - } - - fields = append(fields, Field{ - ID: [2]byte(fieldID), - FieldSize: [2]byte(fieldSize), - Data: buf[4 : 4+fieldSizeInt], - }) - - buf = buf[fieldSizeInt+4:] - } - - if len(buf) != 0 { - return []Field{}, fmt.Errorf("extra field bytes") - } - - return fields, nil -} - // Read implements the io.Reader interface for Transaction func (t *Transaction) Read(p []byte) (int, error) { payloadSize := t.Size() @@ -318,12 +278,12 @@ func (t *Transaction) Size() []byte { return bs } -func (t *Transaction) GetField(id [2]byte) Field { +func (t *Transaction) GetField(id [2]byte) *Field { for _, field := range t.Fields { - if id == field.ID { - return field + if id == field.Type { + return &field } } - return Field{} + return &Field{} } diff --git a/hotline/transaction_handlers.go b/hotline/transaction_handlers.go index 271f308..ead394a 100644 --- a/hotline/transaction_handlers.go +++ b/hotline/transaction_handlers.go @@ -5,14 +5,11 @@ import ( "bytes" "encoding/binary" "fmt" - "github.com/davecgh/go-spew/spew" - "gopkg.in/yaml.v3" "io" "math/big" "os" "path" "path/filepath" - "sort" "strings" "time" ) @@ -71,7 +68,7 @@ var TransactionHandlers = map[TranType]HandlerFunc{ const chatMsgLimit = 8192 func HandleChatSend(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessSendChat) { + if !cc.Authorize(AccessSendChat) { return cc.NewErrReply(t, "You are not allowed to participate in chat.") } @@ -95,10 +92,9 @@ func HandleChatSend(cc *ClientConn, t *Transaction) (res []Transaction) { // All clients *except* Frogblast omit this field for public chat, but Frogblast sends a value of 00 00 00 00. chatID := t.GetField(FieldChatID).Data if chatID != nil && !bytes.Equal([]byte{0, 0, 0, 0}, chatID) { - privChat := cc.Server.PrivateChats[[4]byte(chatID)] // send the message to all connected clients of the private chat - for _, c := range privChat.ClientConn { + for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) { res = append(res, NewTransaction( TranChatMsg, c.ID, @@ -110,12 +106,12 @@ func HandleChatSend(cc *ClientConn, t *Transaction) (res []Transaction) { } //cc.Server.mux.Lock() - for _, c := range cc.Server.Clients { + for _, c := range cc.Server.ClientMgr.List() { if c == nil || cc.Account == nil { continue } // Skip clients that do not have the read chat permission. - if c.Authorize(accessReadChat) { + if c.Authorize(AccessReadChat) { res = append(res, NewTransaction(TranChatMsg, c.ID, NewField(FieldData, []byte(formattedMsg)))) } } @@ -127,7 +123,7 @@ func HandleChatSend(cc *ClientConn, t *Transaction) (res []Transaction) { // HandleSendInstantMsg sends instant message to the user on the current server. // Fields used in the request: // -// 103 User ID +// 103 User Type // 113 Options // One of the following values: // - User message (myOpt_UserMessage = 1) @@ -140,7 +136,7 @@ func HandleChatSend(cc *ClientConn, t *Transaction) (res []Transaction) { // Fields used in the reply: // None func HandleSendInstantMsg(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessSendPrivMsg) { + if !cc.Authorize(AccessSendPrivMsg) { return cc.NewErrReply(t, "You are not allowed to send private messages.") } @@ -162,8 +158,8 @@ func HandleSendInstantMsg(cc *ClientConn, t *Transaction) (res []Transaction) { reply.Fields = append(reply.Fields, NewField(FieldQuotingMsg, t.GetField(FieldQuotingMsg).Data)) } - otherClient, ok := cc.Server.Clients[[2]byte(userID.Data)] - if !ok { + otherClient := cc.Server.ClientMgr.Get([2]byte(userID.Data)) + if otherClient == nil { return res } @@ -272,11 +268,11 @@ func HandleSetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction) { if t.GetField(FieldFileComment).Data != nil { switch mode := fi.Mode(); { case mode.IsDir(): - if !cc.Authorize(accessSetFolderComment) { + if !cc.Authorize(AccessSetFolderComment) { return cc.NewErrReply(t, "You are not allowed to set comments for folders.") } case mode.IsRegular(): - if !cc.Authorize(accessSetFileComment) { + if !cc.Authorize(AccessSetFileComment) { return cc.NewErrReply(t, "You are not allowed to set comments for files.") } } @@ -304,7 +300,7 @@ func HandleSetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction) { if fileNewName != nil { switch mode := fi.Mode(); { case mode.IsDir(): - if !cc.Authorize(accessRenameFolder) { + if !cc.Authorize(AccessRenameFolder) { return cc.NewErrReply(t, "You are not allowed to rename folders.") } err = os.Rename(fullFilePath, fullNewFilePath) @@ -313,7 +309,7 @@ func HandleSetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction) { } case mode.IsRegular(): - if !cc.Authorize(accessRenameFile) { + if !cc.Authorize(AccessRenameFile) { return cc.NewErrReply(t, "You are not allowed to rename files.") } fileDir, err := readPath(cc.Server.Config.FileRoot, filePath, []byte{}) @@ -365,11 +361,11 @@ func HandleDeleteFile(cc *ClientConn, t *Transaction) (res []Transaction) { switch mode := fi.Mode(); { case mode.IsDir(): - if !cc.Authorize(accessDeleteFolder) { + if !cc.Authorize(AccessDeleteFolder) { return cc.NewErrReply(t, "You are not allowed to delete folders.") } case mode.IsRegular(): - if !cc.Authorize(accessDeleteFile) { + if !cc.Authorize(AccessDeleteFile) { return cc.NewErrReply(t, "You are not allowed to delete files.") } } @@ -409,11 +405,11 @@ func HandleMoveFile(cc *ClientConn, t *Transaction) (res []Transaction) { } switch mode := fi.Mode(); { case mode.IsDir(): - if !cc.Authorize(accessMoveFolder) { + if !cc.Authorize(AccessMoveFolder) { return cc.NewErrReply(t, "You are not allowed to move folders.") } case mode.IsRegular(): - if !cc.Authorize(accessMoveFile) { + if !cc.Authorize(AccessMoveFile) { return cc.NewErrReply(t, "You are not allowed to move files.") } } @@ -427,7 +423,7 @@ func HandleMoveFile(cc *ClientConn, t *Transaction) (res []Transaction) { } func HandleNewFolder(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessCreateFolder) { + if !cc.Authorize(AccessCreateFolder) { return cc.NewErrReply(t, "You are not allowed to create folders.") } folderName := string(t.GetField(FieldFileName).Data) @@ -466,21 +462,20 @@ func HandleNewFolder(cc *ClientConn, t *Transaction) (res []Transaction) { return cc.NewErrReply(t, msg) } - res = append(res, cc.NewReply(t)) - return res + return append(res, cc.NewReply(t)) } func HandleSetUser(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessModifyUser) { + if !cc.Authorize(AccessModifyUser) { return cc.NewErrReply(t, "You are not allowed to modify accounts.") } - login := string(encodeString(t.GetField(FieldUserLogin).Data)) + login := t.GetField(FieldUserLogin).DecodeObfuscatedString() userName := string(t.GetField(FieldUserName).Data) newAccessLvl := t.GetField(FieldUserAccess).Data - account := cc.Server.Accounts[login] + account := cc.Server.AccountManager.Get(login) if account == nil { return cc.NewErrReply(t, "Account not found.") } @@ -497,21 +492,18 @@ func HandleSetUser(cc *ClientConn, t *Transaction) (res []Transaction) { account.Password = hashAndSalt(t.GetField(FieldUserPassword).Data) } - out, err := yaml.Marshal(&account) + err := cc.Server.AccountManager.Update(*account, account.Login) if err != nil { - return res - } - if err := os.WriteFile(filepath.Join(cc.Server.ConfigDir, "Users", login+".yaml"), out, 0666); err != nil { - return res + cc.logger.Error("Error updating account", "Err", err) } // Notify connected clients logged in as the user of the new access level - for _, c := range cc.Server.Clients { + for _, c := range cc.Server.ClientMgr.List() { if c.Account.Login == login { newT := NewTransaction(TranUserAccess, c.ID, NewField(FieldUserAccess, newAccessLvl)) res = append(res, newT) - if c.Authorize(accessDisconUser) { + if c.Authorize(AccessDisconUser) { c.Flags.Set(UserFlagAdmin, 1) } else { c.Flags.Set(UserFlagAdmin, 0) @@ -519,7 +511,7 @@ func HandleSetUser(cc *ClientConn, t *Transaction) (res []Transaction) { c.Account.Access = account.Access - cc.sendAll( + cc.SendAll( TranNotifyChangeUser, NewField(FieldUserID, c.ID[:]), NewField(FieldUserFlags, c.Flags[:]), @@ -529,47 +521,44 @@ func HandleSetUser(cc *ClientConn, t *Transaction) (res []Transaction) { } } - res = append(res, cc.NewReply(t)) - return res + return append(res, cc.NewReply(t)) } func HandleGetUser(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessOpenUser) { + if !cc.Authorize(AccessOpenUser) { return cc.NewErrReply(t, "You are not allowed to view accounts.") } - account := cc.Server.Accounts[string(t.GetField(FieldUserLogin).Data)] + account := cc.Server.AccountManager.Get(string(t.GetField(FieldUserLogin).Data)) if account == nil { return cc.NewErrReply(t, "Account does not exist.") } - res = append(res, cc.NewReply(t, + return append(res, cc.NewReply(t, NewField(FieldUserName, []byte(account.Name)), NewField(FieldUserLogin, encodeString(t.GetField(FieldUserLogin).Data)), NewField(FieldUserPassword, []byte(account.Password)), NewField(FieldUserAccess, account.Access[:]), )) - return res } func HandleListUsers(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessOpenUser) { + if !cc.Authorize(AccessOpenUser) { return cc.NewErrReply(t, "You are not allowed to view accounts.") } var userFields []Field - for _, acc := range cc.Server.Accounts { - accCopy := *acc - b, err := io.ReadAll(&accCopy) + for _, acc := range cc.Server.AccountManager.List() { + b, err := io.ReadAll(&acc) if err != nil { - return res + cc.logger.Error("Error reading account", "Account", acc.Login, "Err", err) + continue } userFields = append(userFields, NewField(FieldData, b)) } - res = append(res, cc.NewReply(t, userFields...)) - return res + return append(res, cc.NewReply(t, userFields...)) } // HandleUpdateUser is used by the v1.5+ multi-user editor to perform account editing for multiple users at a time. @@ -601,16 +590,37 @@ func HandleUpdateUser(cc *ClientConn, t *Transaction) (res []Transaction) { // If there's only one subfield, that indicates this is a delete operation for the login in FieldData if len(subFields) == 1 { - if !cc.Authorize(accessDeleteUser) { + if !cc.Authorize(AccessDeleteUser) { return cc.NewErrReply(t, "You are not allowed to delete accounts.") } login := string(encodeString(getField(FieldData, &subFields).Data)) + cc.logger.Info("DeleteUser", "login", login) - if err := cc.Server.DeleteUser(login); err != nil { + if err := cc.Server.AccountManager.Delete(login); err != nil { + cc.logger.Error("Error deleting account", "Err", err) return res } + + for _, client := range cc.Server.ClientMgr.List() { + if client.Account.Login == login { + // "You are logged in with an account which was deleted." + + res = append(res, + NewTransaction(TranServerMsg, [2]byte{}, + NewField(FieldData, []byte("You are logged in with an account which was deleted.")), + NewField(FieldChatOptions, []byte{0}), + ), + ) + + go func(c *ClientConn) { + time.Sleep(3 * time.Second) + c.Disconnect() + }(client) + } + } + continue } @@ -630,15 +640,15 @@ func HandleUpdateUser(cc *ClientConn, t *Transaction) (res []Transaction) { } // Check if accountToUpdate has an existing account. If so, we know we are updating an existing user. - if acc, ok := cc.Server.Accounts[accountToUpdate]; ok { + if acc := cc.Server.AccountManager.Get(accountToUpdate); acc != nil { if loginToRename != "" { cc.logger.Info("RenameUser", "prevLogin", accountToUpdate, "newLogin", userLogin) } else { cc.logger.Info("UpdateUser", "login", accountToUpdate) } - // account exists, so this is an update action - if !cc.Authorize(accessModifyUser) { + // Account exists, so this is an update action. + if !cc.Authorize(AccessModifyUser) { return cc.NewErrReply(t, "You are not allowed to modify accounts.") } @@ -662,18 +672,15 @@ func HandleUpdateUser(cc *ClientConn, t *Transaction) (res []Transaction) { copy(acc.Access[:], getField(FieldUserAccess, &subFields).Data) } - err := cc.Server.UpdateUser( - string(encodeString(getField(FieldData, &subFields).Data)), - string(encodeString(getField(FieldUserLogin, &subFields).Data)), - string(getField(FieldUserName, &subFields).Data), - acc.Password, - acc.Access, - ) + acc.Name = string(getField(FieldUserName, &subFields).Data) + + err := cc.Server.AccountManager.Update(*acc, string(encodeString(getField(FieldUserLogin, &subFields).Data))) + if err != nil { return res } } else { - if !cc.Authorize(accessCreateUser) { + if !cc.Authorize(AccessCreateUser) { return cc.NewErrReply(t, "You are not allowed to create new accounts.") } @@ -691,7 +698,9 @@ func HandleUpdateUser(cc *ClientConn, t *Transaction) (res []Transaction) { } } - err := cc.Server.NewUser(userLogin, string(getField(FieldUserName, &subFields).Data), string(getField(FieldUserPassword, &subFields).Data), newAccess) + account := NewAccount(userLogin, string(getField(FieldUserName, &subFields).Data), string(getField(FieldUserPassword, &subFields).Data), newAccess) + + err := cc.Server.AccountManager.Create(*account) if err != nil { return cc.NewErrReply(t, "Cannot create account because there is already an account with that login.") } @@ -703,18 +712,18 @@ func HandleUpdateUser(cc *ClientConn, t *Transaction) (res []Transaction) { // HandleNewUser creates a new user account func HandleNewUser(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessCreateUser) { + if !cc.Authorize(AccessCreateUser) { return cc.NewErrReply(t, "You are not allowed to create new accounts.") } - login := string(encodeString(t.GetField(FieldUserLogin).Data)) + login := t.GetField(FieldUserLogin).DecodeObfuscatedString() - // If the account already dataFile, reply with an error - if _, ok := cc.Server.Accounts[login]; ok { + // If the account already exists, reply with an error. + if account := cc.Server.AccountManager.Get(login); account != nil { return cc.NewErrReply(t, "Cannot create account "+login+" because there is already an account with that login.") } - newAccess := accessBitmap{} + var newAccess accessBitmap copy(newAccess[:], t.GetField(FieldUserAccess).Data) // Prevent account from creating new account with greater permission @@ -726,7 +735,10 @@ func HandleNewUser(cc *ClientConn, t *Transaction) (res []Transaction) { } } - if err := cc.Server.NewUser(login, string(t.GetField(FieldUserName).Data), string(t.GetField(FieldUserPassword).Data), newAccess); err != nil { + account := NewAccount(login, string(t.GetField(FieldUserName).Data), string(t.GetField(FieldUserPassword).Data), newAccess) + + err := cc.Server.AccountManager.Create(*account) + if err != nil { return cc.NewErrReply(t, "Cannot create account because there is already an account with that login.") } @@ -734,26 +746,43 @@ func HandleNewUser(cc *ClientConn, t *Transaction) (res []Transaction) { } func HandleDeleteUser(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessDeleteUser) { + if !cc.Authorize(AccessDeleteUser) { return cc.NewErrReply(t, "You are not allowed to delete accounts.") } - login := string(encodeString(t.GetField(FieldUserLogin).Data)) + login := t.GetField(FieldUserLogin).DecodeObfuscatedString() - if err := cc.Server.DeleteUser(login); err != nil { + if err := cc.Server.AccountManager.Delete(login); err != nil { + cc.logger.Error("Error deleting account", "Err", err) return res } + for _, client := range cc.Server.ClientMgr.List() { + if client.Account.Login == login { + res = append(res, + NewTransaction(TranServerMsg, client.ID, + NewField(FieldData, []byte("You are logged in with an account which was deleted.")), + NewField(FieldChatOptions, []byte{2}), + ), + ) + + go func(c *ClientConn) { + time.Sleep(2 * time.Second) + c.Disconnect() + }(client) + } + } + return append(res, cc.NewReply(t)) } // HandleUserBroadcast sends an Administrator Message to all connected clients of the server func HandleUserBroadcast(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessBroadcast) { + if !cc.Authorize(AccessBroadcast) { return cc.NewErrReply(t, "You are not allowed to send broadcast messages.") } - cc.sendAll( + cc.SendAll( TranServerMsg, NewField(FieldData, t.GetField(FieldData).Data), NewField(FieldChatOptions, []byte{0}), @@ -765,37 +794,51 @@ func HandleUserBroadcast(cc *ClientConn, t *Transaction) (res []Transaction) { // HandleGetClientInfoText returns user information for the specific user. // // Fields used in the request: -// 103 User ID +// 103 User Type // // Fields used in the reply: // 102 User Name // 101 Data User info text string func HandleGetClientInfoText(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessGetClientInfo) { + if !cc.Authorize(AccessGetClientInfo) { return cc.NewErrReply(t, "You are not allowed to get client info.") } clientID := t.GetField(FieldUserID).Data - clientConn := cc.Server.Clients[[2]byte(clientID)] + clientConn := cc.Server.ClientMgr.Get(ClientID(clientID)) if clientConn == nil { return cc.NewErrReply(t, "User not found.") } - res = append(res, cc.NewReply(t, + return append(res, cc.NewReply(t, NewField(FieldData, []byte(clientConn.String())), NewField(FieldUserName, clientConn.UserName), )) - return res } func HandleGetUserNameList(cc *ClientConn, t *Transaction) (res []Transaction) { - return []Transaction{cc.NewReply(t, cc.Server.connectedUsers()...)} + var fields []Field + for _, c := range cc.Server.ClientMgr.List() { + b, err := io.ReadAll(&User{ + ID: c.ID, + Icon: c.Icon, + Flags: c.Flags[:], + Name: string(c.UserName), + }) + if err != nil { + return nil + } + + fields = append(fields, NewField(FieldUsernameWithInfo, b)) + } + + return []Transaction{cc.NewReply(t, fields...)} } func HandleTranAgreed(cc *ClientConn, t *Transaction) (res []Transaction) { if t.GetField(FieldUserName).Data != nil { - if cc.Authorize(accessAnyName) { + if cc.Authorize(AccessAnyName) { cc.UserName = t.GetField(FieldUserName).Data } else { cc.UserName = []byte(cc.Account.Name) @@ -805,7 +848,7 @@ func HandleTranAgreed(cc *ClientConn, t *Transaction) (res []Transaction) { cc.Icon = t.GetField(FieldUserIconID).Data cc.logger = cc.logger.With("Name", string(cc.UserName)) - cc.logger.Info("Login successful", "clientVersion", fmt.Sprintf("%v", func() int { i, _ := byteToInt(cc.Version); return i }())) + cc.logger.Info("Login successful") options := t.GetField(FieldOptions).Data optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options))) @@ -824,7 +867,7 @@ func HandleTranAgreed(cc *ClientConn, t *Transaction) (res []Transaction) { cc.AutoReply = t.GetField(FieldAutomaticResponse).Data } - trans := cc.notifyOthers( + trans := cc.NotifyOthers( NewTransaction( TranNotifyChangeUser, [2]byte{0, 0}, NewField(FieldUserName, cc.UserName), @@ -848,13 +891,10 @@ func HandleTranAgreed(cc *ClientConn, t *Transaction) (res []Transaction) { // Fields used in this request: // 101 Data func HandleTranOldPostNews(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessNewsPostArt) { + if !cc.Authorize(AccessNewsPostArt) { return cc.NewErrReply(t, "You are not allowed to post news.") } - cc.Server.flatNewsMux.Lock() - defer cc.Server.flatNewsMux.Unlock() - newsDateTemplate := defaultNewsDateFormat if cc.Server.Config.NewsDateFormat != "" { newsDateTemplate = cc.Server.Config.NewsDateFormat @@ -868,32 +908,30 @@ func HandleTranOldPostNews(cc *ClientConn, t *Transaction) (res []Transaction) { newsPost := fmt.Sprintf(newsTemplate+"\r", cc.UserName, time.Now().Format(newsDateTemplate), t.GetField(FieldData).Data) newsPost = strings.ReplaceAll(newsPost, "\n", "\r") - // update news in memory - cc.Server.FlatNews = append([]byte(newsPost), cc.Server.FlatNews...) - - // update news on disk - if err := cc.Server.FS.WriteFile(filepath.Join(cc.Server.ConfigDir, "MessageBoard.txt"), cc.Server.FlatNews, 0644); err != nil { - return res + _, err := cc.Server.MessageBoard.Write([]byte(newsPost)) + if err != nil { + cc.logger.Error("error writing news post", "err", err) + return nil } // Notify all clients of updated news - cc.sendAll( + cc.SendAll( TranNewMsg, NewField(FieldData, []byte(newsPost)), ) - res = append(res, cc.NewReply(t)) - return res + return append(res, cc.NewReply(t)) } func HandleDisconnectUser(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessDisconUser) { + if !cc.Authorize(AccessDisconUser) { return cc.NewErrReply(t, "You are not allowed to disconnect users.") } - clientConn := cc.Server.Clients[[2]byte(t.GetField(FieldUserID).Data)] + clientID := [2]byte(t.GetField(FieldUserID).Data) + clientConn := cc.Server.ClientMgr.Get(clientID) - if clientConn.Authorize(accessCannotBeDiscon) { + if clientConn.Authorize(AccessCannotBeDiscon) { return cc.NewErrReply(t, clientConn.Account.Login+" is not allowed to be disconnected.") } @@ -914,7 +952,13 @@ func HandleDisconnectUser(cc *ClientConn, t *Transaction) (res []Transaction) { )) banUntil := time.Now().Add(tempBanDuration) - cc.Server.banList[strings.Split(clientConn.RemoteAddr, ":")[0]] = &banUntil + ip := strings.Split(clientConn.RemoteAddr, ":")[0] + + err := cc.Server.BanList.Add(ip, &banUntil) + if err != nil { + cc.logger.Error("Error saving ban", "err", err) + // TODO + } case 2: // send message: "You are permanently banned on this server" cc.logger.Info("Disconnect & ban " + string(clientConn.UserName)) @@ -926,12 +970,12 @@ func HandleDisconnectUser(cc *ClientConn, t *Transaction) (res []Transaction) { NewField(FieldChatOptions, []byte{0, 0}), )) - cc.Server.banList[strings.Split(clientConn.RemoteAddr, ":")[0]] = nil - } + ip := strings.Split(clientConn.RemoteAddr, ":")[0] - err := cc.Server.writeBanList() - if err != nil { - return res + err := cc.Server.BanList.Add(ip, nil) + if err != nil { + // TODO + } } } @@ -948,81 +992,67 @@ func HandleDisconnectUser(cc *ClientConn, t *Transaction) (res []Transaction) { // Fields used in the request: // 325 News path (Optional) func HandleGetNewsCatNameList(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessNewsReadArt) { + if !cc.Authorize(AccessNewsReadArt) { return cc.NewErrReply(t, "You are not allowed to read news.") } - pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data) - cats := cc.Server.GetNewsCatByPath(pathStrs) + pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath() + if err != nil { - // To store the keys in slice in sorted order - keys := make([]string, len(cats)) - i := 0 - for k := range cats { - keys[i] = k - i++ } - sort.Strings(keys) - - var fieldData []Field - for _, k := range keys { - cat := cats[k] - b, _ := io.ReadAll(&cat) + var fields []Field + for _, cat := range cc.Server.ThreadedNewsMgr.GetCategories(pathStrs) { + b, err := io.ReadAll(&cat) + if err != nil { + // TODO + } - fieldData = append(fieldData, NewField(FieldNewsCatListData15, b)) + fields = append(fields, NewField(FieldNewsCatListData15, b)) } - res = append(res, cc.NewReply(t, fieldData...)) - return res + return append(res, cc.NewReply(t, fields...)) } func HandleNewNewsCat(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessNewsCreateCat) { + if !cc.Authorize(AccessNewsCreateCat) { return cc.NewErrReply(t, "You are not allowed to create news categories.") } name := string(t.GetField(FieldNewsCatName).Data) - pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data) - - cats := cc.Server.GetNewsCatByPath(pathStrs) - cats[name] = NewsCategoryListData15{ - Name: name, - Type: [2]byte{0, 3}, - Articles: map[uint32]*NewsArtData{}, - SubCats: make(map[string]NewsCategoryListData15), + pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath() + if err != nil { + return res } - if err := cc.Server.writeThreadedNews(); err != nil { - return res + err = cc.Server.ThreadedNewsMgr.CreateGrouping(pathStrs, name, NewsCategory) + if err != nil { + cc.logger.Error("error creating news category", "err", err) } - res = append(res, cc.NewReply(t)) - return res + + return []Transaction{cc.NewReply(t)} } // Fields used in the request: // 322 News category Name // 325 News path func HandleNewNewsFldr(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessNewsCreateFldr) { + if !cc.Authorize(AccessNewsCreateFldr) { return cc.NewErrReply(t, "You are not allowed to create news folders.") } name := string(t.GetField(FieldFileName).Data) - pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data) - - cats := cc.Server.GetNewsCatByPath(pathStrs) - cats[name] = NewsCategoryListData15{ - Name: name, - Type: [2]byte{0, 2}, - Articles: map[uint32]*NewsArtData{}, - SubCats: make(map[string]NewsCategoryListData15), - } - if err := cc.Server.writeThreadedNews(); err != nil { + pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath() + if err != nil { return res } - res = append(res, cc.NewReply(t)) - return res + + err = cc.Server.ThreadedNewsMgr.CreateGrouping(pathStrs, name, NewsBundle) + if err != nil { + cc.logger.Error("error creating news bundle", "err", err) + } + + return append(res, cc.NewReply(t)) } // HandleGetNewsArtData gets the list of article names at the specified news path. @@ -1033,29 +1063,23 @@ func HandleNewNewsFldr(cc *ClientConn, t *Transaction) (res []Transaction) { // Fields used in the reply: // 321 News article list data Optional func HandleGetNewsArtNameList(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessNewsReadArt) { + if !cc.Authorize(AccessNewsReadArt) { return cc.NewErrReply(t, "You are not allowed to read news.") } - pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data) - - var cat NewsCategoryListData15 - cats := cc.Server.ThreadedNews.Categories - - for _, fp := range pathStrs { - cat = cats[fp] - cats = cats[fp].SubCats + pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath() + if err != nil { + return res } - nald := cat.GetNewsArtListData() + nald := cc.Server.ThreadedNewsMgr.ListArticles(pathStrs) b, err := io.ReadAll(&nald) if err != nil { return res } - res = append(res, cc.NewReply(t, NewField(FieldNewsArtListData, b))) - return res + return append(res, cc.NewReply(t, NewField(FieldNewsArtListData, b))) } // HandleGetNewsArtData requests information about the specific news article. @@ -1063,40 +1087,35 @@ func HandleGetNewsArtNameList(cc *ClientConn, t *Transaction) (res []Transaction // // Request fields // 325 News path -// 326 News article ID +// 326 News article Type // 327 News article data flavor // // Fields used in the reply: // 328 News article title // 329 News article poster // 330 News article date -// 331 Previous article ID -// 332 Next article ID -// 335 Parent article ID -// 336 First child article ID +// 331 Previous article Type +// 332 Next article Type +// 335 Parent article Type +// 336 First child article Type // 327 News article data flavor "Should be “text/plain” // 333 News article data Optional (if data flavor is “text/plain”) func HandleGetNewsArtData(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessNewsReadArt) { + if !cc.Authorize(AccessNewsReadArt) { return cc.NewErrReply(t, "You are not allowed to read news.") } - var cat NewsCategoryListData15 - cats := cc.Server.ThreadedNews.Categories - - for _, fp := range ReadNewsPath(t.GetField(FieldNewsPath).Data) { - cat = cats[fp] - cats = cats[fp].SubCats + newsPath, err := t.GetField(FieldNewsPath).DecodeNewsPath() + if err != nil { + return res } - // The official Hotline clients will send the article ID as 2 bytes if possible, but - // some third party clients such as Frogblast and Heildrun will always send 4 bytes - convertedID, err := byteToInt(t.GetField(FieldNewsArtID).Data) + convertedID, err := t.GetField(FieldNewsArtID).DecodeInt() if err != nil { return res } - art := cat.Articles[uint32(convertedID)] + art := cc.Server.ThreadedNewsMgr.GetArticle(newsPath, uint32(convertedID)) if art == nil { return append(res, cc.NewReply(t)) } @@ -1115,144 +1134,104 @@ func HandleGetNewsArtData(cc *ClientConn, t *Transaction) (res []Transaction) { return res } -// HandleDelNewsItem deletes an existing threaded news folder or category from the server. +// HandleDelNewsItem deletes a threaded news folder or category. // Fields used in the request: // 325 News path // Fields used in the reply: // None func HandleDelNewsItem(cc *ClientConn, t *Transaction) (res []Transaction) { - pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data) - - cats := cc.Server.ThreadedNews.Categories - delName := pathStrs[len(pathStrs)-1] - if len(pathStrs) > 1 { - for _, fp := range pathStrs[0 : len(pathStrs)-1] { - cats = cats[fp].SubCats - } + pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath() + if err != nil { + return res } - if cats[delName].Type == [2]byte{0, 3} { - if !cc.Authorize(accessNewsDeleteCat) { + item := cc.Server.ThreadedNewsMgr.NewsItem(pathStrs) + + if item.Type == [2]byte{0, 3} { + if !cc.Authorize(AccessNewsDeleteCat) { return cc.NewErrReply(t, "You are not allowed to delete news categories.") } } else { - if !cc.Authorize(accessNewsDeleteFldr) { + if !cc.Authorize(AccessNewsDeleteFldr) { return cc.NewErrReply(t, "You are not allowed to delete news folders.") } } - delete(cats, delName) - - if err := cc.Server.writeThreadedNews(); err != nil { + err = cc.Server.ThreadedNewsMgr.DeleteNewsItem(pathStrs) + if err != nil { return res } return append(res, cc.NewReply(t)) } +// HandleDelNewsArt deletes a threaded news article. +// Request Fields +// 325 News path +// 326 News article Type +// 337 News article recursive delete - Delete child articles (1) or not (0) func HandleDelNewsArt(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessNewsDeleteArt) { + if !cc.Authorize(AccessNewsDeleteArt) { return cc.NewErrReply(t, "You are not allowed to delete news articles.") } - // Request Fields - // 325 News path - // 326 News article ID - // 337 News article – recursive delete Delete child articles (1) or not (0) - pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data) - ID, err := byteToInt(t.GetField(FieldNewsArtID).Data) + pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath() if err != nil { return res } - // TODO: Delete recursive - cats := cc.Server.GetNewsCatByPath(pathStrs[:len(pathStrs)-1]) - - catName := pathStrs[len(pathStrs)-1] - cat := cats[catName] + articleID, err := t.GetField(FieldNewsArtID).DecodeInt() + if err != nil { + cc.logger.Error("error reading article Type", "err", err) + return + } - delete(cat.Articles, uint32(ID)) + deleteRecursive := bytes.Equal([]byte{0, 1}, t.GetField(FieldNewsArtRecurseDel).Data) - cats[catName] = cat - if err := cc.Server.writeThreadedNews(); err != nil { - return res + err = cc.Server.ThreadedNewsMgr.DeleteArticle(pathStrs, uint32(articleID), deleteRecursive) + if err != nil { + cc.logger.Error("error deleting news article", "err", err) } - res = append(res, cc.NewReply(t)) - return res + return []Transaction{cc.NewReply(t)} } // Request fields // 325 News path -// 326 News article ID ID of the parent article? +// 326 News article Type Type of the parent article? // 328 News article title // 334 News article flags // 327 News article data flavor Currently “text/plain” // 333 News article data func HandlePostNewsArt(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessNewsPostArt) { + if !cc.Authorize(AccessNewsPostArt) { return cc.NewErrReply(t, "You are not allowed to post news articles.") } - pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data) - cats := cc.Server.GetNewsCatByPath(pathStrs[:len(pathStrs)-1]) - - catName := pathStrs[len(pathStrs)-1] - cat := cats[catName] - - artID, err := byteToInt(t.GetField(FieldNewsArtID).Data) + pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath() if err != nil { return res } - convertedArtID := uint32(artID) - bs := make([]byte, 4) - binary.BigEndian.PutUint32(bs, convertedArtID) - - cc.Server.mux.Lock() - defer cc.Server.mux.Unlock() - - newArt := NewsArtData{ - Title: string(t.GetField(FieldNewsArtTitle).Data), - Poster: string(cc.UserName), - Date: toHotlineTime(time.Now()), - ParentArt: [4]byte(bs), - DataFlav: []byte("text/plain"), - Data: string(t.GetField(FieldNewsArtData).Data), - } - - var keys []int - for k := range cat.Articles { - keys = append(keys, int(k)) - } - - nextID := uint32(1) - if len(keys) > 0 { - sort.Ints(keys) - prevID := uint32(keys[len(keys)-1]) - nextID = prevID + 1 - - binary.BigEndian.PutUint32(newArt.PrevArt[:], prevID) - - // Set next article ID - binary.BigEndian.PutUint32(cat.Articles[prevID].NextArt[:], nextID) - } - - // Update parent article with first child reply - parentID := convertedArtID - if parentID != 0 { - parentArt := cat.Articles[parentID] - if parentArt.FirstChildArt == [4]byte{0, 0, 0, 0} { - binary.BigEndian.PutUint32(parentArt.FirstChildArt[:], nextID) - } + parentArticleID, err := t.GetField(FieldNewsArtID).DecodeInt() + if err != nil { + return res } - cat.Articles[nextID] = &newArt - - cats[catName] = cat - if err := cc.Server.writeThreadedNews(); err != nil { - return res + err = cc.Server.ThreadedNewsMgr.PostArticle( + pathStrs, + uint32(parentArticleID), + NewsArtData{ + Title: string(t.GetField(FieldNewsArtTitle).Data), + Poster: string(cc.UserName), + Date: toHotlineTime(time.Now()), + DataFlav: NewsFlavor, + Data: string(t.GetField(FieldNewsArtData).Data), + }, + ) + if err != nil { + cc.logger.Error("error posting news article", "err", err) } return append(res, cc.NewReply(t)) @@ -1260,17 +1239,22 @@ func HandlePostNewsArt(cc *ClientConn, t *Transaction) (res []Transaction) { // HandleGetMsgs returns the flat news data func HandleGetMsgs(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessNewsReadArt) { + if !cc.Authorize(AccessNewsReadArt) { return cc.NewErrReply(t, "You are not allowed to read news.") } - res = append(res, cc.NewReply(t, NewField(FieldData, cc.Server.FlatNews))) + _, _ = cc.Server.MessageBoard.Seek(0, 0) - return res + newsData, err := io.ReadAll(cc.Server.MessageBoard) + if err != nil { + // TODO + } + + return append(res, cc.NewReply(t, NewField(FieldData, newsData))) } func HandleDownloadFile(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessDownloadFile) { + if !cc.Authorize(AccessDownloadFile) { return cc.NewErrReply(t, "You are not allowed to download files.") } @@ -1311,7 +1295,7 @@ func HandleDownloadFile(cc *ClientConn, t *Transaction) (res []Transaction) { ft.fileResumeData = &frd } - // Optional field for when a HL v1.5+ client requests file preview + // Optional field for when a client requests file preview // Used only for TEXT, JPEG, GIFF, BMP or PICT files // The value will always be 2 if t.GetField(FieldFileTransferOptions).Data != nil { @@ -1331,7 +1315,7 @@ func HandleDownloadFile(cc *ClientConn, t *Transaction) (res []Transaction) { // Download all files from the specified folder and sub-folders func HandleDownloadFolder(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessDownloadFile) { + if !cc.Authorize(AccessDownloadFile) { return cc.NewErrReply(t, "You are not allowed to download folders.") } @@ -1348,7 +1332,6 @@ func HandleDownloadFolder(cc *ClientConn, t *Transaction) (res []Transaction) { if err != nil { return res } - spew.Dump(itemCount) fileTransfer := cc.newFileTransfer(FolderDownload, t.GetField(FieldFileName).Data, t.GetField(FieldFilePath).Data, transferSize) @@ -1383,7 +1366,7 @@ func HandleUploadFolder(cc *ClientConn, t *Transaction) (res []Transaction) { } // Handle special cases for Upload and Drop Box folders - if !cc.Authorize(accessUploadAnywhere) { + if !cc.Authorize(AccessUploadAnywhere) { if !fp.IsUploadDir() && !fp.IsDropbox() { return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload of the folder \"%v\" because you are only allowed to upload to the \"Uploads\" folder.", string(t.GetField(FieldFileName).Data))) } @@ -1408,7 +1391,7 @@ func HandleUploadFolder(cc *ClientConn, t *Transaction) (res []Transaction) { // Used only to resume download, currently has value 2" // 108 File transfer size "Optional used if download is not resumed" func HandleUploadFile(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessUploadFile) { + if !cc.Authorize(AccessUploadFile) { return cc.NewErrReply(t, "You are not allowed to upload files.") } @@ -1425,7 +1408,7 @@ func HandleUploadFile(cc *ClientConn, t *Transaction) (res []Transaction) { } // Handle special cases for Upload and Drop Box folders - if !cc.Authorize(accessUploadAnywhere) { + if !cc.Authorize(AccessUploadAnywhere) { if !fp.IsUploadDir() && !fp.IsDropbox() { return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload of the file \"%v\" because you are only allowed to upload to the \"Uploads\" folder.", string(fileName))) } @@ -1474,24 +1457,24 @@ func HandleSetClientUserInfo(cc *ClientConn, t *Transaction) (res []Transaction) } else { cc.Icon = t.GetField(FieldUserIconID).Data } - if cc.Authorize(accessAnyName) { + if cc.Authorize(AccessAnyName) { cc.UserName = t.GetField(FieldUserName).Data } - cc.flagsMU.Lock() - defer cc.flagsMU.Unlock() - // the options field is only passed by the client versions > 1.2.3. options := t.GetField(FieldOptions).Data if options != nil { optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options))) - flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(cc.Flags[:]))) - flagBitmap.SetBit(flagBitmap, UserFlagRefusePM, optBitmap.Bit(UserOptRefusePM)) - binary.BigEndian.PutUint16(cc.Flags[:], uint16(flagBitmap.Int64())) + //flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(cc.Flags[:]))) + //flagBitmap.SetBit(flagBitmap, UserFlagRefusePM, optBitmap.Bit(UserOptRefusePM)) + //binary.BigEndian.PutUint16(cc.Flags[:], uint16(flagBitmap.Int64())) - flagBitmap.SetBit(flagBitmap, UserFlagRefusePChat, optBitmap.Bit(UserOptRefuseChat)) - binary.BigEndian.PutUint16(cc.Flags[:], uint16(flagBitmap.Int64())) + cc.Flags.Set(UserFlagRefusePM, optBitmap.Bit(UserOptRefusePM)) + cc.Flags.Set(UserFlagRefusePChat, optBitmap.Bit(UserOptRefuseChat)) + // + //flagBitmap.SetBit(flagBitmap, UserFlagRefusePChat, optBitmap.Bit(UserOptRefuseChat)) + //binary.BigEndian.PutUint16(cc.Flags[:], uint16(flagBitmap.Int64())) // Check auto response if optBitmap.Bit(UserOptAutoResponse) == 1 { @@ -1501,7 +1484,7 @@ func HandleSetClientUserInfo(cc *ClientConn, t *Transaction) (res []Transaction) } } - for _, c := range cc.Server.Clients { + for _, c := range cc.Server.ClientMgr.List() { res = append(res, NewTransaction( TranNotifyChangeUser, c.ID, @@ -1542,7 +1525,7 @@ func HandleGetFileNameList(cc *ClientConn, t *Transaction) (res []Transaction) { } // Handle special case for drop box folders - if fp.IsDropbox() && !cc.Authorize(accessViewDropBoxes) { + if fp.IsDropbox() && !cc.Authorize(AccessViewDropBoxes) { return cc.NewErrReply(t, "You are not allowed to view drop boxes.") } @@ -1559,10 +1542,10 @@ func HandleGetFileNameList(cc *ClientConn, t *Transaction) (res []Transaction) { // ================================= // Hotline private chat flow // ================================= -// 1. ClientA sends TranInviteNewChat to server with user ID to invite +// 1. ClientA sends TranInviteNewChat to server with user Type to invite // 2. Server creates new ChatID // 3. Server sends TranInviteToChat to invitee -// 4. Server replies to ClientA with new Chat ID +// 4. Server replies to ClientA with new Chat Type // // A dialog box pops up in the invitee client with options to accept or decline the invitation. // If Accepted is clicked: @@ -1570,16 +1553,18 @@ func HandleGetFileNameList(cc *ClientConn, t *Transaction) (res []Transaction) { // HandleInviteNewChat invites users to new private chat func HandleInviteNewChat(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessOpenChat) { + if !cc.Authorize(AccessOpenChat) { return cc.NewErrReply(t, "You are not allowed to request private chat.") } // Client to Invite targetID := t.GetField(FieldUserID).Data - newChatID := cc.Server.NewPrivateChat(cc) + + // Create a new chat with self as initial member. + newChatID := cc.Server.ChatMgr.New(cc) // Check if target user has "Refuse private chat" flag - targetClient := cc.Server.Clients[[2]byte(targetID)] + targetClient := cc.Server.ClientMgr.Get([2]byte(targetID)) flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(targetClient.Flags[:]))) if flagBitmap.Bit(UserFlagRefusePChat) == 1 { res = append(res, @@ -1604,7 +1589,8 @@ func HandleInviteNewChat(cc *ClientConn, t *Transaction) (res []Transaction) { ) } - res = append(res, + return append( + res, cc.NewReply(t, NewField(FieldChatID, newChatID[:]), NewField(FieldUserName, cc.UserName), @@ -1613,12 +1599,10 @@ func HandleInviteNewChat(cc *ClientConn, t *Transaction) (res []Transaction) { NewField(FieldUserFlags, cc.Flags[:]), ), ) - - return res } func HandleInviteToChat(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessOpenChat) { + if !cc.Authorize(AccessOpenChat) { return cc.NewErrReply(t, "You are not allowed to request private chat.") } @@ -1647,9 +1631,8 @@ func HandleInviteToChat(cc *ClientConn, t *Transaction) (res []Transaction) { func HandleRejectChatInvite(cc *ClientConn, t *Transaction) (res []Transaction) { chatID := [4]byte(t.GetField(FieldChatID).Data) - privChat := cc.Server.PrivateChats[chatID] - for _, c := range privChat.ClientConn { + for _, c := range cc.Server.ChatMgr.Members(chatID) { res = append(res, NewTransaction( TranChatMsg, @@ -1671,10 +1654,8 @@ func HandleRejectChatInvite(cc *ClientConn, t *Transaction) (res []Transaction) func HandleJoinChat(cc *ClientConn, t *Transaction) (res []Transaction) { chatID := t.GetField(FieldChatID).Data - privChat := cc.Server.PrivateChats[[4]byte(chatID)] - // Send TranNotifyChatChangeUser to current members of the chat to inform of new user - for _, c := range privChat.ClientConn { + for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) { res = append(res, NewTransaction( TranNotifyChatChangeUser, @@ -1688,10 +1669,12 @@ func HandleJoinChat(cc *ClientConn, t *Transaction) (res []Transaction) { ) } - privChat.ClientConn[cc.ID] = cc + cc.Server.ChatMgr.Join(ChatID(chatID), cc) + + subject := cc.Server.ChatMgr.GetSubject(ChatID(chatID)) - replyFields := []Field{NewField(FieldChatSubject, []byte(privChat.Subject))} - for _, c := range privChat.ClientConn { + replyFields := []Field{NewField(FieldChatSubject, []byte(subject))} + for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) { b, err := io.ReadAll(&User{ ID: c.ID, Icon: c.Icon, @@ -1704,8 +1687,7 @@ func HandleJoinChat(cc *ClientConn, t *Transaction) (res []Transaction) { replyFields = append(replyFields, NewField(FieldUsernameWithInfo, b)) } - res = append(res, cc.NewReply(t, replyFields...)) - return res + return append(res, cc.NewReply(t, replyFields...)) } // HandleLeaveChat is sent from a v1.8+ Hotline client when the user exits a private chat @@ -1716,15 +1698,10 @@ func HandleJoinChat(cc *ClientConn, t *Transaction) (res []Transaction) { func HandleLeaveChat(cc *ClientConn, t *Transaction) (res []Transaction) { chatID := t.GetField(FieldChatID).Data - privChat, ok := cc.Server.PrivateChats[[4]byte(chatID)] - if !ok { - return res - } - - delete(privChat.ClientConn, cc.ID) + cc.Server.ChatMgr.Leave([4]byte(chatID), cc.ID) // Notify members of the private chat that the user has left - for _, c := range privChat.ClientConn { + for _, c := range cc.Server.ChatMgr.Members(ChatID(chatID)) { res = append(res, NewTransaction( TranNotifyChatDeleteUser, @@ -1740,16 +1717,16 @@ func HandleLeaveChat(cc *ClientConn, t *Transaction) (res []Transaction) { // HandleSetChatSubject is sent from a v1.8+ Hotline client when the user sets a private chat subject // Fields used in the request: -// * 114 Chat ID +// * 114 Chat Type // * 115 Chat subject // Reply is not expected. func HandleSetChatSubject(cc *ClientConn, t *Transaction) (res []Transaction) { chatID := t.GetField(FieldChatID).Data - privChat := cc.Server.PrivateChats[[4]byte(chatID)] - privChat.Subject = string(t.GetField(FieldChatSubject).Data) + cc.Server.ChatMgr.SetSubject([4]byte(chatID), string(t.GetField(FieldChatSubject).Data)) - for _, c := range privChat.ClientConn { + // Notify chat members of new subject. + for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) { res = append(res, NewTransaction( TranNotifyChatSubject, @@ -1772,7 +1749,7 @@ func HandleSetChatSubject(cc *ClientConn, t *Transaction) (res []Transaction) { // Fields used in the reply: // None func HandleMakeAlias(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(accessMakeAlias) { + if !cc.Authorize(AccessMakeAlias) { return cc.NewErrReply(t, "You are not allowed to make aliases.") } fileName := t.GetField(FieldFileName).Data @@ -1806,7 +1783,7 @@ func HandleMakeAlias(cc *ClientConn, t *Transaction) (res []Transaction) { // 107 FieldRefNum Used later for transfer // 108 FieldTransferSize Size of data to be downloaded func HandleDownloadBanner(cc *ClientConn, t *Transaction) (res []Transaction) { - ft := cc.newFileTransfer(bannerDownload, []byte{}, []byte{}, make([]byte, 4)) + ft := cc.newFileTransfer(BannerDownload, []byte{}, []byte{}, make([]byte, 4)) binary.BigEndian.PutUint32(ft.TransferSize, uint32(len(cc.Server.banner))) return append(res, cc.NewReply(t, diff --git a/hotline/transaction_handlers_test.go b/hotline/transaction_handlers_test.go index b7cc0af..610e113 100644 --- a/hotline/transaction_handlers_test.go +++ b/hotline/transaction_handlers_test.go @@ -9,7 +9,6 @@ import ( "os" "path/filepath" "strings" - "sync" "testing" "time" ) @@ -30,39 +29,63 @@ func TestHandleSetChatSubject(t *testing.T) { cc: &ClientConn{ UserName: []byte{0x00, 0x01}, Server: &Server{ - PrivateChats: map[[4]byte]*PrivateChat{ - [4]byte{0, 0, 0, 1}: { - Subject: "unset", - ClientConn: map[[2]byte]*ClientConn{ - [2]byte{0, 1}: { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 1}, + ChatMgr: func() *MockChatManager { + m := MockChatManager{} + m.On("Members", ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*ClientConn{ + { + Account: &Account{ + Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, }, - [2]byte{0, 2}: { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 2}, + ID: [2]byte{0, 1}, + }, + { + Account: &Account{ + Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, }, + ID: [2]byte{0, 2}, }, - }, - }, - Clients: map[[2]byte]*ClientConn{ - [2]byte{0, 1}: { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }) + m.On("SetSubject", ChatID{0x0, 0x0, 0x0, 0x1}, "Test Subject") + return &m + }(), + //PrivateChats: map[[4]byte]*PrivateChat{ + // [4]byte{0, 0, 0, 1}: { + // Subject: "unset", + // ClientConn: map[[2]byte]*ClientConn{ + // [2]byte{0, 1}: { + // Account: &Account{ + // Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + // }, + // ID: [2]byte{0, 1}, + // }, + // [2]byte{0, 2}: { + // Account: &Account{ + // Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + // }, + // ID: [2]byte{0, 2}, + // }, + // }, + // }, + //}, + ClientMgr: func() *MockClientMgr { + m := MockClientMgr{} + m.On("List").Return([]*ClientConn{ + { + Account: &Account{ + Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, }, - ID: [2]byte{0, 1}, - }, - [2]byte{0, 2}: { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + { + Account: &Account{ + Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 2}, }, - ID: [2]byte{0, 2}, }, - }, + ) + return &m + }(), }, }, t: Transaction{ @@ -115,43 +138,44 @@ func TestHandleLeaveChat(t *testing.T) { want []Transaction }{ { - name: "returns expected transactions", + name: "when client 2 leaves chat", args: args{ cc: &ClientConn{ ID: [2]byte{0, 2}, Server: &Server{ - PrivateChats: map[[4]byte]*PrivateChat{ - [4]byte{0, 0, 0, 1}: { - ClientConn: map[[2]byte]*ClientConn{ - [2]byte{0, 1}: { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 1}, - }, - [2]byte{0, 2}: { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 2}, + ChatMgr: func() *MockChatManager { + m := MockChatManager{} + m.On("Members", ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*ClientConn{ + { + Account: &Account{ + Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, }, + ID: [2]byte{0, 1}, }, - }, - }, - Clients: map[[2]byte]*ClientConn{ - [2]byte{0, 1}: { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }) + m.On("Leave", ChatID{0x0, 0x0, 0x0, 0x1}, [2]uint8{0x0, 0x2}) + m.On("GetSubject").Return("unset") + return &m + }(), + ClientMgr: func() *MockClientMgr { + m := MockClientMgr{} + m.On("Get").Return([]*ClientConn{ + { + Account: &Account{ + Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, }, - ID: [2]byte{0, 1}, - }, - [2]byte{0, 2}: { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + { + Account: &Account{ + Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 2}, }, - ID: [2]byte{0, 2}, }, - }, + ) + return &m + }(), }, }, t: NewTransaction(TranDeleteUser, [2]byte{}, NewField(FieldChatID, []byte{0, 0, 0, 1})), @@ -194,20 +218,25 @@ func TestHandleGetUserNameList(t *testing.T) { cc: &ClientConn{ ID: [2]byte{0, 1}, Server: &Server{ - Clients: map[[2]byte]*ClientConn{ - [2]byte{0, 1}: { - ID: [2]byte{0, 1}, - Icon: []byte{0, 2}, - Flags: [2]byte{0, 3}, - UserName: []byte{0, 4}, - }, - [2]byte{0, 2}: { - ID: [2]byte{0, 2}, - Icon: []byte{0, 2}, - Flags: [2]byte{0, 3}, - UserName: []byte{0, 4}, + ClientMgr: func() *MockClientMgr { + m := MockClientMgr{} + m.On("List").Return([]*ClientConn{ + { + ID: [2]byte{0, 1}, + Icon: []byte{0, 2}, + Flags: [2]byte{0, 3}, + UserName: []byte{0, 4}, + }, + { + ID: [2]byte{0, 2}, + Icon: []byte{0, 2}, + Flags: [2]byte{0, 3}, + UserName: []byte{0, 4}, + }, }, - }, + ) + return &m + }(), }, }, t: Transaction{}, @@ -255,26 +284,31 @@ func TestHandleChatSend(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessSendChat) + bits.Set(AccessSendChat) return bits }(), }, UserName: []byte{0x00, 0x01}, Server: &Server{ - Clients: map[[2]byte]*ClientConn{ - [2]byte{0, 1}: { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + ClientMgr: func() *MockClientMgr { + m := MockClientMgr{} + m.On("List").Return([]*ClientConn{ + { + Account: &Account{ + Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, }, - ID: [2]byte{0, 1}, - }, - [2]byte{0, 2}: { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + { + Account: &Account{ + Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 2}, }, - ID: [2]byte{0, 2}, }, - }, + ) + return &m + }(), }, }, t: Transaction{ @@ -305,32 +339,37 @@ func TestHandleChatSend(t *testing.T) { }, }, { - name: "treats Chat ID 00 00 00 00 as a public chat message", + name: "treats Chat Type 00 00 00 00 as a public chat message", args: args{ cc: &ClientConn{ Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessSendChat) + bits.Set(AccessSendChat) return bits }(), }, UserName: []byte{0x00, 0x01}, Server: &Server{ - Clients: map[[2]byte]*ClientConn{ - [2]byte{0, 1}: { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + ClientMgr: func() *MockClientMgr { + m := MockClientMgr{} + m.On("List").Return([]*ClientConn{ + { + Account: &Account{ + Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, }, - ID: [2]byte{0, 1}, - }, - [2]byte{0, 2}: { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + { + Account: &Account{ + Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 2}, }, - ID: [2]byte{0, 2}, }, - }, + ) + return &m + }(), }, }, t: Transaction{ @@ -368,7 +407,7 @@ func TestHandleChatSend(t *testing.T) { }(), }, Server: &Server{ - Accounts: map[string]*Account{}, + //Accounts: map[string]*Account{}, }, }, t: NewTransaction( @@ -393,26 +432,31 @@ func TestHandleChatSend(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessSendChat) + bits.Set(AccessSendChat) return bits }(), }, UserName: []byte("Testy McTest"), Server: &Server{ - Clients: map[[2]byte]*ClientConn{ - [2]byte{0, 1}: { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + ClientMgr: func() *MockClientMgr { + m := MockClientMgr{} + m.On("List").Return([]*ClientConn{ + { + Account: &Account{ + Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, }, - ID: [2]byte{0, 1}, - }, - [2]byte{0, 2}: { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + { + Account: &Account{ + Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 2}, }, - ID: [2]byte{0, 2}, }, - }, + ) + return &m + }(), }, }, t: Transaction{ @@ -450,26 +494,31 @@ func TestHandleChatSend(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessSendChat) + bits.Set(AccessSendChat) return bits }(), }, UserName: []byte("Testy McTest"), Server: &Server{ - Clients: map[[2]byte]*ClientConn{ - [2]byte{0, 1}: { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + ClientMgr: func() *MockClientMgr { + m := MockClientMgr{} + m.On("List").Return([]*ClientConn{ + { + Account: &Account{ + Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, }, - ID: [2]byte{0, 1}, - }, - [2]byte{0, 2}: { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + { + Account: &Account{ + Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 2}, }, - ID: [2]byte{0, 2}, }, - }, + ) + return &m + }(), }, }, t: Transaction{ @@ -497,35 +546,39 @@ func TestHandleChatSend(t *testing.T) { }, }, { - name: "only sends chat msg to clients with accessReadChat permission", + name: "only sends chat msg to clients with AccessReadChat permission", args: args{ cc: &ClientConn{ Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessSendChat) + bits.Set(AccessSendChat) return bits }(), }, UserName: []byte{0x00, 0x01}, Server: &Server{ - Clients: map[[2]byte]*ClientConn{ - [2]byte{0, 1}: { - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(accessReadChat) - return bits - }()}, - ID: [2]byte{0, 1}, - }, - [2]byte{0, 2}: { - Account: &Account{ - Access: accessBitmap{0, 0, 0, 0, 0, 0, 0, 0}, + ClientMgr: func() *MockClientMgr { + m := MockClientMgr{} + m.On("List").Return([]*ClientConn{ + { + Account: &Account{ + Access: func() accessBitmap { + var bits accessBitmap + bits.Set(AccessReadChat) + return bits + }(), + }, + ID: [2]byte{0, 1}, + }, + { + Account: &Account{}, + ID: [2]byte{0, 2}, }, - ID: [2]byte{0, 2}, }, - }, + ) + return &m + }(), }, }, t: Transaction{ @@ -551,44 +604,50 @@ func TestHandleChatSend(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessSendChat) + bits.Set(AccessSendChat) return bits }(), }, UserName: []byte{0x00, 0x01}, Server: &Server{ - PrivateChats: map[[4]byte]*PrivateChat{ - [4]byte{0, 0, 0, 1}: { - ClientConn: map[[2]byte]*ClientConn{ - [2]byte{0, 1}: { - ID: [2]byte{0, 1}, - }, - [2]byte{0, 2}: { - ID: [2]byte{0, 2}, - }, + ChatMgr: func() *MockChatManager { + m := MockChatManager{} + m.On("Members", ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*ClientConn{ + { + ID: [2]byte{0, 1}, }, - }, - }, - Clients: map[[2]byte]*ClientConn{ - [2]byte{0, 1}: { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + { + ID: [2]byte{0, 2}, }, - ID: [2]byte{0, 1}, - }, - [2]byte{0, 2}: { - Account: &Account{ - Access: accessBitmap{0, 0, 0, 0, 0, 0, 0, 0}, + }) + m.On("GetSubject").Return("unset") + return &m + }(), + ClientMgr: func() *MockClientMgr { + m := MockClientMgr{} + m.On("List").Return([]*ClientConn{ + { + Account: &Account{ + Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, }, - ID: [2]byte{0, 2}, - }, - [2]byte{0, 3}: { - Account: &Account{ - Access: accessBitmap{0, 0, 0, 0, 0, 0, 0, 0}, + { + Account: &Account{ + Access: accessBitmap{0, 0, 0, 0, 0, 0, 0, 0}, + }, + ID: [2]byte{0, 2}, + }, + { + Account: &Account{ + Access: accessBitmap{0, 0, 0, 0, 0, 0, 0, 0}, + }, + ID: [2]byte{0, 3}, }, - ID: [2]byte{0, 3}, }, - }, + ) + return &m + }(), }, }, t: Transaction{ @@ -643,7 +702,7 @@ func TestHandleGetFileInfo(t *testing.T) { ID: [2]byte{0x00, 0x01}, Server: &Server{ FS: &OSFileStore{}, - Config: &Config{ + Config: Config{ FileRoot: func() string { path, _ := os.Getwd() return filepath.Join(path, "/test/config/Files") @@ -734,13 +793,13 @@ func TestHandleNewFolder(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessCreateFolder) + bits.Set(AccessCreateFolder) return bits }(), }, ID: [2]byte{0, 1}, Server: &Server{ - Config: &Config{ + Config: Config{ FileRoot: "/Files/", }, FS: func() *MockFileStore { @@ -776,13 +835,13 @@ func TestHandleNewFolder(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessCreateFolder) + bits.Set(AccessCreateFolder) return bits }(), }, ID: [2]byte{0, 1}, Server: &Server{ - Config: &Config{ + Config: Config{ FileRoot: "/Files", }, FS: func() *MockFileStore { @@ -812,13 +871,13 @@ func TestHandleNewFolder(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessCreateFolder) + bits.Set(AccessCreateFolder) return bits }(), }, ID: [2]byte{0, 1}, Server: &Server{ - Config: &Config{ + Config: Config{ FileRoot: "/Files/", }, FS: func() *MockFileStore { @@ -846,13 +905,13 @@ func TestHandleNewFolder(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessCreateFolder) + bits.Set(AccessCreateFolder) return bits }(), }, ID: [2]byte{0, 1}, Server: &Server{ - Config: &Config{ + Config: Config{ FileRoot: "/Files/", }, FS: func() *MockFileStore { @@ -882,13 +941,13 @@ func TestHandleNewFolder(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessCreateFolder) + bits.Set(AccessCreateFolder) return bits }(), }, ID: [2]byte{0, 1}, Server: &Server{ - Config: &Config{ + Config: Config{ FileRoot: "/Files/", }, FS: func() *MockFileStore { @@ -947,19 +1006,17 @@ func TestHandleUploadFile(t *testing.T) { args: args{ cc: &ClientConn{ Server: &Server{ - FS: &OSFileStore{}, - fileTransfers: map[[4]byte]*FileTransfer{}, - Config: &Config{ + FS: &OSFileStore{}, + FileTransferMgr: NewMemFileTransferMgr(), + Config: Config{ FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(), }}, - transfers: map[int]map[[4]byte]*FileTransfer{ - FileUpload: {}, - }, + ClientFileTransferMgr: NewClientFileTransferMgr(), Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessUploadFile) - bits.Set(accessUploadAnywhere) + bits.Set(AccessUploadFile) + bits.Set(AccessUploadAnywhere) return bits }(), }, @@ -1043,12 +1100,12 @@ func TestHandleMakeAlias(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessMakeAlias) + bits.Set(AccessMakeAlias) return bits }(), }, Server: &Server{ - Config: &Config{ + Config: Config{ FileRoot: func() string { path, _ := os.Getwd() return path + "/test/config/Files" @@ -1089,12 +1146,12 @@ func TestHandleMakeAlias(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessMakeAlias) + bits.Set(AccessMakeAlias) return bits }(), }, Server: &Server{ - Config: &Config{ + Config: Config{ FileRoot: func() string { path, _ := os.Getwd() return path + "/test/config/Files" @@ -1136,13 +1193,10 @@ func TestHandleMakeAlias(t *testing.T) { cc: &ClientConn{ logger: NewTestLogger(), Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), + Access: accessBitmap{}, }, Server: &Server{ - Config: &Config{ + Config: Config{ FileRoot: func() string { path, _ := os.Getwd() return path + "/test/config/Files" @@ -1203,19 +1257,21 @@ func TestHandleGetUser(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessOpenUser) + bits.Set(AccessOpenUser) return bits }(), }, Server: &Server{ - Accounts: map[string]*Account{ - "guest": { + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "guest").Return(&Account{ Login: "guest", Name: "Guest", Password: "password", Access: accessBitmap{}, - }, - }, + }) + return &m + }(), }, }, t: NewTransaction( @@ -1246,7 +1302,7 @@ func TestHandleGetUser(t *testing.T) { }(), }, Server: &Server{ - Accounts: map[string]*Account{}, + //Accounts: map[string]*Account{}, }, }, t: NewTransaction( @@ -1271,12 +1327,16 @@ func TestHandleGetUser(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessOpenUser) + bits.Set(AccessOpenUser) return bits }(), }, Server: &Server{ - Accounts: map[string]*Account{}, + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "nonExistentUser").Return((*Account)(nil)) + return &m + }(), }, }, t: NewTransaction( @@ -1316,29 +1376,26 @@ func TestHandleDeleteUser(t *testing.T) { wantRes []Transaction }{ { - name: "when user dataFile", + name: "when user exists", args: args{ cc: &ClientConn{ Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessDeleteUser) + bits.Set(AccessDeleteUser) return bits }(), }, Server: &Server{ - Accounts: map[string]*Account{ - "testuser": { - Login: "testuser", - Name: "Testy McTest", - Password: "password", - Access: accessBitmap{}, - }, - }, - FS: func() *MockFileStore { - mfs := &MockFileStore{} - mfs.On("Remove", "Users/testuser.yaml").Return(nil) - return mfs + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Delete", "testuser").Return(nil) + return &m + }(), + ClientMgr: func() *MockClientMgr { + m := MockClientMgr{} + m.On("List").Return([]*ClientConn{}) // TODO + return &m }(), }, }, @@ -1361,13 +1418,10 @@ func TestHandleDeleteUser(t *testing.T) { args: args{ cc: &ClientConn{ Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), + Access: accessBitmap{}, }, Server: &Server{ - Accounts: map[string]*Account{}, + //Accounts: map[string]*Account{}, }, }, t: NewTransaction( @@ -1411,12 +1465,20 @@ func TestHandleGetMsgs(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessNewsReadArt) + bits.Set(AccessNewsReadArt) return bits }(), }, Server: &Server{ - FlatNews: []byte("TEST"), + MessageBoard: func() *mockReadWriteSeeker { + m := mockReadWriteSeeker{} + m.On("Seek", int64(0), 0).Return(int64(0), nil) + m.On("Read", mock.AnythingOfType("[]uint8")).Run(func(args mock.Arguments) { + arg := args.Get(0).([]uint8) + copy(arg, "TEST") + }).Return(4, io.EOF) + return &m + }(), }, }, t: NewTransaction( @@ -1437,13 +1499,10 @@ func TestHandleGetMsgs(t *testing.T) { args: args{ cc: &ClientConn{ Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), + Access: accessBitmap{}, }, Server: &Server{ - Accounts: map[string]*Account{}, + //Accounts: map[string]*Account{}, }, }, t: NewTransaction( @@ -1490,7 +1549,7 @@ func TestHandleNewUser(t *testing.T) { }(), }, Server: &Server{ - Accounts: map[string]*Account{}, + //Accounts: map[string]*Account{}, }, }, t: NewTransaction( @@ -1514,22 +1573,26 @@ func TestHandleNewUser(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessCreateUser) + bits.Set(AccessCreateUser) return bits }(), }, Server: &Server{ - Accounts: map[string]*Account{}, + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "userB").Return((*Account)(nil)) + return &m + }(), }, }, t: NewTransaction( TranNewUser, [2]byte{0, 1}, - NewField(FieldUserLogin, []byte("userB")), + NewField(FieldUserLogin, encodeString([]byte("userB"))), NewField( FieldUserAccess, func() []byte { var bits accessBitmap - bits.Set(accessDisconUser) + bits.Set(AccessDisconUser) return bits[:] }(), ), @@ -1575,7 +1638,7 @@ func TestHandleListUsers(t *testing.T) { }(), }, Server: &Server{ - Accounts: map[string]*Account{}, + //Accounts: map[string]*Account{}, }, }, t: NewTransaction( @@ -1599,19 +1662,23 @@ func TestHandleListUsers(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessOpenUser) + bits.Set(AccessOpenUser) return bits }(), }, Server: &Server{ - Accounts: map[string]*Account{ - "guest": { - Name: "guest", - Login: "guest", - Password: "zz", - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - }, + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("List").Return([]Account{ + { + Name: "guest", + Login: "guest", + Password: "zz", + Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + }) + return &m + }(), }, }, t: NewTransaction( @@ -1680,23 +1747,20 @@ func TestHandleDownloadFile(t *testing.T) { name: "with a valid file", args: args{ cc: &ClientConn{ - transfers: map[int]map[[4]byte]*FileTransfer{ - FileDownload: {}, - }, + ClientFileTransferMgr: NewClientFileTransferMgr(), Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessDownloadFile) + bits.Set(AccessDownloadFile) return bits }(), }, Server: &Server{ - FS: &OSFileStore{}, - fileTransfers: map[[4]byte]*FileTransfer{}, - Config: &Config{ + FS: &OSFileStore{}, + FileTransferMgr: NewMemFileTransferMgr(), + Config: Config{ FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(), }, - Accounts: map[string]*Account{}, }, }, t: NewTransaction( @@ -1722,12 +1786,11 @@ func TestHandleDownloadFile(t *testing.T) { name: "when client requests to resume 1k test file at offset 256", args: args{ cc: &ClientConn{ - transfers: map[int]map[[4]byte]*FileTransfer{ - FileDownload: {}, - }, Account: &Account{ + ClientFileTransferMgr: NewClientFileTransferMgr(), + Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessDownloadFile) + bits.Set(AccessDownloadFile) return bits }(), }, @@ -1751,11 +1814,11 @@ func TestHandleDownloadFile(t *testing.T) { // // return mfs // }(), - fileTransfers: map[[4]byte]*FileTransfer{}, - Config: &Config{ + FileTransferMgr: NewMemFileTransferMgr(), + Config: Config{ FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(), }, - Accounts: map[string]*Account{}, + //Accounts: map[string]*Account{}, }, }, t: NewTransaction( @@ -1822,13 +1885,15 @@ func TestHandleUpdateUser(t *testing.T) { cc: &ClientConn{ logger: NewTestLogger(), Server: &Server{ + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "bbb").Return((*Account)(nil)) + return &m + }(), Logger: NewTestLogger(), }, Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), + Access: accessBitmap{}, }, }, t: NewTransaction( @@ -1872,9 +1937,11 @@ func TestHandleUpdateUser(t *testing.T) { logger: NewTestLogger(), Server: &Server{ Logger: NewTestLogger(), - Accounts: map[string]*Account{ - "bbb": {}, - }, + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "bbb").Return(&Account{}) + return &m + }(), }, Account: &Account{ Access: func() accessBitmap { @@ -1922,16 +1989,9 @@ func TestHandleUpdateUser(t *testing.T) { args: args{ cc: &ClientConn{ logger: NewTestLogger(), - Server: &Server{ - Accounts: map[string]*Account{ - "bbb": {}, - }, - }, + Server: &Server{}, Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), + Access: accessBitmap{}, }, }, t: NewTransaction( @@ -2050,23 +2110,26 @@ func TestHandleDisconnectUser(t *testing.T) { args: args{ cc: &ClientConn{ Server: &Server{ - Clients: map[[2]byte]*ClientConn{ - [2]byte{0, 1}: { + ClientMgr: func() *MockClientMgr { + m := MockClientMgr{} + m.On("Get", ClientID{0x0, 0x1}).Return(&ClientConn{ Account: &Account{ Login: "unnamed", Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessCannotBeDiscon) + bits.Set(AccessCannotBeDiscon) return bits }(), }, }, - }, + ) + return &m + }(), }, Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessDisconUser) + bits.Set(AccessDisconUser) return bits }(), }, @@ -2139,19 +2202,22 @@ func TestHandleSendInstantMsg(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessSendPrivMsg) + bits.Set(AccessSendPrivMsg) return bits }(), }, ID: [2]byte{0, 1}, UserName: []byte("User1"), Server: &Server{ - Clients: map[[2]byte]*ClientConn{ - [2]byte{0, 2}: { + ClientMgr: func() *MockClientMgr { + m := MockClientMgr{} + m.On("Get", ClientID{0x0, 0x2}).Return(&ClientConn{ AutoReply: []byte(nil), Flags: [2]byte{0, 0}, }, - }, + ) + return &m + }(), }, }, t: NewTransaction( @@ -2184,21 +2250,23 @@ func TestHandleSendInstantMsg(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessSendPrivMsg) + bits.Set(AccessSendPrivMsg) return bits }(), }, ID: [2]byte{0, 1}, UserName: []byte("User1"), Server: &Server{ - Clients: map[[2]byte]*ClientConn{ - [2]byte{0, 2}: { + ClientMgr: func() *MockClientMgr { + m := MockClientMgr{} + m.On("Get", ClientID{0x0, 0x2}).Return(&ClientConn{ Flags: [2]byte{0, 0}, ID: [2]byte{0, 2}, UserName: []byte("User2"), AutoReply: []byte("autohai"), - }, - }, + }) + return &m + }(), }, }, t: NewTransaction( @@ -2239,20 +2307,23 @@ func TestHandleSendInstantMsg(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessSendPrivMsg) + bits.Set(AccessSendPrivMsg) return bits }(), }, ID: [2]byte{0, 1}, UserName: []byte("User1"), Server: &Server{ - Clients: map[[2]byte]*ClientConn{ - [2]byte{0, 2}: { + ClientMgr: func() *MockClientMgr { + m := MockClientMgr{} + m.On("Get", ClientID{0x0, 0x2}).Return(&ClientConn{ Flags: [2]byte{255, 255}, ID: [2]byte{0, 2}, UserName: []byte("User2"), }, - }, + ) + return &m + }(), }, }, t: NewTransaction( @@ -2308,7 +2379,7 @@ func TestHandleDeleteFile(t *testing.T) { }(), }, Server: &Server{ - Config: &Config{ + Config: Config{ FileRoot: func() string { return "/fakeRoot/Files" }(), @@ -2328,7 +2399,7 @@ func TestHandleDeleteFile(t *testing.T) { return mfs }(), - Accounts: map[string]*Account{}, + //Accounts: map[string]*Account{}, }, }, t: NewTransaction( @@ -2359,12 +2430,12 @@ func TestHandleDeleteFile(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessDeleteFile) + bits.Set(AccessDeleteFile) return bits }(), }, Server: &Server{ - Config: &Config{ + Config: Config{ FileRoot: func() string { return "/fakeRoot/Files" }(), @@ -2389,7 +2460,7 @@ func TestHandleDeleteFile(t *testing.T) { return mfs }(), - Accounts: map[string]*Account{}, + //Accounts: map[string]*Account{}, }, }, t: NewTransaction( @@ -2432,7 +2503,7 @@ func TestHandleGetFileNameList(t *testing.T) { wantRes []Transaction }{ { - name: "when FieldFilePath is a drop box, but user does not have accessViewDropBoxes ", + name: "when FieldFilePath is a drop box, but user does not have AccessViewDropBoxes ", args: args{ cc: &ClientConn{ Account: &Account{ @@ -2443,7 +2514,7 @@ func TestHandleGetFileNameList(t *testing.T) { }, Server: &Server{ - Config: &Config{ + Config: Config{ FileRoot: func() string { path, _ := os.Getwd() return filepath.Join(path, "/test/config/Files/getFileNameListTestDir") @@ -2476,7 +2547,7 @@ func TestHandleGetFileNameList(t *testing.T) { args: args{ cc: &ClientConn{ Server: &Server{ - Config: &Config{ + Config: Config{ FileRoot: func() string { path, _ := os.Getwd() return filepath.Join(path, "/test/config/Files/getFileNameListTestDir") @@ -2548,7 +2619,7 @@ func TestHandleGetClientInfoText(t *testing.T) { }(), }, Server: &Server{ - Accounts: map[string]*Account{}, + //Accounts: map[string]*Account{}, }, }, t: NewTransaction( @@ -2575,36 +2646,33 @@ func TestHandleGetClientInfoText(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessGetClientInfo) + bits.Set(AccessGetClientInfo) return bits }(), Name: "test", Login: "test", }, Server: &Server{ - Accounts: map[string]*Account{}, - Clients: map[[2]byte]*ClientConn{ - [2]byte{0, 1}: { + ClientMgr: func() *MockClientMgr { + m := MockClientMgr{} + m.On("Get", ClientID{0x0, 0x1}).Return(&ClientConn{ UserName: []byte("Testy McTest"), RemoteAddr: "1.2.3.4:12345", Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessGetClientInfo) + bits.Set(AccessGetClientInfo) return bits }(), Name: "test", Login: "test", }, }, - }, - }, - transfers: map[int]map[[4]byte]*FileTransfer{ - FileDownload: {}, - FileUpload: {}, - FolderDownload: {}, - FolderUpload: {}, + ) + return &m + }(), }, + ClientFileTransferMgr: ClientFileTransferMgr{}, }, t: NewTransaction( TranGetClientInfoText, [2]byte{0, 1}, @@ -2674,8 +2742,8 @@ func TestHandleTranAgreed(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessDisconUser) - bits.Set(accessAnyName) + bits.Set(AccessDisconUser) + bits.Set(AccessAnyName) return bits }()}, Icon: []byte{0, 1}, @@ -2684,9 +2752,20 @@ func TestHandleTranAgreed(t *testing.T) { ID: [2]byte{0, 1}, logger: NewTestLogger(), Server: &Server{ - Config: &Config{ + Config: Config{ BannerFile: "banner.jpg", }, + ClientMgr: func() *MockClientMgr { + m := MockClientMgr{} + m.On("List").Return([]*ClientConn{ + //{ + // ID: [2]byte{0, 2}, + // UserName: []byte("UserB"), + //}, + }, + ) + return &m + }(), }, }, t: NewTransaction( @@ -2731,7 +2810,7 @@ func TestHandleSetClientUserInfo(t *testing.T) { wantRes []Transaction }{ { - name: "when client does not have accessAnyName", + name: "when client does not have AccessAnyName", args: args{ cc: &ClientConn{ Account: &Account{ @@ -2744,11 +2823,15 @@ func TestHandleSetClientUserInfo(t *testing.T) { UserName: []byte("Guest"), Flags: [2]byte{0, 1}, Server: &Server{ - Clients: map[[2]byte]*ClientConn{ - [2]byte{0, 1}: { - ID: [2]byte{0, 1}, - }, - }, + ClientMgr: func() *MockClientMgr { + m := MockClientMgr{} + m.On("List").Return([]*ClientConn{ + { + ID: [2]byte{0, 1}, + }, + }) + return &m + }(), }, }, t: NewTransaction( @@ -2797,12 +2880,13 @@ func TestHandleDelNewsItem(t *testing.T) { }, ID: [2]byte{0, 1}, Server: &Server{ - ThreadedNews: &ThreadedNews{Categories: map[string]NewsCategoryListData15{ - "test": { - Type: [2]byte{0, 3}, - Name: "zz", - }, - }}, + ThreadedNewsMgr: func() *mockThreadNewsMgr { + m := mockThreadNewsMgr{} + m.On("NewsItem", []string{"test"}).Return(NewsCategoryListData15{ + Type: NewsCategory, + }) + return &m + }(), }, }, t: NewTransaction( @@ -2837,12 +2921,13 @@ func TestHandleDelNewsItem(t *testing.T) { }, ID: [2]byte{0, 1}, Server: &Server{ - ThreadedNews: &ThreadedNews{Categories: map[string]NewsCategoryListData15{ - "testcat": { - Type: [2]byte{0, 2}, - Name: "test", - }, - }}, + ThreadedNewsMgr: func() *mockThreadNewsMgr { + m := mockThreadNewsMgr{} + m.On("NewsItem", []string{"test"}).Return(NewsCategoryListData15{ + Type: NewsBundle, + }) + return &m + }(), }, }, t: NewTransaction( @@ -2875,24 +2960,18 @@ func TestHandleDelNewsItem(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessNewsDeleteFldr) + bits.Set(AccessNewsDeleteFldr) return bits }(), }, ID: [2]byte{0, 1}, Server: &Server{ - ConfigDir: "/fakeConfigRoot", - FS: func() *MockFileStore { - mfs := &MockFileStore{} - mfs.On("WriteFile", "/fakeConfigRoot/ThreadedNews.yaml", mock.Anything, mock.Anything).Return(nil, os.ErrNotExist) - return mfs + ThreadedNewsMgr: func() *mockThreadNewsMgr { + m := mockThreadNewsMgr{} + m.On("NewsItem", []string{"test"}).Return(NewsCategoryListData15{Type: NewsBundle}) + m.On("DeleteNewsItem", []string{"test"}).Return(nil) + return &m }(), - ThreadedNews: &ThreadedNews{Categories: map[string]NewsCategoryListData15{ - "testcat": { - Type: [2]byte{0, 2}, - Name: "test", - }, - }}, }, }, t: NewTransaction( @@ -2940,10 +3019,7 @@ func TestHandleTranOldPostNews(t *testing.T) { args: args{ cc: &ClientConn{ Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), + Access: accessBitmap{}, }, }, t: NewTransaction( @@ -2968,18 +3044,29 @@ func TestHandleTranOldPostNews(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessNewsPostArt) + bits.Set(AccessNewsPostArt) return bits }(), }, Server: &Server{ - FS: func() *MockFileStore { - mfs := &MockFileStore{} - mfs.On("WriteFile", "/fakeConfigRoot/MessageBoard.txt", mock.Anything, mock.Anything).Return(nil, os.ErrNotExist) - return mfs + Config: Config{ + NewsDateFormat: "", + }, + ClientMgr: func() *MockClientMgr { + m := MockClientMgr{} + m.On("List").Return([]*ClientConn{}) + return &m + }(), + MessageBoard: func() *mockReadWriteSeeker { + m := mockReadWriteSeeker{} + m.On("Seek", int64(0), 0).Return(int64(0), nil) + m.On("Read", mock.AnythingOfType("[]uint8")).Run(func(args mock.Arguments) { + arg := args.Get(0).([]uint8) + copy(arg, "TEST") + }).Return(4, io.EOF) + m.On("Write", mock.AnythingOfType("[]uint8")).Return(3, nil) + return &m }(), - ConfigDir: "/fakeConfigRoot", - Config: &Config{}, }, }, t: NewTransaction( @@ -3044,7 +3131,7 @@ func TestHandleInviteNewChat(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessOpenChat) + bits.Set(AccessOpenChat) return bits }(), }, @@ -3052,13 +3139,19 @@ func TestHandleInviteNewChat(t *testing.T) { Icon: []byte{0, 1}, Flags: [2]byte{0, 0}, Server: &Server{ - Clients: map[[2]byte]*ClientConn{ - [2]byte{0, 2}: { + ClientMgr: func() *MockClientMgr { + m := MockClientMgr{} + m.On("Get", ClientID{0x0, 0x2}).Return(&ClientConn{ ID: [2]byte{0, 2}, UserName: []byte("UserB"), - }, - }, - PrivateChats: make(map[[4]byte]*PrivateChat), + }) + return &m + }(), + ChatMgr: func() *MockChatManager { + m := MockChatManager{} + m.On("New", mock.AnythingOfType("*hotline.ClientConn")).Return(ChatID{0x52, 0xfd, 0xfc, 0x07}) + return &m + }(), }, }, t: NewTransaction( @@ -3076,7 +3169,6 @@ func TestHandleInviteNewChat(t *testing.T) { NewField(FieldUserID, []byte{0, 1}), }, }, - { clientID: [2]byte{0, 1}, IsReply: 0x01, @@ -3098,7 +3190,7 @@ func TestHandleInviteNewChat(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessOpenChat) + bits.Set(AccessOpenChat) return bits }(), }, @@ -3106,14 +3198,21 @@ func TestHandleInviteNewChat(t *testing.T) { Icon: []byte{0, 1}, Flags: [2]byte{0, 0}, Server: &Server{ - Clients: map[[2]byte]*ClientConn{ - [2]byte{0, 2}: { + ClientMgr: func() *MockClientMgr { + m := MockClientMgr{} + m.On("Get", ClientID{0, 2}).Return(&ClientConn{ ID: [2]byte{0, 2}, + Icon: []byte{0, 1}, UserName: []byte("UserB"), Flags: [2]byte{255, 255}, - }, - }, - PrivateChats: make(map[[4]byte]*PrivateChat), + }) + return &m + }(), + ChatMgr: func() *MockChatManager { + m := MockChatManager{} + m.On("New", mock.AnythingOfType("*hotline.ClientConn")).Return(ChatID{0x52, 0xfd, 0xfc, 0x07}) + return &m + }(), }, }, t: NewTransaction( @@ -3148,6 +3247,7 @@ func TestHandleInviteNewChat(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + gotRes := HandleInviteNewChat(tt.args.cc, &tt.args.t) tranAssertEqual(t, tt.wantRes, gotRes) @@ -3167,28 +3267,73 @@ func TestHandleGetNewsArtData(t *testing.T) { }{ { name: "when user does not have required permission", + args: args{ + cc: &ClientConn{Account: &Account{}}, + t: NewTransaction( + TranGetNewsArtData, [2]byte{0, 1}, + ), + }, + wantRes: []Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []Field{ + NewField(FieldError, []byte("You are not allowed to read news.")), + }, + }, + }, + }, + { + name: "when user has required permission", args: args{ cc: &ClientConn{ Account: &Account{ Access: func() accessBitmap { var bits accessBitmap + bits.Set(AccessNewsReadArt) return bits }(), }, Server: &Server{ - Accounts: map[string]*Account{}, + ThreadedNewsMgr: func() *mockThreadNewsMgr { + m := mockThreadNewsMgr{} + m.On("GetArticle", []string{"Example Category"}, uint32(1)).Return(&NewsArtData{ + Title: "title", + Poster: "poster", + Date: [8]byte{}, + PrevArt: [4]byte{0, 0, 0, 1}, + NextArt: [4]byte{0, 0, 0, 2}, + ParentArt: [4]byte{0, 0, 0, 3}, + FirstChildArt: [4]byte{0, 0, 0, 4}, + DataFlav: []byte("text/plain"), + Data: "article data", + }) + return &m + }(), }, }, t: NewTransaction( TranGetNewsArtData, [2]byte{0, 1}, + NewField(FieldNewsPath, []byte{ + // Example Category + 0x00, 0x01, 0x00, 0x00, 0x10, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x20, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, + }), + NewField(FieldNewsArtID, []byte{0, 1}), ), }, wantRes: []Transaction{ { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, + IsReply: 1, Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to read news.")), + NewField(FieldNewsArtTitle, []byte("title")), + NewField(FieldNewsArtPoster, []byte("poster")), + NewField(FieldNewsArtDate, []byte{0, 0, 0, 0, 0, 0, 0, 0}), + NewField(FieldNewsArtPrevArt, []byte{0, 0, 0, 1}), + NewField(FieldNewsArtNextArt, []byte{0, 0, 0, 2}), + NewField(FieldNewsArtParentArt, []byte{0, 0, 0, 3}), + NewField(FieldNewsArt1stChildArt, []byte{0, 0, 0, 4}), + NewField(FieldNewsArtDataFlav, []byte("text/plain")), + NewField(FieldNewsArtData, []byte("article data")), }, }, }, @@ -3223,7 +3368,7 @@ func TestHandleGetNewsArtNameList(t *testing.T) { }(), }, Server: &Server{ - Accounts: map[string]*Account{}, + //Accounts: map[string]*Account{}, }, }, t: NewTransaction( @@ -3242,74 +3387,54 @@ func TestHandleGetNewsArtNameList(t *testing.T) { }, }, }, - { - name: "when user has required access", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(accessNewsReadArt) - return bits - }(), - }, - Server: &Server{ - ThreadedNews: &ThreadedNews{ - Categories: map[string]NewsCategoryListData15{ - "Example Category": { - Type: [2]byte{0, 2}, - Name: "", - Articles: map[uint32]*NewsArtData{ - uint32(1): { - Title: "testTitle", - Poster: "testPoster", - Data: "testBody", - }, - }, - SubCats: nil, - GUID: [16]byte{}, - AddSN: [4]byte{}, - DeleteSN: [4]byte{}, - }, - }, - }, - - //Accounts: map[string]*Account{ - // "guest": { - // Name: "guest", - // Login: "guest", - // Password: "zz", - // Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - // }, - //}, - }, - }, - t: NewTransaction( - TranGetNewsArtNameList, - [2]byte{0, 1}, - // 00000000 00 01 00 00 10 45 78 61 6d 70 6c 65 20 43 61 74 |.....Example Cat| - // 00000010 65 67 6f 72 79 |egory| - NewField(FieldNewsPath, []byte{ - 0x00, 0x01, 0x00, 0x00, 0x10, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x20, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, - }), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - Fields: []Field{ - NewField(FieldNewsArtListData, []byte{ - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, - 0x09, 0x74, 0x65, 0x73, 0x74, 0x54, 0x69, 0x74, 0x6c, 0x65, 0x0a, 0x74, 0x65, 0x73, 0x74, 0x50, - 0x6f, 0x73, 0x74, 0x65, 0x72, 0x0a, 0x74, 0x65, 0x78, 0x74, 0x2f, 0x70, 0x6c, 0x61, 0x69, 0x6e, - 0x00, 0x08, - }, - ), - }, - }, - }, - }, + //{ + // name: "when user has required access", + // args: args{ + // cc: &ClientConn{ + // Account: &Account{ + // Access: func() accessBitmap { + // var bits accessBitmap + // bits.Set(AccessNewsReadArt) + // return bits + // }(), + // }, + // Server: &Server{ + // ThreadedNewsMgr: func() *mockThreadNewsMgr { + // m := mockThreadNewsMgr{} + // m.On("ListArticles", []string{"Example Category"}).Return(NewsArtListData{ + // Name: []byte("testTitle"), + // NewsArtList: []byte{}, + // }) + // return &m + // }(), + // }, + // }, + // t: NewTransaction( + // TranGetNewsArtNameList, + // [2]byte{0, 1}, + // // 00000000 00 01 00 00 10 45 78 61 6d 70 6c 65 20 43 61 74 |.....Example Cat| + // // 00000010 65 67 6f 72 79 |egory| + // NewField(FieldNewsPath, []byte{ + // 0x00, 0x01, 0x00, 0x00, 0x10, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x20, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, + // }), + // ), + // }, + // wantRes: []Transaction{ + // { + // IsReply: 0x01, + // Fields: []Field{ + // NewField(FieldNewsArtListData, []byte{ + // 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + // 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + // 0x09, 0x74, 0x65, 0x73, 0x74, 0x54, 0x69, 0x74, 0x6c, 0x65, 0x0a, 0x74, 0x65, 0x73, 0x74, 0x50, + // 0x6f, 0x73, 0x74, 0x65, 0x72, 0x0a, 0x74, 0x65, 0x78, 0x74, 0x2f, 0x70, 0x6c, 0x61, 0x69, 0x6e, + // 0x00, 0x08, + // }, + // ), + // }, + // }, + // }, + //}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -3341,7 +3466,7 @@ func TestHandleNewNewsFldr(t *testing.T) { }(), }, Server: &Server{ - Accounts: map[string]*Account{}, + //Accounts: map[string]*Account{}, }, }, t: NewTransaction( @@ -3367,26 +3492,18 @@ func TestHandleNewNewsFldr(t *testing.T) { Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessNewsCreateFldr) + bits.Set(AccessNewsCreateFldr) return bits }(), }, logger: NewTestLogger(), ID: [2]byte{0, 1}, Server: &Server{ - ConfigDir: "/fakeConfigRoot", - FS: func() *MockFileStore { - mfs := &MockFileStore{} - mfs.On("WriteFile", "/fakeConfigRoot/ThreadedNews.yaml", mock.Anything, mock.Anything).Return(nil) - return mfs + ThreadedNewsMgr: func() *mockThreadNewsMgr { + m := mockThreadNewsMgr{} + m.On("CreateGrouping", []string{"test"}, "testFolder", NewsBundle).Return(nil) + return &m }(), - ThreadedNews: &ThreadedNews{Categories: map[string]NewsCategoryListData15{ - "test": { - Type: [2]byte{0, 2}, - Name: "test", - SubCats: make(map[string]NewsCategoryListData15), - }, - }}, }, }, t: NewTransaction( @@ -3417,12 +3534,12 @@ func TestHandleNewNewsFldr(t *testing.T) { // Account: &Account{ // Access: func() accessBitmap { // var bits accessBitmap - // bits.Set(accessNewsCreateFldr) + // bits.Set(AccessNewsCreateFldr) // return bits // }(), // }, // logger: NewTestLogger(), - // ID: [2]byte{0, 1}, + // Type: [2]byte{0, 1}, // Server: &Server{ // ConfigDir: "/fakeConfigRoot", // FS: func() *MockFileStore { @@ -3538,32 +3655,16 @@ func TestHandlePostNewsArt(t *testing.T) { args: args{ cc: &ClientConn{ Server: &Server{ - FS: func() *MockFileStore { - mfs := &MockFileStore{} - mfs.On("WriteFile", "ThreadedNews.yaml", mock.Anything, mock.Anything).Return(nil, os.ErrNotExist) - return mfs + ThreadedNewsMgr: func() *mockThreadNewsMgr { + m := mockThreadNewsMgr{} + m.On("PostArticle", []string{"www"}, uint32(0), mock.AnythingOfType("hotline.NewsArtData")).Return(nil) + return &m }(), - mux: sync.Mutex{}, - threadedNewsMux: sync.Mutex{}, - ThreadedNews: &ThreadedNews{ - Categories: map[string]NewsCategoryListData15{ - "www": { - Type: [2]byte{}, - Name: "www", - Articles: map[uint32]*NewsArtData{}, - SubCats: nil, - GUID: [16]byte{}, - AddSN: [4]byte{}, - DeleteSN: [4]byte{}, - readOffset: 0, - }, - }, - }, }, Account: &Account{ Access: func() accessBitmap { var bits accessBitmap - bits.Set(accessNewsPostArt) + bits.Set(AccessNewsPostArt) return bits }(), }, diff --git a/hotline/transaction_test.go b/hotline/transaction_test.go index 820b08c..e7b535d 100644 --- a/hotline/transaction_test.go +++ b/hotline/transaction_test.go @@ -6,111 +6,6 @@ import ( "testing" ) -func TestReadFields(t *testing.T) { - type args struct { - paramCount []byte - buf []byte - } - tests := []struct { - name string - args args - want []Field - wantErr bool - }{ - { - name: "valid field data", - args: args{ - paramCount: []byte{0x00, 0x02}, - buf: []byte{ - 0x00, 0x65, // ID: FieldData - 0x00, 0x04, // Size: 2 bytes - 0x01, 0x02, 0x03, 0x04, // Data - 0x00, 0x66, // ID: FieldUserName - 0x00, 0x02, // Size: 2 bytes - 0x00, 0x01, // Data - }, - }, - want: []Field{ - { - ID: [2]byte{0x00, 0x65}, - FieldSize: [2]byte{0x00, 0x04}, - Data: []byte{0x01, 0x02, 0x03, 0x04}, - }, - { - ID: [2]byte{0x00, 0x66}, - FieldSize: [2]byte{0x00, 0x02}, - Data: []byte{0x00, 0x01}, - }, - }, - wantErr: false, - }, - { - name: "empty bytes", - args: args{ - paramCount: []byte{0x00, 0x00}, - buf: []byte{}, - }, - want: []Field(nil), - wantErr: false, - }, - { - name: "when field size does not match data length", - args: args{ - paramCount: []byte{0x00, 0x01}, - buf: []byte{ - 0x00, 0x65, // ID: FieldData - 0x00, 0x04, // Size: 4 bytes - 0x01, 0x02, 0x03, // Data - }, - }, - want: []Field{}, - wantErr: true, - }, - { - name: "when field size of second field does not match data length", - args: args{ - paramCount: []byte{0x00, 0x01}, - buf: []byte{ - 0x00, 0x65, // ID: FieldData - 0x00, 0x02, // Size: 2 bytes - 0x01, 0x02, // Data - 0x00, 0x65, // ID: FieldData - 0x00, 0x04, // Size: 4 bytes - 0x01, 0x02, 0x03, // Data - }, - }, - want: []Field{}, - wantErr: true, - }, - { - name: "when field data has extra bytes", - args: args{ - paramCount: []byte{0x00, 0x01}, - buf: []byte{ - 0x00, 0x65, // ID: FieldData - 0x00, 0x02, // Size: 2 bytes - 0x01, 0x02, 0x03, // Data - }, - }, - want: []Field{}, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ReadFields(tt.args.paramCount, tt.args.buf) - if (err != nil) != tt.wantErr { - t.Errorf("ReadFields() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if !assert.Equal(t, tt.want, got) { - t.Errorf("ReadFields() got = %v, want %v", got, tt.want) - } - }) - } -} - func Test_transactionScanner(t *testing.T) { type args struct { data []byte @@ -462,7 +357,7 @@ func TestTransaction_Write(t1 *testing.T) { ParamCount: [2]byte{0, 1}, Fields: []Field{ { - ID: FieldData, + Type: FieldData, FieldSize: [2]byte{0, 3}, Data: []byte("hai"), }, diff --git a/hotline/transfer.go b/hotline/transfer.go index 1257f8a..5cb28c8 100644 --- a/hotline/transfer.go +++ b/hotline/transfer.go @@ -9,7 +9,7 @@ import ( type transfer struct { Protocol [4]byte // "HTXF" 0x48545846 - ReferenceNumber [4]byte // Unique ID generated for the transfer + ReferenceNumber [4]byte // Unique Type generated for the transfer DataSize [4]byte // File size RSVD [4]byte // Not implemented in Hotline Protocol } diff --git a/hotline/user.go b/hotline/user.go index 26625a2..f3754d3 100644 --- a/hotline/user.go +++ b/hotline/user.go @@ -15,7 +15,7 @@ const ( UserFlagRefusePChat = 3 // User refuses private chat ) -// FieldOptions flags are sent from v1.5+ clients as part of TranAgreed +// User options are sent from clients and represent options set in the client's preferences. const ( UserOptRefusePM = 0 // User has "Refuse private messages" pref set UserOptRefuseChat = 1 // User has "Refuse private chat" pref set @@ -24,15 +24,15 @@ const ( type UserFlags [2]byte -func (flag *UserFlags) IsSet(i int) bool { - flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(flag[:]))) +func (f *UserFlags) IsSet(i int) bool { + flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(f[:]))) return flagBitmap.Bit(i) == 1 } -func (flag *UserFlags) Set(i int, newVal uint) { - flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(flag[:]))) +func (f *UserFlags) Set(i int, newVal uint) { + flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(f[:]))) flagBitmap.SetBit(flagBitmap, i, newVal) - binary.BigEndian.PutUint16(flag[:], uint16(flagBitmap.Int64())) + binary.BigEndian.PutUint16(f[:], uint16(flagBitmap.Int64())) } type User struct { diff --git a/hotline/util.go b/hotline/util.go deleted file mode 100644 index d37feb5..0000000 --- a/hotline/util.go +++ /dev/null @@ -1,17 +0,0 @@ -package hotline - -import ( - "encoding/binary" - "errors" -) - -func byteToInt(bytes []byte) (int, error) { - switch len(bytes) { - case 2: - return int(binary.BigEndian.Uint16(bytes)), nil - case 4: - return int(binary.BigEndian.Uint32(bytes)), nil - } - - return 0, errors.New("unknown byte length") -} diff --git a/hotline/util_test.go b/hotline/util_test.go deleted file mode 100644 index 30ae2b6..0000000 --- a/hotline/util_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package hotline - -import ( - "fmt" - "github.com/stretchr/testify/assert" - "testing" -) - -func Test_byteToInt(t *testing.T) { - type args struct { - bytes []byte - } - tests := []struct { - name string - args args - want int - wantErr assert.ErrorAssertionFunc - }{ - { - name: "with 2 bytes of input", - args: args{bytes: []byte{0, 1}}, - want: 1, - wantErr: assert.NoError, - }, - { - name: "with 4 bytes of input", - args: args{bytes: []byte{0, 1, 0, 0}}, - want: 65536, - wantErr: assert.NoError, - }, - { - name: "with invalid number of bytes of input", - args: args{bytes: []byte{1, 0, 0, 0, 0, 0, 0, 0}}, - want: 0, - wantErr: assert.Error, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := byteToInt(tt.args.bytes) - if !tt.wantErr(t, err, fmt.Sprintf("byteToInt(%v)", tt.args.bytes)) { - return - } - assert.Equalf(t, tt.want, got, "byteToInt(%v)", tt.args.bytes) - }) - } -} diff --git a/internal/mobius/ban.go b/internal/mobius/ban.go new file mode 100644 index 0000000..f78e3f3 --- /dev/null +++ b/internal/mobius/ban.go @@ -0,0 +1,76 @@ +package mobius + +import ( + "gopkg.in/yaml.v3" + "os" + "path/filepath" + "sync" + "time" +) + +type BanFile struct { + banList map[string]*time.Time + filePath string + + sync.Mutex +} + +func NewBanFile(path string) (*BanFile, error) { + bf := &BanFile{ + filePath: path, + banList: make(map[string]*time.Time), + } + + err := bf.Load() + + return bf, err +} + +func (bf *BanFile) Load() error { + bf.Lock() + defer bf.Unlock() + + bf.banList = make(map[string]*time.Time) + + fh, err := os.Open(bf.filePath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer fh.Close() + + decoder := yaml.NewDecoder(fh) + err = decoder.Decode(&bf.banList) + if err != nil { + return err + } + + return nil +} + +func (bf *BanFile) Add(ip string, until *time.Time) error { + bf.Lock() + defer bf.Unlock() + + bf.banList[ip] = until + + out, err := yaml.Marshal(bf.banList) + if err != nil { + return err + } + + return os.WriteFile(filepath.Join(bf.filePath), out, 0644) +} + +func (bf *BanFile) IsBanned(ip string) (bool, *time.Time) { + bf.Lock() + defer bf.Unlock() + + if until, ok := bf.banList[ip]; ok { + return true, until + } + + return false, nil +} diff --git a/internal/mobius/config.go b/internal/mobius/config.go new file mode 100644 index 0000000..e985dd7 --- /dev/null +++ b/internal/mobius/config.go @@ -0,0 +1,30 @@ +package mobius + +import ( + "github.com/go-playground/validator/v10" + "github.com/jhalter/mobius/hotline" + "gopkg.in/yaml.v3" + "log" + "os" +) + +func LoadConfig(path string) (*hotline.Config, error) { + var config hotline.Config + + yamlFile, err := os.ReadFile(path) + if err != nil { + return nil, err + } + err = yaml.Unmarshal(yamlFile, &config) + if err != nil { + log.Fatalf("Unmarshal: %v", err) + } + + validate := validator.New() + err = validate.Struct(config) + if err != nil { + return nil, err + } + + return &config, nil +} diff --git a/internal/mobius/news.go b/internal/mobius/news.go new file mode 100644 index 0000000..51e212d --- /dev/null +++ b/internal/mobius/news.go @@ -0,0 +1,87 @@ +package mobius + +import ( + "fmt" + "io" + "os" + "slices" + "sync" +) + +type FlatNews struct { + mu sync.Mutex + + data []byte + filePath string + + readOffset int // Internal offset to track read progress +} + +func NewFlatNews(path string) (*FlatNews, error) { + data, err := os.ReadFile(path) + if err != nil { + return &FlatNews{}, err + } + + return &FlatNews{ + data: data, + filePath: path, + }, nil +} + +func (f *FlatNews) Reload() error { + f.mu.Lock() + defer f.mu.Unlock() + + data, err := os.ReadFile(f.filePath) + if err != nil { + return err + } + f.data = data + + return nil +} + +// It returns the number of bytes read and any error encountered. +func (f *FlatNews) Read(p []byte) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + + if f.readOffset >= len(f.data) { + return 0, io.EOF // All bytes have been read + } + + n := copy(p, f.data[f.readOffset:]) + + f.readOffset += n + + return n, nil +} + +// Write implements io.Writer for flat news. +// p is guaranteed to contain the full data of a news post. +func (f *FlatNews) Write(p []byte) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + + f.data = slices.Concat(p, f.data) + + tempFilePath := f.filePath + ".tmp" + + if err := os.WriteFile(tempFilePath, f.data, 0644); err != nil { + return 0, fmt.Errorf("write to temporary file: %v", err) + } + + // Atomically rename the temporary file to the final file path. + if err := os.Rename(tempFilePath, f.filePath); err != nil { + return 0, fmt.Errorf("rename temporary file to final file: %v", err) + } + + return len(p), os.WriteFile(f.filePath, f.data, 0644) +} + +func (f *FlatNews) Seek(offset int64, _ int) (int64, error) { + f.readOffset = int(offset) + + return 0, nil +} diff --git a/internal/mobius/threaded_news.go b/internal/mobius/threaded_news.go new file mode 100644 index 0000000..bae6779 --- /dev/null +++ b/internal/mobius/threaded_news.go @@ -0,0 +1,251 @@ +package mobius + +import ( + "cmp" + "encoding/binary" + "fmt" + "github.com/jhalter/mobius/hotline" + "gopkg.in/yaml.v3" + "os" + "slices" + "sort" + "sync" +) + +type ThreadedNewsYAML struct { + ThreadedNews hotline.ThreadedNews + + filePath string + + mu sync.Mutex +} + +func NewThreadedNewsYAML(filePath string) (*ThreadedNewsYAML, error) { + tn := &ThreadedNewsYAML{filePath: filePath} + + err := tn.Load() + + return tn, err +} + +func (n *ThreadedNewsYAML) CreateGrouping(newsPath []string, name string, t [2]byte) error { + n.mu.Lock() + defer n.mu.Unlock() + + cats := n.getCatByPath(newsPath) + cats[name] = hotline.NewsCategoryListData15{ + Name: name, + Type: t, + Articles: map[uint32]*hotline.NewsArtData{}, + SubCats: make(map[string]hotline.NewsCategoryListData15), + } + + return n.writeFile() +} + +func (n *ThreadedNewsYAML) NewsItem(newsPath []string) hotline.NewsCategoryListData15 { + n.mu.Lock() + defer n.mu.Unlock() + + cats := n.ThreadedNews.Categories + delName := newsPath[len(newsPath)-1] + if len(newsPath) > 1 { + for _, fp := range newsPath[0 : len(newsPath)-1] { + cats = cats[fp].SubCats + } + } + + return cats[delName] +} + +func (n *ThreadedNewsYAML) DeleteNewsItem(newsPath []string) error { + n.mu.Lock() + defer n.mu.Unlock() + + cats := n.ThreadedNews.Categories + delName := newsPath[len(newsPath)-1] + if len(newsPath) > 1 { + for _, fp := range newsPath[0 : len(newsPath)-1] { + cats = cats[fp].SubCats + } + } + + delete(cats, delName) + + return n.writeFile() +} + +func (n *ThreadedNewsYAML) GetArticle(newsPath []string, articleID uint32) *hotline.NewsArtData { + n.mu.Lock() + defer n.mu.Unlock() + + var cat hotline.NewsCategoryListData15 + cats := n.ThreadedNews.Categories + + for _, fp := range newsPath { + cat = cats[fp] + cats = cats[fp].SubCats + } + + art := cat.Articles[articleID] + if art == nil { + return nil + } + + return art +} + +// +//func (n *ThreadedNewsYAML) GetNewsCatByPath(paths []string) map[string]hotline.NewsCategoryListData15 { +// n.mu.Lock() +// defer n.mu.Unlock() +// +// cats := n.getCatByPath(paths) +// +// return cats +//} + +func (n *ThreadedNewsYAML) GetCategories(paths []string) []hotline.NewsCategoryListData15 { + n.mu.Lock() + defer n.mu.Unlock() + + var categories []hotline.NewsCategoryListData15 + for _, c := range n.getCatByPath(paths) { + categories = append(categories, c) + } + + slices.SortFunc(categories, func(a, b hotline.NewsCategoryListData15) int { + return cmp.Compare( + a.Name, + b.Name, + ) + }) + + return categories +} + +func (n *ThreadedNewsYAML) getCatByPath(paths []string) map[string]hotline.NewsCategoryListData15 { + cats := n.ThreadedNews.Categories + for _, path := range paths { + cats = cats[path].SubCats + } + + return cats +} + +func (n *ThreadedNewsYAML) PostArticle(newsPath []string, parentArticleID uint32, article hotline.NewsArtData) error { + n.mu.Lock() + defer n.mu.Unlock() + + binary.BigEndian.PutUint32(article.ParentArt[:], parentArticleID) + + cats := n.getCatByPath(newsPath[:len(newsPath)-1]) + + catName := newsPath[len(newsPath)-1] + cat := cats[catName] + + var keys []int + for k := range cat.Articles { + keys = append(keys, int(k)) + } + + nextID := uint32(1) + if len(keys) > 0 { + sort.Ints(keys) + prevID := uint32(keys[len(keys)-1]) + nextID = prevID + 1 + + binary.BigEndian.PutUint32(article.PrevArt[:], prevID) + + // Set next article Type + binary.BigEndian.PutUint32(cat.Articles[prevID].NextArt[:], nextID) + } + + // Update parent article with first child reply + parentID := parentArticleID + if parentID != 0 { + parentArt := cat.Articles[parentID] + + if parentArt.FirstChildArt == [4]byte{0, 0, 0, 0} { + binary.BigEndian.PutUint32(parentArt.FirstChildArt[:], nextID) + } + } + + cat.Articles[nextID] = &article + + cats[catName] = cat + + return n.writeFile() +} + +func (n *ThreadedNewsYAML) DeleteArticle(newsPath []string, articleID uint32, recursive bool) error { + n.mu.Lock() + defer n.mu.Unlock() + + if recursive { + // TODO: Handle delete recursive + } + + cats := n.getCatByPath(newsPath[:len(newsPath)-1]) + + catName := newsPath[len(newsPath)-1] + + cat := cats[catName] + delete(cat.Articles, articleID) + cats[catName] = cat + + return n.writeFile() +} + +func (n *ThreadedNewsYAML) ListArticles(newsPath []string) hotline.NewsArtListData { + n.mu.Lock() + defer n.mu.Unlock() + + var cat hotline.NewsCategoryListData15 + cats := n.ThreadedNews.Categories + + for _, fp := range newsPath { + cat = cats[fp] + cats = cats[fp].SubCats + } + + return cat.GetNewsArtListData() +} + +func (n *ThreadedNewsYAML) Load() error { + n.mu.Lock() + defer n.mu.Unlock() + + fh, err := os.Open(n.filePath) + if err != nil { + return err + } + defer fh.Close() + + n.ThreadedNews = hotline.ThreadedNews{} + + decoder := yaml.NewDecoder(fh) + return decoder.Decode(&n.ThreadedNews) +} + +func (n *ThreadedNewsYAML) writeFile() error { + out, err := yaml.Marshal(&n.ThreadedNews) + if err != nil { + return err + } + + // Define a temporary file path in the same directory. + tempFilePath := n.filePath + ".tmp" + + // Write the marshaled YAML to the temporary file. + if err := os.WriteFile(tempFilePath, out, 0644); err != nil { + return fmt.Errorf("write to temporary file: %v", err) + } + + // Atomically rename the temporary file to the final file path. + if err := os.Rename(tempFilePath, n.filePath); err != nil { + return fmt.Errorf("rename temporary file to final file: %v", err) + } + + return nil +}