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 /internal | |
| 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 'internal')
25 files changed, 6396 insertions, 38 deletions
diff --git a/internal/mobius/account_manager.go b/internal/mobius/account_manager.go new file mode 100644 index 0000000..cba33b2 --- /dev/null +++ b/internal/mobius/account_manager.go @@ -0,0 +1,193 @@ +package mobius + +import ( + "fmt" + "github.com/jhalter/mobius/hotline" + "github.com/stretchr/testify/mock" + "gopkg.in/yaml.v3" + "os" + "path" + "path/filepath" + "sync" +) + +// 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) +} + +type YAMLAccountManager struct { + accounts map[string]hotline.Account + accountDir string + + mu sync.Mutex +} + +func NewYAMLAccountManager(accountDir string) (*YAMLAccountManager, error) { + accountMgr := YAMLAccountManager{ + accountDir: accountDir, + accounts: make(map[string]hotline.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 hotline.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 hotline.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("create 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 hotline.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) *hotline.Account { + am.mu.Lock() + defer am.mu.Unlock() + + account, ok := am.accounts[login] + if !ok { + return nil + } + + return &account +} + +func (am *YAMLAccountManager) List() []hotline.Account { + am.mu.Lock() + defer am.mu.Unlock() + + var accounts []hotline.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 hotline.Account) error { + args := m.Called(account) + + return args.Error(0) +} + +func (m *MockAccountManager) Update(account hotline.Account, newLogin string) error { + args := m.Called(account, newLogin) + + return args.Error(0) +} + +func (m *MockAccountManager) Get(login string) *hotline.Account { + args := m.Called(login) + + return args.Get(0).(*hotline.Account) +} + +func (m *MockAccountManager) List() []hotline.Account { + args := m.Called() + + return args.Get(0).([]hotline.Account) +} + +func (m *MockAccountManager) Delete(login string) error { + args := m.Called(login) + + return args.Error(0) +} diff --git a/internal/mobius/agreement.go b/internal/mobius/agreement.go new file mode 100644 index 0000000..c2a67c5 --- /dev/null +++ b/internal/mobius/agreement.go @@ -0,0 +1,78 @@ +package mobius + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" +) + +const agreementFile = "Agreement.txt" + +type Agreement struct { + data []byte + filePath string + lineEndings string + + mu sync.RWMutex + readOffset int // Internal offset to track read progress +} + +func NewAgreement(path, lineEndings string) (*Agreement, error) { + data, err := os.ReadFile(filepath.Join(path, agreementFile)) + if err != nil { + return &Agreement{}, fmt.Errorf("read file: %w", err) + } + + // Swap line breaks + agreement := strings.ReplaceAll(string(data), "\n", lineEndings) + agreement = strings.ReplaceAll(agreement, "\r\n", lineEndings) + + return &Agreement{ + data: []byte(agreement), + filePath: filepath.Join(path, agreementFile), + lineEndings: lineEndings, + }, nil +} + +func (a *Agreement) Reload() error { + a.mu.Lock() + defer a.mu.Unlock() + + data, err := os.ReadFile(a.filePath) + if err != nil { + return fmt.Errorf("read file: %w", err) + } + + // Swap line breaks + agreement := strings.ReplaceAll(string(data), "\n", a.lineEndings) + agreement = strings.ReplaceAll(agreement, "\r\n", a.lineEndings) + + a.data = []byte(agreement) + + return nil +} + +// It returns the number of bytes read and any error encountered. +func (a *Agreement) Read(p []byte) (int, error) { + a.mu.Lock() + defer a.mu.Unlock() + + if a.readOffset >= len(a.data) { + return 0, io.EOF // All bytes have been read + } + + n := copy(p, a.data[a.readOffset:]) + + a.readOffset += n + + return n, nil +} + +func (a *Agreement) Seek(offset int64, _ int) (int64, error) { + a.readOffset = int(offset) + + return 0, nil +} diff --git a/internal/mobius/ban.go b/internal/mobius/ban.go index f78e3f3..e73b14a 100644 --- a/internal/mobius/ban.go +++ b/internal/mobius/ban.go @@ -1,6 +1,7 @@ package mobius import ( + "fmt" "gopkg.in/yaml.v3" "os" "path/filepath" @@ -22,8 +23,11 @@ func NewBanFile(path string) (*BanFile, error) { } err := bf.Load() + if err != nil { + return nil, fmt.Errorf("load ban file: %w", err) + } - return bf, err + return bf, nil } func (bf *BanFile) Load() error { @@ -33,18 +37,17 @@ func (bf *BanFile) Load() error { bf.banList = make(map[string]*time.Time) fh, err := os.Open(bf.filePath) + if os.IsNotExist(err) { + return nil + } if err != nil { - if os.IsNotExist(err) { - return nil - } - return err + return fmt.Errorf("open file: %v", err) } defer fh.Close() - decoder := yaml.NewDecoder(fh) - err = decoder.Decode(&bf.banList) + err = yaml.NewDecoder(fh).Decode(&bf.banList) if err != nil { - return err + return fmt.Errorf("decode yaml: %v", err) } return nil @@ -58,10 +61,15 @@ func (bf *BanFile) Add(ip string, until *time.Time) error { out, err := yaml.Marshal(bf.banList) if err != nil { - return err + return fmt.Errorf("marshal yaml: %v", err) } - return os.WriteFile(filepath.Join(bf.filePath), out, 0644) + err = os.WriteFile(filepath.Join(bf.filePath), out, 0644) + if err != nil { + return fmt.Errorf("write file: %v", err) + } + + return nil } func (bf *BanFile) IsBanned(ip string) (bool, *time.Time) { diff --git a/internal/mobius/ban_test.go b/internal/mobius/ban_test.go new file mode 100644 index 0000000..46860c6 --- /dev/null +++ b/internal/mobius/ban_test.go @@ -0,0 +1,154 @@ +package mobius + +import ( + "fmt" + "github.com/stretchr/testify/assert" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +func TestNewBanFile(t *testing.T) { + cwd, _ := os.Getwd() + str := "2024-06-29T11:34:43.245899-07:00" + testTime, _ := time.Parse(time.RFC3339Nano, str) + + type args struct { + path string + } + tests := []struct { + name string + args args + want *BanFile + wantErr assert.ErrorAssertionFunc + }{ + { + name: "Valid path with valid content", + args: args{path: filepath.Join(cwd, "test", "config", "Banlist.yaml")}, + want: &BanFile{ + filePath: filepath.Join(cwd, "test", "config", "Banlist.yaml"), + banList: map[string]*time.Time{"192.168.86.29": &testTime}, + }, + wantErr: assert.NoError, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewBanFile(tt.args.path) + if !tt.wantErr(t, err, fmt.Sprintf("NewBanFile(%v)", tt.args.path)) { + return + } + assert.Equalf(t, tt.want, got, "NewBanFile(%v)", tt.args.path) + }) + } +} + +// TestAdd tests the Add function. +func TestAdd(t *testing.T) { + // Create a temporary directory. + tmpDir, err := os.MkdirTemp("", "banfile_test") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tmpDir) // Clean up the temporary directory. + + // Path to the temporary ban file. + tmpFilePath := filepath.Join(tmpDir, "banfile.yaml") + + // Initialize BanFile. + bf := &BanFile{ + filePath: tmpFilePath, + banList: make(map[string]*time.Time), + } + + // Define the test cases. + tests := []struct { + name string + ip string + until *time.Time + expect map[string]*time.Time + }{ + { + name: "Add IP with no expiration", + ip: "192.168.1.1", + until: nil, + expect: map[string]*time.Time{ + "192.168.1.1": nil, + }, + }, + { + name: "Add IP with expiration", + ip: "192.168.1.2", + until: func() *time.Time { t := time.Date(2024, 6, 29, 11, 34, 43, 245899000, time.UTC); return &t }(), + expect: map[string]*time.Time{ + "192.168.1.1": nil, + "192.168.1.2": func() *time.Time { t := time.Date(2024, 6, 29, 11, 34, 43, 245899000, time.UTC); return &t }(), + }, + }, + } + + // Run the test cases. + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := bf.Add(tt.ip, tt.until) + assert.NoError(t, err, "Add() error") + + // Load the file to check its contents. + loadedBanFile := &BanFile{filePath: tmpFilePath} + err = loadedBanFile.Load() + assert.NoError(t, err, "Load() error") + assert.Equal(t, tt.expect, loadedBanFile.banList, "Ban list does not match") + }) + } +} + +func TestBanFile_IsBanned(t *testing.T) { + type fields struct { + banList map[string]*time.Time + Mutex sync.Mutex + } + type args struct { + ip string + } + tests := []struct { + name string + fields fields + args args + want bool + want1 *time.Time + }{ + { + name: "with permanent ban", + fields: fields{ + banList: map[string]*time.Time{ + "192.168.86.1": nil, + }, + }, + args: args{ip: "192.168.86.1"}, + want: true, + want1: nil, + }, + { + name: "with no ban", + fields: fields{ + banList: map[string]*time.Time{}, + }, + args: args{ip: "192.168.86.1"}, + want: false, + want1: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bf := &BanFile{ + banList: tt.fields.banList, + Mutex: sync.Mutex{}, + } + got, got1 := bf.IsBanned(tt.args.ip) + assert.Equalf(t, tt.want, got, "IsBanned(%v)", tt.args.ip) + assert.Equalf(t, tt.want1, got1, "IsBanned(%v)", tt.args.ip) + }) + } +} diff --git a/internal/mobius/config.go b/internal/mobius/config.go index e985dd7..d04b14d 100644 --- a/internal/mobius/config.go +++ b/internal/mobius/config.go @@ -1,29 +1,40 @@ package mobius import ( + "fmt" "github.com/go-playground/validator/v10" "github.com/jhalter/mobius/hotline" "gopkg.in/yaml.v3" - "log" "os" + "path/filepath" ) +var ConfigSearchOrder = []string{ + "config", + "/usr/local/var/mobius/config", + "/opt/homebrew/var/mobius/config", +} + func LoadConfig(path string) (*hotline.Config, error) { var config hotline.Config yamlFile, err := os.ReadFile(path) if err != nil { - return nil, err + return nil, fmt.Errorf("read file: %v", err) } - err = yaml.Unmarshal(yamlFile, &config) - if err != nil { - log.Fatalf("Unmarshal: %v", err) + + if err := yaml.Unmarshal(yamlFile, &config); err != nil { + return nil, fmt.Errorf("unmarshal YAML: %v", err) } validate := validator.New() - err = validate.Struct(config) - if err != nil { - return nil, err + if err = validate.Struct(config); err != nil { + return nil, fmt.Errorf("validate config: %v", err) + } + + // If the FileRoot is an absolute path, use it, otherwise treat as a relative path to the config dir. + if !filepath.IsAbs(config.FileRoot) { + config.FileRoot = filepath.Join(path, "../", config.FileRoot) } return &config, nil diff --git a/internal/mobius/news.go b/internal/mobius/news.go index 51e212d..13a728a 100644 --- a/internal/mobius/news.go +++ b/internal/mobius/news.go @@ -5,28 +5,25 @@ import ( "io" "os" "slices" + "strings" "sync" ) type FlatNews struct { - mu sync.Mutex - data []byte filePath string + mu sync.Mutex readOffset int // Internal offset to track read progress } func NewFlatNews(path string) (*FlatNews, error) { - data, err := os.ReadFile(path) - if err != nil { - return &FlatNews{}, err + flatNews := &FlatNews{filePath: path} + if err := flatNews.Reload(); err != nil { + return nil, fmt.Errorf("reload: %w", err) } - return &FlatNews{ - data: data, - filePath: path, - }, nil + return flatNews, nil } func (f *FlatNews) Reload() error { @@ -37,7 +34,12 @@ func (f *FlatNews) Reload() error { if err != nil { return err } - f.data = data + + // Swap line breaks + agreement := strings.ReplaceAll(string(data), "\n", "\r") + agreement = strings.ReplaceAll(agreement, "\r\n", "\r") + + f.data = []byte(agreement) return nil } diff --git a/internal/mobius/test/config/Agreement.txt b/internal/mobius/test/config/Agreement.txt new file mode 100644 index 0000000..2a3bdb7 --- /dev/null +++ b/internal/mobius/test/config/Agreement.txt @@ -0,0 +1 @@ +This is a server agreement. Say you agree.
\ No newline at end of file diff --git a/internal/mobius/test/config/Banlist.yaml b/internal/mobius/test/config/Banlist.yaml new file mode 100644 index 0000000..cf3fd5e --- /dev/null +++ b/internal/mobius/test/config/Banlist.yaml @@ -0,0 +1 @@ +192.168.86.29: 2024-06-29T11:34:43.245899-07:00
\ No newline at end of file diff --git a/internal/mobius/test/config/Files/getFileNameListTestDir/testfile-1k b/internal/mobius/test/config/Files/getFileNameListTestDir/testfile-1k Binary files differnew file mode 100644 index 0000000..31758a0 --- /dev/null +++ b/internal/mobius/test/config/Files/getFileNameListTestDir/testfile-1k diff --git a/internal/mobius/test/config/Files/test/testfile-1k b/internal/mobius/test/config/Files/test/testfile-1k Binary files differnew file mode 100644 index 0000000..31758a0 --- /dev/null +++ b/internal/mobius/test/config/Files/test/testfile-1k diff --git a/internal/mobius/test/config/Files/test/testfile-5k b/internal/mobius/test/config/Files/test/testfile-5k Binary files differnew file mode 100644 index 0000000..c889187 --- /dev/null +++ b/internal/mobius/test/config/Files/test/testfile-5k diff --git a/internal/mobius/test/config/Files/testdir/some-nested-file.txt b/internal/mobius/test/config/Files/testdir/some-nested-file.txt new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/internal/mobius/test/config/Files/testdir/some-nested-file.txt diff --git a/internal/mobius/test/config/Files/testfile-1k b/internal/mobius/test/config/Files/testfile-1k Binary files differnew file mode 100644 index 0000000..31758a0 --- /dev/null +++ b/internal/mobius/test/config/Files/testfile-1k diff --git a/internal/mobius/test/config/Files/testfile-8b b/internal/mobius/test/config/Files/testfile-8b new file mode 100644 index 0000000..ecb617f --- /dev/null +++ b/internal/mobius/test/config/Files/testfile-8b @@ -0,0 +1 @@ +|9à¼dâÍÞ
\ No newline at end of file diff --git a/internal/mobius/test/config/Files/testfile.sit b/internal/mobius/test/config/Files/testfile.sit new file mode 100644 index 0000000..8d1d542 --- /dev/null +++ b/internal/mobius/test/config/Files/testfile.sit @@ -0,0 +1 @@ +nothing to see here
\ No newline at end of file diff --git a/internal/mobius/test/config/Files/testfile.txt b/internal/mobius/test/config/Files/testfile.txt new file mode 100644 index 0000000..f0607d4 --- /dev/null +++ b/internal/mobius/test/config/Files/testfile.txt @@ -0,0 +1 @@ +Hello, I'm a test file!
\ No newline at end of file diff --git a/internal/mobius/test/config/MessageBoard.txt b/internal/mobius/test/config/MessageBoard.txt new file mode 100644 index 0000000..1a2f57a --- /dev/null +++ b/internal/mobius/test/config/MessageBoard.txt @@ -0,0 +1 @@ +From test (Dec31 15:55):
Test News Post
__________________________________________________________
From test (Dec31 15:54):
Test News Post
__________________________________________________________
From test (Dec31 15:53):
Test News Post
__________________________________________________________
From test (Dec31 15:52):
Test News Post
__________________________________________________________
From test (Dec31 15:50):
Test News Post
__________________________________________________________
From test (Dec31 15:50):
Test News Post
__________________________________________________________
From test (Dec31 15:50):
Test News Post
__________________________________________________________
From test (Dec31 15:49):
Test News Post
__________________________________________________________
From test (Dec31 15:47):
Test News Post
__________________________________________________________
From test (Dec31 15:47):
Test News Post
__________________________________________________________
From test (Dec31 15:47):
Test News Post
__________________________________________________________
From test (Dec31 15:44):
Test News Post
__________________________________________________________
From test (Dec31 15:44):
Test News Post
__________________________________________________________
From test (Dec31 15:43):
Test News Post
__________________________________________________________
From test (Dec31 15:43):
Test News Post
__________________________________________________________
From test (Dec31 15:29):
Test News Post
__________________________________________________________
From test (Dec31 15:23):
Test News Post
__________________________________________________________
From test (Dec31 15:18):
Test News Post
__________________________________________________________
From test (Dec31 15:13):
Test News Post
__________________________________________________________
From test (Dec31 14:23):
Test News Post
__________________________________________________________
From test (Dec31 14:21):
Test News Post
__________________________________________________________
From test (Dec31 14:20):
Test News Post
__________________________________________________________
From test (Dec31 14:20):
Test News Post
__________________________________________________________
From test (Dec31 14:19):
Test News Post
__________________________________________________________
From test (Dec31 14:18):
Test News Post
__________________________________________________________
From test (Dec31 14:14):
Test News Post
__________________________________________________________
From test (Dec31 14:14):
Test News Post
__________________________________________________________
From test (Dec31 14:13):
Test News Post
__________________________________________________________
From test (Dec31 14:13):
Test News Post
__________________________________________________________
From test (Dec31 14:12):
Test News Post
__________________________________________________________
From test (Dec31 14:10):
Test News Post
__________________________________________________________
From test (Dec31 14:10):
Test News Post
__________________________________________________________
From test (Dec31 14:10):
Test News Post
__________________________________________________________
From test (Dec31 14:9):
Test News Post
__________________________________________________________
From test (Dec31 14:9):
Test News Post
__________________________________________________________
From test (Dec31 14:9):
Test News Post
__________________________________________________________
From test (Dec31 14:2):
Test News Post
__________________________________________________________
From test (Dec31 14:1):
Test News Post
__________________________________________________________
From test (Dec31 14:1):
Test News Post
__________________________________________________________
From test (Dec31 13:59):
Test News Post
__________________________________________________________
From test (Dec31 13:13):
Test News Post
__________________________________________________________
From test (Dec31 10:58):
Test News Post
__________________________________________________________
From test (Dec08 14:39):
Test News Post
__________________________________________________________
From test (Dec08 9:52):
Test News Post
__________________________________________________________
From test (Dec08 7:59):
Test News Post
__________________________________________________________
From test (Dec08 7:59):
Test News Post
__________________________________________________________
From test (Dec07 11:44):
Test News Post
__________________________________________________________
From test (Dec07 11:44):
Test News Post
__________________________________________________________
From test (Dec07 11:44):
Test News Post
__________________________________________________________
From test (Dec07 11:43):
Test News Post
__________________________________________________________
From test (Dec07 11:30):
Test News Post
__________________________________________________________
From test (Dec07 11:29):
Test News Post
__________________________________________________________
From test (Dec07 11:29):
Test News Post
__________________________________________________________
From test (Dec07 10:13):
Test News Post
__________________________________________________________
From test (Dec07 10:13):
Test News Post
__________________________________________________________
From test (Dec07 10:12):
Test News Post
__________________________________________________________
From test (Dec07 10:11):
Test News Post
__________________________________________________________
From test (Dec07 9:19):
Test News Post
__________________________________________________________
From test (Dec05 17:9):
Test News Post
__________________________________________________________
From test (Dec03 10:58):
Test News Post
__________________________________________________________
From test (Dec02 17:19):
Test News Post
__________________________________________________________
From test (Dec02 17:18):
Test News Post
__________________________________________________________
From test (Dec02 15:38):
Test News Post
__________________________________________________________
From test (Dec02 15:38):
Test News Post
__________________________________________________________
From test (Dec02 15:34):
Test News Post
__________________________________________________________
From test (Dec02 15:27):
Test News Post
__________________________________________________________
From test (Dec02 15:27):
Test News Post
__________________________________________________________
From test (Dec02 15:18):
Test News Post
__________________________________________________________
From test (Dec02 15:17):
Test News Post
__________________________________________________________
From test (Dec02 15:16):
Test News Post
__________________________________________________________
From test (Dec02 14:56):
Test News Post
__________________________________________________________
From test (Dec02 14:55):
Test News Post
__________________________________________________________
From test (Dec02 14:55):
Test News Post
__________________________________________________________
From test (Dec02 14:55):
Test News Post
__________________________________________________________
From test (Dec02 14:54):
Test News Post
__________________________________________________________
From test (Dec02 14:54):
Test News Post
__________________________________________________________
From test (Dec02 14:53):
Test News Post
__________________________________________________________
From test (Dec02 14:50):
Test News Post
__________________________________________________________
From test (Dec02 14:49):
Test News Post
__________________________________________________________
From test (Dec02 14:49):
Test News Post
__________________________________________________________
From test (Dec02 14:47):
Test News Post
__________________________________________________________
From test (Dec02 14:34):
Test News Post
__________________________________________________________
From test (Dec02 14:34):
Test News Post
__________________________________________________________
From test (Dec02 14:26):
Test News Post
__________________________________________________________
From test (Dec02 14:23):
Test News Post
__________________________________________________________
From test (Dec02 14:22):
Test News Post
__________________________________________________________
From test (Dec02 14:21):
Test News Post
__________________________________________________________
From test (Dec02 14:17):
Test News Post
__________________________________________________________
From test (Dec02 14:15):
Test News Post
__________________________________________________________
From test (Dec02 14:14):
Test News Post
__________________________________________________________
From test (Dec02 14:13):
Test News Post
__________________________________________________________
From test (Dec02 14:13):
Test News Post
__________________________________________________________
From test (Dec02 14:13):
Test News Post
__________________________________________________________
From test (Dec02 14:13):
Test News Post
__________________________________________________________
From test (Dec02 14:13):
Test News Post
__________________________________________________________
From test (Dec02 14:12):
Test News Post
__________________________________________________________
From test (Dec02 14:12):
Test News Post
__________________________________________________________
From test (Dec01 13:58):
Test News Post
__________________________________________________________
From test (Dec01 13:54):
Test News Post
__________________________________________________________
From test (Dec01 13:34):
Test News Post
__________________________________________________________
From test (Dec01 12:26):
Test News Post
__________________________________________________________
From test (Dec01 12:26):
Test News Post
__________________________________________________________
From test (Dec01 12:26):
Test News Post
__________________________________________________________
From test (Dec01 12:26):
Test News Post
__________________________________________________________
From test (Dec01 12:16):
Test News Post
__________________________________________________________
From test (Dec01 12:16):
Test News Post
__________________________________________________________
From test (Dec01 12:16):
Test News Post
__________________________________________________________
From test (Dec01 12:16):
Test News Post
__________________________________________________________
From test (Dec01 12:16):
Test News Post
__________________________________________________________
From test (Dec01 12:16):
Test News Post
__________________________________________________________
From test (Dec01 12:16):
Test News Post
__________________________________________________________
From test (Dec01 12:16):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:15):
Test News Post
__________________________________________________________
From test (Dec01 12:14):
Test News Post
__________________________________________________________
From test (Dec01 12:14):
Test News Post
__________________________________________________________
From test (Dec01 12:14):
Test News Post
__________________________________________________________
From test (Dec01 12:14):
Test News Post
__________________________________________________________
From test (Dec01 12:14):
Test News Post
__________________________________________________________
From test (Dec01 12:14):
Test News Post
__________________________________________________________
From test (Dec01 12:14):
Test News Post
__________________________________________________________
From test (Dec01 12:14):
Test News Post
__________________________________________________________
From test (Dec01 12:11):
Test News Post
__________________________________________________________
From test (Dec01 12:11):
Test News Post
__________________________________________________________
From test (Dec01 12:11):
Test News Post
__________________________________________________________
From test (Dec01 12:11):
Test News Post
__________________________________________________________
From test (Dec01 12:11):
Test News Post
__________________________________________________________
From test (Dec01 12:11):
Test News Post
__________________________________________________________
From test (Dec01 12:11):
Test News Post
__________________________________________________________
From test (Dec01 12:3):
Test News Post
__________________________________________________________
From test (Dec01 12:3):
Test News Post
__________________________________________________________
From test (Dec01 12:3):
Test News Post
__________________________________________________________
From test (Dec01 11:55):
Test News Post
__________________________________________________________
From test (Dec01 11:55):
Test News Post
__________________________________________________________
From test (Dec01 11:54):
Test News Post
__________________________________________________________
From test (Dec01 11:53):
Test News Post
__________________________________________________________
From test (Dec01 11:53):
Test News Post
__________________________________________________________
From test (Dec01 11:49):
Test News Post
__________________________________________________________
From test (Dec01 11:49):
Test News Post
__________________________________________________________
From test (Dec01 11:49):
Test News Post
__________________________________________________________
From test (Dec01 11:47):
Test News Post
__________________________________________________________
From test (Dec01 11:47):
Test News Post
__________________________________________________________
From test (Dec01 11:46):
Test News Post
__________________________________________________________
From test (Dec01 11:46):
Test News Post
__________________________________________________________
From test (Dec01 11:45):
Test News Post
__________________________________________________________
From test (Dec01 11:45):
Test News Post
__________________________________________________________
From test (Dec01 11:44):
Test News Post
__________________________________________________________
From test (Dec01 11:20):
Test News Post
__________________________________________________________
From test (Dec01 11:18):
Test News Post
__________________________________________________________
From test (Dec01 11:14):
Test News Post
__________________________________________________________
From test (Dec01 10:54):
Test News Post
__________________________________________________________
From test (Dec01 10:48):
Test News Post
__________________________________________________________
From test (Dec01 10:48):
Test News Post
__________________________________________________________
From test (Dec01 10:48):
Test News Post
__________________________________________________________
From test (Dec01 10:45):
Test News Post
__________________________________________________________
From test (Dec01 10:31):
Test News Post
__________________________________________________________
From test (Dec01 10:30):
Test News Post
__________________________________________________________
From test (Dec01 10:29):
Test News Post
__________________________________________________________
From test (Dec01 10:18):
Test News Post
__________________________________________________________
From test (Dec01 10:18):
Test News Post
__________________________________________________________
From test (Dec01 10:18):
Test News Post
__________________________________________________________
From test (Dec01 10:18):
Test News Post
__________________________________________________________
From test (Dec01 10:18):
Test News Post
__________________________________________________________
From test (Dec01 10:18):
Test News Post
__________________________________________________________
From test (Dec01 10:15):
Test News Post
__________________________________________________________
From test (Dec01 10:15):
Test News Post
__________________________________________________________
From test (Dec01 10:15):
Test News Post
__________________________________________________________
From test (Dec01 10:15):
Test News Post
__________________________________________________________
From test (Dec01 10:13):
Test News Post
__________________________________________________________
From test (Nov30 14:29):
Test News Post
__________________________________________________________
From test (Nov30 14:29):
Test News Post
__________________________________________________________
From test (Nov30 14:29):
Test News Post
__________________________________________________________
From test (Nov30 14:29):
Test News Post
__________________________________________________________
From test (Nov30 14:29):
Test News Post
__________________________________________________________
From test (Nov30 14:29):
Test News Post
__________________________________________________________
From test (Nov30 14:29):
Test News Post
__________________________________________________________
From test (Nov30 14:28):
Test News Post
__________________________________________________________
From test (Nov30 14:19):
Test News Post
__________________________________________________________
From test (Nov30 14:19):
Test News Post
__________________________________________________________
From test (Nov30 14:19):
Test News Post
__________________________________________________________
From test (Nov30 14:19):
Test News Post
__________________________________________________________
From (Nov30 11:42):
Test News Post
__________________________________________________________
From test (Nov30 11:22):
Test News Post
__________________________________________________________
From test (Nov30 11:22):
Test News Post
__________________________________________________________
From test (Nov30 11:21):
Test News Post
__________________________________________________________
From test (Nov30 11:18):
Test News Post
__________________________________________________________
From test (Nov30 11:18):
Test News Post
__________________________________________________________
From test (Nov30 11:18):
Test News Post
__________________________________________________________
From test (Nov30 11:18):
Test News Post
__________________________________________________________
From test (Nov30 11:17):
Test News Post
__________________________________________________________
From test (Nov30 11:15):
Test News Post
__________________________________________________________
From test (Nov30 11:13):
Test News Post
__________________________________________________________
From test (Nov30 11:11):
Test News Post
__________________________________________________________
From test (Nov30 11:11):
Test News Post
__________________________________________________________
From test (Nov30 11:10):
Test News Post
__________________________________________________________
From test (Nov30 11:8):
Test News Post
__________________________________________________________
From test (Nov30 11:5):
Test News Post
__________________________________________________________
From test (Nov30 11:2):
Test News Post
__________________________________________________________
From test (Nov30 11:2):
Test News Post
__________________________________________________________
From test (Nov30 11:1):
Test News Post
__________________________________________________________
From test (Nov30 11:1):
Test News Post
__________________________________________________________
From test (Nov30 11:1):
Test News Post
__________________________________________________________
From test (Nov30 11:1):
Test News Post
__________________________________________________________
From test (Nov30 11:1):
Test News Post
__________________________________________________________
From test (Nov30 11:1):
Test News Post
__________________________________________________________
From test (Nov30 11:0):
Test News Post
__________________________________________________________
From test (Nov30 10:49):
Test News Post
__________________________________________________________
From test (Nov30 10:49):
Test News Post
__________________________________________________________
From test (Nov30 10:49):
Test News Post
__________________________________________________________
From test (Nov30 10:49):
Test News Post
__________________________________________________________
From test (Nov30 10:49):
Test News Post
__________________________________________________________
From test (Nov30 10:48):
Test News Post
__________________________________________________________
From test (Nov30 10:48):
Test News Post
__________________________________________________________
From test (Nov30 10:48):
Test News Post
__________________________________________________________
From test (Nov30 10:48):
Test News Post
__________________________________________________________
From test (Nov30 10:48):
Test News Post
__________________________________________________________
From test (Nov30 10:48):
Test News Post
__________________________________________________________
From test (Nov30 10:48):
Test News Post
__________________________________________________________
From test (Nov30 10:48):
Test News Post
__________________________________________________________
From test (Nov30 10:48):
Test News Post
__________________________________________________________
From test (Nov30 10:48):
Test News Post
__________________________________________________________
From test (Nov30 10:45):
Test News Post
__________________________________________________________
From test (Nov30 10:44):
Test News Post
__________________________________________________________
From test (Nov30 10:38):
Test News Post
__________________________________________________________
From test (Nov30 10:38):
Test News Post
__________________________________________________________
From test (Nov30 10:38):
Test News Post
__________________________________________________________
From test (Nov30 10:38):
Test News Post
__________________________________________________________
From test (Nov30 10:38):
Test News Post
__________________________________________________________
From test (Nov30 10:38):
Test News Post
__________________________________________________________
From test (Nov30 10:38):
Test News Post
__________________________________________________________
From test (Nov30 10:38):
Test News Post
__________________________________________________________
From test (Nov30 10:38):
Test News Post
__________________________________________________________
From test (Nov30 10:38):
Test News Post
__________________________________________________________
From test (Nov30 10:38):
Test News Post
__________________________________________________________
From test (Nov30 10:38):
Test News Post
__________________________________________________________
From test (Nov30 10:38):
Test News Post
__________________________________________________________
From test (Nov30 10:37):
Test News Post
__________________________________________________________
From test (Nov30 10:37):
Test News Post
__________________________________________________________
From test (Nov30 10:37):
Test News Post
__________________________________________________________
From test (Nov30 10:37):
Test News Post
__________________________________________________________
From test (Nov30 10:37):
Test News Post
__________________________________________________________
From test (Nov30 10:37):
Test News Post
__________________________________________________________
From test (Nov30 10:37):
Test News Post
__________________________________________________________
From test (Nov30 10:37):
Test News Post
__________________________________________________________
From test (Nov30 10:37):
Test News Post
__________________________________________________________
From test (Nov30 10:37):
Test News Post
__________________________________________________________
From test (Nov30 10:37):
Test News Post
__________________________________________________________
From test (Nov30 10:37):
Test News Post
__________________________________________________________
From test (Nov30 10:37):
Test News Post
__________________________________________________________
From test (Nov30 10:37):
Test News Post
__________________________________________________________
From test (Nov30 10:37):
Test News Post
__________________________________________________________
From test (Nov30 10:37):
Test News Post
__________________________________________________________
From test (Nov30 10:37):
Test News Post
__________________________________________________________
From test (Nov30 10:37):
Test News Post
__________________________________________________________
From test (Nov30 10:37):
Test News Post
__________________________________________________________
From test (Nov30 10:37):
Test News Post
__________________________________________________________
From test (Nov30 10:37):
Test News Post
__________________________________________________________
From test (Nov30 10:36):
Test News Post
__________________________________________________________
From test (Nov30 10:36):
Test News Post
__________________________________________________________
From test (Nov30 10:36):
Test News Post
__________________________________________________________
From test (Nov30 10:36):
Test News Post
__________________________________________________________
From test (Nov30 10:36):
Test News Post
__________________________________________________________
From test (Nov30 10:36):
Test News Post
__________________________________________________________
From test (Nov30 10:36):
Test News Post
__________________________________________________________
From test (Nov30 10:36):
Test News Post
__________________________________________________________
From test (Nov30 10:36):
Test News Post
__________________________________________________________
From test (Nov30 10:34):
Test News Post
__________________________________________________________
From test (Nov30 10:34):
Test News Post
__________________________________________________________
From test (Nov30 10:34):
Test News Post
__________________________________________________________
From test (Nov30 10:34):
Test News Post
__________________________________________________________
From test (Nov30 10:34):
Test News Post
__________________________________________________________
From test (Nov30 10:34):
Test News Post
__________________________________________________________
From test (Nov30 10:34):
Test News Post
__________________________________________________________
From test (Nov30 10:34):
Test News Post
__________________________________________________________
From test (Nov30 10:33):
Test News Post
__________________________________________________________
From test (Nov30 10:33):
Test News Post
__________________________________________________________
From test (Nov30 10:33):
Test News Post
__________________________________________________________
From test (Nov30 10:33):
Test News Post
__________________________________________________________
From test (Nov30 10:31):
Test News Post
__________________________________________________________
From test (Nov30 10:29):
Test News Post
__________________________________________________________
From test (Nov30 10:25):
Test News Post
__________________________________________________________
From test (Nov30 10:25):
Test News Post
__________________________________________________________
From test (Nov30 10:23):
Test News Post
__________________________________________________________
From test (Nov30 10:23):
Test News Post
__________________________________________________________
From test (Nov30 10:22):
Test News Post
__________________________________________________________
From test (Nov30 10:21):
Test News Post
__________________________________________________________
From test (Nov30 10:20):
Test News Post
__________________________________________________________
From test (Nov30 10:19):
Test News Post
__________________________________________________________
From test (Nov30 10:19):
Test News Post
__________________________________________________________
From test (Nov30 10:19):
Test News Post
__________________________________________________________
From test (Nov30 10:12):
Test News Post
__________________________________________________________
From test (Nov30 9:59):
Test News Post
__________________________________________________________
From test (Nov30 9:58):
Test News Post
__________________________________________________________
From test (Nov30 9:58):
Test News Post
__________________________________________________________
From test (Nov30 9:58):
Test News Post
__________________________________________________________
From test (Nov30 9:58):
Test News Post
__________________________________________________________
From test (Nov30 9:52):
Test News Post
__________________________________________________________
From test (Nov30 9:52):
Test News Post
__________________________________________________________
From test (Nov30 9:52):
Test News Post
__________________________________________________________
From test (Nov30 9:52):
Test News Post
__________________________________________________________
From test (Nov30 9:52):
Test News Post
__________________________________________________________
From test (Nov30 9:52):
Test News Post
__________________________________________________________
From test (Nov30 9:52):
Test News Post
__________________________________________________________
From test (Nov30 9:51):
Test News Post
__________________________________________________________
From test (Nov30 9:49):
Test News Post
__________________________________________________________
From test (Nov30 9:48):
Test News Post
__________________________________________________________
From test (Nov29 17:36):
Test News Post
__________________________________________________________
From test (Nov29 17:35):
Test News Post
__________________________________________________________
From test (Nov29 17:34):
Test News Post
__________________________________________________________
From test (Nov29 17:33):
Test News Post
__________________________________________________________
From test (Nov29 17:33):
Test News Post
__________________________________________________________
From test (Nov29 17:33):
Test News Post
__________________________________________________________
From test (Nov29 17:32):
Test News Post
__________________________________________________________
From test (Nov29 17:24):
Test News Post
__________________________________________________________
From test (Nov29 17:24):
Test News Post
__________________________________________________________
From test (Nov29 17:24):
Test News Post
__________________________________________________________
From test (Nov29 17:23):
Test News Post
__________________________________________________________
From test (Nov29 17:23):
Test News Post
__________________________________________________________
From test (Nov29 17:22):
Test News Post
__________________________________________________________
From test (Nov29 17:22):
Test News Post
__________________________________________________________
From test (Nov29 17:22):
Test News Post
__________________________________________________________
From test (Nov29 17:22):
Test News Post
__________________________________________________________
From test (Nov29 17:22):
Test News Post
__________________________________________________________
From test (Nov29 17:13):
Test News Post
__________________________________________________________
From test (Nov29 17:11):
Test News Post
__________________________________________________________
From test (Nov29 17:11):
Test News Post
__________________________________________________________
From test (Nov29 17:11):
Test News Post
__________________________________________________________
From test (Nov29 17:9):
Test News Post
__________________________________________________________
From test (Nov29 17:8):
Test News Post
__________________________________________________________
From test (Nov29 17:8):
Test News Post
__________________________________________________________
From test (Nov29 17:7):
Test News Post
__________________________________________________________
From test (Nov29 17:5):
Test News Post
__________________________________________________________
From test (Nov29 16:53):
Test News Post
__________________________________________________________
From test (Nov29 16:52):
Test News Post
__________________________________________________________
From test (Nov29 16:50):
Test News Post
__________________________________________________________
From test (Nov29 16:50):
Test News Post
__________________________________________________________
From test (Nov29 16:46):
Test News Post
__________________________________________________________
From test (Nov29 16:29):
Test News Post
__________________________________________________________
From test (Nov29 16:29):
Test News Post
__________________________________________________________
From test (Nov29 16:28):
Test News Post
__________________________________________________________
From test (Nov29 16:22):
Test News Post
__________________________________________________________
From test (Nov29 10:55):
Test News Post
__________________________________________________________
From test (Nov29 10:24):
Test News Post
__________________________________________________________
From test (Nov28 16:6):
Test News Post
__________________________________________________________
From test (Nov28 16:6):
Test News Post
__________________________________________________________
From test (Nov28 15:46):
Test News Post
__________________________________________________________
From test (Nov28 15:46):
Test News Post
__________________________________________________________
From test (Nov28 15:46):
Test News Post
__________________________________________________________
From test (Nov28 15:46):
Test News Post
__________________________________________________________
From test (Nov28 15:46):
Test News Post
__________________________________________________________
From test (Nov28 15:46):
Test News Post
__________________________________________________________
From test (Nov28 15:45):
Test News Post
__________________________________________________________
From test (Nov28 15:44):
Test News Post
__________________________________________________________
From test (Nov28 15:44):
Test News Post
__________________________________________________________
From test (Nov28 15:43):
Test News Post
__________________________________________________________
From test (Nov28 15:19):
Test News Post
__________________________________________________________
From test (Nov28 15:19):
Test News Post
__________________________________________________________
From test (Nov28 15:18):
Test News Post
__________________________________________________________
From test (Nov28 15:18):
Test News Post
__________________________________________________________
From test (Nov28 15:13):
Test News Post
__________________________________________________________
From test (Nov28 15:13):
Test News Post
__________________________________________________________
From test (Nov28 15:12):
Test News Post
__________________________________________________________
From test (Nov28 14:24):
Test News Post
__________________________________________________________
From test (Nov28 14:13):
Test News Post
__________________________________________________________
From test (Nov28 14:13):
Test News Post
__________________________________________________________
From test (Nov28 14:12):
Test News Post
__________________________________________________________
From test (Nov28 14:11):
Test News Post
__________________________________________________________
From test (Nov28 14:10):
Test News Post
__________________________________________________________
From test (Nov28 14:10):
Test News Post
__________________________________________________________
From test (Nov28 14:10):
Test News Post
__________________________________________________________
From test (Nov28 14:10):
Test News Post
__________________________________________________________
From test (Nov28 14:10):
Test News Post
__________________________________________________________
From test (Nov28 14:9):
Test News Post
__________________________________________________________
From test (Nov28 14:9):
Test News Post
__________________________________________________________
From test (Nov28 14:7):
Test News Post
__________________________________________________________
From test (Nov28 14:7):
Test News Post
__________________________________________________________
From test (Nov28 14:6):
Test News Post
__________________________________________________________
From test (Nov28 14:6):
Test News Post
__________________________________________________________
From test (Nov28 14:5):
Test News Post
__________________________________________________________
From test (Nov28 14:4):
Test News Post
__________________________________________________________
From test (Nov28 14:4):
Test News Post
__________________________________________________________
From test (Nov28 14:4):
Test News Post
__________________________________________________________
From test (Nov28 14:3):
Test News Post
__________________________________________________________
From test (Nov28 14:2):
Test News Post
__________________________________________________________
From test (Nov28 14:2):
Test News Post
__________________________________________________________
From test (Nov28 14:1):
Test News Post
__________________________________________________________
From test (Nov28 14:1):
Test News Post
__________________________________________________________
From test (Nov28 14:1):
Test News Post
__________________________________________________________
From test (Nov28 14:0):
Test News Post
__________________________________________________________
From test (Nov28 14:0):
Test News Post
__________________________________________________________
From test (Nov28 13:56):
Test News Post
__________________________________________________________
From test (Nov28 13:56):
Test News Post
__________________________________________________________
From test (Nov28 13:56):
Test News Post
__________________________________________________________
From test (Nov28 13:55):
Test News Post
__________________________________________________________
From test (Nov28 13:54):
Test News Post
__________________________________________________________
From test (Nov28 13:51):
Test News Post
__________________________________________________________
From test (Nov28 13:50):
Test News Post
__________________________________________________________
From test (Nov28 13:45):
Test News Post
__________________________________________________________
From test (Nov28 13:37):
Test News Post
__________________________________________________________
From test (Nov28 12:37):
Test News Post
__________________________________________________________
From test (Nov28 12:37):
Test News Post
__________________________________________________________
From test (Nov28 12:37):
Test News Post
__________________________________________________________
From test (Nov28 12:37):
Test News Post
__________________________________________________________
From test (Nov28 12:34):
Test News Post
__________________________________________________________
From test (Nov28 12:33):
Test News Post
__________________________________________________________
From test (Nov28 12:33):
Test News Post
__________________________________________________________
From test (Nov28 12:33):
Test News Post
__________________________________________________________
From test (Nov28 12:32):
Test News Post
__________________________________________________________
From test (Nov28 12:32):
Test News Post
__________________________________________________________
From test (Nov28 12:21):
Test News Post
__________________________________________________________
From test (Nov28 12:21):
Test News Post
__________________________________________________________
From test (Nov28 12:21):
Test News Post
__________________________________________________________
From test (Nov28 12:21):
Test News Post
__________________________________________________________
From test (Nov28 12:20):
Test News Post
__________________________________________________________
From test (Nov28 12:19):
Test News Post
__________________________________________________________
From test (Nov28 12:19):
Test News Post
__________________________________________________________
From test (Nov28 12:4):
Test News Post
__________________________________________________________
From test (Nov28 12:1):
Test News Post
__________________________________________________________
From test (Nov28 11:58):
Test News Post
__________________________________________________________
From test (Nov28 11:58):
Test News Post
__________________________________________________________
From test (Nov28 11:55):
Test News Post
__________________________________________________________
From test (Nov28 11:54):
Test News Post
__________________________________________________________
From test (Nov28 11:54):
Test News Post
__________________________________________________________
From test (Nov28 11:52):
Test News Post
__________________________________________________________
From test (Nov28 11:51):
Test News Post
__________________________________________________________
From test (Nov28 11:48):
Test News Post
__________________________________________________________
From test (Nov28 11:48):
Test News Post
__________________________________________________________
From test (Nov28 11:47):
Test News Post
__________________________________________________________
From test (Nov28 11:47):
Test News Post
__________________________________________________________
From test (Nov28 11:47):
Test News Post
__________________________________________________________
From test (Nov28 11:46):
Test News Post
__________________________________________________________
From test (Nov28 11:46):
Test News Post
__________________________________________________________
From test (Nov28 11:46):
Test News Post
__________________________________________________________
From test (Nov28 11:42):
Test News Post
__________________________________________________________
From test (Nov28 11:42):
Test News Post
__________________________________________________________
From test (Nov28 11:38):
Test News Post
__________________________________________________________
From test (Nov28 11:38):
Test News Post
__________________________________________________________
From test (Nov28 11:37):
Test News Post
__________________________________________________________
From test (Nov28 11:31):
Test News Post
__________________________________________________________
From test (Nov28 11:31):
Test News Post
__________________________________________________________
From test (Nov28 11:31):
Test News Post
__________________________________________________________
From test (Nov28 11:31):
Test News Post
__________________________________________________________
From test (Nov28 11:30):
Test News Post
__________________________________________________________
From test (Nov28 11:30):
Test News Post
__________________________________________________________
From test (Nov28 11:30):
Test News Post
__________________________________________________________
From test (Nov28 11:30):
Test News Post
__________________________________________________________
From test (Nov28 11:30):
Test News Post
__________________________________________________________
From test (Nov28 11:28):
Test News Post
__________________________________________________________
From test (Nov28 11:28):
Test News Post
__________________________________________________________
From test (Nov28 11:28):
Test News Post
__________________________________________________________
From test (Nov28 11:27):
Test News Post
__________________________________________________________
From test (Nov28 11:27):
Test News Post
__________________________________________________________
From test (Nov28 11:27):
Test News Post
__________________________________________________________
From test (Nov28 11:26):
Test News Post
__________________________________________________________
From test (Nov28 11:25):
Test News Post
__________________________________________________________
From test (Nov28 11:24):
Test News Post
__________________________________________________________
From test (Nov28 11:24):
Test News Post
__________________________________________________________
From test (Nov28 11:23):
Test News Post
__________________________________________________________
From test (Nov28 11:19):
Test News Post
__________________________________________________________
From test (Nov28 11:15):
Test News Post
__________________________________________________________
From test (Nov28 11:15):
Test News Post
__________________________________________________________
From test (Nov28 11:9):
Test News Post
__________________________________________________________
From test (Nov28 11:9):
Test News Post
__________________________________________________________
From test (Nov28 11:8):
Test News Post
__________________________________________________________
From test (Nov28 11:8):
Test News Post
__________________________________________________________
From test (Nov28 11:7):
Test News Post
__________________________________________________________
From test (Nov28 11:7):
Test News Post
__________________________________________________________
From test (Nov28 10:58):
Test News Post
__________________________________________________________
From test (Nov28 10:58):
Test News Post
__________________________________________________________
From test (Nov28 10:58):
Test News Post
__________________________________________________________
From test (Nov28 10:57):
Test News Post
__________________________________________________________
From test (Nov28 10:57):
Test News Post
__________________________________________________________
From test (Nov28 10:54):
Test News Post
__________________________________________________________
From test (Nov28 10:54):
Test News Post
__________________________________________________________
From test (Nov28 10:54):
Test News Post
__________________________________________________________
From test (Nov28 10:53):
Test News Post
__________________________________________________________
From test (Nov28 10:52):
Test News Post
__________________________________________________________
From test (Nov28 10:48):
Test News Post
__________________________________________________________
From test (Nov28 10:47):
Test News Post
__________________________________________________________
From test (Nov28 10:47):
Test News Post
__________________________________________________________
From test (Nov28 10:47):
Test News Post
__________________________________________________________
From test (Nov28 10:47):
Test News Post
__________________________________________________________
From test (Nov28 10:40):
Test News Post
__________________________________________________________
From test (Jul12 17:20):
Test News Post
__________________________________________________________
From test (Jul12 17:20):
Test News Post
__________________________________________________________
From test (Jul12 17:19):
Test News Post
__________________________________________________________
From test (Jul12 17:19):
Test News Post
__________________________________________________________
From test (Jul12 17:19):
Test News Post
__________________________________________________________
From test (Jul12 17:19):
Test News Post
__________________________________________________________
From test (Jul12 17:19):
Test News Post
__________________________________________________________
From test (Jul12 17:19):
Test News Post
__________________________________________________________
From test (Jul12 17:19):
Test News Post
__________________________________________________________
From test (Jul12 17:19):
Test News Post
__________________________________________________________
From test (Jul12 17:19):
Test News Post
__________________________________________________________
From test (Jul12 17:19):
Test News Post
__________________________________________________________
From test (Jul12 17:19):
Test News Post
__________________________________________________________
From test (Jul12 17:19):
Test News Post
__________________________________________________________
From test (Jul12 17:19):
Test News Post
__________________________________________________________
From test (Jul12 17:18):
Test News Post
__________________________________________________________
From test (Jul12 17:18):
Test News Post
__________________________________________________________
From test (Jul12 17:18):
Test News Post
__________________________________________________________
From test (Jul12 17:14):
Test News Post
__________________________________________________________
From test (Jul12 17:13):
Test News Post
__________________________________________________________
From test (Jul12 17:12):
Test News Post
__________________________________________________________
From test (Jul12 17:12):
Test News Post
__________________________________________________________
From test (Jul12 17:12):
Test News Post
__________________________________________________________
From test (Jul12 17:12):
Test News Post
__________________________________________________________
From test (Jul12 16:41):
Test News Post
__________________________________________________________
From test (Jul12 16:29):
Test News Post
__________________________________________________________
From test (Jul12 16:29):
Test News Post
__________________________________________________________
From test (Jul12 16:29):
Test News Post
__________________________________________________________
From test (Jul12 16:28):
Test News Post
__________________________________________________________
From test (Jul12 16:27):
Test News Post
__________________________________________________________
From test (Jul12 16:27):
Test News Post
__________________________________________________________
From test (Jul12 16:26):
Test News Post
__________________________________________________________
From test (Jul12 16:25):
Test News Post
__________________________________________________________
From test (Jul12 16:24):
Test News Post
__________________________________________________________
From test (Jul12 16:13):
Test News Post
__________________________________________________________
From test (Jul12 16:12):
Test News Post
__________________________________________________________
From test (Jul12 16:11):
Test News Post
__________________________________________________________
From test (Jul12 16:10):
Test News Post
__________________________________________________________
From test (Jul12 16:10):
Test News Post
__________________________________________________________
From test (Jul12 16:0):
Test News Post
__________________________________________________________
From test (Jul12 15:59):
Test News Post
__________________________________________________________
From test (Jul12 15:58):
Test News Post
__________________________________________________________
From test (Jul12 15:54):
Test News Post
__________________________________________________________
From test (Jul12 15:53):
Test News Post
__________________________________________________________
From test (Jul12 15:51):
Test News Post
__________________________________________________________
From test (Jul12 15:48):
Test News Post
__________________________________________________________
From test (Jul12 15:47):
Test News Post
__________________________________________________________
From test (Jul12 15:38):
Test News Post
__________________________________________________________
From test (Jul12 15:22):
Test News Post
__________________________________________________________
From test (Jul12 11:36):
Test News Post
__________________________________________________________
From test (Jul12 11:35):
Test News Post
__________________________________________________________
From test (Jul12 11:31):
Test News Post
__________________________________________________________
From test (Jul12 11:19):
Test News Post
__________________________________________________________
From test (Jul12 11:19):
Test News Post
__________________________________________________________
From test (Jul12 11:19):
Test News Post
__________________________________________________________
From test (Jul12 11:18):
Test News Post
__________________________________________________________
From test (Jul12 10:58):
Test News Post
__________________________________________________________
From test (Jul12 10:52):
Test News Post
__________________________________________________________
From test (Jul12 10:52):
Test News Post
__________________________________________________________
From test (Jul12 10:51):
Test News Post
__________________________________________________________
From test (Jul12 10:51):
Test News Post
__________________________________________________________
From test (Jul12 10:51):
Test News Post
__________________________________________________________
From test (Jul12 10:51):
Test News Post
__________________________________________________________
From test (Jul12 10:50):
Test News Post
__________________________________________________________
From test (Jul12 10:47):
Test News Post
__________________________________________________________
From test (Jul11 13:25):
Test News Post
__________________________________________________________
From test (Jul01 17:25):
Test News Post
__________________________________________________________
From test (Jul01 17:25):
Test News Post
__________________________________________________________
From test (Jul01 9:51):
Test News Post
__________________________________________________________
From test (Jul01 9:51):
Test News Post
__________________________________________________________
From test (Jul01 9:51):
Test News Post
__________________________________________________________
From test (Jul01 9:51):
Test News Post
__________________________________________________________
From test (Jul01 9:50):
Test News Post
__________________________________________________________
From test (Jul01 9:49):
Test News Post
__________________________________________________________
From test (Jul01 9:49):
Test News Post
__________________________________________________________
From test (Jul01 9:49):
Test News Post
__________________________________________________________
From test (Jul01 9:45):
Test News Post
__________________________________________________________
\ No newline at end of file diff --git a/internal/mobius/test/config/ThreadedNews.yaml b/internal/mobius/test/config/ThreadedNews.yaml new file mode 100644 index 0000000..9f3fd65 --- /dev/null +++ b/internal/mobius/test/config/ThreadedNews.yaml @@ -0,0 +1,232 @@ +Categories: + TestBundle: + Type: + - 0 + - 2 + Name: TestBundle + Articles: {} + SubCats: + NestedBundle: + Type: + - 0 + - 2 + Name: NestedBundle + Articles: {} + SubCats: + NestedCat: + Type: + - 0 + - 3 + Name: NestedCat + Articles: {} + SubCats: {} + count: [] + addsn: [] + deletesn: [] + guid: [] + count: [] + addsn: [] + deletesn: [] + guid: [] + TestSubCat: + Type: + - 0 + - 3 + Name: TestSubCat + Articles: + 1: + Title: SubCatArt + Poster: Halcyon 1.9.2 + Date: + - 7 + - 228 + - 0 + - 0 + - 0 + - 254 + - 252 + - 246 + PrevArt: + - 0 + - 0 + - 0 + - 0 + NextArt: + - 0 + - 0 + - 0 + - 0 + ParentArt: + - 0 + - 0 + - 0 + - 0 + FirstChildArtArt: + - 0 + - 0 + - 0 + - 0 + DataFlav: + - 116 + - 101 + - 120 + - 116 + - 47 + - 112 + - 108 + - 97 + - 105 + - 110 + Data: I'm an article in a subcategory! + SubCats: {} + count: [] + addsn: [] + deletesn: [] + guid: [] + count: [] + addsn: [] + deletesn: [] + guid: [] + TestCat: + Type: + - 0 + - 3 + Name: TestCat + Articles: + 1: + Title: TestArt + Poster: Halcyon 1.9.2 + Date: + - 7 + - 228 + - 0 + - 0 + - 0 + - 254 + - 252 + - 204 + PrevArt: + - 0 + - 0 + - 0 + - 0 + NextArt: + - 0 + - 0 + - 0 + - 2 + ParentArt: + - 0 + - 0 + - 0 + - 0 + FirstChildArtArt: + - 0 + - 0 + - 0 + - 2 + DataFlav: + - 116 + - 101 + - 120 + - 116 + - 47 + - 112 + - 108 + - 97 + - 105 + - 110 + Data: TestArt Body + 2: + Title: 'Re: TestArt' + Poster: Halcyon 1.9.2 + Date: + - 7 + - 228 + - 0 + - 0 + - 0 + - 254 + - 252 + - 216 + PrevArt: + - 0 + - 0 + - 0 + - 1 + NextArt: + - 0 + - 0 + - 0 + - 3 + ParentArt: + - 0 + - 0 + - 0 + - 1 + FirstChildArtArt: + - 0 + - 0 + - 0 + - 0 + DataFlav: + - 116 + - 101 + - 120 + - 116 + - 47 + - 112 + - 108 + - 97 + - 105 + - 110 + Data: I'm a reply + 3: + Title: TestArt 2 + Poster: Halcyon 1.9.2 + Date: + - 7 + - 228 + - 0 + - 0 + - 0 + - 254 + - 253 + - 6 + PrevArt: + - 0 + - 0 + - 0 + - 2 + NextArt: + - 0 + - 0 + - 0 + - 0 + ParentArt: + - 0 + - 0 + - 0 + - 0 + FirstChildArtArt: + - 0 + - 0 + - 0 + - 0 + DataFlav: + - 116 + - 101 + - 120 + - 116 + - 47 + - 112 + - 108 + - 97 + - 105 + - 110 + Data: Hello world + SubCats: {} + count: [] + addsn: [] + deletesn: [] + guid: [] diff --git a/internal/mobius/test/config/Users/admin.yaml b/internal/mobius/test/config/Users/admin.yaml new file mode 100644 index 0000000..1bf656b --- /dev/null +++ b/internal/mobius/test/config/Users/admin.yaml @@ -0,0 +1,13 @@ +Login: admin +Name: admin +Password: $2a$04$2itGEYx8C1N5bsfRSoC9JuonS3I4YfnyVPZHLSwp7kEInRX0yoB.a +Access: +- 255 +- 255 +- 255 +- 255 +- 255 +- 255 +- 0 +- 0 + diff --git a/internal/mobius/test/config/Users/guest.yaml b/internal/mobius/test/config/Users/guest.yaml new file mode 100644 index 0000000..57117bd --- /dev/null +++ b/internal/mobius/test/config/Users/guest.yaml @@ -0,0 +1,12 @@ +Login: guest +Name: guest +Password: $2a$04$9P/jgLn1fR9TjSoWL.rKxuN6g.1TSpf2o6Hw.aaRuBwrWIJNwsKkS +Access: +- 125 +- 240 +- 12 +- 239 +- 171 +- 128 +- 0 +- 0 diff --git a/internal/mobius/test/config/config.yaml b/internal/mobius/test/config/config.yaml new file mode 100644 index 0000000..5b2fefd --- /dev/null +++ b/internal/mobius/test/config/config.yaml @@ -0,0 +1,6 @@ +Name: Halcyon's Test Server +Description: Experimental Hotline server +FileRoot: conFiles/ +EnableTrackerRegistration: false +Trackers: + - hltracker.com:5499
\ No newline at end of file diff --git a/internal/mobius/threaded_news.go b/internal/mobius/threaded_news.go index bae6779..a49055c 100644 --- a/internal/mobius/threaded_news.go +++ b/internal/mobius/threaded_news.go @@ -95,16 +95,6 @@ func (n *ThreadedNewsYAML) GetArticle(newsPath []string, articleID uint32) *hotl return art } -// -//func (n *ThreadedNewsYAML) GetNewsCatByPath(paths []string) map[string]hotline.NewsCategoryListData15 { -// n.mu.Lock() -// defer n.mu.Unlock() -// -// cats := n.getCatByPath(paths) -// -// return cats -//} - func (n *ThreadedNewsYAML) GetCategories(paths []string) []hotline.NewsCategoryListData15 { n.mu.Lock() defer n.mu.Unlock() diff --git a/internal/mobius/threaded_news_test.go b/internal/mobius/threaded_news_test.go new file mode 100644 index 0000000..9269a85 --- /dev/null +++ b/internal/mobius/threaded_news_test.go @@ -0,0 +1,65 @@ +package mobius + +import ( + "github.com/stretchr/testify/assert" + "os" + "testing" +) + +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/internal/mobius/transaction_handlers.go b/internal/mobius/transaction_handlers.go new file mode 100644 index 0000000..3b07d0e --- /dev/null +++ b/internal/mobius/transaction_handlers.go @@ -0,0 +1,1793 @@ +package mobius + +import ( + "bufio" + "bytes" + "encoding/binary" + "fmt" + "github.com/jhalter/mobius/hotline" + "golang.org/x/text/encoding/charmap" + "io" + "math/big" + "os" + "path" + "path/filepath" + "strings" + "time" +) + +// Converts bytes from Mac Roman encoding to UTF-8 +var txtDecoder = charmap.Macintosh.NewDecoder() + +// Converts bytes from UTF-8 to Mac Roman encoding +var txtEncoder = charmap.Macintosh.NewEncoder() + +// Assign functions to handle specific Hotline transaction types +func RegisterHandlers(srv *hotline.Server) { + srv.HandleFunc(hotline.TranAgreed, HandleTranAgreed) + srv.HandleFunc(hotline.TranChatSend, HandleChatSend) + srv.HandleFunc(hotline.TranDelNewsArt, HandleDelNewsArt) + srv.HandleFunc(hotline.TranDelNewsItem, HandleDelNewsItem) + srv.HandleFunc(hotline.TranDeleteFile, HandleDeleteFile) + srv.HandleFunc(hotline.TranDeleteUser, HandleDeleteUser) + srv.HandleFunc(hotline.TranDisconnectUser, HandleDisconnectUser) + srv.HandleFunc(hotline.TranDownloadFile, HandleDownloadFile) + srv.HandleFunc(hotline.TranDownloadFldr, HandleDownloadFolder) + srv.HandleFunc(hotline.TranGetClientInfoText, HandleGetClientInfoText) + srv.HandleFunc(hotline.TranGetFileInfo, HandleGetFileInfo) + srv.HandleFunc(hotline.TranGetFileNameList, HandleGetFileNameList) + srv.HandleFunc(hotline.TranGetMsgs, HandleGetMsgs) + srv.HandleFunc(hotline.TranGetNewsArtData, HandleGetNewsArtData) + srv.HandleFunc(hotline.TranGetNewsArtNameList, HandleGetNewsArtNameList) + srv.HandleFunc(hotline.TranGetNewsCatNameList, HandleGetNewsCatNameList) + srv.HandleFunc(hotline.TranGetUser, HandleGetUser) + srv.HandleFunc(hotline.TranGetUserNameList, HandleGetUserNameList) + srv.HandleFunc(hotline.TranInviteNewChat, HandleInviteNewChat) + srv.HandleFunc(hotline.TranInviteToChat, HandleInviteToChat) + srv.HandleFunc(hotline.TranJoinChat, HandleJoinChat) + srv.HandleFunc(hotline.TranKeepAlive, HandleKeepAlive) + srv.HandleFunc(hotline.TranLeaveChat, HandleLeaveChat) + srv.HandleFunc(hotline.TranListUsers, HandleListUsers) + srv.HandleFunc(hotline.TranMoveFile, HandleMoveFile) + srv.HandleFunc(hotline.TranNewFolder, HandleNewFolder) + srv.HandleFunc(hotline.TranNewNewsCat, HandleNewNewsCat) + srv.HandleFunc(hotline.TranNewNewsFldr, HandleNewNewsFldr) + srv.HandleFunc(hotline.TranNewUser, HandleNewUser) + srv.HandleFunc(hotline.TranUpdateUser, HandleUpdateUser) + srv.HandleFunc(hotline.TranOldPostNews, HandleTranOldPostNews) + srv.HandleFunc(hotline.TranPostNewsArt, HandlePostNewsArt) + srv.HandleFunc(hotline.TranRejectChatInvite, HandleRejectChatInvite) + srv.HandleFunc(hotline.TranSendInstantMsg, HandleSendInstantMsg) + srv.HandleFunc(hotline.TranSetChatSubject, HandleSetChatSubject) + srv.HandleFunc(hotline.TranMakeFileAlias, HandleMakeAlias) + srv.HandleFunc(hotline.TranSetClientUserInfo, HandleSetClientUserInfo) + srv.HandleFunc(hotline.TranSetFileInfo, HandleSetFileInfo) + srv.HandleFunc(hotline.TranSetUser, HandleSetUser) + srv.HandleFunc(hotline.TranUploadFile, HandleUploadFile) + srv.HandleFunc(hotline.TranUploadFldr, HandleUploadFolder) + srv.HandleFunc(hotline.TranUserBroadcast, HandleUserBroadcast) + srv.HandleFunc(hotline.TranDownloadBanner, HandleDownloadBanner) +} + +func HandleChatSend(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.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(hotline.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(hotline.FieldChatOptions).Data != nil && bytes.Equal(t.GetField(hotline.FieldChatOptions).Data, []byte{0, 1}) { + formattedMsg = fmt.Sprintf("\r*** %s %s", cc.UserName, t.GetField(hotline.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), hotline.LimitChatMsg)] + + // 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(hotline.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, hotline.NewTransaction( + hotline.TranChatMsg, + c.ID, + hotline.NewField(hotline.FieldChatID, chatID), + hotline.NewField(hotline.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(hotline.AccessReadChat) { + res = append(res, hotline.NewTransaction(hotline.TranChatMsg, c.ID, hotline.NewField(hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessSendPrivMsg) { + return cc.NewErrReply(t, "You are not allowed to send private messages.") + } + + msg := t.GetField(hotline.FieldData) + userID := t.GetField(hotline.FieldUserID) + + reply := hotline.NewTransaction( + hotline.TranServerMsg, + [2]byte(userID.Data), + hotline.NewField(hotline.FieldData, msg.Data), + hotline.NewField(hotline.FieldUserName, cc.UserName), + hotline.NewField(hotline.FieldUserID, cc.ID[:]), + hotline.NewField(hotline.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(hotline.FieldQuotingMsg).Data != nil { + reply.Fields = append(reply.Fields, hotline.NewField(hotline.FieldQuotingMsg, t.GetField(hotline.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(hotline.UserFlagRefusePM) { + res = append(res, + hotline.NewTransaction( + hotline.TranServerMsg, + cc.ID, + hotline.NewField(hotline.FieldData, []byte(string(otherClient.UserName)+" does not accept private messages.")), + hotline.NewField(hotline.FieldUserName, otherClient.UserName), + hotline.NewField(hotline.FieldUserID, otherClient.ID[:]), + hotline.NewField(hotline.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, + hotline.NewTransaction( + hotline.TranServerMsg, + cc.ID, + hotline.NewField(hotline.FieldData, otherClient.AutoReply), + hotline.NewField(hotline.FieldUserName, otherClient.UserName), + hotline.NewField(hotline.FieldUserID, otherClient.ID[:]), + hotline.NewField(hotline.FieldOptions, []byte{0, 1}), + ), + ) + } + + return append(res, cc.NewReply(t)) +} + +var fileTypeFLDR = [4]byte{0x66, 0x6c, 0x64, 0x72} + +func HandleGetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + fileName := t.GetField(hotline.FieldFileName).Data + filePath := t.GetField(hotline.FieldFilePath).Data + + fullFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, filePath, fileName) + if err != nil { + return res + } + + fw, err := hotline.NewFileWrapper(cc.Server.FS, fullFilePath, 0) + if err != nil { + return res + } + + encodedName, err := txtEncoder.String(fw.Name) + if err != nil { + return res + } + + fields := []hotline.Field{ + hotline.NewField(hotline.FieldFileName, []byte(encodedName)), + hotline.NewField(hotline.FieldFileTypeString, fw.Ffo.FlatFileInformationFork.FriendlyType()), + hotline.NewField(hotline.FieldFileCreatorString, fw.Ffo.FlatFileInformationFork.FriendlyCreator()), + hotline.NewField(hotline.FieldFileType, fw.Ffo.FlatFileInformationFork.TypeSignature[:]), + hotline.NewField(hotline.FieldFileCreateDate, fw.Ffo.FlatFileInformationFork.CreateDate[:]), + hotline.NewField(hotline.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, hotline.NewField(hotline.FieldFileComment, fw.Ffo.FlatFileInformationFork.Comment)) + } + + // Include the FileSize field for files. + if fw.Ffo.FlatFileInformationFork.TypeSignature != fileTypeFLDR { + fields = append(fields, hotline.NewField(hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + fileName := t.GetField(hotline.FieldFileName).Data + filePath := t.GetField(hotline.FieldFilePath).Data + + fullFilePath, err := hotline.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 := hotline.NewFileWrapper(cc.Server.FS, fullFilePath, 0) + if err != nil { + return res + } + if t.GetField(hotline.FieldFileComment).Data != nil { + switch mode := fi.Mode(); { + case mode.IsDir(): + if !cc.Authorize(hotline.AccessSetFolderComment) { + return cc.NewErrReply(t, "You are not allowed to set comments for folders.") + } + case mode.IsRegular(): + if !cc.Authorize(hotline.AccessSetFileComment) { + return cc.NewErrReply(t, "You are not allowed to set comments for files.") + } + } + + if err := hlFile.Ffo.FlatFileInformationFork.SetComment(t.GetField(hotline.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 := hotline.ReadPath(cc.Server.Config.FileRoot, filePath, t.GetField(hotline.FieldFileNewName).Data) + if err != nil { + return nil + } + + fileNewName := t.GetField(hotline.FieldFileNewName).Data + + if fileNewName != nil { + switch mode := fi.Mode(); { + case mode.IsDir(): + if !cc.Authorize(hotline.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(hotline.AccessRenameFile) { + return cc.NewErrReply(t, "You are not allowed to rename files.") + } + fileDir, err := hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + fileName := t.GetField(hotline.FieldFileName).Data + filePath := t.GetField(hotline.FieldFilePath).Data + + fullFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, filePath, fileName) + if err != nil { + return res + } + + hlFile, err := hotline.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(hotline.AccessDeleteFolder) { + return cc.NewErrReply(t, "You are not allowed to delete folders.") + } + case mode.IsRegular(): + if !cc.Authorize(hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + fileName := string(t.GetField(hotline.FieldFileName).Data) + + filePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, t.GetField(hotline.FieldFilePath).Data, t.GetField(hotline.FieldFileName).Data) + if err != nil { + return res + } + + fileNewPath, err := hotline.ReadPath(cc.Server.Config.FileRoot, t.GetField(hotline.FieldFileNewPath).Data, nil) + if err != nil { + return res + } + + cc.Logger.Info("Move file", "src", filePath+"/"+fileName, "dst", fileNewPath+"/"+fileName) + + hlFile, err := hotline.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(hotline.AccessMoveFolder) { + return cc.NewErrReply(t, "You are not allowed to move folders.") + } + case mode.IsRegular(): + if !cc.Authorize(hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessCreateFolder) { + return cc.NewErrReply(t, "You are not allowed to create folders.") + } + folderName := string(t.GetField(hotline.FieldFileName).Data) + + folderName = path.Join("/", folderName) + + var subPath string + + // FieldFilePath is only present for nested paths + if t.GetField(hotline.FieldFilePath).Data != nil { + var newFp hotline.FilePath + _, err := newFp.Write(t.GetField(hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessModifyUser) { + return cc.NewErrReply(t, "You are not allowed to modify accounts.") + } + + login := t.GetField(hotline.FieldUserLogin).DecodeObfuscatedString() + userName := string(t.GetField(hotline.FieldUserName).Data) + + newAccessLvl := t.GetField(hotline.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(hotline.FieldUserPassword).Data == nil { + account.Password = hotline.HashAndSalt([]byte("")) + } + + if !bytes.Equal([]byte{0}, t.GetField(hotline.FieldUserPassword).Data) { + account.Password = hotline.HashAndSalt(t.GetField(hotline.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 := hotline.NewTransaction(hotline.TranUserAccess, c.ID, hotline.NewField(hotline.FieldUserAccess, newAccessLvl)) + res = append(res, newT) + + if c.Authorize(hotline.AccessDisconUser) { + c.Flags.Set(hotline.UserFlagAdmin, 1) + } else { + c.Flags.Set(hotline.UserFlagAdmin, 0) + } + + c.Account.Access = account.Access + + cc.SendAll( + hotline.TranNotifyChangeUser, + hotline.NewField(hotline.FieldUserID, c.ID[:]), + hotline.NewField(hotline.FieldUserFlags, c.Flags[:]), + hotline.NewField(hotline.FieldUserName, c.UserName), + hotline.NewField(hotline.FieldUserIconID, c.Icon), + ) + } + } + + return append(res, cc.NewReply(t)) +} + +func HandleGetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessOpenUser) { + return cc.NewErrReply(t, "You are not allowed to view accounts.") + } + + account := cc.Server.AccountManager.Get(string(t.GetField(hotline.FieldUserLogin).Data)) + if account == nil { + return cc.NewErrReply(t, "Account does not exist.") + } + + return append(res, cc.NewReply(t, + hotline.NewField(hotline.FieldUserName, []byte(account.Name)), + hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString(t.GetField(hotline.FieldUserLogin).Data)), + hotline.NewField(hotline.FieldUserPassword, []byte(account.Password)), + hotline.NewField(hotline.FieldUserAccess, account.Access[:]), + )) +} + +func HandleListUsers(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessOpenUser) { + return cc.NewErrReply(t, "You are not allowed to view accounts.") + } + + var userFields []hotline.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, hotline.NewField(hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + for _, field := range t.Fields { + var subFields []hotline.Field + + // Create a new scanner for parsing incoming bytes into transaction tokens + scanner := bufio.NewScanner(bytes.NewReader(field.Data[2:])) + scanner.Split(hotline.FieldScanner) + + for i := 0; i < int(binary.BigEndian.Uint16(field.Data[0:2])); i++ { + scanner.Scan() + + var field hotline.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(hotline.AccessDeleteUser) { + return cc.NewErrReply(t, "You are not allowed to delete accounts.") + } + + login := string(hotline.EncodeString(hotline.GetField(hotline.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, + hotline.NewTransaction(hotline.TranServerMsg, [2]byte{}, + hotline.NewField(hotline.FieldData, []byte("You are logged in with an account which was deleted.")), + hotline.NewField(hotline.FieldChatOptions, []byte{0}), + ), + ) + + go func(c *hotline.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 hotline.GetField(hotline.FieldData, &subFields) != nil { + loginToRename = string(hotline.EncodeString(hotline.GetField(hotline.FieldData, &subFields).Data)) + } + userLogin := string(hotline.EncodeString(hotline.GetField(hotline.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(hotline.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 hotline.GetField(hotline.FieldUserPassword, &subFields) != nil { + newPass := hotline.GetField(hotline.FieldUserPassword, &subFields).Data + if !bytes.Equal([]byte{0}, newPass) { + acc.Password = hotline.HashAndSalt(newPass) + } + } else { + acc.Password = hotline.HashAndSalt([]byte("")) + } + + if hotline.GetField(hotline.FieldUserAccess, &subFields) != nil { + copy(acc.Access[:], hotline.GetField(hotline.FieldUserAccess, &subFields).Data) + } + + acc.Name = string(hotline.GetField(hotline.FieldUserName, &subFields).Data) + + err := cc.Server.AccountManager.Update(*acc, string(hotline.EncodeString(hotline.GetField(hotline.FieldUserLogin, &subFields).Data))) + + if err != nil { + return res + } + } else { + if !cc.Authorize(hotline.AccessCreateUser) { + return cc.NewErrReply(t, "You are not allowed to create new accounts.") + } + + cc.Logger.Info("CreateUser", "login", userLogin) + + var newAccess hotline.AccessBitmap + copy(newAccess[:], hotline.GetField(hotline.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 := hotline.NewAccount( + userLogin, + string(hotline.GetField(hotline.FieldUserName, &subFields).Data), + string(hotline.GetField(hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessCreateUser) { + return cc.NewErrReply(t, "You are not allowed to create new accounts.") + } + + login := t.GetField(hotline.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 hotline.AccessBitmap + copy(newAccess[:], t.GetField(hotline.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 := hotline.NewAccount(login, string(t.GetField(hotline.FieldUserName).Data), string(t.GetField(hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessDeleteUser) { + return cc.NewErrReply(t, "You are not allowed to delete accounts.") + } + + login := t.GetField(hotline.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, + hotline.NewTransaction(hotline.TranServerMsg, client.ID, + hotline.NewField(hotline.FieldData, []byte("You are logged in with an account which was deleted.")), + hotline.NewField(hotline.FieldChatOptions, []byte{2}), + ), + ) + + go func(c *hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessBroadcast) { + return cc.NewErrReply(t, "You are not allowed to send broadcast messages.") + } + + cc.SendAll( + hotline.TranServerMsg, + hotline.NewField(hotline.FieldData, t.GetField(hotline.FieldData).Data), + hotline.NewField(hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessGetClientInfo) { + return cc.NewErrReply(t, "You are not allowed to get client info.") + } + + clientID := t.GetField(hotline.FieldUserID).Data + + clientConn := cc.Server.ClientMgr.Get(hotline.ClientID(clientID)) + if clientConn == nil { + return cc.NewErrReply(t, "User not found.") + } + + return append(res, cc.NewReply(t, + hotline.NewField(hotline.FieldData, []byte(clientConn.String())), + hotline.NewField(hotline.FieldUserName, clientConn.UserName), + )) +} + +func HandleGetUserNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + var fields []hotline.Field + for _, c := range cc.Server.ClientMgr.List() { + b, err := io.ReadAll(&hotline.User{ + ID: c.ID, + Icon: c.Icon, + Flags: c.Flags[:], + Name: string(c.UserName), + }) + if err != nil { + return nil + } + + fields = append(fields, hotline.NewField(hotline.FieldUsernameWithInfo, b)) + } + + return []hotline.Transaction{cc.NewReply(t, fields...)} +} + +func HandleTranAgreed(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if t.GetField(hotline.FieldUserName).Data != nil { + if cc.Authorize(hotline.AccessAnyName) { + cc.UserName = t.GetField(hotline.FieldUserName).Data + } else { + cc.UserName = []byte(cc.Account.Name) + } + } + + cc.Icon = t.GetField(hotline.FieldUserIconID).Data + + cc.Logger = cc.Logger.With("Name", string(cc.UserName)) + cc.Logger.Info("Login successful") + + options := t.GetField(hotline.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(hotline.UserFlagRefusePM, optBitmap.Bit(hotline.UserOptRefusePM)) + + // Check refuse private chat option + cc.Flags.Set(hotline.UserFlagRefusePChat, optBitmap.Bit(hotline.UserOptRefuseChat)) + + // Check auto response + if optBitmap.Bit(hotline.UserOptAutoResponse) == 1 { + cc.AutoReply = t.GetField(hotline.FieldAutomaticResponse).Data + } + + trans := cc.NotifyOthers( + hotline.NewTransaction( + hotline.TranNotifyChangeUser, [2]byte{0, 0}, + hotline.NewField(hotline.FieldUserName, cc.UserName), + hotline.NewField(hotline.FieldUserID, cc.ID[:]), + hotline.NewField(hotline.FieldUserIconID, cc.Icon), + hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]), + ), + ) + res = append(res, trans...) + + if cc.Server.Config.BannerFile != "" { + res = append(res, hotline.NewTransaction(hotline.TranServerBanner, cc.ID, hotline.NewField(hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessNewsPostArt) { + return cc.NewErrReply(t, "You are not allowed to post news.") + } + + newsDateTemplate := hotline.NewsDateFormat + if cc.Server.Config.NewsDateFormat != "" { + newsDateTemplate = cc.Server.Config.NewsDateFormat + } + + newsTemplate := hotline.NewsTemplate + if cc.Server.Config.NewsDelimiter != "" { + newsTemplate = cc.Server.Config.NewsDelimiter + } + + newsPost := fmt.Sprintf(newsTemplate+"\r", cc.UserName, time.Now().Format(newsDateTemplate), t.GetField(hotline.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( + hotline.TranNewMsg, + hotline.NewField(hotline.FieldData, []byte(newsPost)), + ) + + return append(res, cc.NewReply(t)) +} + +func HandleDisconnectUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessDisconUser) { + return cc.NewErrReply(t, "You are not allowed to disconnect users.") + } + + clientID := [2]byte(t.GetField(hotline.FieldUserID).Data) + clientConn := cc.Server.ClientMgr.Get(clientID) + + if clientConn.Authorize(hotline.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(hotline.FieldOptions).Data != nil { + switch t.GetField(hotline.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, hotline.NewTransaction( + hotline.TranServerMsg, + clientConn.ID, + hotline.NewField(hotline.FieldData, []byte("You are temporarily banned on this server")), + hotline.NewField(hotline.FieldChatOptions, []byte{0, 0}), + )) + + banUntil := time.Now().Add(hotline.BanDuration) + 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, hotline.NewTransaction( + hotline.TranServerMsg, + clientConn.ID, + hotline.NewField(hotline.FieldData, []byte("You are permanently banned on this server")), + hotline.NewField(hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessNewsReadArt) { + return cc.NewErrReply(t, "You are not allowed to read news.") + } + + pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() + if err != nil { + + } + + var fields []hotline.Field + for _, cat := range cc.Server.ThreadedNewsMgr.GetCategories(pathStrs) { + b, err := io.ReadAll(&cat) + if err != nil { + // TODO + } + + fields = append(fields, hotline.NewField(hotline.FieldNewsCatListData15, b)) + } + + return append(res, cc.NewReply(t, fields...)) +} + +func HandleNewNewsCat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessNewsCreateCat) { + return cc.NewErrReply(t, "You are not allowed to create news categories.") + } + + name := string(t.GetField(hotline.FieldNewsCatName).Data) + pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() + if err != nil { + return res + } + + err = cc.Server.ThreadedNewsMgr.CreateGrouping(pathStrs, name, hotline.NewsCategory) + if err != nil { + cc.Logger.Error("error creating news category", "err", err) + } + + return []hotline.Transaction{cc.NewReply(t)} +} + +// Fields used in the request: +// 322 News category Name +// 325 News path +func HandleNewNewsFldr(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessNewsCreateFldr) { + return cc.NewErrReply(t, "You are not allowed to create news folders.") + } + + name := string(t.GetField(hotline.FieldFileName).Data) + pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() + if err != nil { + return res + } + + err = cc.Server.ThreadedNewsMgr.CreateGrouping(pathStrs, name, hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessNewsReadArt) { + return cc.NewErrReply(t, "You are not allowed to read news.") + } + + pathStrs, err := t.GetField(hotline.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, hotline.NewField(hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessNewsReadArt) { + return cc.NewErrReply(t, "You are not allowed to read news.") + } + + newsPath, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() + if err != nil { + return res + } + + convertedID, err := t.GetField(hotline.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, + hotline.NewField(hotline.FieldNewsArtTitle, []byte(art.Title)), + hotline.NewField(hotline.FieldNewsArtPoster, []byte(art.Poster)), + hotline.NewField(hotline.FieldNewsArtDate, art.Date[:]), + hotline.NewField(hotline.FieldNewsArtPrevArt, art.PrevArt[:]), + hotline.NewField(hotline.FieldNewsArtNextArt, art.NextArt[:]), + hotline.NewField(hotline.FieldNewsArtParentArt, art.ParentArt[:]), + hotline.NewField(hotline.FieldNewsArt1stChildArt, art.FirstChildArt[:]), + hotline.NewField(hotline.FieldNewsArtDataFlav, []byte("text/plain")), + hotline.NewField(hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() + if err != nil { + return res + } + + item := cc.Server.ThreadedNewsMgr.NewsItem(pathStrs) + + if item.Type == [2]byte{0, 3} { + if !cc.Authorize(hotline.AccessNewsDeleteCat) { + return cc.NewErrReply(t, "You are not allowed to delete news categories.") + } + } else { + if !cc.Authorize(hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessNewsDeleteArt) { + return cc.NewErrReply(t, "You are not allowed to delete news articles.") + + } + + pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() + if err != nil { + return res + } + + articleID, err := t.GetField(hotline.FieldNewsArtID).DecodeInt() + if err != nil { + cc.Logger.Error("error reading article Type", "err", err) + return + } + + deleteRecursive := bytes.Equal([]byte{0, 1}, t.GetField(hotline.FieldNewsArtRecurseDel).Data) + + err = cc.Server.ThreadedNewsMgr.DeleteArticle(pathStrs, uint32(articleID), deleteRecursive) + if err != nil { + cc.Logger.Error("error deleting news article", "err", err) + } + + return []hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessNewsPostArt) { + return cc.NewErrReply(t, "You are not allowed to post news articles.") + } + + pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() + if err != nil { + return res + } + + parentArticleID, err := t.GetField(hotline.FieldNewsArtID).DecodeInt() + if err != nil { + return res + } + + err = cc.Server.ThreadedNewsMgr.PostArticle( + pathStrs, + uint32(parentArticleID), + hotline.NewsArtData{ + Title: string(t.GetField(hotline.FieldNewsArtTitle).Data), + Poster: string(cc.UserName), + Date: hotline.NewTime(time.Now()), + DataFlav: hotline.NewsFlavor, + Data: string(t.GetField(hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.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, hotline.NewField(hotline.FieldData, newsData))) +} + +func HandleDownloadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessDownloadFile) { + return cc.NewErrReply(t, "You are not allowed to download files.") + } + + fileName := t.GetField(hotline.FieldFileName).Data + filePath := t.GetField(hotline.FieldFilePath).Data + resumeData := t.GetField(hotline.FieldFileResumeData).Data + + var dataOffset int64 + var frd hotline.FileResumeData + if resumeData != nil { + if err := frd.UnmarshalBinary(t.GetField(hotline.FieldFileResumeData).Data); err != nil { + return res + } + // TODO: handle rsrc fork offset + dataOffset = int64(binary.BigEndian.Uint32(frd.ForkInfoList[0].DataSize[:])) + } + + fullFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, filePath, fileName) + if err != nil { + return res + } + + hlFile, err := hotline.NewFileWrapper(cc.Server.FS, fullFilePath, dataOffset) + if err != nil { + return res + } + + xferSize := hlFile.Ffo.TransferSize(0) + + ft := cc.NewFileTransfer(hotline.FileDownload, fileName, filePath, xferSize) + + // TODO: refactor to remove this + if resumeData != nil { + var frd hotline.FileResumeData + if err := frd.UnmarshalBinary(t.GetField(hotline.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(hotline.FieldFileTransferOptions).Data != nil { + ft.Options = t.GetField(hotline.FieldFileTransferOptions).Data + xferSize = hlFile.Ffo.FlatFileDataForkHeader.DataSize[:] + } + + res = append(res, cc.NewReply(t, + hotline.NewField(hotline.FieldRefNum, ft.RefNum[:]), + hotline.NewField(hotline.FieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count + hotline.NewField(hotline.FieldTransferSize, xferSize), + hotline.NewField(hotline.FieldFileSize, hlFile.Ffo.FlatFileDataForkHeader.DataSize[:]), + )) + + return res +} + +// Download all files from the specified folder and sub-folders +func HandleDownloadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessDownloadFile) { + return cc.NewErrReply(t, "You are not allowed to download folders.") + } + + fullFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, t.GetField(hotline.FieldFilePath).Data, t.GetField(hotline.FieldFileName).Data) + if err != nil { + return res + } + + transferSize, err := hotline.CalcTotalSize(fullFilePath) + if err != nil { + return res + } + itemCount, err := hotline.CalcItemCount(fullFilePath) + if err != nil { + return res + } + + fileTransfer := cc.NewFileTransfer(hotline.FolderDownload, t.GetField(hotline.FieldFileName).Data, t.GetField(hotline.FieldFilePath).Data, transferSize) + + var fp hotline.FilePath + _, err = fp.Write(t.GetField(hotline.FieldFilePath).Data) + if err != nil { + return res + } + + res = append(res, cc.NewReply(t, + hotline.NewField(hotline.FieldRefNum, fileTransfer.RefNum[:]), + hotline.NewField(hotline.FieldTransferSize, transferSize), + hotline.NewField(hotline.FieldFolderItemCount, itemCount), + hotline.NewField(hotline.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 hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + var fp hotline.FilePath + if t.GetField(hotline.FieldFilePath).Data != nil { + if _, err := fp.Write(t.GetField(hotline.FieldFilePath).Data); err != nil { + return res + } + } + + // Handle special cases for Upload and Drop Box folders + if !cc.Authorize(hotline.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(hotline.FieldFileName).Data))) + } + } + + fileTransfer := cc.NewFileTransfer(hotline.FolderUpload, + t.GetField(hotline.FieldFileName).Data, + t.GetField(hotline.FieldFilePath).Data, + t.GetField(hotline.FieldTransferSize).Data, + ) + + fileTransfer.FolderItemCount = t.GetField(hotline.FieldFolderItemCount).Data + + return append(res, cc.NewReply(t, hotline.NewField(hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessUploadFile) { + return cc.NewErrReply(t, "You are not allowed to upload files.") + } + + fileName := t.GetField(hotline.FieldFileName).Data + filePath := t.GetField(hotline.FieldFilePath).Data + transferOptions := t.GetField(hotline.FieldFileTransferOptions).Data + transferSize := t.GetField(hotline.FieldTransferSize).Data // not sent for resume + + var fp hotline.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(hotline.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 := hotline.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(hotline.FileUpload, fileName, filePath, transferSize) + + replyT := cc.NewReply(t, hotline.NewField(hotline.FieldRefNum, ft.RefNum[:])) + + // client has requested to resume a partially transferred file + if transferOptions != nil { + fileInfo, err := cc.Server.FS.Stat(fullFilePath + hotline.IncompleteFileSuffix) + if err != nil { + return res + } + + offset := make([]byte, 4) + binary.BigEndian.PutUint32(offset, uint32(fileInfo.Size())) + + fileResumeData := hotline.NewFileResumeData([]hotline.ForkInfoList{ + *hotline.NewForkInfoList(offset), + }) + + b, _ := fileResumeData.BinaryMarshal() + + ft.TransferSize = offset + + replyT.Fields = append(replyT.Fields, hotline.NewField(hotline.FieldFileResumeData, b)) + } + + res = append(res, replyT) + return res +} + +func HandleSetClientUserInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if len(t.GetField(hotline.FieldUserIconID).Data) == 4 { + cc.Icon = t.GetField(hotline.FieldUserIconID).Data[2:] + } else { + cc.Icon = t.GetField(hotline.FieldUserIconID).Data + } + if cc.Authorize(hotline.AccessAnyName) { + cc.UserName = t.GetField(hotline.FieldUserName).Data + } + + // the options field is only passed by the client versions > 1.2.3. + options := t.GetField(hotline.FieldOptions).Data + if options != nil { + optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options))) + + cc.Flags.Set(hotline.UserFlagRefusePM, optBitmap.Bit(hotline.UserOptRefusePM)) + cc.Flags.Set(hotline.UserFlagRefusePChat, optBitmap.Bit(hotline.UserOptRefuseChat)) + + // Check auto response + if optBitmap.Bit(hotline.UserOptAutoResponse) == 1 { + cc.AutoReply = t.GetField(hotline.FieldAutomaticResponse).Data + } else { + cc.AutoReply = []byte{} + } + } + + for _, c := range cc.Server.ClientMgr.List() { + res = append(res, hotline.NewTransaction( + hotline.TranNotifyChangeUser, + c.ID, + hotline.NewField(hotline.FieldUserID, cc.ID[:]), + hotline.NewField(hotline.FieldUserIconID, cc.Icon), + hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]), + hotline.NewField(hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + res = append(res, cc.NewReply(t)) + + return res +} + +func HandleGetFileNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + fullPath, err := hotline.ReadPath( + cc.Server.Config.FileRoot, + t.GetField(hotline.FieldFilePath).Data, + nil, + ) + if err != nil { + return res + } + + var fp hotline.FilePath + if t.GetField(hotline.FieldFilePath).Data != nil { + if _, err = fp.Write(t.GetField(hotline.FieldFilePath).Data); err != nil { + return res + } + } + + // Handle special case for drop box folders + if fp.IsDropbox() && !cc.Authorize(hotline.AccessViewDropBoxes) { + return cc.NewErrReply(t, "You are not allowed to view drop boxes.") + } + + fileNames, err := hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessOpenChat) { + return cc.NewErrReply(t, "You are not allowed to request private chat.") + } + + // Client to Invite + targetID := t.GetField(hotline.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(hotline.UserFlagRefusePChat) == 1 { + res = append(res, + hotline.NewTransaction( + hotline.TranServerMsg, + cc.ID, + hotline.NewField(hotline.FieldData, []byte(string(targetClient.UserName)+" does not accept private chats.")), + hotline.NewField(hotline.FieldUserName, targetClient.UserName), + hotline.NewField(hotline.FieldUserID, targetClient.ID[:]), + hotline.NewField(hotline.FieldOptions, []byte{0, 2}), + ), + ) + } else { + res = append(res, + hotline.NewTransaction( + hotline.TranInviteToChat, + [2]byte(targetID), + hotline.NewField(hotline.FieldChatID, newChatID[:]), + hotline.NewField(hotline.FieldUserName, cc.UserName), + hotline.NewField(hotline.FieldUserID, cc.ID[:]), + ), + ) + } + + return append( + res, + cc.NewReply(t, + hotline.NewField(hotline.FieldChatID, newChatID[:]), + hotline.NewField(hotline.FieldUserName, cc.UserName), + hotline.NewField(hotline.FieldUserID, cc.ID[:]), + hotline.NewField(hotline.FieldUserIconID, cc.Icon), + hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]), + ), + ) +} + +func HandleInviteToChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessOpenChat) { + return cc.NewErrReply(t, "You are not allowed to request private chat.") + } + + // Client to Invite + targetID := t.GetField(hotline.FieldUserID).Data + chatID := t.GetField(hotline.FieldChatID).Data + + return []hotline.Transaction{ + hotline.NewTransaction( + hotline.TranInviteToChat, + [2]byte(targetID), + hotline.NewField(hotline.FieldChatID, chatID), + hotline.NewField(hotline.FieldUserName, cc.UserName), + hotline.NewField(hotline.FieldUserID, cc.ID[:]), + ), + cc.NewReply( + t, + hotline.NewField(hotline.FieldChatID, chatID), + hotline.NewField(hotline.FieldUserName, cc.UserName), + hotline.NewField(hotline.FieldUserID, cc.ID[:]), + hotline.NewField(hotline.FieldUserIconID, cc.Icon), + hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]), + ), + } +} + +func HandleRejectChatInvite(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + chatID := [4]byte(t.GetField(hotline.FieldChatID).Data) + + for _, c := range cc.Server.ChatMgr.Members(chatID) { + res = append(res, + hotline.NewTransaction( + hotline.TranChatMsg, + c.ID, + hotline.NewField(hotline.FieldChatID, chatID[:]), + hotline.NewField(hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + chatID := t.GetField(hotline.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, + hotline.NewTransaction( + hotline.TranNotifyChatChangeUser, + c.ID, + hotline.NewField(hotline.FieldChatID, chatID), + hotline.NewField(hotline.FieldUserName, cc.UserName), + hotline.NewField(hotline.FieldUserID, cc.ID[:]), + hotline.NewField(hotline.FieldUserIconID, cc.Icon), + hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]), + ), + ) + } + + cc.Server.ChatMgr.Join(hotline.ChatID(chatID), cc) + + subject := cc.Server.ChatMgr.GetSubject(hotline.ChatID(chatID)) + + replyFields := []hotline.Field{hotline.NewField(hotline.FieldChatSubject, []byte(subject))} + for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) { + b, err := io.ReadAll(&hotline.User{ + ID: c.ID, + Icon: c.Icon, + Flags: c.Flags[:], + Name: string(c.UserName), + }) + if err != nil { + return res + } + replyFields = append(replyFields, hotline.NewField(hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + chatID := t.GetField(hotline.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(hotline.ChatID(chatID)) { + res = append(res, + hotline.NewTransaction( + hotline.TranNotifyChatDeleteUser, + c.ID, + hotline.NewField(hotline.FieldChatID, chatID), + hotline.NewField(hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + chatID := t.GetField(hotline.FieldChatID).Data + + cc.Server.ChatMgr.SetSubject([4]byte(chatID), string(t.GetField(hotline.FieldChatSubject).Data)) + + // Notify chat members of new subject. + for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) { + res = append(res, + hotline.NewTransaction( + hotline.TranNotifyChatSubject, + c.ID, + hotline.NewField(hotline.FieldChatID, chatID), + hotline.NewField(hotline.FieldChatSubject, t.GetField(hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessMakeAlias) { + return cc.NewErrReply(t, "You are not allowed to make aliases.") + } + fileName := t.GetField(hotline.FieldFileName).Data + filePath := t.GetField(hotline.FieldFilePath).Data + fileNewPath := t.GetField(hotline.FieldFileNewPath).Data + + fullFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, filePath, fileName) + if err != nil { + return res + } + + fullNewFilePath, err := hotline.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 *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + ft := cc.NewFileTransfer(hotline.BannerDownload, []byte{}, []byte{}, make([]byte, 4)) + binary.BigEndian.PutUint32(ft.TransferSize, uint32(len(cc.Server.Banner))) + + return append(res, cc.NewReply(t, + hotline.NewField(hotline.FieldRefNum, ft.RefNum[:]), + hotline.NewField(hotline.FieldTransferSize, ft.TransferSize), + )) +} diff --git a/internal/mobius/transaction_handlers_test.go b/internal/mobius/transaction_handlers_test.go new file mode 100644 index 0000000..7e9faef --- /dev/null +++ b/internal/mobius/transaction_handlers_test.go @@ -0,0 +1,3795 @@ +package mobius + +import ( + "cmp" + "encoding/binary" + "encoding/hex" + "errors" + "github.com/jhalter/mobius/hotline" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "io" + "io/fs" + "log/slog" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" +) + +type mockReadWriteSeeker struct { + mock.Mock +} + +func (m *mockReadWriteSeeker) Read(p []byte) (int, error) { + args := m.Called(p) + + return args.Int(0), args.Error(1) +} + +func (m *mockReadWriteSeeker) Write(p []byte) (int, error) { + args := m.Called(p) + + return args.Int(0), args.Error(1) +} + +func (m *mockReadWriteSeeker) Seek(offset int64, whence int) (int64, error) { + args := m.Called(offset, whence) + + return args.Get(0).(int64), args.Error(1) +} + +func NewTestLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(os.Stdout, nil)) +} + +// assertTransferBytesEqual takes a string with a hexdump in the same format that `hexdump -C` produces and compares with +// a hexdump for the bytes in got, after stripping the create/modify timestamps. +// I don't love this, but as git does not preserve file create/modify timestamps, we either need to fully mock the +// filesystem interactions or work around in this way. +// TODO: figure out a better solution +func assertTransferBytesEqual(t *testing.T, wantHexDump string, got []byte) bool { + if wantHexDump == "" { + return true + } + + clean := slices.Concat( + got[:92], + make([]byte, 16), + got[108:], + ) + return assert.Equal(t, wantHexDump, hex.Dump(clean)) +} + +var tranSortFunc = func(a, b hotline.Transaction) int { + return cmp.Compare( + 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 []hotline.Transaction) bool { + var newT1 []hotline.Transaction + var newT2 []hotline.Transaction + + for _, trans := range tran1 { + trans.ID = [4]byte{0, 0, 0, 0} + var fs []hotline.Field + for _, field := range trans.Fields { + if field.Type == hotline.FieldRefNum { // FieldRefNum + continue + } + if field.Type == hotline.FieldChatID { // FieldChatID + continue + } + + fs = append(fs, field) + } + trans.Fields = fs + newT1 = append(newT1, trans) + } + + for _, trans := range tran2 { + trans.ID = [4]byte{0, 0, 0, 0} + var fs []hotline.Field + for _, field := range trans.Fields { + if field.Type == hotline.FieldRefNum { // FieldRefNum + continue + } + if field.Type == hotline.FieldChatID { // FieldChatID + continue + } + + fs = append(fs, field) + } + trans.Fields = fs + newT2 = append(newT2, trans) + } + + slices.SortFunc(newT1, tranSortFunc) + slices.SortFunc(newT2, tranSortFunc) + + return assert.Equal(t, newT1, newT2) +} + +func TestHandleSetChatSubject(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + want []hotline.Transaction + }{ + { + name: "sends chat subject to private chat members", + args: args{ + cc: &hotline.ClientConn{ + UserName: []byte{0x00, 0x01}, + Server: &hotline.Server{ + ChatMgr: func() *hotline.MockChatManager { + m := hotline.MockChatManager{} + m.On("Members", hotline.ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*hotline.ClientConn{ + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, + }, + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 2}, + }, + }) + m.On("SetSubject", hotline.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: &hotline.Account{ + // Access: AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + // }, + // ID: [2]byte{0, 1}, + // }, + // [2]byte{0, 2}: { + // Account: &hotline.Account{ + // Access: AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + // }, + // ID: [2]byte{0, 2}, + // }, + // }, + // }, + //}, + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, + }, + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 2}, + }, + }, + ) + return &m + }(), + }, + }, + t: hotline.Transaction{ + Type: [2]byte{0, 0x6a}, + ID: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), + hotline.NewField(hotline.FieldChatSubject, []byte("Test Subject")), + }, + }, + }, + want: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0, 0x77}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), + hotline.NewField(hotline.FieldChatSubject, []byte("Test Subject")), + }, + }, + { + ClientID: [2]byte{0, 2}, + Type: [2]byte{0, 0x77}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), + hotline.NewField(hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + want []hotline.Transaction + }{ + { + name: "when client 2 leaves chat", + args: args{ + cc: &hotline.ClientConn{ + ID: [2]byte{0, 2}, + Server: &hotline.Server{ + ChatMgr: func() *hotline.MockChatManager { + m := hotline.MockChatManager{} + m.On("Members", hotline.ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*hotline.ClientConn{ + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, + }, + }) + m.On("Leave", hotline.ChatID{0x0, 0x0, 0x0, 0x1}, [2]uint8{0x0, 0x2}) + m.On("GetSubject").Return("unset") + return &m + }(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("Get").Return([]*hotline.ClientConn{ + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, + }, + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 2}, + }, + }, + ) + return &m + }(), + }, + }, + t: hotline.NewTransaction(hotline.TranDeleteUser, [2]byte{}, hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1})), + }, + want: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0, 0x76}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), + hotline.NewField(hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + want []hotline.Transaction + }{ + { + name: "replies with userlist transaction", + args: args{ + cc: &hotline.ClientConn{ + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.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: hotline.Transaction{}, + }, + want: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField( + hotline.FieldUsernameWithInfo, + []byte{00, 01, 00, 02, 00, 03, 00, 02, 00, 04}, + ), + hotline.NewField( + hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + want []hotline.Transaction + }{ + { + name: "sends chat msg transaction to all clients", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessSendChat) + return bits + }(), + }, + UserName: []byte{0x00, 0x01}, + Server: &hotline.Server{ + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, + }, + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 2}, + }, + }, + ) + return &m + }(), + }, + }, + t: hotline.Transaction{ + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("hai")), + }, + }, + }, + want: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Flags: 0x00, + IsReply: 0x00, + Type: [2]byte{0, 0x6a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.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: []hotline.Field{ + hotline.NewField(hotline.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: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessSendChat) + return bits + }(), + }, + UserName: []byte{0x00, 0x01}, + Server: &hotline.Server{ + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, + }, + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 2}, + }, + }, + ) + return &m + }(), + }, + }, + t: hotline.Transaction{ + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("hai")), + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 0}), + }, + }, + }, + want: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0, 0x6a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.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: []hotline.Field{ + hotline.NewField(hotline.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: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranChatSend, [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("hai")), + ), + }, + want: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.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: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessSendChat) + return bits + }(), + }, + UserName: []byte("Testy McTest"), + Server: &hotline.Server{ + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, + }, + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 2}, + }, + }, + ) + return &m + }(), + }, + }, + t: hotline.Transaction{ + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("performed action")), + hotline.NewField(hotline.FieldChatOptions, []byte{0x00, 0x01}), + }, + }, + }, + want: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Flags: 0x00, + IsReply: 0x00, + Type: [2]byte{0, 0x6a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("\r*** Testy McTest performed action")), + }, + }, + { + ClientID: [2]byte{0, 2}, + Flags: 0x00, + IsReply: 0x00, + Type: [2]byte{0, 0x6a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("\r*** Testy McTest performed action")), + }, + }, + }, + }, + { + name: "does not send chat msg as emote if FieldChatOptions is set to 0", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessSendChat) + return bits + }(), + }, + UserName: []byte("Testy McTest"), + Server: &hotline.Server{ + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, + }, + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 2}, + }, + }, + ) + return &m + }(), + }, + }, + t: hotline.Transaction{ + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("hello")), + hotline.NewField(hotline.FieldChatOptions, []byte{0x00, 0x00}), + }, + }, + }, + want: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0, 0x6a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("\r Testy McTest: hello")), + }, + }, + { + ClientID: [2]byte{0, 2}, + Type: [2]byte{0, 0x6a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("\r Testy McTest: hello")), + }, + }, + }, + }, + { + name: "only sends chat msg to clients with AccessReadChat permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessSendChat) + return bits + }(), + }, + UserName: []byte{0x00, 0x01}, + Server: &hotline.Server{ + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessReadChat) + return bits + }(), + }, + ID: [2]byte{0, 1}, + }, + { + Account: &hotline.Account{}, + ID: [2]byte{0, 2}, + }, + }, + ) + return &m + }(), + }, + }, + t: hotline.Transaction{ + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("hai")), + }, + }, + }, + want: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0, 0x6a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.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: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessSendChat) + return bits + }(), + }, + UserName: []byte{0x00, 0x01}, + Server: &hotline.Server{ + ChatMgr: func() *hotline.MockChatManager { + m := hotline.MockChatManager{} + m.On("Members", hotline.ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*hotline.ClientConn{ + { + ID: [2]byte{0, 1}, + }, + { + ID: [2]byte{0, 2}, + }, + }) + m.On("GetSubject").Return("unset") + return &m + }(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, + }, + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{0, 0, 0, 0, 0, 0, 0, 0}, + }, + ID: [2]byte{0, 2}, + }, + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{0, 0, 0, 0, 0, 0, 0, 0}, + }, + ID: [2]byte{0, 3}, + }, + }, + ) + return &m + }(), + }, + }, + t: hotline.Transaction{ + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("hai")), + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), + }, + }, + }, + want: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0, 0x6a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), + hotline.NewField(hotline.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: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), + hotline.NewField(hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "returns expected fields when a valid file is requested", + args: args{ + cc: &hotline.ClientConn{ + ID: [2]byte{0x00, 0x01}, + Server: &hotline.Server{ + FS: &hotline.OSFileStore{}, + Config: hotline.Config{ + FileRoot: func() string { + path, _ := os.Getwd() + return filepath.Join(path, "/test/config/Files") + }(), + }, + }, + }, + t: hotline.NewTransaction( + hotline.TranGetFileInfo, [2]byte{}, + hotline.NewField(hotline.FieldFileName, []byte("testfile.txt")), + hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x00}), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Type: [2]byte{0, 0}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldFileName, []byte("testfile.txt")), + hotline.NewField(hotline.FieldFileTypeString, []byte("Text File")), + hotline.NewField(hotline.FieldFileCreatorString, []byte("ttxt")), + hotline.NewField(hotline.FieldFileType, []byte("TEXT")), + hotline.NewField(hotline.FieldFileCreateDate, make([]byte, 8)), + hotline.NewField(hotline.FieldFileModifyDate, make([]byte, 8)), + hotline.NewField(hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "without required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranNewFolder, + [2]byte{0, 0}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to create folders.")), + }, + }, + }, + }, + { + name: "when path is nested", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessCreateFolder) + return bits + }(), + }, + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + Config: hotline.Config{ + FileRoot: "/Files/", + }, + FS: func() *hotline.MockFileStore { + mfs := &hotline.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: hotline.NewTransaction( + hotline.TranNewFolder, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFolder")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x03, + 0x61, 0x61, 0x61, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + }, + }, + }, + { + name: "when path is not nested", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessCreateFolder) + return bits + }(), + }, + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + Config: hotline.Config{ + FileRoot: "/Files", + }, + FS: func() *hotline.MockFileStore { + mfs := &hotline.MockFileStore{} + mfs.On("Mkdir", "/Files/testFolder", fs.FileMode(0777)).Return(nil) + mfs.On("Stat", "/Files/testFolder").Return(nil, os.ErrNotExist) + return mfs + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranNewFolder, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFolder")), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + }, + }, + }, + { + name: "when Write returns an err", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessCreateFolder) + return bits + }(), + }, + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + Config: hotline.Config{ + FileRoot: "/Files/", + }, + FS: func() *hotline.MockFileStore { + mfs := &hotline.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: hotline.NewTransaction( + hotline.TranNewFolder, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFolder")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, + }), + ), + }, + wantRes: []hotline.Transaction{}, + }, + { + name: "FieldFileName does not allow directory traversal", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessCreateFolder) + return bits + }(), + }, + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + Config: hotline.Config{ + FileRoot: "/Files/", + }, + FS: func() *hotline.MockFileStore { + mfs := &hotline.MockFileStore{} + mfs.On("Mkdir", "/Files/testFolder", fs.FileMode(0777)).Return(nil) + mfs.On("Stat", "/Files/testFolder").Return(nil, os.ErrNotExist) + return mfs + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranNewFolder, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("../../testFolder")), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + }, + }, + }, + { + name: "FieldFilePath does not allow directory traversal", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessCreateFolder) + return bits + }(), + }, + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + Config: hotline.Config{ + FileRoot: "/Files/", + }, + FS: func() *hotline.MockFileStore { + mfs := &hotline.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: hotline.NewTransaction( + hotline.TranNewFolder, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFolder")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x02, + 0x00, 0x00, + 0x03, + 0x2e, 0x2e, 0x2f, + 0x00, 0x00, + 0x03, + 0x66, 0x6f, 0x6f, + }), + ), + }, + wantRes: []hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when request is valid and user has Upload Anywhere permission", + args: args{ + cc: &hotline.ClientConn{ + Server: &hotline.Server{ + FS: &hotline.OSFileStore{}, + FileTransferMgr: hotline.NewMemFileTransferMgr(), + Config: hotline.Config{ + FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(), + }}, + ClientFileTransferMgr: hotline.NewClientFileTransferMgr(), + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessUploadFile) + bits.Set(hotline.AccessUploadAnywhere) + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranUploadFile, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFile")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x03, + 0x2e, 0x2e, 0x2f, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}), // rand.Seed(1) + }, + }, + }, + }, + { + name: "when user does not have required access", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranUploadFile, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFile")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x03, + 0x2e, 0x2e, 0x2f, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "with valid input and required permissions", + args: args{ + cc: &hotline.ClientConn{ + Logger: NewTestLogger(), + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessMakeAlias) + return bits + }(), + }, + Server: &hotline.Server{ + Config: hotline.Config{ + FileRoot: func() string { + path, _ := os.Getwd() + return path + "/test/config/Files" + }(), + }, + Logger: NewTestLogger(), + FS: func() *hotline.MockFileStore { + mfs := &hotline.MockFileStore{} + path, _ := os.Getwd() + mfs.On( + "Symlink", + path+"/test/config/Files/foo/testFile", + path+"/test/config/Files/bar/testFile", + ).Return(nil) + return mfs + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranMakeFileAlias, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFile")), + hotline.NewField(hotline.FieldFilePath, hotline.EncodeFilePath(strings.Join([]string{"foo"}, "/"))), + hotline.NewField(hotline.FieldFileNewPath, hotline.EncodeFilePath(strings.Join([]string{"bar"}, "/"))), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.Field(nil), + }, + }, + }, + { + name: "when symlink returns an error", + args: args{ + cc: &hotline.ClientConn{ + Logger: NewTestLogger(), + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessMakeAlias) + return bits + }(), + }, + Server: &hotline.Server{ + Config: hotline.Config{ + FileRoot: func() string { + path, _ := os.Getwd() + return path + "/test/config/Files" + }(), + }, + Logger: NewTestLogger(), + FS: func() *hotline.MockFileStore { + mfs := &hotline.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: hotline.NewTransaction( + hotline.TranMakeFileAlias, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFile")), + hotline.NewField(hotline.FieldFilePath, hotline.EncodeFilePath(strings.Join([]string{"foo"}, "/"))), + hotline.NewField(hotline.FieldFileNewPath, hotline.EncodeFilePath(strings.Join([]string{"bar"}, "/"))), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("Error creating alias")), + }, + }, + }, + }, + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Logger: NewTestLogger(), + Account: &hotline.Account{ + Access: hotline.AccessBitmap{}, + }, + Server: &hotline.Server{ + Config: hotline.Config{ + FileRoot: func() string { + path, _ := os.Getwd() + return path + "/test/config/Files" + }(), + }, + }, + }, + t: hotline.NewTransaction( + hotline.TranMakeFileAlias, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFile")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x03, + 0x2e, 0x2e, 0x2e, + }), + hotline.NewField(hotline.FieldFileNewPath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x03, + 0x2e, 0x2e, 0x2e, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when account is valid", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessOpenUser) + return bits + }(), + }, + Server: &hotline.Server{ + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "guest").Return(&hotline.Account{ + Login: "guest", + Name: "Guest", + Password: "password", + Access: hotline.AccessBitmap{}, + }) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranGetUser, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserLogin, []byte("guest")), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserName, []byte("Guest")), + hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("guest"))), + hotline.NewField(hotline.FieldUserPassword, []byte("password")), + hotline.NewField(hotline.FieldUserAccess, []byte{0, 0, 0, 0, 0, 0, 0, 0}), + }, + }, + }, + }, + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranGetUser, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserLogin, []byte("nonExistentUser")), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to view accounts.")), + }, + }, + }, + }, + { + name: "when account does not exist", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessOpenUser) + return bits + }(), + }, + Server: &hotline.Server{ + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "nonExistentUser").Return((*hotline.Account)(nil)) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranGetUser, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserLogin, []byte("nonExistentUser")), + ), + }, + wantRes: []hotline.Transaction{ + { + Flags: 0x00, + IsReply: 0x01, + Type: [2]byte{0, 0}, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user exists", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessDeleteUser) + return bits + }(), + }, + Server: &hotline.Server{ + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Delete", "testuser").Return(nil) + return &m + }(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{}) // TODO + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranDeleteUser, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))), + ), + }, + wantRes: []hotline.Transaction{ + { + Flags: 0x00, + IsReply: 0x01, + Type: [2]byte{0, 0}, + Fields: []hotline.Field(nil), + }, + }, + }, + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: hotline.AccessBitmap{}, + }, + Server: &hotline.Server{ + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranDeleteUser, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "returns news data", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessNewsReadArt) + return bits + }(), + }, + Server: &hotline.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: hotline.NewTransaction( + hotline.TranGetMsgs, [2]byte{0, 1}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("TEST")), + }, + }, + }, + }, + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: hotline.AccessBitmap{}, + }, + Server: &hotline.Server{ + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranGetMsgs, [2]byte{0, 1}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranNewUser, [2]byte{0, 1}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to create new accounts.")), + }, + }, + }, + }, + { + name: "when user attempts to create account with greater access", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessCreateUser) + return bits + }(), + }, + Server: &hotline.Server{ + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "userB").Return((*hotline.Account)(nil)) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranNewUser, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("userB"))), + hotline.NewField( + hotline.FieldUserAccess, + func() []byte { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessDisconUser) + return bits[:] + }(), + ), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranNewUser, [2]byte{0, 1}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to view accounts.")), + }, + }, + }, + }, + { + name: "when user has required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessOpenUser) + return bits + }(), + }, + Server: &hotline.Server{ + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("List").Return([]hotline.Account{ + { + Name: "guest", + Login: "guest", + Password: "zz", + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + }) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranGetClientInfoText, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField(hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{}, + }, + t: hotline.NewTransaction(hotline.TranDownloadFile, [2]byte{0, 1}), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to download files.")), + }, + }, + }, + }, + { + name: "with a valid file", + args: args{ + cc: &hotline.ClientConn{ + ClientFileTransferMgr: hotline.NewClientFileTransferMgr(), + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessDownloadFile) + return bits + }(), + }, + Server: &hotline.Server{ + FS: &hotline.OSFileStore{}, + FileTransferMgr: hotline.NewMemFileTransferMgr(), + Config: hotline.Config{ + FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(), + }, + }, + }, + t: hotline.NewTransaction( + hotline.TranDownloadFile, + [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testfile.txt")), + hotline.NewField(hotline.FieldFilePath, []byte{0x0, 0x00}), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}), + hotline.NewField(hotline.FieldWaitingCount, []byte{0x00, 0x00}), + hotline.NewField(hotline.FieldTransferSize, []byte{0x00, 0x00, 0x00, 0xa5}), + hotline.NewField(hotline.FieldFileSize, []byte{0x00, 0x00, 0x00, 0x17}), + }, + }, + }, + }, + { + name: "when client requests to resume 1k test file at offset 256", + args: args{ + cc: &hotline.ClientConn{ + ClientFileTransferMgr: hotline.NewClientFileTransferMgr(), + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessDownloadFile) + return bits + }(), + }, + Server: &hotline.Server{ + FS: &hotline.OSFileStore{}, + + // FS: func() *hotline.MockFileStore { + // path, _ := os.Getwd() + // testFile, err := os.Open(path + "/test/config/Files/testfile-1k") + // if err != nil { + // panic(err) + // } + // + // mfi := &hotline.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: hotline.NewMemFileTransferMgr(), + Config: hotline.Config{ + FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(), + }, + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranDownloadFile, + [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testfile-1k")), + hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x00}), + hotline.NewField( + hotline.FieldFileResumeData, + func() []byte { + frd := hotline.FileResumeData{ + ForkCount: [2]byte{0, 2}, + ForkInfoList: []hotline.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: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}), + hotline.NewField(hotline.FieldWaitingCount, []byte{0x00, 0x00}), + hotline.NewField(hotline.FieldTransferSize, []byte{0x00, 0x00, 0x03, 0x8d}), + hotline.NewField(hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when action is create user without required permission", + args: args{ + cc: &hotline.ClientConn{ + Logger: NewTestLogger(), + Server: &hotline.Server{ + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "bbb").Return((*hotline.Account)(nil)) + return &m + }(), + Logger: NewTestLogger(), + }, + Account: &hotline.Account{ + Access: hotline.AccessBitmap{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranUpdateUser, + [2]byte{0, 0}, + hotline.NewField(hotline.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: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to create new accounts.")), + }, + }, + }, + }, + { + name: "when action is modify user without required permission", + args: args{ + cc: &hotline.ClientConn{ + Logger: NewTestLogger(), + Server: &hotline.Server{ + Logger: NewTestLogger(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "bbb").Return(&hotline.Account{}) + return &m + }(), + }, + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranUpdateUser, + [2]byte{0, 0}, + hotline.NewField(hotline.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: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to modify accounts.")), + }, + }, + }, + }, + { + name: "when action is delete user without required permission", + args: args{ + cc: &hotline.ClientConn{ + Logger: NewTestLogger(), + Server: &hotline.Server{}, + Account: &hotline.Account{ + Access: hotline.AccessBitmap{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranUpdateUser, + [2]byte{0, 0}, + hotline.NewField(hotline.FieldData, []byte{ + 0x00, 0x01, + 0x00, 0x65, + 0x00, 0x03, + 0x88, 0x9e, 0x8b, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "without required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranDelNewsArt, + [2]byte{0, 0}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "without required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranDelNewsArt, + [2]byte{0, 0}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to disconnect users.")), + }, + }, + }, + }, + { + name: "when target user has 'cannot be disconnected' priv", + args: args{ + cc: &hotline.ClientConn{ + Server: &hotline.Server{ + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("Get", hotline.ClientID{0x0, 0x1}).Return(&hotline.ClientConn{ + Account: &hotline.Account{ + Login: "unnamed", + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessCannotBeDiscon) + return bits + }(), + }, + }, + ) + return &m + }(), + }, + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessDisconUser) + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranDelNewsArt, + [2]byte{0, 0}, + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "without required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranDelNewsArt, + [2]byte{0, 0}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to send private messages.")), + }, + }, + }, + }, + { + name: "when client 1 sends a message to client 2", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessSendPrivMsg) + return bits + }(), + }, + ID: [2]byte{0, 1}, + UserName: []byte("User1"), + Server: &hotline.Server{ + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{ + AutoReply: []byte(nil), + Flags: [2]byte{0, 0}, + }, + ) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSendInstantMsg, + [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("hai")), + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + ), + }, + wantRes: []hotline.Transaction{ + hotline.NewTransaction( + hotline.TranServerMsg, + [2]byte{0, 2}, + hotline.NewField(hotline.FieldData, []byte("hai")), + hotline.NewField(hotline.FieldUserName, []byte("User1")), + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldOptions, []byte{0, 1}), + ), + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.Field(nil), + }, + }, + }, + { + name: "when client 2 has autoreply enabled", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessSendPrivMsg) + return bits + }(), + }, + ID: [2]byte{0, 1}, + UserName: []byte("User1"), + Server: &hotline.Server{ + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{ + Flags: [2]byte{0, 0}, + ID: [2]byte{0, 2}, + UserName: []byte("User2"), + AutoReply: []byte("autohai"), + }) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSendInstantMsg, + [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("hai")), + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + ), + }, + wantRes: []hotline.Transaction{ + hotline.NewTransaction( + hotline.TranServerMsg, + [2]byte{0, 2}, + hotline.NewField(hotline.FieldData, []byte("hai")), + hotline.NewField(hotline.FieldUserName, []byte("User1")), + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldOptions, []byte{0, 1}), + ), + hotline.NewTransaction( + hotline.TranServerMsg, + [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("autohai")), + hotline.NewField(hotline.FieldUserName, []byte("User2")), + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + hotline.NewField(hotline.FieldOptions, []byte{0, 1}), + ), + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.Field(nil), + }, + }, + }, + { + name: "when client 2 has refuse private messages enabled", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessSendPrivMsg) + return bits + }(), + }, + ID: [2]byte{0, 1}, + UserName: []byte("User1"), + Server: &hotline.Server{ + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{ + Flags: [2]byte{255, 255}, + ID: [2]byte{0, 2}, + UserName: []byte("User2"), + }, + ) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSendInstantMsg, + [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("hai")), + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + ), + }, + wantRes: []hotline.Transaction{ + hotline.NewTransaction( + hotline.TranServerMsg, + [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("User2 does not accept private messages.")), + hotline.NewField(hotline.FieldUserName, []byte("User2")), + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + hotline.NewField(hotline.FieldOptions, []byte{0, 2}), + ), + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission to delete a folder", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + Config: hotline.Config{ + FileRoot: func() string { + return "/fakeRoot/Files" + }(), + }, + FS: func() *hotline.MockFileStore { + mfi := &hotline.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 := &hotline.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: hotline.NewTransaction( + hotline.TranDeleteFile, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testfile")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x03, + 0x61, 0x61, 0x61, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete files.")), + }, + }, + }, + }, + { + name: "deletes all associated metadata files", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessDeleteFile) + return bits + }(), + }, + Server: &hotline.Server{ + Config: hotline.Config{ + FileRoot: func() string { + return "/fakeRoot/Files" + }(), + }, + FS: func() *hotline.MockFileStore { + mfi := &hotline.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 := &hotline.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: hotline.NewTransaction( + hotline.TranDeleteFile, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testfile")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x03, + 0x61, 0x61, 0x61, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.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.(*hotline.MockFileStore).AssertExpectations(t) + }) + } +} + +func TestHandleGetFileNameList(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when FieldFilePath is a drop box, but user does not have AccessViewDropBoxes ", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + + Config: hotline.Config{ + FileRoot: func() string { + path, _ := os.Getwd() + return filepath.Join(path, "/test/config/Files/getFileNameListTestDir") + }(), + }, + }, + }, + t: hotline.NewTransaction( + hotline.TranGetFileNameList, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x08, + 0x64, 0x72, 0x6f, 0x70, 0x20, 0x62, 0x6f, 0x78, // "drop box" + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to view drop boxes.")), + }, + }, + }, + }, + { + name: "with file root", + args: args{ + cc: &hotline.ClientConn{ + Server: &hotline.Server{ + Config: hotline.Config{ + FileRoot: func() string { + path, _ := os.Getwd() + return filepath.Join(path, "/test/config/Files/getFileNameListTestDir") + }(), + }, + }, + }, + t: hotline.NewTransaction( + hotline.TranGetFileNameList, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x00, + 0x00, 0x00, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField( + hotline.FieldFileNameWithInfo, + func() []byte { + fnwi := hotline.FileNameWithInfo{ + FileNameWithInfoHeader: hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranGetClientInfoText, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to get client info.")), + }, + }, + }, + }, + { + name: "with a valid user", + args: args{ + cc: &hotline.ClientConn{ + UserName: []byte("Testy McTest"), + RemoteAddr: "1.2.3.4:12345", + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessGetClientInfo) + return bits + }(), + Name: "test", + Login: "test", + }, + Server: &hotline.Server{ + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("Get", hotline.ClientID{0x0, 0x1}).Return(&hotline.ClientConn{ + UserName: []byte("Testy McTest"), + RemoteAddr: "1.2.3.4:12345", + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessGetClientInfo) + return bits + }(), + Name: "test", + Login: "test", + }, + }, + ) + return &m + }(), + }, + ClientFileTransferMgr: hotline.ClientFileTransferMgr{}, + }, + t: hotline.NewTransaction( + hotline.TranGetClientInfoText, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField(hotline.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")), + ), + hotline.NewField(hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "normal request flow", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessDisconUser) + bits.Set(hotline.AccessAnyName) + return bits + }()}, + Icon: []byte{0, 1}, + Flags: [2]byte{0, 1}, + Version: []byte{0, 1}, + ID: [2]byte{0, 1}, + Logger: NewTestLogger(), + Server: &hotline.Server{ + Config: hotline.Config{ + BannerFile: "Banner.jpg", + }, + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + //{ + // ID: [2]byte{0, 2}, + // UserName: []byte("UserB"), + //}, + }, + ) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranAgreed, [2]byte{}, + hotline.NewField(hotline.FieldUserName, []byte("username")), + hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}), + hotline.NewField(hotline.FieldOptions, []byte{0, 0}), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0, 0x7a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldBannerType, []byte("JPEG")), + }, + }, + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when client does not have AccessAnyName", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + ID: [2]byte{0, 1}, + UserName: []byte("Guest"), + Flags: [2]byte{0, 1}, + Server: &hotline.Server{ + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + ID: [2]byte{0, 1}, + }, + }) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSetClientUserInfo, [2]byte{}, + hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}), + hotline.NewField(hotline.FieldUserName, []byte("NOPE")), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0x01, 0x2d}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}), + hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}), + hotline.NewField(hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have permission to delete a news category", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: hotline.AccessBitmap{}, + }, + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { + m := hotline.MockThreadNewsMgr{} + m.On("NewsItem", []string{"test"}).Return(hotline.NewsCategoryListData15{ + Type: hotline.NewsCategory, + }) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranDelNewsItem, [2]byte{}, + hotline.NewField(hotline.FieldNewsPath, + []byte{ + 0, 1, + 0, 0, + 4, + 0x74, 0x65, 0x73, 0x74, + }, + ), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.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: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: hotline.AccessBitmap{}, + }, + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { + m := hotline.MockThreadNewsMgr{} + m.On("NewsItem", []string{"test"}).Return(hotline.NewsCategoryListData15{ + Type: hotline.NewsBundle, + }) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranDelNewsItem, [2]byte{}, + hotline.NewField(hotline.FieldNewsPath, + []byte{ + 0, 1, + 0, 0, + 4, + 0x74, 0x65, 0x73, 0x74, + }, + ), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete news folders.")), + }, + }, + }, + }, + { + name: "when user deletes a news folder", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessNewsDeleteFldr) + return bits + }(), + }, + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { + m := hotline.MockThreadNewsMgr{} + m.On("NewsItem", []string{"test"}).Return(hotline.NewsCategoryListData15{Type: hotline.NewsBundle}) + m.On("DeleteNewsItem", []string{"test"}).Return(nil) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranDelNewsItem, [2]byte{}, + hotline.NewField(hotline.FieldNewsPath, + []byte{ + 0, 1, + 0, 0, + 4, + 0x74, 0x65, 0x73, 0x74, + }, + ), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: hotline.AccessBitmap{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranOldPostNews, [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("hai")), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to post news.")), + }, + }, + }, + }, + { + name: "when user posts news update", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessNewsPostArt) + return bits + }(), + }, + Server: &hotline.Server{ + Config: hotline.Config{ + NewsDateFormat: "", + }, + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.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: hotline.NewTransaction( + hotline.TranOldPostNews, [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("hai")), + ), + }, + wantRes: []hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + }, + t: hotline.NewTransaction(hotline.TranInviteNewChat, [2]byte{0, 1}), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to request private chat.")), + }, + }, + }, + }, + { + name: "when userA invites userB to new private chat", + args: args{ + cc: &hotline.ClientConn{ + ID: [2]byte{0, 1}, + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessOpenChat) + return bits + }(), + }, + UserName: []byte("UserA"), + Icon: []byte{0, 1}, + Flags: [2]byte{0, 0}, + Server: &hotline.Server{ + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{ + ID: [2]byte{0, 2}, + UserName: []byte("UserB"), + }) + return &m + }(), + ChatMgr: func() *hotline.MockChatManager { + m := hotline.MockChatManager{} + m.On("New", mock.AnythingOfType("*hotline.ClientConn")).Return(hotline.ChatID{0x52, 0xfd, 0xfc, 0x07}) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranInviteNewChat, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 2}, + Type: [2]byte{0, 0x71}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}), + hotline.NewField(hotline.FieldUserName, []byte("UserA")), + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + }, + }, + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}), + hotline.NewField(hotline.FieldUserName, []byte("UserA")), + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}), + hotline.NewField(hotline.FieldUserFlags, []byte{0, 0}), + }, + }, + }, + }, + { + name: "when userA invites userB to new private chat, but UserB has refuse private chat enabled", + args: args{ + cc: &hotline.ClientConn{ + ID: [2]byte{0, 1}, + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessOpenChat) + return bits + }(), + }, + UserName: []byte("UserA"), + Icon: []byte{0, 1}, + Flags: [2]byte{0, 0}, + Server: &hotline.Server{ + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("Get", hotline.ClientID{0, 2}).Return(&hotline.ClientConn{ + ID: [2]byte{0, 2}, + Icon: []byte{0, 1}, + UserName: []byte("UserB"), + Flags: [2]byte{255, 255}, + }) + return &m + }(), + ChatMgr: func() *hotline.MockChatManager { + m := hotline.MockChatManager{} + m.On("New", mock.AnythingOfType("*hotline.ClientConn")).Return(hotline.ChatID{0x52, 0xfd, 0xfc, 0x07}) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranInviteNewChat, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0, 0x68}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("UserB does not accept private chats.")), + hotline.NewField(hotline.FieldUserName, []byte("UserB")), + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + hotline.NewField(hotline.FieldOptions, []byte{0, 2}), + }, + }, + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}), + hotline.NewField(hotline.FieldUserName, []byte("UserA")), + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}), + hotline.NewField(hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{Account: &hotline.Account{}}, + t: hotline.NewTransaction( + hotline.TranGetNewsArtData, [2]byte{0, 1}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to read news.")), + }, + }, + }, + }, + { + name: "when user has required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessNewsReadArt) + return bits + }(), + }, + Server: &hotline.Server{ + ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { + m := hotline.MockThreadNewsMgr{} + m.On("GetArticle", []string{"Example Category"}, uint32(1)).Return(&hotline.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: hotline.NewTransaction( + hotline.TranGetNewsArtData, [2]byte{0, 1}, + hotline.NewField(hotline.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, + }), + hotline.NewField(hotline.FieldNewsArtID, []byte{0, 1}), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 1, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldNewsArtTitle, []byte("title")), + hotline.NewField(hotline.FieldNewsArtPoster, []byte("poster")), + hotline.NewField(hotline.FieldNewsArtDate, []byte{0, 0, 0, 0, 0, 0, 0, 0}), + hotline.NewField(hotline.FieldNewsArtPrevArt, []byte{0, 0, 0, 1}), + hotline.NewField(hotline.FieldNewsArtNextArt, []byte{0, 0, 0, 2}), + hotline.NewField(hotline.FieldNewsArtParentArt, []byte{0, 0, 0, 3}), + hotline.NewField(hotline.FieldNewsArt1stChildArt, []byte{0, 0, 0, 4}), + hotline.NewField(hotline.FieldNewsArtDataFlav, []byte("text/plain")), + hotline.NewField(hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranGetNewsArtNameList, [2]byte{0, 1}, + ), + }, + wantRes: []hotline.Transaction{ + { + Flags: 0x00, + IsReply: 0x01, + Type: [2]byte{0, 0}, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to read news.")), + }, + }, + }, + }, + //{ + // name: "when user has required access", + // args: args{ + // cc: &hotline.ClientConn{ + // Account: &hotline.Account{ + // Access: func() hotline.AccessBitmap { + // var bits hotline.AccessBitmap + // bits.Set(hotline.AccessNewsReadArt) + // return bits + // }(), + // }, + // Server: &hotline.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(hotline.FieldNewsPath, []byte{ + // 0x00, 0x01, 0x00, 0x00, 0x10, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x20, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, + // }), + // ), + // }, + // wantRes: []hotline.Transaction{ + // { + // IsReply: 0x01, + // Fields: []hotline.Field{ + // NewField(hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranGetNewsArtNameList, [2]byte{0, 1}, + ), + }, + wantRes: []hotline.Transaction{ + { + Flags: 0x00, + IsReply: 0x01, + Type: [2]byte{0, 0}, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to create news folders.")), + }, + }, + }, + }, + { + name: "with a valid request", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessNewsCreateFldr) + return bits + }(), + }, + Logger: NewTestLogger(), + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { + m := hotline.MockThreadNewsMgr{} + m.On("CreateGrouping", []string{"test"}, "testFolder", hotline.NewsBundle).Return(nil) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranGetNewsArtNameList, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFolder")), + hotline.NewField(hotline.FieldNewsPath, + []byte{ + 0, 1, + 0, 0, + 4, + 0x74, 0x65, 0x73, 0x74, + }, + ), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.Field{}, + }, + }, + }, + //{ + // Name: "when there is an error writing the threaded news file", + // args: args{ + // cc: &hotline.ClientConn{ + // Account: &hotline.Account{ + // Access: func() hotline.AccessBitmap { + // var bits hotline.AccessBitmap + // bits.Set(hotline.AccessNewsCreateFldr) + // return bits + // }(), + // }, + // logger: NewTestLogger(), + // Type: [2]byte{0, 1}, + // Server: &hotline.Server{ + // ConfigDir: "/fakeConfigRoot", + // FS: func() *hotline.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(hotline.FieldFileName, []byte("testFolder")), + // NewField(hotline.FieldNewsPath, + // []byte{ + // 0, 1, + // 0, 0, + // 4, + // 0x74, 0x65, 0x73, 0x74, + // }, + // ), + // ), + // }, + // wantRes: []hotline.Transaction{ + // { + // ClientID: [2]byte{0, 1}, + // Flags: 0x00, + // IsReply: 0x01, + // Type: [2]byte{0, 0}, + // ErrorCode: [4]byte{0, 0, 0, 1}, + // Fields: []hotline.Field{ + // NewField(hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.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 *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "without required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranPostNewsArt, + [2]byte{0, 0}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to post news articles.")), + }, + }, + }, + }, + { + name: "with required permission", + args: args{ + cc: &hotline.ClientConn{ + Server: &hotline.Server{ + ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { + m := hotline.MockThreadNewsMgr{} + m.On("PostArticle", []string{"www"}, uint32(0), mock.AnythingOfType("hotline.NewsArtData")).Return(nil) + return &m + }(), + }, + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessNewsPostArt) + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranPostNewsArt, + [2]byte{0, 0}, + hotline.NewField(hotline.FieldNewsPath, []byte{0x00, 0x01, 0x00, 0x00, 0x03, 0x77, 0x77, 0x77}), + hotline.NewField(hotline.FieldNewsArtID, []byte{0x00, 0x00, 0x00, 0x00}), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 0}, + Fields: []hotline.Field{}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + TranAssertEqual(t, tt.wantRes, HandlePostNewsArt(tt.args.cc, &tt.args.t)) + }) + } +} |