diff options
| author | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2024-07-17 15:41:20 -0700 |
|---|---|---|
| committer | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2024-07-17 15:42:37 -0700 |
| commit | fd740bc499ebc6d3a381479316f74cdc736d02de (patch) | |
| tree | e77563ce0997e51debaec75c9f7a053759ca3a0a /hotline | |
| parent | 80aed6b19ff0b0927670e459ce5cc7a16ef047ec (diff) | |
Extensive refactor, quality of life enhancements
* Added ability to reload config, agreement, news, and user accounts without restarting the server by sending SIGHUP to the running process
* Added ability to use modern unix or windows line breaks in Agreement.txt and MessageBoard.txt instead of classic MacOS `\r` breaks.
* Extensive refactor towards swappable backends for the active server state
* Extensive refactored towards making the hotline package generic and re-usable for alternate server implemenations
* Fix bug where users whose accounts have been deleted would not be disconnected
Diffstat (limited to 'hotline')
40 files changed, 531 insertions, 6277 deletions
diff --git a/hotline/access.go b/hotline/access.go index 179941f..2d78464 100644 --- a/hotline/access.go +++ b/hotline/access.go @@ -41,12 +41,12 @@ const ( AccessSendPrivMsg = 40 // Messaging: Can Send Messages (Note: 1.9 protocol doc incorrectly says this is bit 19) ) -type accessBitmap [8]byte +type AccessBitmap [8]byte -func (bits *accessBitmap) Set(i int) { +func (bits *AccessBitmap) Set(i int) { bits[i/8] |= 1 << uint(7-i%8) } -func (bits *accessBitmap) IsSet(i int) bool { +func (bits *AccessBitmap) IsSet(i int) bool { return bits[i/8]&(1<<uint(7-i%8)) != 0 } diff --git a/hotline/access_test.go b/hotline/access_test.go index b262407..f2755f3 100644 --- a/hotline/access_test.go +++ b/hotline/access_test.go @@ -11,13 +11,13 @@ func Test_accessBitmap_IsSet(t *testing.T) { } tests := []struct { name string - bits accessBitmap + bits AccessBitmap args args want bool }{ { name: "returns true when bit is set", - bits: func() (access accessBitmap) { + bits: func() (access AccessBitmap) { access.Set(22) return access }(), @@ -26,7 +26,7 @@ func Test_accessBitmap_IsSet(t *testing.T) { }, { name: "returns false when bit is unset", - bits: accessBitmap{}, + bits: AccessBitmap{}, args: args{i: 22}, want: false, }, diff --git a/hotline/account.go b/hotline/account.go index 7b2aafe..7f770b1 100644 --- a/hotline/account.go +++ b/hotline/account.go @@ -14,16 +14,16 @@ type Account struct { Login string `yaml:"Login"` Name string `yaml:"Name"` Password string `yaml:"Password"` - Access accessBitmap `yaml:"Access,flow"` + Access AccessBitmap `yaml:"Access,flow"` readOffset int // Internal offset to track read progress } -func NewAccount(login, name, password string, access accessBitmap) *Account { +func NewAccount(login, name, password string, access AccessBitmap) *Account { return &Account{ Login: login, Name: name, - Password: hashAndSalt([]byte(password)), + Password: HashAndSalt([]byte(password)), Access: access, } } @@ -32,7 +32,7 @@ func NewAccount(login, name, password string, access accessBitmap) *Account { func (a *Account) Read(p []byte) (int, error) { fields := []Field{ NewField(FieldUserName, []byte(a.Name)), - NewField(FieldUserLogin, encodeString([]byte(a.Login))), + NewField(FieldUserLogin, EncodeString([]byte(a.Login))), NewField(FieldUserAccess, a.Access[:]), } @@ -63,8 +63,8 @@ func (a *Account) Read(p []byte) (int, error) { return n, nil } -// hashAndSalt generates a password hash from a users obfuscated plaintext password -func hashAndSalt(pwd []byte) string { +// HashAndSalt generates a password hash from a users obfuscated plaintext password +func HashAndSalt(pwd []byte) string { hash, _ := bcrypt.GenerateFromPassword(pwd, bcrypt.MinCost) return string(hash) diff --git a/hotline/account_manager.go b/hotline/account_manager.go index 769d12c..03621df 100644 --- a/hotline/account_manager.go +++ b/hotline/account_manager.go @@ -1,15 +1,5 @@ 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 @@ -17,172 +7,3 @@ type AccountManager interface { 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 14f41b9..7a9fd31 100644 --- a/hotline/ban.go +++ b/hotline/ban.go @@ -2,7 +2,8 @@ package hotline import "time" -const tempBanDuration = 30 * time.Minute +// BanDuration is the length of time for temporary bans. +const BanDuration = 30 * time.Minute type BanMgr interface { Add(ip string, until *time.Time) error diff --git a/hotline/chat.go b/hotline/chat.go index 6f2a4dd..dcde1d3 100644 --- a/hotline/chat.go +++ b/hotline/chat.go @@ -23,41 +23,6 @@ type ChatManager interface { 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 @@ -135,3 +100,38 @@ func (cm *MemChatManager) SetSubject(id ChatID, subject string) { chat.Subject = subject } + +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) +} diff --git a/hotline/client.go b/hotline/client.go index d7af920..e300bb8 100644 --- a/hotline/client.go +++ b/hotline/client.go @@ -84,8 +84,8 @@ func (c *Client) Connect(address, login, passwd string) (err error) { TranLogin, [2]byte{0, 0}, NewField(FieldUserName, []byte(c.Pref.Username)), NewField(FieldUserIconID, c.Pref.IconBytes()), - NewField(FieldUserLogin, encodeString([]byte(login))), - NewField(FieldUserPassword, encodeString([]byte(passwd))), + NewField(FieldUserLogin, EncodeString([]byte(login))), + NewField(FieldUserPassword, EncodeString([]byte(passwd))), ), ) if err != nil { @@ -185,12 +185,6 @@ func (c *Client) HandleTransaction(ctx context.Context, t *Transaction) error { return err } } - } else { - c.Logger.Debug( - "Unimplemented transaction type", - "IsReply", t.IsReply, - "type", t.Type[:], - ) } return nil diff --git a/hotline/client_conn.go b/hotline/client_conn.go index dc58996..060a2b9 100644 --- a/hotline/client_conn.go +++ b/hotline/client_conn.go @@ -26,7 +26,7 @@ type ClientConn struct { Icon []byte // TODO: make fixed size of 2 Version []byte // TODO: make fixed size of 2 - flagsMU sync.Mutex // TODO: move into UserFlags struct + FlagsMU sync.Mutex // TODO: move into UserFlags struct Flags UserFlags UserName []byte @@ -37,7 +37,7 @@ type ClientConn struct { ClientFileTransferMgr ClientFileTransferMgr - logger *slog.Logger + Logger *slog.Logger mu sync.RWMutex } @@ -64,7 +64,7 @@ func (cftm *ClientFileTransferMgr) Add(ftType FileTransferType, ft *FileTransfer cftm.mu.Lock() defer cftm.mu.Unlock() - cftm.transfers[ftType][ft.refNum] = ft + cftm.transfers[ftType][ft.RefNum] = ft } func (cftm *ClientFileTransferMgr) Get(ftType FileTransferType) []FileTransfer { @@ -95,9 +95,9 @@ func (cc *ClientConn) SendAll(t [2]byte, fields ...Field) { } func (cc *ClientConn) handleTransaction(transaction Transaction) { - if handler, ok := TransactionHandlers[transaction.Type]; ok { + if handler, ok := cc.Server.handlers[transaction.Type]; ok { if transaction.Type != TranKeepAlive { - cc.logger.Info(tranTypeNames[transaction.Type]) + cc.Logger.Info(tranTypeNames[transaction.Type]) } for _, t := range handler(cc, &transaction) { @@ -144,7 +144,7 @@ func (cc *ClientConn) Authorize(access int) bool { return cc.Account.Access.IsSet(access) } -// Disconnect notifies other clients that a client has disconnected +// Disconnect notifies other clients that a client has disconnected and closes the connection. func (cc *ClientConn) Disconnect() { cc.Server.ClientMgr.Delete(cc.ID) @@ -153,7 +153,7 @@ func (cc *ClientConn) Disconnect() { } if err := cc.Connection.Close(); err != nil { - cc.Server.Logger.Error("error closing client connection", "RemoteAddr", cc.RemoteAddr) + cc.Server.Logger.Debug("error closing client connection", "RemoteAddr", cc.RemoteAddr) } } @@ -161,7 +161,7 @@ func (cc *ClientConn) Disconnect() { func (cc *ClientConn) NotifyOthers(t Transaction) (trans []Transaction) { for _, c := range cc.Server.ClientMgr.List() { if c.ID != cc.ID { - t.clientID = c.ID + t.ClientID = c.ID trans = append(trans, t) } } @@ -173,7 +173,7 @@ func (cc *ClientConn) NewReply(t *Transaction, fields ...Field) Transaction { return Transaction{ IsReply: 1, ID: t.ID, - clientID: cc.ID, + ClientID: cc.ID, Fields: fields, } } @@ -182,7 +182,7 @@ func (cc *ClientConn) NewReply(t *Transaction, fields ...Field) Transaction { func (cc *ClientConn) NewErrReply(t *Transaction, errMsg string) []Transaction { return []Transaction{ { - clientID: cc.ID, + ClientID: cc.ID, IsReply: 1, ID: t.ID, ErrorCode: [4]byte{0, 0, 0, 1}, diff --git a/hotline/config.go b/hotline/config.go index a30bbf7..1ad0ec2 100644 --- a/hotline/config.go +++ b/hotline/config.go @@ -1,15 +1,9 @@ package hotline -const ( - userIdleSeconds = 300 // time in seconds before an inactive user is marked idle - idleCheckInterval = 10 // time in seconds to check for idle users - trackerUpdateFrequency = 300 // time in seconds between tracker re-registration -) - type Config struct { Name string `yaml:"Name" validate:"required,max=50"` // Name used for Tracker registration Description string `yaml:"Description" validate:"required,max=200"` // Description used for Tracker registration - BannerFile string `yaml:"BannerFile"` // Path to banner jpg + BannerFile string `yaml:"BannerFile"` // Path to Banner jpg FileRoot string `yaml:"FileRoot" validate:"required"` // Path to Files EnableTrackerRegistration bool `yaml:"EnableTrackerRegistration"` // Toggle Tracker Registration Trackers []string `yaml:"Trackers" validate:"dive,hostname_port"` // List of trackers that the server should register with diff --git a/hotline/doc.go b/hotline/doc.go new file mode 100644 index 0000000..5a6df33 --- /dev/null +++ b/hotline/doc.go @@ -0,0 +1,2 @@ +// Package hotline provides Hotline client and server implementations. +package hotline diff --git a/hotline/field.go b/hotline/field.go index 4c760d5..65ff394 100644 --- a/hotline/field.go +++ b/hotline/field.go @@ -92,8 +92,8 @@ func NewField(fieldType [2]byte, data []byte) Field { return f } -// fieldScanner implements bufio.SplitFunc for parsing byte slices into complete tokens -func fieldScanner(data []byte, _ bool) (advance int, token []byte, err error) { +// FieldScanner implements bufio.SplitFunc for parsing byte slices into complete tokens +func FieldScanner(data []byte, _ bool) (advance int, token []byte, err error) { if len(data) < minFieldLen { return 0, nil, nil } @@ -122,7 +122,7 @@ func (f *Field) DecodeInt() (int, error) { } func (f *Field) DecodeObfuscatedString() string { - return string(encodeString(f.Data)) + return string(EncodeString(f.Data)) } // DecodeNewsPath decodes the field data to a news path. @@ -185,7 +185,7 @@ func (f *Field) Write(p []byte) (int, error) { return minFieldLen + dataSize, nil } -func getField(id [2]byte, fields *[]Field) *Field { +func GetField(id [2]byte, fields *[]Field) *Field { for _, field := range *fields { if id == field.Type { return &field diff --git a/hotline/field_test.go b/hotline/field_test.go index abb4d44..fc11c74 100644 --- a/hotline/field_test.go +++ b/hotline/field_test.go @@ -90,12 +90,12 @@ func Test_fieldScanner(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotAdvance, gotToken, err := fieldScanner(tt.args.data, tt.args.in1) - if !tt.wantErr(t, err, fmt.Sprintf("fieldScanner(%v, %v)", tt.args.data, tt.args.in1)) { + gotAdvance, gotToken, err := FieldScanner(tt.args.data, tt.args.in1) + if !tt.wantErr(t, err, fmt.Sprintf("FieldScanner(%v, %v)", tt.args.data, tt.args.in1)) { return } - assert.Equalf(t, tt.wantAdvance, gotAdvance, "fieldScanner(%v, %v)", tt.args.data, tt.args.in1) - assert.Equalf(t, tt.wantToken, gotToken, "fieldScanner(%v, %v)", tt.args.data, tt.args.in1) + assert.Equalf(t, tt.wantAdvance, gotAdvance, "FieldScanner(%v, %v)", tt.args.data, tt.args.in1) + assert.Equalf(t, tt.wantToken, gotToken, "FieldScanner(%v, %v)", tt.args.data, tt.args.in1) }) } } diff --git a/hotline/file_header.go b/hotline/file_header.go deleted file mode 100644 index b18229a..0000000 --- a/hotline/file_header.go +++ /dev/null @@ -1,46 +0,0 @@ -package hotline - -import ( - "encoding/binary" - "io" - "slices" -) - -type FileHeader struct { - Size [2]byte // Total size of FileHeader payload - Type [2]byte // 0 for file, 1 for dir - FilePath []byte // encoded file path - - readOffset int // Internal offset to track read progress -} - -func NewFileHeader(fileName string, isDir bool) FileHeader { - fh := FileHeader{ - FilePath: EncodeFilePath(fileName), - } - if isDir { - fh.Type = [2]byte{0x00, 0x01} - } - - encodedPathLen := uint16(len(fh.FilePath) + len(fh.Type)) - binary.BigEndian.PutUint16(fh.Size[:], encodedPathLen) - - return fh -} - -func (fh *FileHeader) Read(p []byte) (int, error) { - buf := slices.Concat( - fh.Size[:], - fh.Type[:], - fh.FilePath, - ) - - if fh.readOffset >= len(buf) { - return 0, io.EOF // All bytes have been read - } - - n := copy(p, buf[fh.readOffset:]) - fh.readOffset += n - - return n, nil -} diff --git a/hotline/file_header_test.go b/hotline/file_header_test.go deleted file mode 100644 index 308d6b9..0000000 --- a/hotline/file_header_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package hotline - -import ( - "io" - "reflect" - "testing" -) - -func TestNewFileHeader(t *testing.T) { - type args struct { - fileName string - isDir bool - } - tests := []struct { - name string - args args - want FileHeader - }{ - { - name: "when path is file", - args: args{ - fileName: "foo", - isDir: false, - }, - want: FileHeader{ - Size: [2]byte{0x00, 0x0a}, - Type: [2]byte{0x00, 0x00}, - FilePath: EncodeFilePath("foo"), - }, - }, - { - name: "when path is dir", - args: args{ - fileName: "foo", - isDir: true, - }, - want: FileHeader{ - Size: [2]byte{0x00, 0x0a}, - Type: [2]byte{0x00, 0x01}, - FilePath: EncodeFilePath("foo"), - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := NewFileHeader(tt.args.fileName, tt.args.isDir); !reflect.DeepEqual(got, tt.want) { - t.Errorf("NewFileHeader() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestFileHeader_Payload(t *testing.T) { - type fields struct { - Size [2]byte - Type [2]byte - FilePath []byte - } - tests := []struct { - name string - fields fields - want []byte - }{ - { - name: "has expected payload bytes", - fields: fields{ - Size: [2]byte{0x00, 0x0a}, - Type: [2]byte{0x00, 0x00}, - FilePath: EncodeFilePath("foo"), - }, - want: []byte{ - 0x00, 0x0a, // total size - 0x00, 0x00, // type - 0x00, 0x01, // path item count - 0x00, 0x00, // path separator - 0x03, // pathName len - 0x66, 0x6f, 0x6f, // "foo" - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - fh := &FileHeader{ - Size: tt.fields.Size, - Type: tt.fields.Type, - FilePath: tt.fields.FilePath, - } - got, _ := io.ReadAll(fh) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("Read() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/hotline/file_name_with_info.go b/hotline/file_name_with_info.go index 3324a62..3a4a795 100644 --- a/hotline/file_name_with_info.go +++ b/hotline/file_name_with_info.go @@ -8,14 +8,14 @@ import ( ) type FileNameWithInfo struct { - fileNameWithInfoHeader + FileNameWithInfoHeader Name []byte // File Name readOffset int // Internal offset to track read progress } -// fileNameWithInfoHeader contains the fixed length fields of FileNameWithInfo -type fileNameWithInfoHeader struct { +// FileNameWithInfoHeader contains the fixed length fields of FileNameWithInfo +type FileNameWithInfoHeader struct { Type [4]byte // File type code Creator [4]byte // File creator code FileSize [4]byte // File Size in bytes @@ -24,7 +24,7 @@ type fileNameWithInfoHeader struct { NameSize [2]byte // Length of Name field } -func (f *fileNameWithInfoHeader) nameLen() int { +func (f *FileNameWithInfoHeader) nameLen() int { return int(binary.BigEndian.Uint16(f.NameSize[:])) } @@ -51,11 +51,11 @@ func (f *FileNameWithInfo) Read(p []byte) (int, error) { } func (f *FileNameWithInfo) Write(p []byte) (int, error) { - err := binary.Read(bytes.NewReader(p), binary.BigEndian, &f.fileNameWithInfoHeader) + err := binary.Read(bytes.NewReader(p), binary.BigEndian, &f.FileNameWithInfoHeader) if err != nil { return 0, err } - headerLen := binary.Size(f.fileNameWithInfoHeader) + headerLen := binary.Size(f.FileNameWithInfoHeader) f.Name = p[headerLen : headerLen+f.nameLen()] return len(p), nil diff --git a/hotline/file_name_with_info_test.go b/hotline/file_name_with_info_test.go index 321e247..d1b03c2 100644 --- a/hotline/file_name_with_info_test.go +++ b/hotline/file_name_with_info_test.go @@ -9,7 +9,7 @@ import ( func TestFileNameWithInfo_MarshalBinary(t *testing.T) { type fields struct { - fileNameWithInfoHeader fileNameWithInfoHeader + fileNameWithInfoHeader FileNameWithInfoHeader name []byte } tests := []struct { @@ -21,7 +21,7 @@ func TestFileNameWithInfo_MarshalBinary(t *testing.T) { { name: "returns expected bytes", fields: fields{ - fileNameWithInfoHeader: fileNameWithInfoHeader{ + fileNameWithInfoHeader: FileNameWithInfoHeader{ Type: [4]byte{0x54, 0x45, 0x58, 0x54}, // TEXT Creator: [4]byte{0x54, 0x54, 0x58, 0x54}, // TTXT FileSize: [4]byte{0x00, 0x43, 0x16, 0xd3}, // File Size @@ -46,7 +46,7 @@ func TestFileNameWithInfo_MarshalBinary(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { f := &FileNameWithInfo{ - fileNameWithInfoHeader: tt.fields.fileNameWithInfoHeader, + FileNameWithInfoHeader: tt.fields.fileNameWithInfoHeader, Name: tt.fields.name, } gotData, err := io.ReadAll(f) @@ -63,7 +63,7 @@ func TestFileNameWithInfo_MarshalBinary(t *testing.T) { func TestFileNameWithInfo_UnmarshalBinary(t *testing.T) { type fields struct { - fileNameWithInfoHeader fileNameWithInfoHeader + fileNameWithInfoHeader FileNameWithInfoHeader name []byte } type args struct { @@ -90,7 +90,7 @@ func TestFileNameWithInfo_UnmarshalBinary(t *testing.T) { }, }, want: &FileNameWithInfo{ - fileNameWithInfoHeader: fileNameWithInfoHeader{ + FileNameWithInfoHeader: FileNameWithInfoHeader{ Type: [4]byte{0x54, 0x45, 0x58, 0x54}, // TEXT Creator: [4]byte{0x54, 0x54, 0x58, 0x54}, // TTXT FileSize: [4]byte{0x00, 0x43, 0x16, 0xd3}, // File Size @@ -106,7 +106,7 @@ func TestFileNameWithInfo_UnmarshalBinary(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { f := &FileNameWithInfo{ - fileNameWithInfoHeader: tt.fields.fileNameWithInfoHeader, + FileNameWithInfoHeader: tt.fields.fileNameWithInfoHeader, Name: tt.fields.name, } if _, err := f.Write(tt.args.data); (err != nil) != tt.wantErr { diff --git a/hotline/file_path.go b/hotline/file_path.go index 620f54d..f4a27cc 100644 --- a/hotline/file_path.go +++ b/hotline/file_path.go @@ -102,7 +102,7 @@ func (fp *FilePath) Len() uint16 { return binary.BigEndian.Uint16(fp.ItemCount[:]) } -func readPath(fileRoot string, filePath, fileName []byte) (fullPath string, err error) { +func ReadPath(fileRoot string, filePath, fileName []byte) (fullPath string, err error) { var fp FilePath if filePath != nil { if _, err = fp.Write(filePath); err != nil { diff --git a/hotline/file_path_test.go b/hotline/file_path_test.go index effd462..23c9a96 100644 --- a/hotline/file_path_test.go +++ b/hotline/file_path_test.go @@ -154,13 +154,13 @@ func Test_readPath(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := readPath(tt.args.fileRoot, tt.args.filePath, tt.args.fileName) + got, err := ReadPath(tt.args.fileRoot, tt.args.filePath, tt.args.fileName) if (err != nil) != tt.wantErr { - t.Errorf("readPath() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("ReadPath() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { - t.Errorf("readPath() got = %v, want %v", got, tt.want) + t.Errorf("ReadPath() got = %v, want %v", got, tt.want) } }) } diff --git a/hotline/file_transfer.go b/hotline/file_transfer.go index b567b18..e37295a 100644 --- a/hotline/file_transfer.go +++ b/hotline/file_transfer.go @@ -12,6 +12,7 @@ import ( "math" "os" "path/filepath" + "slices" "strings" "sync" ) @@ -59,15 +60,11 @@ func (ftm *MemFileTransferMgr) Add(ft *FileTransfer) { ftm.mu.Lock() defer ftm.mu.Unlock() - _, _ = rand.Read(ft.refNum[:]) + _, _ = rand.Read(ft.RefNum[:]) - ftm.fileTransfers[ft.refNum] = ft + 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 { @@ -83,9 +80,6 @@ func (ftm *MemFileTransferMgr) Delete(id FileTransferID) { 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) @@ -95,12 +89,12 @@ func (ftm *MemFileTransferMgr) Delete(id FileTransferID) { type FileTransfer struct { FileName []byte FilePath []byte - refNum [4]byte + RefNum [4]byte Type FileTransferType TransferSize []byte FolderItemCount []byte - fileResumeData *FileResumeData - options []byte + FileResumeData *FileResumeData + Options []byte bytesSentCounter *WriteCounter ClientConn *ClientConn } @@ -122,7 +116,7 @@ func (wc *WriteCounter) Write(p []byte) (int, error) { return n, nil } -func (cc *ClientConn) newFileTransfer(transferType FileTransferType, fileName, filePath, size []byte) *FileTransfer { +func (cc *ClientConn) NewFileTransfer(transferType FileTransferType, fileName, filePath, size []byte) *FileTransfer { ft := &FileTransfer{ FileName: fileName, FilePath: filePath, @@ -131,19 +125,9 @@ func (cc *ClientConn) newFileTransfer(transferType FileTransferType, fileName, f ClientConn: cc, bytesSentCounter: &WriteCounter{}, } - // - //_, _ = 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 - return ft } @@ -217,6 +201,45 @@ func (fu *folderUpload) FormattedPath() string { return filepath.Join(pathSegments...) } +type FileHeader struct { + Size [2]byte // Total size of FileHeader payload + Type [2]byte // 0 for file, 1 for dir + FilePath []byte // encoded file path + + readOffset int // Internal offset to track read progress +} + +func NewFileHeader(fileName string, isDir bool) FileHeader { + fh := FileHeader{ + FilePath: EncodeFilePath(fileName), + } + if isDir { + fh.Type = [2]byte{0x00, 0x01} + } + + encodedPathLen := uint16(len(fh.FilePath) + len(fh.Type)) + binary.BigEndian.PutUint16(fh.Size[:], encodedPathLen) + + return fh +} + +func (fh *FileHeader) Read(p []byte) (int, error) { + buf := slices.Concat( + fh.Size[:], + fh.Type[:], + fh.FilePath, + ) + + if fh.readOffset >= len(buf) { + return 0, io.EOF // All bytes have been read + } + + n := copy(p, buf[fh.readOffset:]) + fh.readOffset += n + + return n, nil +} + 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 @@ -225,11 +248,11 @@ func DownloadHandler(w io.Writer, fullPath string, fileTransfer *FileTransfer, f //}() var dataOffset int64 - if fileTransfer.fileResumeData != nil { - dataOffset = int64(binary.BigEndian.Uint32(fileTransfer.fileResumeData.ForkInfoList[0].DataSize[:])) + if fileTransfer.FileResumeData != nil { + dataOffset = int64(binary.BigEndian.Uint32(fileTransfer.FileResumeData.ForkInfoList[0].DataSize[:])) } - fw, err := newFileWrapper(fs, fullPath, 0) + fw, err := NewFileWrapper(fs, fullPath, 0) if err != nil { return fmt.Errorf("reading file header: %v", err) } @@ -238,8 +261,8 @@ func DownloadHandler(w io.Writer, fullPath string, fileTransfer *FileTransfer, f // 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 { - if _, err = io.Copy(w, fw.ffo); err != nil { + if fileTransfer.Options == nil { + if _, err = io.Copy(w, fw.Ffo); err != nil { return fmt.Errorf("send flat file object: %v", err) } } @@ -259,7 +282,7 @@ func DownloadHandler(w io.Writer, fullPath string, fileTransfer *FileTransfer, f } // If the client requested to resume transfer, do not send the resource fork header. - if fileTransfer.fileResumeData == nil { + if fileTransfer.FileResumeData == nil { err = binary.Write(w, binary.BigEndian, fw.rsrcForkHeader()) if err != nil { return fmt.Errorf("send resource fork header: %v", err) @@ -294,13 +317,13 @@ func UploadHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileTransfe } if errors.Is(err, fs.ErrNotExist) { // If not found, open or create a new .incomplete file - file, err = os.OpenFile(fullPath+incompleteFileSuffix, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + file, err = os.OpenFile(fullPath+IncompleteFileSuffix, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) if err != nil { return err } } - f, err := newFileWrapper(fileStore, fullPath, 0) + f, err := NewFileWrapper(fileStore, fullPath, 0) if err != nil { return err } @@ -315,7 +338,7 @@ func UploadHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileTransfe return err } - iForkWriter, err = f.infoForkWriter() + iForkWriter, err = f.InfoForkWriter() if err != nil { return err } @@ -389,7 +412,7 @@ func DownloadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *Fil return nil } - hlFile, err := newFileWrapper(fileStore, path, 0) + hlFile, err := NewFileWrapper(fileStore, path, 0) if err != nil { return err } @@ -445,17 +468,17 @@ func DownloadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *Fil rLogger.Info("File download started", "fileName", info.Name(), - "TransferSize", fmt.Sprintf("%x", hlFile.ffo.TransferSize(dataOffset)), + "TransferSize", fmt.Sprintf("%x", hlFile.Ffo.TransferSize(dataOffset)), ) // Send file size to client - if _, err := rwc.Write(hlFile.ffo.TransferSize(dataOffset)); err != nil { + if _, err := rwc.Write(hlFile.Ffo.TransferSize(dataOffset)); err != nil { rLogger.Error(err.Error()) return fmt.Errorf("error sending file size: %w", err) } // Send ffo bytes to client - _, err = io.Copy(rwc, hlFile.ffo) + _, err = io.Copy(rwc, hlFile.Ffo) if err != nil { return fmt.Errorf("error sending flat file object: %w", err) } @@ -470,7 +493,7 @@ func DownloadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *Fil return fmt.Errorf("error sending file: %w", err) } - if nextAction[1] != 2 && hlFile.ffo.FlatFileHeader.ForkCount[1] == 3 { + if nextAction[1] != 2 && hlFile.Ffo.FlatFileHeader.ForkCount[1] == 3 { err = binary.Write(rwc, binary.BigEndian, hlFile.rsrcForkHeader()) if err != nil { return fmt.Errorf("error sending resource fork header: %w", err) @@ -560,7 +583,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT } // Check if we have a partial file already. If so, send dlFldrAction_ResumeFile to client to resume upload. - incompleteFile, err := os.Stat(filepath.Join(fullPath, fu.FormattedPath()+incompleteFileSuffix)) + incompleteFile, err := os.Stat(filepath.Join(fullPath, fu.FormattedPath()+IncompleteFileSuffix)) if err != nil && !errors.Is(err, fs.ErrNotExist) { return err } @@ -579,7 +602,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT offset := make([]byte, 4) binary.BigEndian.PutUint32(offset, uint32(incompleteFile.Size())) - file, err := os.OpenFile(fullPath+"/"+fu.FormattedPath()+incompleteFileSuffix, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + file, err := os.OpenFile(fullPath+"/"+fu.FormattedPath()+IncompleteFileSuffix, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return err } @@ -615,7 +638,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT filePath := filepath.Join(fullPath, fu.FormattedPath()) - hlFile, err := newFileWrapper(fileStore, filePath, 0) + hlFile, err := NewFileWrapper(fileStore, filePath, 0) if err != nil { return err } @@ -630,7 +653,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT rForkWriter := io.Discard iForkWriter := io.Discard if preserveForks { - iForkWriter, err = hlFile.infoForkWriter() + iForkWriter, err = hlFile.InfoForkWriter() if err != nil { return err } diff --git a/hotline/file_transfer_test.go b/hotline/file_transfer_test.go index d12f29a..b1236b6 100644 --- a/hotline/file_transfer_test.go +++ b/hotline/file_transfer_test.go @@ -3,6 +3,8 @@ package hotline import ( "encoding/binary" "github.com/stretchr/testify/assert" + "io" + "reflect" "testing" ) @@ -74,12 +76,12 @@ func TestFileTransfer_String(t *testing.T) { ft := &FileTransfer{ FileName: tt.fields.FileName, FilePath: tt.fields.FilePath, - refNum: tt.fields.refNum, + RefNum: tt.fields.refNum, Type: tt.fields.Type, TransferSize: tt.fields.TransferSize, FolderItemCount: tt.fields.FolderItemCount, - fileResumeData: tt.fields.fileResumeData, - options: tt.fields.options, + FileResumeData: tt.fields.fileResumeData, + Options: tt.fields.options, bytesSentCounter: tt.fields.bytesSentCounter, ClientConn: tt.fields.ClientConn, } @@ -87,3 +89,90 @@ func TestFileTransfer_String(t *testing.T) { }) } } + +func TestNewFileHeader(t *testing.T) { + type args struct { + fileName string + isDir bool + } + tests := []struct { + name string + args args + want FileHeader + }{ + { + name: "when path is file", + args: args{ + fileName: "foo", + isDir: false, + }, + want: FileHeader{ + Size: [2]byte{0x00, 0x0a}, + Type: [2]byte{0x00, 0x00}, + FilePath: EncodeFilePath("foo"), + }, + }, + { + name: "when path is dir", + args: args{ + fileName: "foo", + isDir: true, + }, + want: FileHeader{ + Size: [2]byte{0x00, 0x0a}, + Type: [2]byte{0x00, 0x01}, + FilePath: EncodeFilePath("foo"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NewFileHeader(tt.args.fileName, tt.args.isDir); !reflect.DeepEqual(got, tt.want) { + t.Errorf("NewFileHeader() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFileHeader_Payload(t *testing.T) { + type fields struct { + Size [2]byte + Type [2]byte + FilePath []byte + } + tests := []struct { + name string + fields fields + want []byte + }{ + { + name: "has expected payload bytes", + fields: fields{ + Size: [2]byte{0x00, 0x0a}, + Type: [2]byte{0x00, 0x00}, + FilePath: EncodeFilePath("foo"), + }, + want: []byte{ + 0x00, 0x0a, // total size + 0x00, 0x00, // type + 0x00, 0x01, // path item count + 0x00, 0x00, // path separator + 0x03, // pathName len + 0x66, 0x6f, 0x6f, // "foo" + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fh := &FileHeader{ + Size: tt.fields.Size, + Type: tt.fields.Type, + FilePath: tt.fields.FilePath, + } + got, _ := io.ReadAll(fh) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Read() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/hotline/file_wrapper.go b/hotline/file_wrapper.go index bc6319b..94b3f03 100644 --- a/hotline/file_wrapper.go +++ b/hotline/file_wrapper.go @@ -12,41 +12,41 @@ import ( ) const ( - incompleteFileSuffix = ".incomplete" - infoForkNameTemplate = ".info_%s" // template string for info fork filenames - rsrcForkNameTemplate = ".rsrc_%s" // template string for resource fork filenames + IncompleteFileSuffix = ".incomplete" + InfoForkNameTemplate = ".info_%s" // template string for info fork filenames + RsrcForkNameTemplate = ".rsrc_%s" // template string for resource fork filenames ) // fileWrapper encapsulates the data, info, and resource forks of a Hotline file and provides methods to manage the files. type fileWrapper struct { fs FileStore - name string // name of the file + Name string // Name of the file path string // path to file directory dataPath string // path to the file data fork dataOffset int64 rsrcPath string // path to the file resource fork infoPath string // path to the file information fork incompletePath string // path to partially transferred temp file - ffo *flattenedFileObject + Ffo *flattenedFileObject } -func newFileWrapper(fs FileStore, path string, dataOffset int64) (*fileWrapper, error) { +func NewFileWrapper(fs FileStore, path string, dataOffset int64) (*fileWrapper, error) { dir := filepath.Dir(path) fName := filepath.Base(path) f := fileWrapper{ fs: fs, - name: fName, + Name: fName, path: dir, dataPath: path, dataOffset: dataOffset, - rsrcPath: filepath.Join(dir, fmt.Sprintf(rsrcForkNameTemplate, fName)), - infoPath: filepath.Join(dir, fmt.Sprintf(infoForkNameTemplate, fName)), - incompletePath: filepath.Join(dir, fName+incompleteFileSuffix), - ffo: &flattenedFileObject{}, + rsrcPath: filepath.Join(dir, fmt.Sprintf(RsrcForkNameTemplate, fName)), + infoPath: filepath.Join(dir, fmt.Sprintf(InfoForkNameTemplate, fName)), + incompletePath: filepath.Join(dir, fName+IncompleteFileSuffix), + Ffo: &flattenedFileObject{}, } var err error - f.ffo, err = f.flattenedFileObject() + f.Ffo, err = f.flattenedFileObject() if err != nil { return nil, err } @@ -54,7 +54,7 @@ func newFileWrapper(fs FileStore, path string, dataOffset int64) (*fileWrapper, return &f, nil } -func (f *fileWrapper) totalSize() []byte { +func (f *fileWrapper) TotalSize() []byte { var s int64 size := make([]byte, 4) @@ -85,23 +85,21 @@ func (f *fileWrapper) rsrcForkSize() (s [4]byte) { func (f *fileWrapper) rsrcForkHeader() FlatFileForkHeader { return FlatFileForkHeader{ - ForkType: [4]byte{0x4D, 0x41, 0x43, 0x52}, // "MACR" - CompressionType: [4]byte{}, - RSVD: [4]byte{}, - DataSize: f.rsrcForkSize(), + ForkType: [4]byte{0x4D, 0x41, 0x43, 0x52}, // "MACR" + DataSize: f.rsrcForkSize(), } } func (f *fileWrapper) incompleteDataName() string { - return f.name + incompleteFileSuffix + return f.Name + IncompleteFileSuffix } func (f *fileWrapper) rsrcForkName() string { - return fmt.Sprintf(rsrcForkNameTemplate, f.name) + return fmt.Sprintf(RsrcForkNameTemplate, f.Name) } func (f *fileWrapper) infoForkName() string { - return fmt.Sprintf(infoForkNameTemplate, f.name) + return fmt.Sprintf(InfoForkNameTemplate, f.Name) } func (f *fileWrapper) rsrcForkWriter() (io.WriteCloser, error) { @@ -113,7 +111,7 @@ func (f *fileWrapper) rsrcForkWriter() (io.WriteCloser, error) { return file, nil } -func (f *fileWrapper) infoForkWriter() (io.WriteCloser, error) { +func (f *fileWrapper) InfoForkWriter() (io.WriteCloser, error) { file, err := os.OpenFile(f.infoPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) if err != nil { return nil, err @@ -139,7 +137,7 @@ func (f *fileWrapper) rsrcForkFile() (*os.File, error) { return f.fs.Open(f.rsrcPath) } -func (f *fileWrapper) dataFile() (os.FileInfo, error) { +func (f *fileWrapper) DataFile() (os.FileInfo, error) { if fi, err := f.fs.Stat(f.dataPath); err == nil { return fi, nil } @@ -150,14 +148,14 @@ func (f *fileWrapper) dataFile() (os.FileInfo, error) { return nil, errors.New("file or directory not found") } -// move a fileWrapper and its associated meta files to newPath. +// Move a file and its associated meta files to newPath. // Meta files include: // * Partially uploaded file ending with .incomplete // * Resource fork starting with .rsrc_ // * Info fork starting with .info -// During move of the meta files, os.ErrNotExist is ignored as these files may legitimately not exist. -func (f *fileWrapper) move(newPath string) error { - err := f.fs.Rename(f.dataPath, filepath.Join(newPath, f.name)) +// During Move of the meta files, os.ErrNotExist is ignored as these files may legitimately not exist. +func (f *fileWrapper) Move(newPath string) error { + err := f.fs.Rename(f.dataPath, filepath.Join(newPath, f.Name)) if err != nil { return err } @@ -180,8 +178,8 @@ func (f *fileWrapper) move(newPath string) error { return nil } -// delete a fileWrapper and its associated metadata files if they exist -func (f *fileWrapper) delete() error { +// Delete a fileWrapper and its associated metadata files if they exist +func (f *fileWrapper) Delete() error { err := f.fs.RemoveAll(f.dataPath) if err != nil { return err @@ -218,20 +216,19 @@ func (f *fileWrapper) flattenedFileObject() (*flattenedFileObject, error) { if errors.Is(err, fs.ErrNotExist) { fileInfo, err = f.fs.Stat(f.incompletePath) if err == nil { - mTime = toHotlineTime(fileInfo.ModTime()) + mTime = NewTime(fileInfo.ModTime()) binary.BigEndian.PutUint32(dataSize, uint32(fileInfo.Size()-f.dataOffset)) ft, _ = fileTypeFromInfo(fileInfo) } } else { - mTime = toHotlineTime(fileInfo.ModTime()) + mTime = NewTime(fileInfo.ModTime()) binary.BigEndian.PutUint32(dataSize, uint32(fileInfo.Size()-f.dataOffset)) ft, _ = fileTypeFromInfo(fileInfo) } - f.ffo.FlatFileHeader = FlatFileHeader{ + f.Ffo.FlatFileHeader = FlatFileHeader{ Format: [4]byte{0x46, 0x49, 0x4c, 0x50}, // "FILP" Version: [2]byte{0, 1}, - RSVD: [16]byte{}, ForkCount: [2]byte{0, 2}, } @@ -242,43 +239,39 @@ func (f *fileWrapper) flattenedFileObject() (*flattenedFileObject, error) { return nil, err } - f.ffo.FlatFileHeader.ForkCount[1] = 3 + f.Ffo.FlatFileHeader.ForkCount[1] = 3 - _, err = io.Copy(&f.ffo.FlatFileInformationFork, bytes.NewReader(b)) + _, err = io.Copy(&f.Ffo.FlatFileInformationFork, bytes.NewReader(b)) if err != nil { return nil, fmt.Errorf("error copying FlatFileInformationFork: %w", err) } } else { - f.ffo.FlatFileInformationFork = FlatFileInformationFork{ + f.Ffo.FlatFileInformationFork = FlatFileInformationFork{ Platform: [4]byte{0x41, 0x4D, 0x41, 0x43}, // "AMAC" TODO: Remove hardcode to support "AWIN" Platform (maybe?) TypeSignature: [4]byte([]byte(ft.TypeCode)), CreatorSignature: [4]byte([]byte(ft.CreatorCode)), PlatformFlags: [4]byte{0, 0, 1, 0}, // TODO: What is this? CreateDate: mTime, // some filesystems don't support createTime ModifyDate: mTime, - Name: []byte(f.name), + Name: []byte(f.Name), Comment: []byte{}, } ns := make([]byte, 2) - binary.BigEndian.PutUint16(ns, uint16(len(f.name))) - f.ffo.FlatFileInformationFork.NameSize = [2]byte(ns[:]) + binary.BigEndian.PutUint16(ns, uint16(len(f.Name))) + f.Ffo.FlatFileInformationFork.NameSize = [2]byte(ns[:]) } - f.ffo.FlatFileInformationForkHeader = FlatFileForkHeader{ - ForkType: [4]byte{0x49, 0x4E, 0x46, 0x4F}, // "INFO" - CompressionType: [4]byte{}, - RSVD: [4]byte{}, - DataSize: f.ffo.FlatFileInformationFork.Size(), + f.Ffo.FlatFileInformationForkHeader = FlatFileForkHeader{ + ForkType: [4]byte{0x49, 0x4E, 0x46, 0x4F}, // "INFO" + DataSize: f.Ffo.FlatFileInformationFork.Size(), } - f.ffo.FlatFileDataForkHeader = FlatFileForkHeader{ - ForkType: [4]byte{0x44, 0x41, 0x54, 0x41}, // "DATA" - CompressionType: [4]byte{}, - RSVD: [4]byte{}, - DataSize: [4]byte{dataSize[0], dataSize[1], dataSize[2], dataSize[3]}, + f.Ffo.FlatFileDataForkHeader = FlatFileForkHeader{ + ForkType: [4]byte{0x44, 0x41, 0x54, 0x41}, // "DATA" + DataSize: [4]byte{dataSize[0], dataSize[1], dataSize[2], dataSize[3]}, } - f.ffo.FlatFileResForkHeader = f.rsrcForkHeader() + f.Ffo.FlatFileResForkHeader = f.rsrcForkHeader() - return f.ffo, nil + return f.Ffo, nil } diff --git a/hotline/files.go b/hotline/files.go index bc2f8d5..a1db329 100644 --- a/hotline/files.go +++ b/hotline/files.go @@ -34,7 +34,7 @@ func fileTypeFromInfo(info fs.FileInfo) (ft fileType, err error) { const maxFileSize = 4294967296 -func getFileNameList(path string, ignoreList []string) (fields []Field, err error) { +func GetFileNameList(path string, ignoreList []string) (fields []Field, err error) { files, err := os.ReadDir(path) if err != nil { return fields, fmt.Errorf("error reading path: %s: %w", path, err) @@ -112,14 +112,14 @@ func getFileNameList(path string, ignoreList []string) (fields []Field, err erro continue } - hlFile, err := newFileWrapper(&OSFileStore{}, path+"/"+file.Name(), 0) + hlFile, err := NewFileWrapper(&OSFileStore{}, path+"/"+file.Name(), 0) if err != nil { - return nil, fmt.Errorf("newFileWrapper: %w", err) + return nil, fmt.Errorf("NewFileWrapper: %w", err) } - copy(fnwi.FileSize[:], hlFile.totalSize()) - copy(fnwi.Type[:], hlFile.ffo.FlatFileInformationFork.TypeSignature[:]) - copy(fnwi.Creator[:], hlFile.ffo.FlatFileInformationFork.CreatorSignature[:]) + copy(fnwi.FileSize[:], hlFile.TotalSize()) + copy(fnwi.Type[:], hlFile.Ffo.FlatFileInformationFork.TypeSignature[:]) + copy(fnwi.Creator[:], hlFile.Ffo.FlatFileInformationFork.CreatorSignature[:]) } strippedName := strings.ReplaceAll(file.Name(), ".incomplete", "") diff --git a/hotline/flattened_file_object.go b/hotline/flattened_file_object.go index fbc319b..99ce204 100644 --- a/hotline/flattened_file_object.go +++ b/hotline/flattened_file_object.go @@ -56,21 +56,21 @@ func NewFlatFileInformationFork(fileName string, modifyTime [8]byte, typeSignatu } } -func (ffif *FlatFileInformationFork) friendlyType() []byte { +func (ffif *FlatFileInformationFork) FriendlyType() []byte { if name, ok := friendlyCreatorNames[string(ffif.TypeSignature[:])]; ok { return []byte(name) } return ffif.TypeSignature[:] } -func (ffif *FlatFileInformationFork) friendlyCreator() []byte { +func (ffif *FlatFileInformationFork) FriendlyCreator() []byte { if name, ok := friendlyCreatorNames[string(ffif.CreatorSignature[:])]; ok { return []byte(name) } return ffif.CreatorSignature[:] } -func (ffif *FlatFileInformationFork) setComment(comment []byte) error { +func (ffif *FlatFileInformationFork) SetComment(comment []byte) error { commentSize := make([]byte, 2) ffif.Comment = comment binary.BigEndian.PutUint16(commentSize, uint16(len(comment))) diff --git a/hotline/handshake.go b/hotline/handshake.go index 55b074d..b8e16c0 100644 --- a/hotline/handshake.go +++ b/hotline/handshake.go @@ -72,15 +72,14 @@ func performHandshake(rw io.ReadWriter) error { // Copy exactly handshakeSize bytes from rw to handshake if _, err := io.CopyN(&h, rw, handshakeSize); err != nil { - return fmt.Errorf("failed to read handshake data: %w", err) + return fmt.Errorf("read handshake: %w", err) } - if !h.Valid() { return errors.New("invalid protocol or sub-protocol in handshake") } if _, err := rw.Write(handshakeResponse[:]); err != nil { - return fmt.Errorf("error sending handshake response: %w", err) + return fmt.Errorf("send handshake response: %w", err) } return nil diff --git a/hotline/handshake_test.go b/hotline/handshake_test.go index 73ebfe4..508fb0f 100644 --- a/hotline/handshake_test.go +++ b/hotline/handshake_test.go @@ -166,7 +166,7 @@ func TestPerformHandshake(t *testing.T) { 0x54, 0x52, 0x54, 0x50, // TRTP }, expectedOutput: nil, - expectedError: "failed to read handshake data: invalid handshake size", + expectedError: "read handshake: invalid handshake size", }, { name: "Invalid Protocol", diff --git a/hotline/message_board.go b/hotline/message_board.go index e5022a4..1125a10 100644 --- a/hotline/message_board.go +++ b/hotline/message_board.go @@ -1,8 +1,8 @@ package hotline -const defaultNewsDateFormat = "Jan02 15:04" // Jun23 20:49 +const NewsDateFormat = "Jan02 15:04" // Jun23 20:49 -const defaultNewsTemplate = `From %s (%s): +const NewsTemplate = `From %s (%s): %s diff --git a/hotline/news.go b/hotline/news.go index 0d0400c..a490a89 100644 --- a/hotline/news.go +++ b/hotline/news.go @@ -3,6 +3,7 @@ package hotline import ( "cmp" "encoding/binary" + "github.com/stretchr/testify/mock" "io" "slices" ) @@ -236,3 +237,53 @@ func newsPathScanner(data []byte, _ bool) (advance int, token []byte, err error) advance = 3 + int(data[2]) return advance, data[3:advance], nil } + +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) +} diff --git a/hotline/news_test.go b/hotline/news_test.go index 588c979..d1b043e 100644 --- a/hotline/news_test.go +++ b/hotline/news_test.go @@ -2,61 +2,10 @@ 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 6232253..58a9209 100644 --- a/hotline/server.go +++ b/hotline/server.go @@ -9,13 +9,10 @@ import ( "errors" "fmt" "golang.org/x/text/encoding/charmap" - "gopkg.in/yaml.v3" "io" "log" "log/slog" "net" - "os" - "path/filepath" "strings" "sync" "time" @@ -39,9 +36,10 @@ type Server struct { NetInterface string Port int - Config Config - ConfigDir string - Logger *slog.Logger + handlers map[TranType]HandlerFunc + + Config Config + Logger *slog.Logger TrackerPassID [4]byte @@ -51,10 +49,8 @@ type Server struct { outbox chan Transaction - // TODO - Agreement []byte - banner []byte - // END TODO + Agreement io.ReadSeeker + Banner []byte FileTransferMgr FileTransferMgr ChatMgr ChatManager @@ -66,60 +62,57 @@ type Server struct { MessageBoard io.ReadWriteSeeker } -// 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(), - } +type Option = func(s *Server) - // generate a new random passID for tracker registration - _, err := rand.Read(server.TrackerPassID[:]) - if err != nil { - return nil, err +func WithConfig(config Config) func(s *Server) { + return func(s *Server) { + s.Config = config } +} - server.Agreement, err = os.ReadFile(filepath.Join(configDir, agreementFile)) - if err != nil { - return nil, err +func WithLogger(logger *slog.Logger) func(s *Server) { + return func(s *Server) { + s.Logger = logger } +} - server.AccountManager, err = NewYAMLAccountManager(filepath.Join(configDir, "Users/")) - if err != nil { - return nil, fmt.Errorf("error loading accounts: %w", err) +// WithPort optionally overrides the default TCP port. +func WithPort(port int) func(s *Server) { + return func(s *Server) { + s.Port = port } +} - // 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) +// WithInterface optionally sets a specific interface to listen on. +func WithInterface(netInterface string) func(s *Server) { + return func(s *Server) { + s.NetInterface = netInterface } +} - server.banner, err = os.ReadFile(filepath.Join(server.ConfigDir, server.Config.BannerFile)) - if err != nil { - return nil, fmt.Errorf("error opening banner: %w", err) - } +type ServerConfig struct { +} - if server.Config.EnableTrackerRegistration { - server.Logger.Info( - "Tracker registration enabled", - "frequency", fmt.Sprintf("%vs", trackerUpdateFrequency), - "trackers", server.Config.Trackers, - ) +func NewServer(options ...Option) (*Server, error) { + server := Server{ + handlers: make(map[TranType]HandlerFunc), + outbox: make(chan Transaction), + FS: &OSFileStore{}, + ChatMgr: NewMemChatManager(), + ClientMgr: NewMemClientMgr(), + FileTransferMgr: NewMemFileTransferMgr(), + Stats: NewStats(), + } - go server.registerWithTrackers() + for _, opt := range options { + opt(&server) } - // Start Client Keepalive go routine - go server.keepaliveHandler() + // generate a new random passID for tracker registration + _, err := rand.Read(server.TrackerPassID[:]) + if err != nil { + return nil, err + } return &server, nil } @@ -129,6 +122,10 @@ func (s *Server) CurrentStats() map[string]interface{} { } func (s *Server) ListenAndServe(ctx context.Context) error { + go s.registerWithTrackers(ctx) + go s.keepaliveHandler(ctx) + go s.processOutbox() + var wg sync.WaitGroup wg.Add(1) @@ -179,7 +176,7 @@ func (s *Server) ServeFileTransfers(ctx context.Context, ln net.Listener) error } func (s *Server) sendTransaction(t Transaction) error { - client := s.ClientMgr.Get(t.clientID) + client := s.ClientMgr.Get(t.ClientID) if client == nil { return nil @@ -187,7 +184,7 @@ func (s *Server) sendTransaction(t Transaction) error { _, err := io.Copy(client.Connection, &t) if err != nil { - return fmt.Errorf("failed to send transaction to client %v: %v", t.clientID, err) + return fmt.Errorf("failed to send transaction to client %v: %v", t.ClientID, err) } return nil @@ -205,78 +202,107 @@ func (s *Server) processOutbox() { } func (s *Server) Serve(ctx context.Context, ln net.Listener) error { - go s.processOutbox() - for { - conn, err := ln.Accept() - if err != nil { - s.Logger.Error("error accepting connection", "err", err) - } - connCtx := context.WithValue(ctx, contextKeyReq, requestCtx{ - remoteAddr: conn.RemoteAddr().String(), - }) + select { + case <-ctx.Done(): + s.Logger.Info("Server shutting down") + return ctx.Err() + default: + conn, err := ln.Accept() + if err != nil { + s.Logger.Error("Error accepting connection", "err", err) + continue + } - go func() { - s.Logger.Info("Connection established", "RemoteAddr", conn.RemoteAddr()) + go func() { + connCtx := context.WithValue(ctx, contextKeyReq, requestCtx{ + remoteAddr: conn.RemoteAddr().String(), + }) + + s.Logger.Info("Connection established", "addr", conn.RemoteAddr()) + defer conn.Close() - defer conn.Close() - if err := s.handleNewConnection(connCtx, conn, conn.RemoteAddr().String()); err != nil { - if err == io.EOF { - s.Logger.Info("Client disconnected", "RemoteAddr", conn.RemoteAddr()) - } else { - s.Logger.Error("error serving request", "RemoteAddr", conn.RemoteAddr(), "err", err) + if err := s.handleNewConnection(connCtx, conn, conn.RemoteAddr().String()); err != nil { + if err == io.EOF { + s.Logger.Info("Client disconnected", "RemoteAddr", conn.RemoteAddr()) + } else { + s.Logger.Error("Error serving request", "RemoteAddr", conn.RemoteAddr(), "err", err) + } } - } - }() + }() + } } } -const ( - agreementFile = "Agreement.txt" -) +// time in seconds between tracker re-registration +const trackerUpdateFrequency = 300 + +// registerWithTrackers runs every trackerUpdateFrequency seconds to update the server's tracker entry on all configured +// trackers. +func (s *Server) registerWithTrackers(ctx context.Context) { + ticker := time.NewTicker(trackerUpdateFrequency * time.Second) + defer ticker.Stop() -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) + select { + case <-ctx.Done(): + return + case <-ticker.C: + if s.Config.EnableTrackerRegistration { + 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) + } + } } } - - time.Sleep(trackerUpdateFrequency * time.Second) } + } -// keepaliveHandler -func (s *Server) keepaliveHandler() { - for { - time.Sleep(idleCheckInterval * time.Second) +const ( + userIdleSeconds = 300 // time in seconds before an inactive user is marked idle + idleCheckInterval = 10 // time in seconds to check for idle users +) - for _, c := range s.ClientMgr.List() { - c.mu.Lock() +// keepaliveHandler runs every idleCheckInterval seconds and increments a user's idle time by idleCheckInterval seconds. +// If the updated idle time exceeds userIdleSeconds and the user was not previously idle, we notify all connected clients +// that the user has gone idle. For most clients, this turns the user grey in the user list. +func (s *Server) keepaliveHandler(ctx context.Context) { + ticker := time.NewTicker(idleCheckInterval * time.Second) + defer ticker.Stop() - c.IdleTime += idleCheckInterval + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + for _, c := range s.ClientMgr.List() { + c.mu.Lock() + c.IdleTime += idleCheckInterval - // Check if the user - if c.IdleTime > userIdleSeconds && !c.Flags.IsSet(UserFlagAway) { - c.Flags.Set(UserFlagAway, 1) + // Check if the user + if c.IdleTime > userIdleSeconds && !c.Flags.IsSet(UserFlagAway) { + c.Flags.Set(UserFlagAway, 1) - c.SendAll( - TranNotifyChangeUser, - NewField(FieldUserID, c.ID[:]), - NewField(FieldUserFlags, c.Flags[:]), - NewField(FieldUserName, c.UserName), - NewField(FieldUserIconID, c.Icon), - ) + c.SendAll( + TranNotifyChangeUser, + NewField(FieldUserID, c.ID[:]), + NewField(FieldUserFlags, c.Flags[:]), + NewField(FieldUserName, c.UserName), + NewField(FieldUserIconID, c.Icon), + ) + } + c.mu.Unlock() } - c.mu.Unlock() } } } @@ -296,18 +322,6 @@ func (s *Server) NewClientConn(conn io.ReadWriteCloser, remoteAddr string) *Clie return clientConn } -// loadFromYAMLFile loads data from a YAML file into the provided data structure. -func loadFromYAMLFile(path string, data interface{}) error { - fh, err := os.Open(path) - if err != nil { - return err - } - defer fh.Close() - - decoder := yaml.NewDecoder(fh) - return decoder.Decode(data) -} - func sendBanMessage(rwc io.Writer, message string) { t := NewTransaction( TranServerMsg, @@ -323,6 +337,10 @@ func sendBanMessage(rwc io.Writer, message string) { func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser, remoteAddr string) error { defer dontPanic(s.Logger) + if err := performHandshake(rwc); err != nil { + return fmt.Errorf("perform handshake: %w", err) + } + // Check if remoteAddr is present in the ban list ipAddr := strings.Split(remoteAddr, ":")[0] if isBanned, banUntil := s.BanList.IsBanned(ipAddr); isBanned { @@ -341,10 +359,6 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser } } - if err := performHandshake(rwc); err != nil { - return fmt.Errorf("error performing handshake: %w", err) - } - // Create a new scanner for parsing incoming bytes into transaction tokens scanner := bufio.NewScanner(rwc) scanner.Split(transactionScanner) @@ -372,7 +386,7 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser login = GuestAccount } - c.logger = s.Logger.With("ip", ipAddr, "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) { @@ -383,7 +397,7 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser return err } - c.logger.Info("Login failed", "clientVersion", fmt.Sprintf("%x", c.Version)) + c.Logger.Info("Login failed", "clientVersion", fmt.Sprintf("%x", c.Version)) return nil } @@ -427,7 +441,10 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser c.Server.outbox <- NewTransaction(TranShowAgreement, c.ID, NewField(FieldNoServerAgreement, []byte{1})) } } else { - c.Server.outbox <- NewTransaction(TranShowAgreement, c.ID, NewField(FieldData, s.Agreement)) + _, _ = c.Server.Agreement.Seek(0, 0) + data, _ := io.ReadAll(c.Server.Agreement) + + c.Server.outbox <- NewTransaction(TranShowAgreement, c.ID, NewField(FieldData, data)) } // If the client has provided a username as part of the login, we can infer that it is using the 1.2.3 login @@ -435,8 +452,8 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser if len(c.UserName) != 0 { // Add the client username to the logger. For 1.5+ clients, we don't have this information yet as it comes as // part of TranAgreed - c.logger = c.logger.With("Name", string(c.UserName)) - c.logger.Info("Login successful", "clientVersion", "Not sent (probably 1.2.3)") + c.Logger = c.Logger.With("name", string(c.UserName)) + c.Logger.Info("Login successful") // 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 @@ -463,11 +480,11 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser // Scan for new transactions and handle them as they come in. for scanner.Scan() { // Copy the scanner bytes to a new slice to it to avoid a data race when the scanner re-uses the buffer. - buf := make([]byte, len(scanner.Bytes())) - copy(buf, scanner.Bytes()) + tmpBuf := make([]byte, len(scanner.Bytes())) + copy(tmpBuf, scanner.Bytes()) var t Transaction - if _, err := t.Write(buf); err != nil { + if _, err := t.Write(tmpBuf); err != nil { return err } @@ -506,15 +523,15 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro "Name", string(fileTransfer.ClientConn.UserName), ) - fullPath, err := readPath(s.Config.FileRoot, fileTransfer.FilePath, fileTransfer.FileName) + fullPath, err := ReadPath(s.Config.FileRoot, fileTransfer.FilePath, fileTransfer.FileName) if err != nil { return err } switch fileTransfer.Type { case BannerDownload: - if _, err := io.Copy(rwc, bytes.NewBuffer(s.banner)); err != nil { - return fmt.Errorf("error sending banner: %w", err) + if _, err := io.Copy(rwc, bytes.NewBuffer(s.Banner)); err != nil { + return fmt.Errorf("error sending Banner: %w", err) } case FileDownload: s.Stats.Increment(StatDownloadCounter, StatDownloadsInProgress) diff --git a/hotline/server_blackbox_test.go b/hotline/server_blackbox_test.go index 45d32d4..888ca3f 100644 --- a/hotline/server_blackbox_test.go +++ b/hotline/server_blackbox_test.go @@ -35,13 +35,13 @@ func assertTransferBytesEqual(t *testing.T, wantHexDump string, got []byte) bool var tranSortFunc = func(a, b Transaction) int { return cmp.Compare( - binary.BigEndian.Uint16(a.clientID[:]), - binary.BigEndian.Uint16(b.clientID[:]), + binary.BigEndian.Uint16(a.ClientID[:]), + binary.BigEndian.Uint16(b.ClientID[:]), ) } -// tranAssertEqual compares equality of transactions slices after stripping out the random transaction Type -func tranAssertEqual(t *testing.T, tran1, tran2 []Transaction) bool { +// 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 diff --git a/hotline/server_test.go b/hotline/server_test.go index 0487d2e..c3d38da 100644 --- a/hotline/server_test.go +++ b/hotline/server_test.go @@ -110,7 +110,7 @@ func TestServer_handleFileTransfer(t *testing.T) { FileTransferMgr: &MemFileTransferMgr{ fileTransfers: map[FileTransferID]*FileTransfer{ {0, 0, 0, 5}: { - refNum: [4]byte{0, 0, 0, 5}, + RefNum: [4]byte{0, 0, 0, 5}, Type: FileDownload, FileName: []byte("testfile-8b"), FilePath: []byte{}, @@ -171,7 +171,6 @@ func TestServer_handleFileTransfer(t *testing.T) { s := &Server{ FileTransferMgr: tt.fields.FileTransferMgr, Config: tt.fields.Config, - ConfigDir: tt.fields.ConfigDir, Logger: tt.fields.Logger, Stats: tt.fields.Stats, FS: tt.fields.FS, @@ -183,61 +182,3 @@ func TestServer_handleFileTransfer(t *testing.T) { }) } } - -type TestData struct { - Name string `yaml:"name"` - Value int `yaml:"value"` -} - -func TestLoadFromYAMLFile(t *testing.T) { - tests := []struct { - name string - fileName string - content string - wantData TestData - wantErr bool - }{ - { - name: "Valid YAML file", - fileName: "valid.yaml", - content: "name: Test\nvalue: 123\n", - wantData: TestData{Name: "Test", Value: 123}, - wantErr: false, - }, - { - name: "File not found", - fileName: "nonexistent.yaml", - content: "", - wantData: TestData{}, - wantErr: true, - }, - { - name: "Invalid YAML content", - fileName: "invalid.yaml", - content: "name: Test\nvalue: invalid_int\n", - wantData: TestData{}, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Setup: Create a temporary file with the provided content if content is not empty - if tt.content != "" { - err := os.WriteFile(tt.fileName, []byte(tt.content), 0644) - assert.NoError(t, err) - defer os.Remove(tt.fileName) // Cleanup the file after the test - } - - var data TestData - err := loadFromYAMLFile(tt.fileName, &data) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.wantData, data) - } - }) - } -} diff --git a/hotline/test/config/config.yaml b/hotline/test/config/config.yaml index cb7f15a..5b2fefd 100644 --- a/hotline/test/config/config.yaml +++ b/hotline/test/config/config.yaml @@ -1,6 +1,5 @@ Name: Halcyon's Test Server Description: Experimental Hotline server -BannerID: 1 FileRoot: conFiles/ EnableTrackerRegistration: false Trackers: diff --git a/hotline/time.go b/hotline/time.go index edc700d..35fa399 100644 --- a/hotline/time.go +++ b/hotline/time.go @@ -6,9 +6,11 @@ import ( "time" ) -// toHotlineTime converts a time.Time to the 8 byte Hotline time format: +type Time [8]byte + +// NewTime converts a time.Time to the 8 byte Hotline time format: // Year (2 bytes), milliseconds (2 bytes) and seconds (4 bytes) -func toHotlineTime(t time.Time) (b [8]byte) { +func NewTime(t time.Time) (b Time) { yearBytes := make([]byte, 2) secondBytes := make([]byte, 4) diff --git a/hotline/transaction.go b/hotline/transaction.go index 7a456a5..fa1e96d 100644 --- a/hotline/transaction.go +++ b/hotline/transaction.go @@ -85,18 +85,18 @@ type Transaction struct { ParamCount [2]byte // Number of the parameters for this transaction Fields []Field - clientID [2]byte // Internal identifier for target client - readOffset int // Internal offset to track read progress + ClientID ClientID // Internal identifier for target client + readOffset int // Internal offset to track read progress } var tranTypeNames = map[TranType]string{ TranChatMsg: "Receive chat", TranNotifyChangeUser: "User change", TranError: "Error", - TranShowAgreement: "Show Agreement", + TranShowAgreement: "Show agreement", TranUserAccess: "User access", TranNotifyDeleteUser: "User left", - TranAgreed: "TranAgreed", + TranAgreed: "Accept agreement", TranChatSend: "Send chat", TranDelNewsArt: "Delete news article", TranDelNewsItem: "Delete news item", @@ -141,18 +141,15 @@ var tranTypeNames = map[TranType]string{ TranDownloadBanner: "Download banner", } -//func (t TranType) LogValue() slog.Value { -// return slog.StringValue(tranTypeNames[t]) -//} - -// NewTransaction creates a new Transaction with the specified type, client Type, and optional fields. -func NewTransaction(t TranType, clientID [2]byte, fields ...Field) Transaction { +// NewTransaction creates a new Transaction with the specified type, client, and optional fields. +func NewTransaction(t TranType, clientID ClientID, fields ...Field) Transaction { transaction := Transaction{ Type: t, - clientID: clientID, + ClientID: clientID, Fields: fields, } + // Give the transaction a random ID. binary.BigEndian.PutUint32(transaction.ID[:], rand.Uint32()) return transaction @@ -183,7 +180,7 @@ func (t *Transaction) Write(p []byte) (n int, err error) { copy(t.ParamCount[:], p[20:22]) scanner := bufio.NewScanner(bytes.NewReader(p[22:tranLen])) - scanner.Split(fieldScanner) + scanner.Split(FieldScanner) for i := 0; i < int(paramCount); i++ { if !scanner.Scan() { diff --git a/hotline/transaction_handlers.go b/hotline/transaction_handlers.go index ead394a..e454d08 100644 --- a/hotline/transaction_handlers.go +++ b/hotline/transaction_handlers.go @@ -1,1793 +1,11 @@ package hotline -import ( - "bufio" - "bytes" - "encoding/binary" - "fmt" - "io" - "math/big" - "os" - "path" - "path/filepath" - "strings" - "time" -) - // HandlerFunc is the signature of a func to handle a Hotline transaction. type HandlerFunc func(*ClientConn, *Transaction) []Transaction -// TransactionHandlers maps a transaction type to a handler function. -var TransactionHandlers = map[TranType]HandlerFunc{ - TranAgreed: HandleTranAgreed, - TranChatSend: HandleChatSend, - TranDelNewsArt: HandleDelNewsArt, - TranDelNewsItem: HandleDelNewsItem, - TranDeleteFile: HandleDeleteFile, - TranDeleteUser: HandleDeleteUser, - TranDisconnectUser: HandleDisconnectUser, - TranDownloadFile: HandleDownloadFile, - TranDownloadFldr: HandleDownloadFolder, - TranGetClientInfoText: HandleGetClientInfoText, - TranGetFileInfo: HandleGetFileInfo, - TranGetFileNameList: HandleGetFileNameList, - TranGetMsgs: HandleGetMsgs, - TranGetNewsArtData: HandleGetNewsArtData, - TranGetNewsArtNameList: HandleGetNewsArtNameList, - TranGetNewsCatNameList: HandleGetNewsCatNameList, - TranGetUser: HandleGetUser, - TranGetUserNameList: HandleGetUserNameList, - TranInviteNewChat: HandleInviteNewChat, - TranInviteToChat: HandleInviteToChat, - TranJoinChat: HandleJoinChat, - TranKeepAlive: HandleKeepAlive, - TranLeaveChat: HandleLeaveChat, - TranListUsers: HandleListUsers, - TranMoveFile: HandleMoveFile, - TranNewFolder: HandleNewFolder, - TranNewNewsCat: HandleNewNewsCat, - TranNewNewsFldr: HandleNewNewsFldr, - TranNewUser: HandleNewUser, - TranUpdateUser: HandleUpdateUser, - TranOldPostNews: HandleTranOldPostNews, - TranPostNewsArt: HandlePostNewsArt, - TranRejectChatInvite: HandleRejectChatInvite, - TranSendInstantMsg: HandleSendInstantMsg, - TranSetChatSubject: HandleSetChatSubject, - TranMakeFileAlias: HandleMakeAlias, - TranSetClientUserInfo: HandleSetClientUserInfo, - TranSetFileInfo: HandleSetFileInfo, - TranSetUser: HandleSetUser, - TranUploadFile: HandleUploadFile, - TranUploadFldr: HandleUploadFolder, - TranUserBroadcast: HandleUserBroadcast, - TranDownloadBanner: HandleDownloadBanner, +func (s *Server) HandleFunc(tranType [2]byte, handler HandlerFunc) { + s.handlers[tranType] = handler } // The total size of a chat message data field is 8192 bytes. -const chatMsgLimit = 8192 - -func HandleChatSend(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(AccessSendChat) { - return cc.NewErrReply(t, "You are not allowed to participate in chat.") - } - - // Truncate long usernames - // %13.13s: This means a string that is right-aligned in a field of 13 characters. - // If the string is longer than 13 characters, it will be truncated to 13 characters. - formattedMsg := fmt.Sprintf("\r%13.13s: %s", cc.UserName, t.GetField(FieldData).Data) - - // By holding the option key, Hotline chat allows users to send /me formatted messages like: - // *** Halcyon does stuff - // This is indicated by the presence of the optional field FieldChatOptions set to a value of 1. - // Most clients do not send this option for normal chat messages. - if t.GetField(FieldChatOptions).Data != nil && bytes.Equal(t.GetField(FieldChatOptions).Data, []byte{0, 1}) { - formattedMsg = fmt.Sprintf("\r*** %s %s", cc.UserName, t.GetField(FieldData).Data) - } - - // Truncate the message to the limit. This does not handle the edge case of a string ending on multibyte character. - formattedMsg = formattedMsg[:min(len(formattedMsg), chatMsgLimit)] - - // The ChatID field is used to identify messages as belonging to a private chat. - // 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) { - - // send the message to all connected clients of the private chat - for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) { - res = append(res, NewTransaction( - TranChatMsg, - c.ID, - NewField(FieldChatID, chatID), - NewField(FieldData, []byte(formattedMsg)), - )) - } - return res - } - - //cc.Server.mux.Lock() - 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) { - res = append(res, NewTransaction(TranChatMsg, c.ID, NewField(FieldData, []byte(formattedMsg)))) - } - } - //cc.Server.mux.Unlock() - - return res -} - -// HandleSendInstantMsg sends instant message to the user on the current server. -// Fields used in the request: -// -// 103 User Type -// 113 Options -// One of the following values: -// - User message (myOpt_UserMessage = 1) -// - Refuse message (myOpt_RefuseMessage = 2) -// - Refuse chat (myOpt_RefuseChat = 3) -// - Automatic response (myOpt_AutomaticResponse = 4)" -// 101 Data Optional -// 214 Quoting message Optional -// -// Fields used in the reply: -// None -func HandleSendInstantMsg(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(AccessSendPrivMsg) { - return cc.NewErrReply(t, "You are not allowed to send private messages.") - } - - msg := t.GetField(FieldData) - userID := t.GetField(FieldUserID) - - reply := NewTransaction( - TranServerMsg, - [2]byte(userID.Data), - NewField(FieldData, msg.Data), - NewField(FieldUserName, cc.UserName), - NewField(FieldUserID, cc.ID[:]), - NewField(FieldOptions, []byte{0, 1}), - ) - - // Later versions of Hotline include the original message in the FieldQuotingMsg field so - // the receiving client can display both the received message and what it is in reply to - if t.GetField(FieldQuotingMsg).Data != nil { - reply.Fields = append(reply.Fields, NewField(FieldQuotingMsg, t.GetField(FieldQuotingMsg).Data)) - } - - otherClient := cc.Server.ClientMgr.Get([2]byte(userID.Data)) - if otherClient == nil { - return res - } - - // Check if target user has "Refuse private messages" flag - if otherClient.Flags.IsSet(UserFlagRefusePM) { - res = append(res, - NewTransaction( - TranServerMsg, - cc.ID, - NewField(FieldData, []byte(string(otherClient.UserName)+" does not accept private messages.")), - NewField(FieldUserName, otherClient.UserName), - NewField(FieldUserID, otherClient.ID[:]), - NewField(FieldOptions, []byte{0, 2}), - ), - ) - } else { - res = append(res, reply) - } - - // Respond with auto reply if other client has it enabled - if len(otherClient.AutoReply) > 0 { - res = append(res, - NewTransaction( - TranServerMsg, - cc.ID, - NewField(FieldData, otherClient.AutoReply), - NewField(FieldUserName, otherClient.UserName), - NewField(FieldUserID, otherClient.ID[:]), - NewField(FieldOptions, []byte{0, 1}), - ), - ) - } - - return append(res, cc.NewReply(t)) -} - -var fileTypeFLDR = [4]byte{0x66, 0x6c, 0x64, 0x72} - -func HandleGetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction) { - fileName := t.GetField(FieldFileName).Data - filePath := t.GetField(FieldFilePath).Data - - fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName) - if err != nil { - return res - } - - fw, err := newFileWrapper(cc.Server.FS, fullFilePath, 0) - if err != nil { - return res - } - - encodedName, err := txtEncoder.String(fw.name) - if err != nil { - return res - } - - fields := []Field{ - NewField(FieldFileName, []byte(encodedName)), - NewField(FieldFileTypeString, fw.ffo.FlatFileInformationFork.friendlyType()), - NewField(FieldFileCreatorString, fw.ffo.FlatFileInformationFork.friendlyCreator()), - NewField(FieldFileType, fw.ffo.FlatFileInformationFork.TypeSignature[:]), - NewField(FieldFileCreateDate, fw.ffo.FlatFileInformationFork.CreateDate[:]), - NewField(FieldFileModifyDate, fw.ffo.FlatFileInformationFork.ModifyDate[:]), - } - - // Include the optional FileComment field if there is a comment. - if len(fw.ffo.FlatFileInformationFork.Comment) != 0 { - fields = append(fields, NewField(FieldFileComment, fw.ffo.FlatFileInformationFork.Comment)) - } - - // Include the FileSize field for files. - if fw.ffo.FlatFileInformationFork.TypeSignature != fileTypeFLDR { - fields = append(fields, NewField(FieldFileSize, fw.totalSize())) - } - - res = append(res, cc.NewReply(t, fields...)) - return res -} - -// HandleSetFileInfo updates a file or folder Name and/or comment from the Get Info window -// Fields used in the request: -// * 201 File Name -// * 202 File path Optional -// * 211 File new Name Optional -// * 210 File comment Optional -// Fields used in the reply: None -func HandleSetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction) { - fileName := t.GetField(FieldFileName).Data - filePath := t.GetField(FieldFilePath).Data - - fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName) - if err != nil { - return res - } - - fi, err := cc.Server.FS.Stat(fullFilePath) - if err != nil { - return res - } - - hlFile, err := newFileWrapper(cc.Server.FS, fullFilePath, 0) - if err != nil { - return res - } - if t.GetField(FieldFileComment).Data != nil { - switch mode := fi.Mode(); { - case mode.IsDir(): - if !cc.Authorize(AccessSetFolderComment) { - return cc.NewErrReply(t, "You are not allowed to set comments for folders.") - } - case mode.IsRegular(): - if !cc.Authorize(AccessSetFileComment) { - return cc.NewErrReply(t, "You are not allowed to set comments for files.") - } - } - - if err := hlFile.ffo.FlatFileInformationFork.setComment(t.GetField(FieldFileComment).Data); err != nil { - return res - } - w, err := hlFile.infoForkWriter() - if err != nil { - return res - } - _, err = io.Copy(w, &hlFile.ffo.FlatFileInformationFork) - if err != nil { - return res - } - } - - fullNewFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, t.GetField(FieldFileNewName).Data) - if err != nil { - return nil - } - - fileNewName := t.GetField(FieldFileNewName).Data - - if fileNewName != nil { - switch mode := fi.Mode(); { - case mode.IsDir(): - if !cc.Authorize(AccessRenameFolder) { - return cc.NewErrReply(t, "You are not allowed to rename folders.") - } - err = os.Rename(fullFilePath, fullNewFilePath) - if os.IsNotExist(err) { - return cc.NewErrReply(t, "Cannot rename folder "+string(fileName)+" because it does not exist or cannot be found.") - - } - case mode.IsRegular(): - if !cc.Authorize(AccessRenameFile) { - return cc.NewErrReply(t, "You are not allowed to rename files.") - } - fileDir, err := readPath(cc.Server.Config.FileRoot, filePath, []byte{}) - if err != nil { - return nil - } - hlFile.name, err = txtDecoder.String(string(fileNewName)) - if err != nil { - return res - } - - err = hlFile.move(fileDir) - if os.IsNotExist(err) { - return cc.NewErrReply(t, "Cannot rename file "+string(fileName)+" because it does not exist or cannot be found.") - } - if err != nil { - return res - } - } - } - - res = append(res, cc.NewReply(t)) - return res -} - -// HandleDeleteFile deletes a file or folder -// Fields used in the request: -// * 201 File Name -// * 202 File path -// Fields used in the reply: none -func HandleDeleteFile(cc *ClientConn, t *Transaction) (res []Transaction) { - fileName := t.GetField(FieldFileName).Data - filePath := t.GetField(FieldFilePath).Data - - fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName) - if err != nil { - return res - } - - hlFile, err := newFileWrapper(cc.Server.FS, fullFilePath, 0) - if err != nil { - return res - } - - fi, err := hlFile.dataFile() - if err != nil { - return cc.NewErrReply(t, "Cannot delete file "+string(fileName)+" because it does not exist or cannot be found.") - } - - switch mode := fi.Mode(); { - case mode.IsDir(): - if !cc.Authorize(AccessDeleteFolder) { - return cc.NewErrReply(t, "You are not allowed to delete folders.") - } - case mode.IsRegular(): - if !cc.Authorize(AccessDeleteFile) { - return cc.NewErrReply(t, "You are not allowed to delete files.") - } - } - - if err := hlFile.delete(); err != nil { - return res - } - - res = append(res, cc.NewReply(t)) - return res -} - -// HandleMoveFile moves files or folders. Note: seemingly not documented -func HandleMoveFile(cc *ClientConn, t *Transaction) (res []Transaction) { - fileName := string(t.GetField(FieldFileName).Data) - - filePath, err := readPath(cc.Server.Config.FileRoot, t.GetField(FieldFilePath).Data, t.GetField(FieldFileName).Data) - if err != nil { - return res - } - - fileNewPath, err := readPath(cc.Server.Config.FileRoot, t.GetField(FieldFileNewPath).Data, nil) - if err != nil { - return res - } - - cc.logger.Info("Move file", "src", filePath+"/"+fileName, "dst", fileNewPath+"/"+fileName) - - hlFile, err := newFileWrapper(cc.Server.FS, filePath, 0) - if err != nil { - return res - } - - fi, err := hlFile.dataFile() - if err != nil { - return cc.NewErrReply(t, "Cannot delete file "+fileName+" because it does not exist or cannot be found.") - } - switch mode := fi.Mode(); { - case mode.IsDir(): - if !cc.Authorize(AccessMoveFolder) { - return cc.NewErrReply(t, "You are not allowed to move folders.") - } - case mode.IsRegular(): - if !cc.Authorize(AccessMoveFile) { - return cc.NewErrReply(t, "You are not allowed to move files.") - } - } - if err := hlFile.move(fileNewPath); err != nil { - return res - } - // TODO: handle other possible errors; e.g. fileWrapper delete fails due to fileWrapper permission issue - - res = append(res, cc.NewReply(t)) - return res -} - -func HandleNewFolder(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(AccessCreateFolder) { - return cc.NewErrReply(t, "You are not allowed to create folders.") - } - folderName := string(t.GetField(FieldFileName).Data) - - folderName = path.Join("/", folderName) - - var subPath string - - // FieldFilePath is only present for nested paths - if t.GetField(FieldFilePath).Data != nil { - var newFp FilePath - _, err := newFp.Write(t.GetField(FieldFilePath).Data) - if err != nil { - return res - } - - for _, pathItem := range newFp.Items { - subPath = filepath.Join("/", subPath, string(pathItem.Name)) - } - } - newFolderPath := path.Join(cc.Server.Config.FileRoot, subPath, folderName) - newFolderPath, err := txtDecoder.String(newFolderPath) - if err != nil { - return res - } - - // TODO: check path and folder Name lengths - - if _, err := cc.Server.FS.Stat(newFolderPath); !os.IsNotExist(err) { - msg := fmt.Sprintf("Cannot create folder \"%s\" because there is already a file or folder with that Name.", folderName) - return cc.NewErrReply(t, msg) - } - - if err := cc.Server.FS.Mkdir(newFolderPath, 0777); err != nil { - msg := fmt.Sprintf("Cannot create folder \"%s\" because an error occurred.", folderName) - return cc.NewErrReply(t, msg) - } - - return append(res, cc.NewReply(t)) -} - -func HandleSetUser(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(AccessModifyUser) { - return cc.NewErrReply(t, "You are not allowed to modify accounts.") - } - - login := t.GetField(FieldUserLogin).DecodeObfuscatedString() - userName := string(t.GetField(FieldUserName).Data) - - newAccessLvl := t.GetField(FieldUserAccess).Data - - account := cc.Server.AccountManager.Get(login) - if account == nil { - return cc.NewErrReply(t, "Account not found.") - } - account.Name = userName - copy(account.Access[:], newAccessLvl) - - // If the password field is cleared in the Hotline edit user UI, the SetUser transaction does - // not include FieldUserPassword - if t.GetField(FieldUserPassword).Data == nil { - account.Password = hashAndSalt([]byte("")) - } - - if !bytes.Equal([]byte{0}, t.GetField(FieldUserPassword).Data) { - account.Password = hashAndSalt(t.GetField(FieldUserPassword).Data) - } - - err := cc.Server.AccountManager.Update(*account, account.Login) - if err != nil { - 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.ClientMgr.List() { - if c.Account.Login == login { - newT := NewTransaction(TranUserAccess, c.ID, NewField(FieldUserAccess, newAccessLvl)) - res = append(res, newT) - - if c.Authorize(AccessDisconUser) { - c.Flags.Set(UserFlagAdmin, 1) - } else { - c.Flags.Set(UserFlagAdmin, 0) - } - - c.Account.Access = account.Access - - cc.SendAll( - TranNotifyChangeUser, - NewField(FieldUserID, c.ID[:]), - NewField(FieldUserFlags, c.Flags[:]), - NewField(FieldUserName, c.UserName), - NewField(FieldUserIconID, c.Icon), - ) - } - } - - return append(res, cc.NewReply(t)) -} - -func HandleGetUser(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(AccessOpenUser) { - return cc.NewErrReply(t, "You are not allowed to view accounts.") - } - - account := cc.Server.AccountManager.Get(string(t.GetField(FieldUserLogin).Data)) - if account == nil { - return cc.NewErrReply(t, "Account does not exist.") - } - - 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[:]), - )) -} - -func HandleListUsers(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(AccessOpenUser) { - return cc.NewErrReply(t, "You are not allowed to view accounts.") - } - - var userFields []Field - for _, acc := range cc.Server.AccountManager.List() { - b, err := io.ReadAll(&acc) - if err != nil { - cc.logger.Error("Error reading account", "Account", acc.Login, "Err", err) - continue - } - - userFields = append(userFields, NewField(FieldData, b)) - } - - 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. -// An update can be a mix of these actions: -// * Create user -// * Delete user -// * Modify user (including renaming the account login) -// -// The Transaction sent by the client includes one data field per user that was modified. This data field in turn -// contains another data field encoded in its payload with a varying number of sub fields depending on which action is -// performed. This seems to be the only place in the Hotline protocol where a data field contains another data field. -func HandleUpdateUser(cc *ClientConn, t *Transaction) (res []Transaction) { - for _, field := range t.Fields { - var subFields []Field - - // Create a new scanner for parsing incoming bytes into transaction tokens - scanner := bufio.NewScanner(bytes.NewReader(field.Data[2:])) - scanner.Split(fieldScanner) - - for i := 0; i < int(binary.BigEndian.Uint16(field.Data[0:2])); i++ { - scanner.Scan() - - var field Field - if _, err := field.Write(scanner.Bytes()); err != nil { - return res - } - subFields = append(subFields, field) - } - - // 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) { - 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.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 - } - - // login of the account to update - var accountToUpdate, loginToRename string - - // If FieldData is included, this is a rename operation where FieldData contains the login of the existing - // account and FieldUserLogin contains the new login. - if getField(FieldData, &subFields) != nil { - loginToRename = string(encodeString(getField(FieldData, &subFields).Data)) - } - userLogin := string(encodeString(getField(FieldUserLogin, &subFields).Data)) - if loginToRename != "" { - accountToUpdate = loginToRename - } else { - accountToUpdate = userLogin - } - - // Check if accountToUpdate has an existing account. If so, we know we are updating an existing user. - 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) { - return cc.NewErrReply(t, "You are not allowed to modify accounts.") - } - - // This part is a bit tricky. There are three possibilities: - // 1) The transaction is intended to update the password. - // In this case, FieldUserPassword is sent with the new password. - // 2) The transaction is intended to remove the password. - // In this case, FieldUserPassword is not sent. - // 3) The transaction updates the users access bits, but not the password. - // In this case, FieldUserPassword is sent with zero as the only byte. - if getField(FieldUserPassword, &subFields) != nil { - newPass := getField(FieldUserPassword, &subFields).Data - if !bytes.Equal([]byte{0}, newPass) { - acc.Password = hashAndSalt(newPass) - } - } else { - acc.Password = hashAndSalt([]byte("")) - } - - if getField(FieldUserAccess, &subFields) != nil { - copy(acc.Access[:], getField(FieldUserAccess, &subFields).Data) - } - - 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) { - return cc.NewErrReply(t, "You are not allowed to create new accounts.") - } - - cc.logger.Info("CreateUser", "login", userLogin) - - newAccess := accessBitmap{} - copy(newAccess[:], getField(FieldUserAccess, &subFields).Data) - - // Prevent account from creating new account with greater permission - for i := 0; i < 64; i++ { - if newAccess.IsSet(i) { - if !cc.Authorize(i) { - return cc.NewErrReply(t, "Cannot create account with more access than yourself.") - } - } - } - - 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.") - } - } - } - - return append(res, cc.NewReply(t)) -} - -// HandleNewUser creates a new user account -func HandleNewUser(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(AccessCreateUser) { - return cc.NewErrReply(t, "You are not allowed to create new accounts.") - } - - login := t.GetField(FieldUserLogin).DecodeObfuscatedString() - - // 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.") - } - - var newAccess accessBitmap - copy(newAccess[:], t.GetField(FieldUserAccess).Data) - - // Prevent account from creating new account with greater permission - for i := 0; i < 64; i++ { - if newAccess.IsSet(i) { - if !cc.Authorize(i) { - return cc.NewErrReply(t, "Cannot create account with more access than yourself.") - } - } - } - - 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.") - } - - return append(res, cc.NewReply(t)) -} - -func HandleDeleteUser(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(AccessDeleteUser) { - return cc.NewErrReply(t, "You are not allowed to delete accounts.") - } - - login := t.GetField(FieldUserLogin).DecodeObfuscatedString() - - 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) { - return cc.NewErrReply(t, "You are not allowed to send broadcast messages.") - } - - cc.SendAll( - TranServerMsg, - NewField(FieldData, t.GetField(FieldData).Data), - NewField(FieldChatOptions, []byte{0}), - ) - - return append(res, cc.NewReply(t)) -} - -// HandleGetClientInfoText returns user information for the specific user. -// -// Fields used in the request: -// 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) { - return cc.NewErrReply(t, "You are not allowed to get client info.") - } - - clientID := t.GetField(FieldUserID).Data - - clientConn := cc.Server.ClientMgr.Get(ClientID(clientID)) - if clientConn == nil { - return cc.NewErrReply(t, "User not found.") - } - - return append(res, cc.NewReply(t, - NewField(FieldData, []byte(clientConn.String())), - NewField(FieldUserName, clientConn.UserName), - )) -} - -func HandleGetUserNameList(cc *ClientConn, t *Transaction) (res []Transaction) { - 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) { - cc.UserName = t.GetField(FieldUserName).Data - } else { - cc.UserName = []byte(cc.Account.Name) - } - } - - cc.Icon = t.GetField(FieldUserIconID).Data - - cc.logger = cc.logger.With("Name", string(cc.UserName)) - cc.logger.Info("Login successful") - - options := t.GetField(FieldOptions).Data - optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options))) - - // Check refuse private PM option - - cc.flagsMU.Lock() - defer cc.flagsMU.Unlock() - cc.Flags.Set(UserFlagRefusePM, optBitmap.Bit(UserOptRefusePM)) - - // Check refuse private chat option - cc.Flags.Set(UserFlagRefusePChat, optBitmap.Bit(UserOptRefuseChat)) - - // Check auto response - if optBitmap.Bit(UserOptAutoResponse) == 1 { - cc.AutoReply = t.GetField(FieldAutomaticResponse).Data - } - - trans := cc.NotifyOthers( - NewTransaction( - TranNotifyChangeUser, [2]byte{0, 0}, - NewField(FieldUserName, cc.UserName), - NewField(FieldUserID, cc.ID[:]), - NewField(FieldUserIconID, cc.Icon), - NewField(FieldUserFlags, cc.Flags[:]), - ), - ) - res = append(res, trans...) - - if cc.Server.Config.BannerFile != "" { - res = append(res, NewTransaction(TranServerBanner, cc.ID, NewField(FieldBannerType, []byte("JPEG")))) - } - - res = append(res, cc.NewReply(t)) - - return res -} - -// HandleTranOldPostNews updates the flat news -// Fields used in this request: -// 101 Data -func HandleTranOldPostNews(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(AccessNewsPostArt) { - return cc.NewErrReply(t, "You are not allowed to post news.") - } - - newsDateTemplate := defaultNewsDateFormat - if cc.Server.Config.NewsDateFormat != "" { - newsDateTemplate = cc.Server.Config.NewsDateFormat - } - - newsTemplate := defaultNewsTemplate - if cc.Server.Config.NewsDelimiter != "" { - newsTemplate = cc.Server.Config.NewsDelimiter - } - - newsPost := fmt.Sprintf(newsTemplate+"\r", cc.UserName, time.Now().Format(newsDateTemplate), t.GetField(FieldData).Data) - newsPost = strings.ReplaceAll(newsPost, "\n", "\r") - - _, 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( - TranNewMsg, - NewField(FieldData, []byte(newsPost)), - ) - - return append(res, cc.NewReply(t)) -} - -func HandleDisconnectUser(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(AccessDisconUser) { - return cc.NewErrReply(t, "You are not allowed to disconnect users.") - } - - clientID := [2]byte(t.GetField(FieldUserID).Data) - clientConn := cc.Server.ClientMgr.Get(clientID) - - if clientConn.Authorize(AccessCannotBeDiscon) { - return cc.NewErrReply(t, clientConn.Account.Login+" is not allowed to be disconnected.") - } - - // If FieldOptions is set, then the client IP is banned in addition to disconnected. - // 00 01 = temporary ban - // 00 02 = permanent ban - if t.GetField(FieldOptions).Data != nil { - switch t.GetField(FieldOptions).Data[1] { - case 1: - // send message: "You are temporarily banned on this server" - cc.logger.Info("Disconnect & temporarily ban " + string(clientConn.UserName)) - - res = append(res, NewTransaction( - TranServerMsg, - clientConn.ID, - NewField(FieldData, []byte("You are temporarily banned on this server")), - NewField(FieldChatOptions, []byte{0, 0}), - )) - - banUntil := time.Now().Add(tempBanDuration) - 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)) - - res = append(res, NewTransaction( - TranServerMsg, - clientConn.ID, - NewField(FieldData, []byte("You are permanently banned on this server")), - NewField(FieldChatOptions, []byte{0, 0}), - )) - - ip := strings.Split(clientConn.RemoteAddr, ":")[0] - - err := cc.Server.BanList.Add(ip, nil) - if err != nil { - // TODO - } - } - } - - // TODO: remove this awful hack - go func() { - time.Sleep(1 * time.Second) - clientConn.Disconnect() - }() - - return append(res, cc.NewReply(t)) -} - -// HandleGetNewsCatNameList returns a list of news categories for a path -// Fields used in the request: -// 325 News path (Optional) -func HandleGetNewsCatNameList(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(AccessNewsReadArt) { - return cc.NewErrReply(t, "You are not allowed to read news.") - } - - pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath() - if err != nil { - - } - - var fields []Field - for _, cat := range cc.Server.ThreadedNewsMgr.GetCategories(pathStrs) { - b, err := io.ReadAll(&cat) - if err != nil { - // TODO - } - - fields = append(fields, NewField(FieldNewsCatListData15, b)) - } - - return append(res, cc.NewReply(t, fields...)) -} - -func HandleNewNewsCat(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(AccessNewsCreateCat) { - return cc.NewErrReply(t, "You are not allowed to create news categories.") - } - - name := string(t.GetField(FieldNewsCatName).Data) - pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath() - if err != nil { - return res - } - - err = cc.Server.ThreadedNewsMgr.CreateGrouping(pathStrs, name, NewsCategory) - if err != nil { - cc.logger.Error("error creating news category", "err", err) - } - - 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) { - return cc.NewErrReply(t, "You are not allowed to create news folders.") - } - - name := string(t.GetField(FieldFileName).Data) - pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath() - if err != nil { - 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. - -// Fields used in the request: -// 325 News path Optional - -// Fields used in the reply: -// 321 News article list data Optional -func HandleGetNewsArtNameList(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(AccessNewsReadArt) { - return cc.NewErrReply(t, "You are not allowed to read news.") - } - - pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath() - if err != nil { - return res - } - - nald := cc.Server.ThreadedNewsMgr.ListArticles(pathStrs) - - b, err := io.ReadAll(&nald) - if err != nil { - return res - } - - return append(res, cc.NewReply(t, NewField(FieldNewsArtListData, b))) -} - -// HandleGetNewsArtData requests information about the specific news article. -// Fields used in the request: -// -// Request fields -// 325 News path -// 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 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) { - return cc.NewErrReply(t, "You are not allowed to read news.") - } - - newsPath, err := t.GetField(FieldNewsPath).DecodeNewsPath() - if err != nil { - return res - } - - convertedID, err := t.GetField(FieldNewsArtID).DecodeInt() - if err != nil { - return res - } - - art := cc.Server.ThreadedNewsMgr.GetArticle(newsPath, uint32(convertedID)) - if art == nil { - return append(res, cc.NewReply(t)) - } - - res = append(res, cc.NewReply(t, - NewField(FieldNewsArtTitle, []byte(art.Title)), - NewField(FieldNewsArtPoster, []byte(art.Poster)), - NewField(FieldNewsArtDate, art.Date[:]), - NewField(FieldNewsArtPrevArt, art.PrevArt[:]), - NewField(FieldNewsArtNextArt, art.NextArt[:]), - NewField(FieldNewsArtParentArt, art.ParentArt[:]), - NewField(FieldNewsArt1stChildArt, art.FirstChildArt[:]), - NewField(FieldNewsArtDataFlav, []byte("text/plain")), - NewField(FieldNewsArtData, []byte(art.Data)), - )) - return res -} - -// 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, err := t.GetField(FieldNewsPath).DecodeNewsPath() - if err != nil { - return res - } - - 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) { - return cc.NewErrReply(t, "You are not allowed to delete news folders.") - } - } - - 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) { - return cc.NewErrReply(t, "You are not allowed to delete news articles.") - - } - - pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath() - if err != nil { - return res - } - - articleID, err := t.GetField(FieldNewsArtID).DecodeInt() - if err != nil { - cc.logger.Error("error reading article Type", "err", err) - return - } - - deleteRecursive := bytes.Equal([]byte{0, 1}, t.GetField(FieldNewsArtRecurseDel).Data) - - err = cc.Server.ThreadedNewsMgr.DeleteArticle(pathStrs, uint32(articleID), deleteRecursive) - if err != nil { - cc.logger.Error("error deleting news article", "err", err) - } - - return []Transaction{cc.NewReply(t)} -} - -// Request fields -// 325 News path -// 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) { - return cc.NewErrReply(t, "You are not allowed to post news articles.") - } - - pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath() - if err != nil { - return res - } - - parentArticleID, err := t.GetField(FieldNewsArtID).DecodeInt() - if 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)) -} - -// HandleGetMsgs returns the flat news data -func HandleGetMsgs(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(AccessNewsReadArt) { - return cc.NewErrReply(t, "You are not allowed to read news.") - } - - _, _ = cc.Server.MessageBoard.Seek(0, 0) - - 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) { - return cc.NewErrReply(t, "You are not allowed to download files.") - } - - fileName := t.GetField(FieldFileName).Data - filePath := t.GetField(FieldFilePath).Data - resumeData := t.GetField(FieldFileResumeData).Data - - var dataOffset int64 - var frd FileResumeData - if resumeData != nil { - if err := frd.UnmarshalBinary(t.GetField(FieldFileResumeData).Data); err != nil { - return res - } - // TODO: handle rsrc fork offset - dataOffset = int64(binary.BigEndian.Uint32(frd.ForkInfoList[0].DataSize[:])) - } - - fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName) - if err != nil { - return res - } - - hlFile, err := newFileWrapper(cc.Server.FS, fullFilePath, dataOffset) - if err != nil { - return res - } - - xferSize := hlFile.ffo.TransferSize(0) - - ft := cc.newFileTransfer(FileDownload, fileName, filePath, xferSize) - - // TODO: refactor to remove this - if resumeData != nil { - var frd FileResumeData - if err := frd.UnmarshalBinary(t.GetField(FieldFileResumeData).Data); err != nil { - return res - } - ft.fileResumeData = &frd - } - - // 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 { - ft.options = t.GetField(FieldFileTransferOptions).Data - xferSize = hlFile.ffo.FlatFileDataForkHeader.DataSize[:] - } - - res = append(res, cc.NewReply(t, - NewField(FieldRefNum, ft.refNum[:]), - NewField(FieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count - NewField(FieldTransferSize, xferSize), - NewField(FieldFileSize, hlFile.ffo.FlatFileDataForkHeader.DataSize[:]), - )) - - return res -} - -// Download all files from the specified folder and sub-folders -func HandleDownloadFolder(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(AccessDownloadFile) { - return cc.NewErrReply(t, "You are not allowed to download folders.") - } - - fullFilePath, err := readPath(cc.Server.Config.FileRoot, t.GetField(FieldFilePath).Data, t.GetField(FieldFileName).Data) - if err != nil { - return res - } - - transferSize, err := CalcTotalSize(fullFilePath) - if err != nil { - return res - } - itemCount, err := CalcItemCount(fullFilePath) - if err != nil { - return res - } - - fileTransfer := cc.newFileTransfer(FolderDownload, t.GetField(FieldFileName).Data, t.GetField(FieldFilePath).Data, transferSize) - - var fp FilePath - _, err = fp.Write(t.GetField(FieldFilePath).Data) - if err != nil { - return res - } - - res = append(res, cc.NewReply(t, - NewField(FieldRefNum, fileTransfer.refNum[:]), - NewField(FieldTransferSize, transferSize), - NewField(FieldFolderItemCount, itemCount), - NewField(FieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count - )) - return res -} - -// Upload all files from the local folder and its subfolders to the specified path on the server -// Fields used in the request -// 201 File Name -// 202 File path -// 108 transfer size Total size of all items in the folder -// 220 Folder item count -// 204 File transfer options "Optional Currently set to 1" (TODO: ??) -func HandleUploadFolder(cc *ClientConn, t *Transaction) (res []Transaction) { - var fp FilePath - if t.GetField(FieldFilePath).Data != nil { - if _, err := fp.Write(t.GetField(FieldFilePath).Data); err != nil { - return res - } - } - - // Handle special cases for Upload and Drop Box folders - 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))) - } - } - - fileTransfer := cc.newFileTransfer(FolderUpload, - t.GetField(FieldFileName).Data, - t.GetField(FieldFilePath).Data, - t.GetField(FieldTransferSize).Data, - ) - - fileTransfer.FolderItemCount = t.GetField(FieldFolderItemCount).Data - - return append(res, cc.NewReply(t, NewField(FieldRefNum, fileTransfer.refNum[:]))) -} - -// HandleUploadFile -// Fields used in the request: -// 201 File Name -// 202 File path -// 204 File transfer options "Optional -// 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) { - return cc.NewErrReply(t, "You are not allowed to upload files.") - } - - fileName := t.GetField(FieldFileName).Data - filePath := t.GetField(FieldFilePath).Data - transferOptions := t.GetField(FieldFileTransferOptions).Data - transferSize := t.GetField(FieldTransferSize).Data // not sent for resume - - var fp FilePath - if filePath != nil { - if _, err := fp.Write(filePath); err != nil { - return res - } - } - - // Handle special cases for Upload and Drop Box folders - 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))) - } - } - fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName) - if err != nil { - return res - } - - if _, err := cc.Server.FS.Stat(fullFilePath); err == nil { - return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload because there is already a file named \"%v\". Try choosing a different Name.", string(fileName))) - } - - ft := cc.newFileTransfer(FileUpload, fileName, filePath, transferSize) - - replyT := cc.NewReply(t, NewField(FieldRefNum, ft.refNum[:])) - - // client has requested to resume a partially transferred file - if transferOptions != nil { - fileInfo, err := cc.Server.FS.Stat(fullFilePath + incompleteFileSuffix) - if err != nil { - return res - } - - offset := make([]byte, 4) - binary.BigEndian.PutUint32(offset, uint32(fileInfo.Size())) - - fileResumeData := NewFileResumeData([]ForkInfoList{ - *NewForkInfoList(offset), - }) - - b, _ := fileResumeData.BinaryMarshal() - - ft.TransferSize = offset - - replyT.Fields = append(replyT.Fields, NewField(FieldFileResumeData, b)) - } - - res = append(res, replyT) - return res -} - -func HandleSetClientUserInfo(cc *ClientConn, t *Transaction) (res []Transaction) { - if len(t.GetField(FieldUserIconID).Data) == 4 { - cc.Icon = t.GetField(FieldUserIconID).Data[2:] - } else { - cc.Icon = t.GetField(FieldUserIconID).Data - } - if cc.Authorize(AccessAnyName) { - cc.UserName = t.GetField(FieldUserName).Data - } - - // 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())) - - 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 { - cc.AutoReply = t.GetField(FieldAutomaticResponse).Data - } else { - cc.AutoReply = []byte{} - } - } - - for _, c := range cc.Server.ClientMgr.List() { - res = append(res, NewTransaction( - TranNotifyChangeUser, - c.ID, - NewField(FieldUserID, cc.ID[:]), - NewField(FieldUserIconID, cc.Icon), - NewField(FieldUserFlags, cc.Flags[:]), - NewField(FieldUserName, cc.UserName), - )) - } - - return res -} - -// HandleKeepAlive responds to keepalive transactions with an empty reply -// * HL 1.9.2 Client sends keepalive msg every 3 minutes -// * HL 1.2.3 Client doesn't send keepalives -func HandleKeepAlive(cc *ClientConn, t *Transaction) (res []Transaction) { - res = append(res, cc.NewReply(t)) - - return res -} - -func HandleGetFileNameList(cc *ClientConn, t *Transaction) (res []Transaction) { - fullPath, err := readPath( - cc.Server.Config.FileRoot, - t.GetField(FieldFilePath).Data, - nil, - ) - if err != nil { - return res - } - - var fp FilePath - if t.GetField(FieldFilePath).Data != nil { - if _, err = fp.Write(t.GetField(FieldFilePath).Data); err != nil { - return res - } - } - - // Handle special case for drop box folders - if fp.IsDropbox() && !cc.Authorize(AccessViewDropBoxes) { - return cc.NewErrReply(t, "You are not allowed to view drop boxes.") - } - - fileNames, err := getFileNameList(fullPath, cc.Server.Config.IgnoreFiles) - if err != nil { - return res - } - - res = append(res, cc.NewReply(t, fileNames...)) - - return res -} - -// ================================= -// Hotline private chat flow -// ================================= -// 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 Type -// -// A dialog box pops up in the invitee client with options to accept or decline the invitation. -// If Accepted is clicked: -// 1. ClientB sends TranJoinChat with FieldChatID - -// HandleInviteNewChat invites users to new private chat -func HandleInviteNewChat(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(AccessOpenChat) { - return cc.NewErrReply(t, "You are not allowed to request private chat.") - } - - // Client to Invite - targetID := t.GetField(FieldUserID).Data - - // 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.ClientMgr.Get([2]byte(targetID)) - flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(targetClient.Flags[:]))) - if flagBitmap.Bit(UserFlagRefusePChat) == 1 { - res = append(res, - NewTransaction( - TranServerMsg, - cc.ID, - NewField(FieldData, []byte(string(targetClient.UserName)+" does not accept private chats.")), - NewField(FieldUserName, targetClient.UserName), - NewField(FieldUserID, targetClient.ID[:]), - NewField(FieldOptions, []byte{0, 2}), - ), - ) - } else { - res = append(res, - NewTransaction( - TranInviteToChat, - [2]byte(targetID), - NewField(FieldChatID, newChatID[:]), - NewField(FieldUserName, cc.UserName), - NewField(FieldUserID, cc.ID[:]), - ), - ) - } - - return append( - res, - cc.NewReply(t, - NewField(FieldChatID, newChatID[:]), - NewField(FieldUserName, cc.UserName), - NewField(FieldUserID, cc.ID[:]), - NewField(FieldUserIconID, cc.Icon), - NewField(FieldUserFlags, cc.Flags[:]), - ), - ) -} - -func HandleInviteToChat(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(AccessOpenChat) { - return cc.NewErrReply(t, "You are not allowed to request private chat.") - } - - // Client to Invite - targetID := t.GetField(FieldUserID).Data - chatID := t.GetField(FieldChatID).Data - - return []Transaction{ - NewTransaction( - TranInviteToChat, - [2]byte(targetID), - NewField(FieldChatID, chatID), - NewField(FieldUserName, cc.UserName), - NewField(FieldUserID, cc.ID[:]), - ), - cc.NewReply( - t, - NewField(FieldChatID, chatID), - NewField(FieldUserName, cc.UserName), - NewField(FieldUserID, cc.ID[:]), - NewField(FieldUserIconID, cc.Icon), - NewField(FieldUserFlags, cc.Flags[:]), - ), - } -} - -func HandleRejectChatInvite(cc *ClientConn, t *Transaction) (res []Transaction) { - chatID := [4]byte(t.GetField(FieldChatID).Data) - - for _, c := range cc.Server.ChatMgr.Members(chatID) { - res = append(res, - NewTransaction( - TranChatMsg, - c.ID, - NewField(FieldChatID, chatID[:]), - NewField(FieldData, append(cc.UserName, []byte(" declined invitation to chat")...)), - ), - ) - } - - return res -} - -// HandleJoinChat is sent from a v1.8+ Hotline client when the joins a private chat -// Fields used in the reply: -// * 115 Chat subject -// * 300 User Name with info (Optional) -// * 300 (more user names with info) -func HandleJoinChat(cc *ClientConn, t *Transaction) (res []Transaction) { - chatID := t.GetField(FieldChatID).Data - - // Send TranNotifyChatChangeUser to current members of the chat to inform of new user - for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) { - res = append(res, - NewTransaction( - TranNotifyChatChangeUser, - c.ID, - NewField(FieldChatID, chatID), - NewField(FieldUserName, cc.UserName), - NewField(FieldUserID, cc.ID[:]), - NewField(FieldUserIconID, cc.Icon), - NewField(FieldUserFlags, cc.Flags[:]), - ), - ) - } - - cc.Server.ChatMgr.Join(ChatID(chatID), cc) - - subject := cc.Server.ChatMgr.GetSubject(ChatID(chatID)) - - 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, - Flags: c.Flags[:], - Name: string(c.UserName), - }) - if err != nil { - return res - } - replyFields = append(replyFields, NewField(FieldUsernameWithInfo, b)) - } - - return append(res, cc.NewReply(t, replyFields...)) -} - -// HandleLeaveChat is sent from a v1.8+ Hotline client when the user exits a private chat -// Fields used in the request: -// - 114 FieldChatID -// -// Reply is not expected. -func HandleLeaveChat(cc *ClientConn, t *Transaction) (res []Transaction) { - chatID := t.GetField(FieldChatID).Data - - cc.Server.ChatMgr.Leave([4]byte(chatID), cc.ID) - - // Notify members of the private chat that the user has left - for _, c := range cc.Server.ChatMgr.Members(ChatID(chatID)) { - res = append(res, - NewTransaction( - TranNotifyChatDeleteUser, - c.ID, - NewField(FieldChatID, chatID), - NewField(FieldUserID, cc.ID[:]), - ), - ) - } - - return res -} - -// HandleSetChatSubject is sent from a v1.8+ Hotline client when the user sets a private chat subject -// Fields used in the request: -// * 114 Chat Type -// * 115 Chat subject -// Reply is not expected. -func HandleSetChatSubject(cc *ClientConn, t *Transaction) (res []Transaction) { - chatID := t.GetField(FieldChatID).Data - - cc.Server.ChatMgr.SetSubject([4]byte(chatID), string(t.GetField(FieldChatSubject).Data)) - - // Notify chat members of new subject. - for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) { - res = append(res, - NewTransaction( - TranNotifyChatSubject, - c.ID, - NewField(FieldChatID, chatID), - NewField(FieldChatSubject, t.GetField(FieldChatSubject).Data), - ), - ) - } - - return res -} - -// HandleMakeAlias makes a file alias using the specified path. -// Fields used in the request: -// 201 File Name -// 202 File path -// 212 File new path Destination path -// -// Fields used in the reply: -// None -func HandleMakeAlias(cc *ClientConn, t *Transaction) (res []Transaction) { - if !cc.Authorize(AccessMakeAlias) { - return cc.NewErrReply(t, "You are not allowed to make aliases.") - } - fileName := t.GetField(FieldFileName).Data - filePath := t.GetField(FieldFilePath).Data - fileNewPath := t.GetField(FieldFileNewPath).Data - - fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName) - if err != nil { - return res - } - - fullNewFilePath, err := readPath(cc.Server.Config.FileRoot, fileNewPath, fileName) - if err != nil { - return res - } - - cc.logger.Debug("Make alias", "src", fullFilePath, "dst", fullNewFilePath) - - if err := cc.Server.FS.Symlink(fullFilePath, fullNewFilePath); err != nil { - return cc.NewErrReply(t, "Error creating alias") - } - - res = append(res, cc.NewReply(t)) - return res -} - -// HandleDownloadBanner handles requests for a new banner from the server -// Fields used in the request: -// None -// Fields used in the reply: -// 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)) - binary.BigEndian.PutUint32(ft.TransferSize, uint32(len(cc.Server.banner))) - - return append(res, cc.NewReply(t, - NewField(FieldRefNum, ft.refNum[:]), - NewField(FieldTransferSize, ft.TransferSize), - )) -} +const LimitChatMsg = 8192 diff --git a/hotline/transaction_handlers_test.go b/hotline/transaction_handlers_test.go deleted file mode 100644 index 610e113..0000000 --- a/hotline/transaction_handlers_test.go +++ /dev/null @@ -1,3693 +0,0 @@ -package hotline - -import ( - "errors" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "io" - "io/fs" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestHandleSetChatSubject(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - want []Transaction - }{ - { - name: "sends chat subject to private chat members", - args: args{ - cc: &ClientConn{ - UserName: []byte{0x00, 0x01}, - Server: &Server{ - 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}, - }, - { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 2}, - }, - }) - 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}, - }, - { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 2}, - }, - }, - ) - return &m - }(), - }, - }, - t: Transaction{ - Type: [2]byte{0, 0x6a}, - ID: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldChatID, []byte{0, 0, 0, 1}), - NewField(FieldChatSubject, []byte("Test Subject")), - }, - }, - }, - want: []Transaction{ - { - clientID: [2]byte{0, 1}, - Type: [2]byte{0, 0x77}, - Fields: []Field{ - NewField(FieldChatID, []byte{0, 0, 0, 1}), - NewField(FieldChatSubject, []byte("Test Subject")), - }, - }, - { - clientID: [2]byte{0, 2}, - Type: [2]byte{0, 0x77}, - Fields: []Field{ - NewField(FieldChatID, []byte{0, 0, 0, 1}), - NewField(FieldChatSubject, []byte("Test Subject")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := HandleSetChatSubject(tt.args.cc, &tt.args.t) - if !tranAssertEqual(t, tt.want, got) { - t.Errorf("HandleSetChatSubject() got = %v, want %v", got, tt.want) - } - }) - } -} - -func TestHandleLeaveChat(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - want []Transaction - }{ - { - name: "when client 2 leaves chat", - args: args{ - cc: &ClientConn{ - ID: [2]byte{0, 2}, - Server: &Server{ - 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}, - }, - }) - 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}, - }, - { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 2}, - }, - }, - ) - return &m - }(), - }, - }, - t: NewTransaction(TranDeleteUser, [2]byte{}, NewField(FieldChatID, []byte{0, 0, 0, 1})), - }, - want: []Transaction{ - { - clientID: [2]byte{0, 1}, - Type: [2]byte{0, 0x76}, - Fields: []Field{ - NewField(FieldChatID, []byte{0, 0, 0, 1}), - NewField(FieldUserID, []byte{0, 2}), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := HandleLeaveChat(tt.args.cc, &tt.args.t) - if !tranAssertEqual(t, tt.want, got) { - t.Errorf("HandleLeaveChat() got = %v, want %v", got, tt.want) - } - }) - } -} - -func TestHandleGetUserNameList(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - want []Transaction - }{ - { - name: "replies with userlist transaction", - args: args{ - cc: &ClientConn{ - ID: [2]byte{0, 1}, - Server: &Server{ - 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{}, - }, - want: []Transaction{ - { - clientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []Field{ - NewField( - FieldUsernameWithInfo, - []byte{00, 01, 00, 02, 00, 03, 00, 02, 00, 04}, - ), - NewField( - FieldUsernameWithInfo, - []byte{00, 02, 00, 02, 00, 03, 00, 02, 00, 04}, - ), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := HandleGetUserNameList(tt.args.cc, &tt.args.t) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestHandleChatSend(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - want []Transaction - }{ - { - name: "sends chat msg transaction to all clients", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessSendChat) - return bits - }(), - }, - UserName: []byte{0x00, 0x01}, - Server: &Server{ - 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}, - }, - { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 2}, - }, - }, - ) - return &m - }(), - }, - }, - t: Transaction{ - Fields: []Field{ - NewField(FieldData, []byte("hai")), - }, - }, - }, - want: []Transaction{ - { - clientID: [2]byte{0, 1}, - Flags: 0x00, - IsReply: 0x00, - Type: [2]byte{0, 0x6a}, - Fields: []Field{ - NewField(FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), - }, - }, - { - clientID: [2]byte{0, 2}, - Flags: 0x00, - IsReply: 0x00, - Type: [2]byte{0, 0x6a}, - Fields: []Field{ - NewField(FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), - }, - }, - }, - }, - { - 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) - return bits - }(), - }, - UserName: []byte{0x00, 0x01}, - Server: &Server{ - 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}, - }, - { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 2}, - }, - }, - ) - return &m - }(), - }, - }, - t: Transaction{ - Fields: []Field{ - NewField(FieldData, []byte("hai")), - NewField(FieldChatID, []byte{0, 0, 0, 0}), - }, - }, - }, - want: []Transaction{ - { - clientID: [2]byte{0, 1}, - Type: [2]byte{0, 0x6a}, - Fields: []Field{ - NewField(FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), - }, - }, - { - clientID: [2]byte{0, 2}, - Type: [2]byte{0, 0x6a}, - Fields: []Field{ - NewField(FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), - }, - }, - }, - }, - { - name: "when user does not have required permission", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), - }, - Server: &Server{ - //Accounts: map[string]*Account{}, - }, - }, - t: NewTransaction( - TranChatSend, [2]byte{0, 1}, - NewField(FieldData, []byte("hai")), - ), - }, - want: []Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to participate in chat.")), - }, - }, - }, - }, - { - name: "sends chat msg as emote if FieldChatOptions is set to 1", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessSendChat) - return bits - }(), - }, - UserName: []byte("Testy McTest"), - Server: &Server{ - 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}, - }, - { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 2}, - }, - }, - ) - return &m - }(), - }, - }, - t: Transaction{ - Fields: []Field{ - NewField(FieldData, []byte("performed action")), - NewField(FieldChatOptions, []byte{0x00, 0x01}), - }, - }, - }, - want: []Transaction{ - { - clientID: [2]byte{0, 1}, - Flags: 0x00, - IsReply: 0x00, - Type: [2]byte{0, 0x6a}, - Fields: []Field{ - NewField(FieldData, []byte("\r*** Testy McTest performed action")), - }, - }, - { - clientID: [2]byte{0, 2}, - Flags: 0x00, - IsReply: 0x00, - Type: [2]byte{0, 0x6a}, - Fields: []Field{ - NewField(FieldData, []byte("\r*** Testy McTest performed action")), - }, - }, - }, - }, - { - name: "does not send chat msg as emote if FieldChatOptions is set to 0", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessSendChat) - return bits - }(), - }, - UserName: []byte("Testy McTest"), - Server: &Server{ - 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}, - }, - { - Account: &Account{ - Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 2}, - }, - }, - ) - return &m - }(), - }, - }, - t: Transaction{ - Fields: []Field{ - NewField(FieldData, []byte("hello")), - NewField(FieldChatOptions, []byte{0x00, 0x00}), - }, - }, - }, - want: []Transaction{ - { - clientID: [2]byte{0, 1}, - Type: [2]byte{0, 0x6a}, - Fields: []Field{ - NewField(FieldData, []byte("\r Testy McTest: hello")), - }, - }, - { - clientID: [2]byte{0, 2}, - Type: [2]byte{0, 0x6a}, - Fields: []Field{ - NewField(FieldData, []byte("\r Testy McTest: hello")), - }, - }, - }, - }, - { - 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) - return bits - }(), - }, - UserName: []byte{0x00, 0x01}, - Server: &Server{ - 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}, - }, - }, - ) - return &m - }(), - }, - }, - t: Transaction{ - Fields: []Field{ - NewField(FieldData, []byte("hai")), - }, - }, - }, - want: []Transaction{ - { - clientID: [2]byte{0, 1}, - Type: [2]byte{0, 0x6a}, - Fields: []Field{ - NewField(FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), - }, - }, - }, - }, - { - name: "only sends private chat msg to members of private chat", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessSendChat) - return bits - }(), - }, - UserName: []byte{0x00, 0x01}, - Server: &Server{ - ChatMgr: func() *MockChatManager { - m := MockChatManager{} - m.On("Members", ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*ClientConn{ - { - ID: [2]byte{0, 1}, - }, - { - ID: [2]byte{0, 2}, - }, - }) - 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}, - }, - { - 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}, - }, - }, - ) - return &m - }(), - }, - }, - t: Transaction{ - Fields: []Field{ - NewField(FieldData, []byte("hai")), - NewField(FieldChatID, []byte{0, 0, 0, 1}), - }, - }, - }, - want: []Transaction{ - { - clientID: [2]byte{0, 1}, - Type: [2]byte{0, 0x6a}, - Fields: []Field{ - NewField(FieldChatID, []byte{0, 0, 0, 1}), - NewField(FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), - }, - }, - { - clientID: [2]byte{0, 2}, - Type: [2]byte{0, 0x6a}, - Fields: []Field{ - NewField(FieldChatID, []byte{0, 0, 0, 1}), - NewField(FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := HandleChatSend(tt.args.cc, &tt.args.t) - tranAssertEqual(t, tt.want, got) - }) - } -} - -func TestHandleGetFileInfo(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "returns expected fields when a valid file is requested", - args: args{ - cc: &ClientConn{ - ID: [2]byte{0x00, 0x01}, - Server: &Server{ - FS: &OSFileStore{}, - Config: Config{ - FileRoot: func() string { - path, _ := os.Getwd() - return filepath.Join(path, "/test/config/Files") - }(), - }, - }, - }, - t: NewTransaction( - TranGetFileInfo, [2]byte{}, - NewField(FieldFileName, []byte("testfile.txt")), - NewField(FieldFilePath, []byte{0x00, 0x00}), - ), - }, - wantRes: []Transaction{ - { - clientID: [2]byte{0, 1}, - IsReply: 0x01, - Type: [2]byte{0, 0}, - Fields: []Field{ - NewField(FieldFileName, []byte("testfile.txt")), - NewField(FieldFileTypeString, []byte("Text File")), - NewField(FieldFileCreatorString, []byte("ttxt")), - NewField(FieldFileType, []byte("TEXT")), - NewField(FieldFileCreateDate, make([]byte, 8)), - NewField(FieldFileModifyDate, make([]byte, 8)), - NewField(FieldFileSize, []byte{0x0, 0x0, 0x0, 0x17}), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleGetFileInfo(tt.args.cc, &tt.args.t) - - // Clear the file timestamp fields to work around problems running the tests in multiple timezones - // TODO: revisit how to test this by mocking the stat calls - gotRes[0].Fields[4].Data = make([]byte, 8) - gotRes[0].Fields[5].Data = make([]byte, 8) - - if !tranAssertEqual(t, tt.wantRes, gotRes) { - t.Errorf("HandleGetFileInfo() gotRes = %v, want %v", gotRes, tt.wantRes) - } - }) - } -} - -func TestHandleNewFolder(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "without required permission", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), - }, - }, - t: NewTransaction( - TranNewFolder, - [2]byte{0, 0}, - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to create folders.")), - }, - }, - }, - }, - { - name: "when path is nested", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessCreateFolder) - return bits - }(), - }, - ID: [2]byte{0, 1}, - Server: &Server{ - Config: Config{ - FileRoot: "/Files/", - }, - FS: func() *MockFileStore { - mfs := &MockFileStore{} - mfs.On("Mkdir", "/Files/aaa/testFolder", fs.FileMode(0777)).Return(nil) - mfs.On("Stat", "/Files/aaa/testFolder").Return(nil, os.ErrNotExist) - return mfs - }(), - }, - }, - t: NewTransaction( - TranNewFolder, [2]byte{0, 1}, - NewField(FieldFileName, []byte("testFolder")), - NewField(FieldFilePath, []byte{ - 0x00, 0x01, - 0x00, 0x00, - 0x03, - 0x61, 0x61, 0x61, - }), - ), - }, - wantRes: []Transaction{ - { - clientID: [2]byte{0, 1}, - IsReply: 0x01, - }, - }, - }, - { - name: "when path is not nested", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessCreateFolder) - return bits - }(), - }, - ID: [2]byte{0, 1}, - Server: &Server{ - Config: Config{ - FileRoot: "/Files", - }, - FS: func() *MockFileStore { - mfs := &MockFileStore{} - mfs.On("Mkdir", "/Files/testFolder", fs.FileMode(0777)).Return(nil) - mfs.On("Stat", "/Files/testFolder").Return(nil, os.ErrNotExist) - return mfs - }(), - }, - }, - t: NewTransaction( - TranNewFolder, [2]byte{0, 1}, - NewField(FieldFileName, []byte("testFolder")), - ), - }, - wantRes: []Transaction{ - { - clientID: [2]byte{0, 1}, - IsReply: 0x01, - }, - }, - }, - { - name: "when Write returns an err", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessCreateFolder) - return bits - }(), - }, - ID: [2]byte{0, 1}, - Server: &Server{ - Config: Config{ - FileRoot: "/Files/", - }, - FS: func() *MockFileStore { - mfs := &MockFileStore{} - mfs.On("Mkdir", "/Files/aaa/testFolder", fs.FileMode(0777)).Return(nil) - mfs.On("Stat", "/Files/aaa/testFolder").Return(nil, os.ErrNotExist) - return mfs - }(), - }, - }, - t: NewTransaction( - TranNewFolder, [2]byte{0, 1}, - NewField(FieldFileName, []byte("testFolder")), - NewField(FieldFilePath, []byte{ - 0x00, - }), - ), - }, - wantRes: []Transaction{}, - }, - { - name: "FieldFileName does not allow directory traversal", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessCreateFolder) - return bits - }(), - }, - ID: [2]byte{0, 1}, - Server: &Server{ - Config: Config{ - FileRoot: "/Files/", - }, - FS: func() *MockFileStore { - mfs := &MockFileStore{} - mfs.On("Mkdir", "/Files/testFolder", fs.FileMode(0777)).Return(nil) - mfs.On("Stat", "/Files/testFolder").Return(nil, os.ErrNotExist) - return mfs - }(), - }, - }, - t: NewTransaction( - TranNewFolder, [2]byte{0, 1}, - NewField(FieldFileName, []byte("../../testFolder")), - ), - }, - wantRes: []Transaction{ - { - clientID: [2]byte{0, 1}, - IsReply: 0x01, - }, - }, - }, - { - name: "FieldFilePath does not allow directory traversal", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessCreateFolder) - return bits - }(), - }, - ID: [2]byte{0, 1}, - Server: &Server{ - Config: Config{ - FileRoot: "/Files/", - }, - FS: func() *MockFileStore { - mfs := &MockFileStore{} - mfs.On("Mkdir", "/Files/foo/testFolder", fs.FileMode(0777)).Return(nil) - mfs.On("Stat", "/Files/foo/testFolder").Return(nil, os.ErrNotExist) - return mfs - }(), - }, - }, - t: NewTransaction( - TranNewFolder, [2]byte{0, 1}, - NewField(FieldFileName, []byte("testFolder")), - NewField(FieldFilePath, []byte{ - 0x00, 0x02, - 0x00, 0x00, - 0x03, - 0x2e, 0x2e, 0x2f, - 0x00, 0x00, - 0x03, - 0x66, 0x6f, 0x6f, - }), - ), - }, - wantRes: []Transaction{ - { - clientID: [2]byte{0, 1}, - IsReply: 0x01, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleNewFolder(tt.args.cc, &tt.args.t) - - if !tranAssertEqual(t, tt.wantRes, gotRes) { - t.Errorf("HandleNewFolder() gotRes = %v, want %v", gotRes, tt.wantRes) - } - }) - } -} - -func TestHandleUploadFile(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "when request is valid and user has Upload Anywhere permission", - args: args{ - cc: &ClientConn{ - Server: &Server{ - FS: &OSFileStore{}, - FileTransferMgr: NewMemFileTransferMgr(), - Config: Config{ - FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(), - }}, - ClientFileTransferMgr: NewClientFileTransferMgr(), - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessUploadFile) - bits.Set(AccessUploadAnywhere) - return bits - }(), - }, - }, - t: NewTransaction( - TranUploadFile, [2]byte{0, 1}, - NewField(FieldFileName, []byte("testFile")), - NewField(FieldFilePath, []byte{ - 0x00, 0x01, - 0x00, 0x00, - 0x03, - 0x2e, 0x2e, 0x2f, - }), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - Fields: []Field{ - NewField(FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}), // rand.Seed(1) - }, - }, - }, - }, - { - name: "when user does not have required access", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), - }, - }, - t: NewTransaction( - TranUploadFile, [2]byte{0, 1}, - NewField(FieldFileName, []byte("testFile")), - NewField(FieldFilePath, []byte{ - 0x00, 0x01, - 0x00, 0x00, - 0x03, - 0x2e, 0x2e, 0x2f, - }), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to upload files.")), // rand.Seed(1) - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleUploadFile(tt.args.cc, &tt.args.t) - tranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleMakeAlias(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "with valid input and required permissions", - args: args{ - cc: &ClientConn{ - logger: NewTestLogger(), - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessMakeAlias) - return bits - }(), - }, - Server: &Server{ - Config: Config{ - FileRoot: func() string { - path, _ := os.Getwd() - return path + "/test/config/Files" - }(), - }, - Logger: NewTestLogger(), - FS: func() *MockFileStore { - mfs := &MockFileStore{} - path, _ := os.Getwd() - mfs.On( - "Symlink", - path+"/test/config/Files/foo/testFile", - path+"/test/config/Files/bar/testFile", - ).Return(nil) - return mfs - }(), - }, - }, - t: NewTransaction( - TranMakeFileAlias, [2]byte{0, 1}, - NewField(FieldFileName, []byte("testFile")), - NewField(FieldFilePath, EncodeFilePath(strings.Join([]string{"foo"}, "/"))), - NewField(FieldFileNewPath, EncodeFilePath(strings.Join([]string{"bar"}, "/"))), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - Fields: []Field(nil), - }, - }, - }, - { - name: "when symlink returns an error", - args: args{ - cc: &ClientConn{ - logger: NewTestLogger(), - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessMakeAlias) - return bits - }(), - }, - Server: &Server{ - Config: Config{ - FileRoot: func() string { - path, _ := os.Getwd() - return path + "/test/config/Files" - }(), - }, - Logger: NewTestLogger(), - FS: func() *MockFileStore { - mfs := &MockFileStore{} - path, _ := os.Getwd() - mfs.On( - "Symlink", - path+"/test/config/Files/foo/testFile", - path+"/test/config/Files/bar/testFile", - ).Return(errors.New("ohno")) - return mfs - }(), - }, - }, - t: NewTransaction( - TranMakeFileAlias, [2]byte{0, 1}, - NewField(FieldFileName, []byte("testFile")), - NewField(FieldFilePath, EncodeFilePath(strings.Join([]string{"foo"}, "/"))), - NewField(FieldFileNewPath, EncodeFilePath(strings.Join([]string{"bar"}, "/"))), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("Error creating alias")), - }, - }, - }, - }, - { - name: "when user does not have required permission", - args: args{ - cc: &ClientConn{ - logger: NewTestLogger(), - Account: &Account{ - Access: accessBitmap{}, - }, - Server: &Server{ - Config: Config{ - FileRoot: func() string { - path, _ := os.Getwd() - return path + "/test/config/Files" - }(), - }, - }, - }, - t: NewTransaction( - TranMakeFileAlias, [2]byte{0, 1}, - NewField(FieldFileName, []byte("testFile")), - NewField(FieldFilePath, []byte{ - 0x00, 0x01, - 0x00, 0x00, - 0x03, - 0x2e, 0x2e, 0x2e, - }), - NewField(FieldFileNewPath, []byte{ - 0x00, 0x01, - 0x00, 0x00, - 0x03, - 0x2e, 0x2e, 0x2e, - }), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to make aliases.")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleMakeAlias(tt.args.cc, &tt.args.t) - tranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleGetUser(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "when account is valid", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessOpenUser) - return bits - }(), - }, - Server: &Server{ - AccountManager: func() *MockAccountManager { - m := MockAccountManager{} - m.On("Get", "guest").Return(&Account{ - Login: "guest", - Name: "Guest", - Password: "password", - Access: accessBitmap{}, - }) - return &m - }(), - }, - }, - t: NewTransaction( - TranGetUser, [2]byte{0, 1}, - NewField(FieldUserLogin, []byte("guest")), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - Fields: []Field{ - NewField(FieldUserName, []byte("Guest")), - NewField(FieldUserLogin, encodeString([]byte("guest"))), - NewField(FieldUserPassword, []byte("password")), - NewField(FieldUserAccess, []byte{0, 0, 0, 0, 0, 0, 0, 0}), - }, - }, - }, - }, - { - name: "when user does not have required permission", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), - }, - Server: &Server{ - //Accounts: map[string]*Account{}, - }, - }, - t: NewTransaction( - TranGetUser, [2]byte{0, 1}, - NewField(FieldUserLogin, []byte("nonExistentUser")), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to view accounts.")), - }, - }, - }, - }, - { - name: "when account does not exist", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessOpenUser) - return bits - }(), - }, - Server: &Server{ - AccountManager: func() *MockAccountManager { - m := MockAccountManager{} - m.On("Get", "nonExistentUser").Return((*Account)(nil)) - return &m - }(), - }, - }, - t: NewTransaction( - TranGetUser, [2]byte{0, 1}, - NewField(FieldUserLogin, []byte("nonExistentUser")), - ), - }, - wantRes: []Transaction{ - { - Flags: 0x00, - IsReply: 0x01, - Type: [2]byte{0, 0}, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("Account does not exist.")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleGetUser(tt.args.cc, &tt.args.t) - tranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleDeleteUser(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "when user exists", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessDeleteUser) - return bits - }(), - }, - Server: &Server{ - 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 - }(), - }, - }, - t: NewTransaction( - TranDeleteUser, [2]byte{0, 1}, - NewField(FieldUserLogin, encodeString([]byte("testuser"))), - ), - }, - wantRes: []Transaction{ - { - Flags: 0x00, - IsReply: 0x01, - Type: [2]byte{0, 0}, - Fields: []Field(nil), - }, - }, - }, - { - name: "when user does not have required permission", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: accessBitmap{}, - }, - Server: &Server{ - //Accounts: map[string]*Account{}, - }, - }, - t: NewTransaction( - TranDeleteUser, [2]byte{0, 1}, - NewField(FieldUserLogin, encodeString([]byte("testuser"))), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to delete accounts.")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleDeleteUser(tt.args.cc, &tt.args.t) - tranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleGetMsgs(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "returns news data", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessNewsReadArt) - return bits - }(), - }, - Server: &Server{ - 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( - TranGetMsgs, [2]byte{0, 1}, - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - Fields: []Field{ - NewField(FieldData, []byte("TEST")), - }, - }, - }, - }, - { - name: "when user does not have required permission", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: accessBitmap{}, - }, - Server: &Server{ - //Accounts: map[string]*Account{}, - }, - }, - t: NewTransaction( - TranGetMsgs, [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.")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleGetMsgs(tt.args.cc, &tt.args.t) - tranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleNewUser(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "when user does not have required permission", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), - }, - Server: &Server{ - //Accounts: map[string]*Account{}, - }, - }, - t: NewTransaction( - TranNewUser, [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 create new accounts.")), - }, - }, - }, - }, - { - name: "when user attempts to create account with greater access", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessCreateUser) - return bits - }(), - }, - Server: &Server{ - AccountManager: func() *MockAccountManager { - m := MockAccountManager{} - m.On("Get", "userB").Return((*Account)(nil)) - return &m - }(), - }, - }, - t: NewTransaction( - TranNewUser, [2]byte{0, 1}, - NewField(FieldUserLogin, encodeString([]byte("userB"))), - NewField( - FieldUserAccess, - func() []byte { - var bits accessBitmap - bits.Set(AccessDisconUser) - return bits[:] - }(), - ), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("Cannot create account with more access than yourself.")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleNewUser(tt.args.cc, &tt.args.t) - tranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleListUsers(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "when user does not have required permission", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), - }, - Server: &Server{ - //Accounts: map[string]*Account{}, - }, - }, - t: NewTransaction( - TranNewUser, [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 view accounts.")), - }, - }, - }, - }, - { - name: "when user has required permission", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessOpenUser) - return bits - }(), - }, - Server: &Server{ - 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( - TranGetClientInfoText, [2]byte{0, 1}, - NewField(FieldUserID, []byte{0, 1}), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - Fields: []Field{ - NewField(FieldData, []byte{ - 0x00, 0x04, 0x00, 0x66, 0x00, 0x05, 0x67, 0x75, 0x65, 0x73, 0x74, 0x00, 0x69, 0x00, 0x05, 0x98, - 0x8a, 0x9a, 0x8c, 0x8b, 0x00, 0x6e, 0x00, 0x08, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0x00, 0x6a, 0x00, 0x01, 0x78, - }), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleListUsers(tt.args.cc, &tt.args.t) - - tranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleDownloadFile(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "when user does not have required permission", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), - }, - Server: &Server{}, - }, - t: NewTransaction(TranDownloadFile, [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 download files.")), - }, - }, - }, - }, - { - name: "with a valid file", - args: args{ - cc: &ClientConn{ - ClientFileTransferMgr: NewClientFileTransferMgr(), - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessDownloadFile) - return bits - }(), - }, - Server: &Server{ - FS: &OSFileStore{}, - FileTransferMgr: NewMemFileTransferMgr(), - Config: Config{ - FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(), - }, - }, - }, - t: NewTransaction( - TranDownloadFile, - [2]byte{0, 1}, - NewField(FieldFileName, []byte("testfile.txt")), - NewField(FieldFilePath, []byte{0x0, 0x00}), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - Fields: []Field{ - NewField(FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}), - NewField(FieldWaitingCount, []byte{0x00, 0x00}), - NewField(FieldTransferSize, []byte{0x00, 0x00, 0x00, 0xa5}), - NewField(FieldFileSize, []byte{0x00, 0x00, 0x00, 0x17}), - }, - }, - }, - }, - { - name: "when client requests to resume 1k test file at offset 256", - args: args{ - cc: &ClientConn{ - ClientFileTransferMgr: NewClientFileTransferMgr(), - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessDownloadFile) - return bits - }(), - }, - Server: &Server{ - FS: &OSFileStore{}, - - // FS: func() *MockFileStore { - // path, _ := os.Getwd() - // testFile, err := os.Open(path + "/test/config/Files/testfile-1k") - // if err != nil { - // panic(err) - // } - // - // mfi := &MockFileInfo{} - // mfi.On("Mode").Return(fs.FileMode(0)) - // mfs := &MockFileStore{} - // mfs.On("Stat", "/fakeRoot/Files/testfile.txt").Return(mfi, nil) - // mfs.On("Open", "/fakeRoot/Files/testfile.txt").Return(testFile, nil) - // mfs.On("Stat", "/fakeRoot/Files/.info_testfile.txt").Return(nil, errors.New("no")) - // mfs.On("Stat", "/fakeRoot/Files/.rsrc_testfile.txt").Return(nil, errors.New("no")) - // - // return mfs - // }(), - FileTransferMgr: NewMemFileTransferMgr(), - Config: Config{ - FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(), - }, - //Accounts: map[string]*Account{}, - }, - }, - t: NewTransaction( - TranDownloadFile, - [2]byte{0, 1}, - NewField(FieldFileName, []byte("testfile-1k")), - NewField(FieldFilePath, []byte{0x00, 0x00}), - NewField( - FieldFileResumeData, - func() []byte { - frd := FileResumeData{ - ForkCount: [2]byte{0, 2}, - ForkInfoList: []ForkInfoList{ - { - Fork: [4]byte{0x44, 0x41, 0x54, 0x41}, // "DATA" - DataSize: [4]byte{0, 0, 0x01, 0x00}, // request offset 256 - }, - { - Fork: [4]byte{0x4d, 0x41, 0x43, 0x52}, // "MACR" - DataSize: [4]byte{0, 0, 0, 0}, - }, - }, - } - b, _ := frd.BinaryMarshal() - return b - }(), - ), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - Fields: []Field{ - NewField(FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}), - NewField(FieldWaitingCount, []byte{0x00, 0x00}), - NewField(FieldTransferSize, []byte{0x00, 0x00, 0x03, 0x8d}), - NewField(FieldFileSize, []byte{0x00, 0x00, 0x03, 0x00}), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleDownloadFile(tt.args.cc, &tt.args.t) - tranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleUpdateUser(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "when action is create user without required permission", - args: args{ - 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: accessBitmap{}, - }, - }, - t: NewTransaction( - TranUpdateUser, - [2]byte{0, 0}, - NewField(FieldData, []byte{ - 0x00, 0x04, // field count - - 0x00, 0x69, // FieldUserLogin = 105 - 0x00, 0x03, - 0x9d, 0x9d, 0x9d, - - 0x00, 0x6a, // FieldUserPassword = 106 - 0x00, 0x03, - 0x9c, 0x9c, 0x9c, - - 0x00, 0x66, // FieldUserName = 102 - 0x00, 0x03, - 0x61, 0x61, 0x61, - - 0x00, 0x6e, // FieldUserAccess = 110 - 0x00, 0x08, - 0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00, - }), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to create new accounts.")), - }, - }, - }, - }, - { - name: "when action is modify user without required permission", - args: args{ - cc: &ClientConn{ - logger: NewTestLogger(), - Server: &Server{ - Logger: NewTestLogger(), - AccountManager: func() *MockAccountManager { - m := MockAccountManager{} - m.On("Get", "bbb").Return(&Account{}) - return &m - }(), - }, - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), - }, - }, - t: NewTransaction( - TranUpdateUser, - [2]byte{0, 0}, - NewField(FieldData, []byte{ - 0x00, 0x04, // field count - - 0x00, 0x69, // FieldUserLogin = 105 - 0x00, 0x03, - 0x9d, 0x9d, 0x9d, - - 0x00, 0x6a, // FieldUserPassword = 106 - 0x00, 0x03, - 0x9c, 0x9c, 0x9c, - - 0x00, 0x66, // FieldUserName = 102 - 0x00, 0x03, - 0x61, 0x61, 0x61, - - 0x00, 0x6e, // FieldUserAccess = 110 - 0x00, 0x08, - 0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00, - }), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to modify accounts.")), - }, - }, - }, - }, - { - name: "when action is delete user without required permission", - args: args{ - cc: &ClientConn{ - logger: NewTestLogger(), - Server: &Server{}, - Account: &Account{ - Access: accessBitmap{}, - }, - }, - t: NewTransaction( - TranUpdateUser, - [2]byte{0, 0}, - NewField(FieldData, []byte{ - 0x00, 0x01, - 0x00, 0x65, - 0x00, 0x03, - 0x88, 0x9e, 0x8b, - }), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to delete accounts.")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleUpdateUser(tt.args.cc, &tt.args.t) - tranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleDelNewsArt(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "without required permission", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), - }, - }, - t: NewTransaction( - TranDelNewsArt, - [2]byte{0, 0}, - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to delete news articles.")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleDelNewsArt(tt.args.cc, &tt.args.t) - tranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleDisconnectUser(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "without required permission", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), - }, - }, - t: NewTransaction( - TranDelNewsArt, - [2]byte{0, 0}, - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to disconnect users.")), - }, - }, - }, - }, - { - name: "when target user has 'cannot be disconnected' priv", - args: args{ - cc: &ClientConn{ - Server: &Server{ - 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) - return bits - }(), - }, - }, - ) - return &m - }(), - }, - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessDisconUser) - return bits - }(), - }, - }, - t: NewTransaction( - TranDelNewsArt, - [2]byte{0, 0}, - NewField(FieldUserID, []byte{0, 1}), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("unnamed is not allowed to be disconnected.")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleDisconnectUser(tt.args.cc, &tt.args.t) - tranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleSendInstantMsg(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "without required permission", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), - }, - }, - t: NewTransaction( - TranDelNewsArt, - [2]byte{0, 0}, - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to send private messages.")), - }, - }, - }, - }, - { - name: "when client 1 sends a message to client 2", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessSendPrivMsg) - return bits - }(), - }, - ID: [2]byte{0, 1}, - UserName: []byte("User1"), - Server: &Server{ - 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( - TranSendInstantMsg, - [2]byte{0, 1}, - NewField(FieldData, []byte("hai")), - NewField(FieldUserID, []byte{0, 2}), - ), - }, - wantRes: []Transaction{ - NewTransaction( - TranServerMsg, - [2]byte{0, 2}, - NewField(FieldData, []byte("hai")), - NewField(FieldUserName, []byte("User1")), - NewField(FieldUserID, []byte{0, 1}), - NewField(FieldOptions, []byte{0, 1}), - ), - { - clientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []Field(nil), - }, - }, - }, - { - name: "when client 2 has autoreply enabled", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessSendPrivMsg) - return bits - }(), - }, - ID: [2]byte{0, 1}, - UserName: []byte("User1"), - Server: &Server{ - 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( - TranSendInstantMsg, - [2]byte{0, 1}, - NewField(FieldData, []byte("hai")), - NewField(FieldUserID, []byte{0, 2}), - ), - }, - wantRes: []Transaction{ - NewTransaction( - TranServerMsg, - [2]byte{0, 2}, - NewField(FieldData, []byte("hai")), - NewField(FieldUserName, []byte("User1")), - NewField(FieldUserID, []byte{0, 1}), - NewField(FieldOptions, []byte{0, 1}), - ), - NewTransaction( - TranServerMsg, - [2]byte{0, 1}, - NewField(FieldData, []byte("autohai")), - NewField(FieldUserName, []byte("User2")), - NewField(FieldUserID, []byte{0, 2}), - NewField(FieldOptions, []byte{0, 1}), - ), - { - clientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []Field(nil), - }, - }, - }, - { - name: "when client 2 has refuse private messages enabled", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessSendPrivMsg) - return bits - }(), - }, - ID: [2]byte{0, 1}, - UserName: []byte("User1"), - Server: &Server{ - 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( - TranSendInstantMsg, - [2]byte{0, 1}, - NewField(FieldData, []byte("hai")), - NewField(FieldUserID, []byte{0, 2}), - ), - }, - wantRes: []Transaction{ - NewTransaction( - TranServerMsg, - [2]byte{0, 1}, - NewField(FieldData, []byte("User2 does not accept private messages.")), - NewField(FieldUserName, []byte("User2")), - NewField(FieldUserID, []byte{0, 2}), - NewField(FieldOptions, []byte{0, 2}), - ), - { - clientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []Field(nil), - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleSendInstantMsg(tt.args.cc, &tt.args.t) - tranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleDeleteFile(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "when user does not have required permission to delete a folder", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), - }, - Server: &Server{ - Config: Config{ - FileRoot: func() string { - return "/fakeRoot/Files" - }(), - }, - FS: func() *MockFileStore { - mfi := &MockFileInfo{} - mfi.On("Mode").Return(fs.FileMode(0)) - mfi.On("Size").Return(int64(100)) - mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout)) - mfi.On("IsDir").Return(false) - mfi.On("Name").Return("testfile") - - mfs := &MockFileStore{} - mfs.On("Stat", "/fakeRoot/Files/aaa/testfile").Return(mfi, nil) - mfs.On("Stat", "/fakeRoot/Files/aaa/.info_testfile").Return(nil, errors.New("err")) - mfs.On("Stat", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil, errors.New("err")) - - return mfs - }(), - //Accounts: map[string]*Account{}, - }, - }, - t: NewTransaction( - TranDeleteFile, [2]byte{0, 1}, - NewField(FieldFileName, []byte("testfile")), - NewField(FieldFilePath, []byte{ - 0x00, 0x01, - 0x00, 0x00, - 0x03, - 0x61, 0x61, 0x61, - }), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to delete files.")), - }, - }, - }, - }, - { - name: "deletes all associated metadata files", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessDeleteFile) - return bits - }(), - }, - Server: &Server{ - Config: Config{ - FileRoot: func() string { - return "/fakeRoot/Files" - }(), - }, - FS: func() *MockFileStore { - mfi := &MockFileInfo{} - mfi.On("Mode").Return(fs.FileMode(0)) - mfi.On("Size").Return(int64(100)) - mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout)) - mfi.On("IsDir").Return(false) - mfi.On("Name").Return("testfile") - - mfs := &MockFileStore{} - mfs.On("Stat", "/fakeRoot/Files/aaa/testfile").Return(mfi, nil) - mfs.On("Stat", "/fakeRoot/Files/aaa/.info_testfile").Return(nil, errors.New("err")) - mfs.On("Stat", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil, errors.New("err")) - - mfs.On("RemoveAll", "/fakeRoot/Files/aaa/testfile").Return(nil) - mfs.On("Remove", "/fakeRoot/Files/aaa/testfile.incomplete").Return(nil) - mfs.On("Remove", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil) - mfs.On("Remove", "/fakeRoot/Files/aaa/.info_testfile").Return(nil) - - return mfs - }(), - //Accounts: map[string]*Account{}, - }, - }, - t: NewTransaction( - TranDeleteFile, [2]byte{0, 1}, - NewField(FieldFileName, []byte("testfile")), - NewField(FieldFilePath, []byte{ - 0x00, 0x01, - 0x00, 0x00, - 0x03, - 0x61, 0x61, 0x61, - }), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - Fields: []Field(nil), - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleDeleteFile(tt.args.cc, &tt.args.t) - tranAssertEqual(t, tt.wantRes, gotRes) - - tt.args.cc.Server.FS.(*MockFileStore).AssertExpectations(t) - }) - } -} - -func TestHandleGetFileNameList(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "when FieldFilePath is a drop box, but user does not have AccessViewDropBoxes ", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), - }, - Server: &Server{ - - Config: Config{ - FileRoot: func() string { - path, _ := os.Getwd() - return filepath.Join(path, "/test/config/Files/getFileNameListTestDir") - }(), - }, - }, - }, - t: NewTransaction( - TranGetFileNameList, [2]byte{0, 1}, - NewField(FieldFilePath, []byte{ - 0x00, 0x01, - 0x00, 0x00, - 0x08, - 0x64, 0x72, 0x6f, 0x70, 0x20, 0x62, 0x6f, 0x78, // "drop box" - }), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to view drop boxes.")), - }, - }, - }, - }, - { - name: "with file root", - args: args{ - cc: &ClientConn{ - Server: &Server{ - Config: Config{ - FileRoot: func() string { - path, _ := os.Getwd() - return filepath.Join(path, "/test/config/Files/getFileNameListTestDir") - }(), - }, - }, - }, - t: NewTransaction( - TranGetFileNameList, [2]byte{0, 1}, - NewField(FieldFilePath, []byte{ - 0x00, 0x00, - 0x00, 0x00, - }), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - Fields: []Field{ - NewField( - FieldFileNameWithInfo, - func() []byte { - fnwi := FileNameWithInfo{ - fileNameWithInfoHeader: fileNameWithInfoHeader{ - Type: [4]byte{0x54, 0x45, 0x58, 0x54}, - Creator: [4]byte{0x54, 0x54, 0x58, 0x54}, - FileSize: [4]byte{0, 0, 0x04, 0}, - RSVD: [4]byte{}, - NameScript: [2]byte{}, - NameSize: [2]byte{0, 0x0b}, - }, - Name: []byte("testfile-1k"), - } - b, _ := io.ReadAll(&fnwi) - return b - }(), - ), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleGetFileNameList(tt.args.cc, &tt.args.t) - tranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleGetClientInfoText(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "when user does not have required permission", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), - }, - Server: &Server{ - //Accounts: map[string]*Account{}, - }, - }, - t: NewTransaction( - TranGetClientInfoText, [2]byte{0, 1}, - NewField(FieldUserID, []byte{0, 1}), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to get client info.")), - }, - }, - }, - }, - { - name: "with a valid user", - args: args{ - cc: &ClientConn{ - UserName: []byte("Testy McTest"), - RemoteAddr: "1.2.3.4:12345", - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessGetClientInfo) - return bits - }(), - Name: "test", - Login: "test", - }, - Server: &Server{ - 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) - return bits - }(), - Name: "test", - Login: "test", - }, - }, - ) - return &m - }(), - }, - ClientFileTransferMgr: ClientFileTransferMgr{}, - }, - t: NewTransaction( - TranGetClientInfoText, [2]byte{0, 1}, - NewField(FieldUserID, []byte{0, 1}), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - Fields: []Field{ - NewField(FieldData, []byte( - strings.ReplaceAll(`Nickname: Testy McTest -Name: test -Account: test -Address: 1.2.3.4:12345 - --------- File Downloads --------- - -None. - -------- Folder Downloads -------- - -None. - ---------- File Uploads ---------- - -None. - --------- Folder Uploads --------- - -None. - -------- Waiting Downloads ------- - -None. - -`, "\n", "\r")), - ), - NewField(FieldUserName, []byte("Testy McTest")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleGetClientInfoText(tt.args.cc, &tt.args.t) - tranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleTranAgreed(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "normal request flow", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessDisconUser) - bits.Set(AccessAnyName) - return bits - }()}, - Icon: []byte{0, 1}, - Flags: [2]byte{0, 1}, - Version: []byte{0, 1}, - ID: [2]byte{0, 1}, - logger: NewTestLogger(), - Server: &Server{ - 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( - TranAgreed, [2]byte{}, - NewField(FieldUserName, []byte("username")), - NewField(FieldUserIconID, []byte{0, 1}), - NewField(FieldOptions, []byte{0, 0}), - ), - }, - wantRes: []Transaction{ - { - clientID: [2]byte{0, 1}, - Type: [2]byte{0, 0x7a}, - Fields: []Field{ - NewField(FieldBannerType, []byte("JPEG")), - }, - }, - { - clientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []Field{}, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleTranAgreed(tt.args.cc, &tt.args.t) - tranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleSetClientUserInfo(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "when client does not have AccessAnyName", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), - }, - ID: [2]byte{0, 1}, - UserName: []byte("Guest"), - Flags: [2]byte{0, 1}, - Server: &Server{ - ClientMgr: func() *MockClientMgr { - m := MockClientMgr{} - m.On("List").Return([]*ClientConn{ - { - ID: [2]byte{0, 1}, - }, - }) - return &m - }(), - }, - }, - t: NewTransaction( - TranSetClientUserInfo, [2]byte{}, - NewField(FieldUserIconID, []byte{0, 1}), - NewField(FieldUserName, []byte("NOPE")), - ), - }, - wantRes: []Transaction{ - { - clientID: [2]byte{0, 1}, - Type: [2]byte{0x01, 0x2d}, - Fields: []Field{ - NewField(FieldUserID, []byte{0, 1}), - NewField(FieldUserIconID, []byte{0, 1}), - NewField(FieldUserFlags, []byte{0, 1}), - NewField(FieldUserName, []byte("Guest"))}, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleSetClientUserInfo(tt.args.cc, &tt.args.t) - tranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleDelNewsItem(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "when user does not have permission to delete a news category", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: accessBitmap{}, - }, - ID: [2]byte{0, 1}, - Server: &Server{ - ThreadedNewsMgr: func() *mockThreadNewsMgr { - m := mockThreadNewsMgr{} - m.On("NewsItem", []string{"test"}).Return(NewsCategoryListData15{ - Type: NewsCategory, - }) - return &m - }(), - }, - }, - t: NewTransaction( - TranDelNewsItem, [2]byte{}, - NewField(FieldNewsPath, - []byte{ - 0, 1, - 0, 0, - 4, - 0x74, 0x65, 0x73, 0x74, - }, - ), - ), - }, - wantRes: []Transaction{ - { - clientID: [2]byte{0, 1}, - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to delete news categories.")), - }, - }, - }, - }, - { - name: "when user does not have permission to delete a news folder", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: accessBitmap{}, - }, - ID: [2]byte{0, 1}, - Server: &Server{ - ThreadedNewsMgr: func() *mockThreadNewsMgr { - m := mockThreadNewsMgr{} - m.On("NewsItem", []string{"test"}).Return(NewsCategoryListData15{ - Type: NewsBundle, - }) - return &m - }(), - }, - }, - t: NewTransaction( - TranDelNewsItem, [2]byte{}, - NewField(FieldNewsPath, - []byte{ - 0, 1, - 0, 0, - 4, - 0x74, 0x65, 0x73, 0x74, - }, - ), - ), - }, - wantRes: []Transaction{ - { - clientID: [2]byte{0, 1}, - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to delete news folders.")), - }, - }, - }, - }, - { - name: "when user deletes a news folder", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessNewsDeleteFldr) - return bits - }(), - }, - ID: [2]byte{0, 1}, - Server: &Server{ - ThreadedNewsMgr: func() *mockThreadNewsMgr { - m := mockThreadNewsMgr{} - m.On("NewsItem", []string{"test"}).Return(NewsCategoryListData15{Type: NewsBundle}) - m.On("DeleteNewsItem", []string{"test"}).Return(nil) - return &m - }(), - }, - }, - t: NewTransaction( - TranDelNewsItem, [2]byte{}, - NewField(FieldNewsPath, - []byte{ - 0, 1, - 0, 0, - 4, - 0x74, 0x65, 0x73, 0x74, - }, - ), - ), - }, - wantRes: []Transaction{ - { - clientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []Field{}, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleDelNewsItem(tt.args.cc, &tt.args.t) - - tranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleTranOldPostNews(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "when user does not have required permission", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: accessBitmap{}, - }, - }, - t: NewTransaction( - TranOldPostNews, [2]byte{0, 1}, - NewField(FieldData, []byte("hai")), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to post news.")), - }, - }, - }, - }, - { - name: "when user posts news update", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessNewsPostArt) - return bits - }(), - }, - Server: &Server{ - 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 - }(), - }, - }, - t: NewTransaction( - TranOldPostNews, [2]byte{0, 1}, - NewField(FieldData, []byte("hai")), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleTranOldPostNews(tt.args.cc, &tt.args.t) - - tranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleInviteNewChat(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "when user does not have required permission", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), - }, - }, - t: NewTransaction(TranInviteNewChat, [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 request private chat.")), - }, - }, - }, - }, - { - name: "when userA invites userB to new private chat", - args: args{ - cc: &ClientConn{ - ID: [2]byte{0, 1}, - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessOpenChat) - return bits - }(), - }, - UserName: []byte("UserA"), - Icon: []byte{0, 1}, - Flags: [2]byte{0, 0}, - Server: &Server{ - ClientMgr: func() *MockClientMgr { - m := MockClientMgr{} - m.On("Get", ClientID{0x0, 0x2}).Return(&ClientConn{ - ID: [2]byte{0, 2}, - UserName: []byte("UserB"), - }) - return &m - }(), - ChatMgr: func() *MockChatManager { - m := MockChatManager{} - m.On("New", mock.AnythingOfType("*hotline.ClientConn")).Return(ChatID{0x52, 0xfd, 0xfc, 0x07}) - return &m - }(), - }, - }, - t: NewTransaction( - TranInviteNewChat, [2]byte{0, 1}, - NewField(FieldUserID, []byte{0, 2}), - ), - }, - wantRes: []Transaction{ - { - clientID: [2]byte{0, 2}, - Type: [2]byte{0, 0x71}, - Fields: []Field{ - NewField(FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}), - NewField(FieldUserName, []byte("UserA")), - NewField(FieldUserID, []byte{0, 1}), - }, - }, - { - clientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []Field{ - NewField(FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}), - NewField(FieldUserName, []byte("UserA")), - NewField(FieldUserID, []byte{0, 1}), - NewField(FieldUserIconID, []byte{0, 1}), - NewField(FieldUserFlags, []byte{0, 0}), - }, - }, - }, - }, - { - name: "when userA invites userB to new private chat, but UserB has refuse private chat enabled", - args: args{ - cc: &ClientConn{ - ID: [2]byte{0, 1}, - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessOpenChat) - return bits - }(), - }, - UserName: []byte("UserA"), - Icon: []byte{0, 1}, - Flags: [2]byte{0, 0}, - Server: &Server{ - 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}, - }) - return &m - }(), - ChatMgr: func() *MockChatManager { - m := MockChatManager{} - m.On("New", mock.AnythingOfType("*hotline.ClientConn")).Return(ChatID{0x52, 0xfd, 0xfc, 0x07}) - return &m - }(), - }, - }, - t: NewTransaction( - TranInviteNewChat, [2]byte{0, 1}, - NewField(FieldUserID, []byte{0, 2}), - ), - }, - wantRes: []Transaction{ - { - clientID: [2]byte{0, 1}, - Type: [2]byte{0, 0x68}, - Fields: []Field{ - NewField(FieldData, []byte("UserB does not accept private chats.")), - NewField(FieldUserName, []byte("UserB")), - NewField(FieldUserID, []byte{0, 2}), - NewField(FieldOptions, []byte{0, 2}), - }, - }, - { - clientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []Field{ - NewField(FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}), - NewField(FieldUserName, []byte("UserA")), - NewField(FieldUserID, []byte{0, 1}), - NewField(FieldUserIconID, []byte{0, 1}), - NewField(FieldUserFlags, []byte{0, 0}), - }, - }, - }, - }, - } - 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) - }) - } -} - -func TestHandleGetNewsArtData(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - 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{ - 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: 1, - Fields: []Field{ - 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")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleGetNewsArtData(tt.args.cc, &tt.args.t) - tranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleGetNewsArtNameList(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "when user does not have required permission", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), - }, - Server: &Server{ - //Accounts: map[string]*Account{}, - }, - }, - t: NewTransaction( - TranGetNewsArtNameList, [2]byte{0, 1}, - ), - }, - wantRes: []Transaction{ - { - Flags: 0x00, - IsReply: 0x01, - Type: [2]byte{0, 0}, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to read news.")), - }, - }, - }, - }, - //{ - // 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) { - gotRes := HandleGetNewsArtNameList(tt.args.cc, &tt.args.t) - - tranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleNewNewsFldr(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "when user does not have required permission", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), - }, - Server: &Server{ - //Accounts: map[string]*Account{}, - }, - }, - t: NewTransaction( - TranGetNewsArtNameList, [2]byte{0, 1}, - ), - }, - wantRes: []Transaction{ - { - Flags: 0x00, - IsReply: 0x01, - Type: [2]byte{0, 0}, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to create news folders.")), - }, - }, - }, - }, - { - name: "with a valid request", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessNewsCreateFldr) - return bits - }(), - }, - logger: NewTestLogger(), - ID: [2]byte{0, 1}, - Server: &Server{ - ThreadedNewsMgr: func() *mockThreadNewsMgr { - m := mockThreadNewsMgr{} - m.On("CreateGrouping", []string{"test"}, "testFolder", NewsBundle).Return(nil) - return &m - }(), - }, - }, - t: NewTransaction( - TranGetNewsArtNameList, [2]byte{0, 1}, - NewField(FieldFileName, []byte("testFolder")), - NewField(FieldNewsPath, - []byte{ - 0, 1, - 0, 0, - 4, - 0x74, 0x65, 0x73, 0x74, - }, - ), - ), - }, - wantRes: []Transaction{ - { - clientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []Field{}, - }, - }, - }, - //{ - // Name: "when there is an error writing the threaded news file", - // args: args{ - // cc: &ClientConn{ - // Account: &Account{ - // Access: func() accessBitmap { - // var bits accessBitmap - // bits.Set(AccessNewsCreateFldr) - // return bits - // }(), - // }, - // logger: NewTestLogger(), - // Type: [2]byte{0, 1}, - // Server: &Server{ - // ConfigDir: "/fakeConfigRoot", - // FS: func() *MockFileStore { - // mfs := &MockFileStore{} - // mfs.On("WriteFile", "/fakeConfigRoot/ThreadedNews.yaml", mock.Anything, mock.Anything).Return(os.ErrNotExist) - // return mfs - // }(), - // ThreadedNews: &ThreadedNews{Categories: map[string]NewsCategoryListData15{ - // "test": { - // Type: []byte{0, 2}, - // Count: nil, - // NameSize: 0, - // Name: "test", - // SubCats: make(map[string]NewsCategoryListData15), - // }, - // }}, - // }, - // }, - // t: NewTransaction( - // TranGetNewsArtNameList, [2]byte{0, 1}, - // NewField(FieldFileName, []byte("testFolder")), - // NewField(FieldNewsPath, - // []byte{ - // 0, 1, - // 0, 0, - // 4, - // 0x74, 0x65, 0x73, 0x74, - // }, - // ), - // ), - // }, - // wantRes: []Transaction{ - // { - // clientID: [2]byte{0, 1}, - // Flags: 0x00, - // IsReply: 0x01, - // Type: [2]byte{0, 0}, - // ErrorCode: [4]byte{0, 0, 0, 1}, - // Fields: []Field{ - // NewField(FieldError, []byte("Error creating news folder.")), - // }, - // }, - // }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleNewNewsFldr(tt.args.cc, &tt.args.t) - - tranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleDownloadBanner(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - // TODO: Add test cases. - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleDownloadBanner(tt.args.cc, &tt.args.t) - - assert.Equalf(t, tt.wantRes, gotRes, "HandleDownloadBanner(%v, %v)", tt.args.cc, &tt.args.t) - }) - } -} - -func TestHandlePostNewsArt(t *testing.T) { - type args struct { - cc *ClientConn - t Transaction - } - tests := []struct { - name string - args args - wantRes []Transaction - }{ - { - name: "without required permission", - args: args{ - cc: &ClientConn{ - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - return bits - }(), - }, - }, - t: NewTransaction( - TranPostNewsArt, - [2]byte{0, 0}, - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(FieldError, []byte("You are not allowed to post news articles.")), - }, - }, - }, - }, - { - name: "with required permission", - args: args{ - cc: &ClientConn{ - Server: &Server{ - ThreadedNewsMgr: func() *mockThreadNewsMgr { - m := mockThreadNewsMgr{} - m.On("PostArticle", []string{"www"}, uint32(0), mock.AnythingOfType("hotline.NewsArtData")).Return(nil) - return &m - }(), - }, - Account: &Account{ - Access: func() accessBitmap { - var bits accessBitmap - bits.Set(AccessNewsPostArt) - return bits - }(), - }, - }, - t: NewTransaction( - TranPostNewsArt, - [2]byte{0, 0}, - NewField(FieldNewsPath, []byte{0x00, 0x01, 0x00, 0x00, 0x03, 0x77, 0x77, 0x77}), - NewField(FieldNewsArtID, []byte{0x00, 0x00, 0x00, 0x00}), - ), - }, - wantRes: []Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 0}, - Fields: []Field{}, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tranAssertEqual(t, tt.wantRes, HandlePostNewsArt(tt.args.cc, &tt.args.t)) - }) - } -} diff --git a/hotline/transaction_test.go b/hotline/transaction_test.go index e7b535d..97d865d 100644 --- a/hotline/transaction_test.go +++ b/hotline/transaction_test.go @@ -282,7 +282,7 @@ func TestTransaction_Read(t1 *testing.T) { for _, tt := range tests { t1.Run(tt.name, func(t1 *testing.T) { t := &Transaction{ - clientID: tt.fields.clientID, + ClientID: tt.fields.clientID, Flags: tt.fields.Flags, IsReply: tt.fields.IsReply, Type: tt.fields.Type, @@ -362,7 +362,7 @@ func TestTransaction_Write(t1 *testing.T) { Data: []byte("hai"), }, }, - clientID: [2]byte{}, + ClientID: [2]byte{}, readOffset: 0, }, }, @@ -376,7 +376,7 @@ func TestTransaction_Write(t1 *testing.T) { } assert.Equalf(t1, tt.wantN, gotN, "Write(%v)", tt.args.p) - tranAssertEqual(t1, []Transaction{tt.wantTransaction}, []Transaction{*t}) + TranAssertEqual(t1, []Transaction{tt.wantTransaction}, []Transaction{*t}) }) } } diff --git a/hotline/transfer_test.go b/hotline/transfer_test.go index 3b6c446..4aa142b 100644 --- a/hotline/transfer_test.go +++ b/hotline/transfer_test.go @@ -122,16 +122,13 @@ func Test_receiveFile(t *testing.T) { FlatFileHeader: FlatFileHeader{ Format: [4]byte{0x46, 0x49, 0x4c, 0x50}, // "FILP" Version: [2]byte{0, 1}, - RSVD: [16]byte{}, ForkCount: [2]byte{0, 2}, }, FlatFileInformationForkHeader: FlatFileForkHeader{}, FlatFileInformationFork: NewFlatFileInformationFork("testfile.txt", [8]byte{}, "TEXT", "TEXT"), FlatFileDataForkHeader: FlatFileForkHeader{ - ForkType: [4]byte{0x4d, 0x41, 0x43, 0x52}, // DATA - CompressionType: [4]byte{0, 0, 0, 0}, - RSVD: [4]byte{0, 0, 0, 0}, - DataSize: [4]byte{0x00, 0x00, 0x00, 0x03}, + ForkType: [4]byte{0x4d, 0x41, 0x43, 0x52}, // DATA + DataSize: [4]byte{0x00, 0x00, 0x00, 0x03}, }, } fakeFileData := []byte{1, 2, 3} diff --git a/hotline/user.go b/hotline/user.go index f3754d3..c4e0790 100644 --- a/hotline/user.go +++ b/hotline/user.go @@ -84,10 +84,10 @@ func (u *User) Write(p []byte) (int, error) { return 8 + namelen, nil } -// encodeString takes []byte s containing cleartext and rotates by 255 into obfuscated cleartext. +// EncodeString takes []byte s containing cleartext and rotates by 255 into obfuscated cleartext. // The Hotline protocol uses this format for sending passwords over network. // Not secure, but hey, it was the 90s! -func encodeString(clearText []byte) []byte { +func EncodeString(clearText []byte) []byte { obfuText := make([]byte, len(clearText)) for i := 0; i < len(clearText); i++ { obfuText[i] = 255 - clearText[i] diff --git a/hotline/user_test.go b/hotline/user_test.go index 90c59f7..2429009 100644 --- a/hotline/user_test.go +++ b/hotline/user_test.go @@ -83,7 +83,7 @@ func TestNegatedUserString(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := encodeString(tt.args.encodedString); !bytes.Equal(got, tt.want) { + if got := EncodeString(tt.args.encodedString); !bytes.Equal(got, tt.want) { t.Errorf("NegatedUserString() = %x, want %x", got, tt.want) } }) |