aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorJeff Halter <868228+jhalter@users.noreply.github.com>2024-07-17 15:41:20 -0700
committerJeff Halter <868228+jhalter@users.noreply.github.com>2024-07-17 15:42:37 -0700
commitfd740bc499ebc6d3a381479316f74cdc736d02de (patch)
treee77563ce0997e51debaec75c9f7a053759ca3a0a /internal
parent80aed6b19ff0b0927670e459ce5cc7a16ef047ec (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')
-rw-r--r--internal/mobius/account_manager.go193
-rw-r--r--internal/mobius/agreement.go78
-rw-r--r--internal/mobius/ban.go28
-rw-r--r--internal/mobius/ban_test.go154
-rw-r--r--internal/mobius/config.go27
-rw-r--r--internal/mobius/news.go22
-rw-r--r--internal/mobius/test/config/Agreement.txt1
-rw-r--r--internal/mobius/test/config/Banlist.yaml1
-rw-r--r--internal/mobius/test/config/Files/getFileNameListTestDir/testfile-1kbin0 -> 1024 bytes
-rw-r--r--internal/mobius/test/config/Files/test/testfile-1kbin0 -> 1024 bytes
-rw-r--r--internal/mobius/test/config/Files/test/testfile-5kbin0 -> 5120 bytes
-rw-r--r--internal/mobius/test/config/Files/testdir/some-nested-file.txt0
-rw-r--r--internal/mobius/test/config/Files/testfile-1kbin0 -> 1024 bytes
-rw-r--r--internal/mobius/test/config/Files/testfile-8b1
-rw-r--r--internal/mobius/test/config/Files/testfile.sit1
-rw-r--r--internal/mobius/test/config/Files/testfile.txt1
-rw-r--r--internal/mobius/test/config/MessageBoard.txt1
-rw-r--r--internal/mobius/test/config/ThreadedNews.yaml232
-rw-r--r--internal/mobius/test/config/Users/admin.yaml13
-rw-r--r--internal/mobius/test/config/Users/guest.yaml12
-rw-r--r--internal/mobius/test/config/config.yaml6
-rw-r--r--internal/mobius/threaded_news.go10
-rw-r--r--internal/mobius/threaded_news_test.go65
-rw-r--r--internal/mobius/transaction_handlers.go1793
-rw-r--r--internal/mobius/transaction_handlers_test.go3795
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
new file mode 100644
index 0000000..31758a0
--- /dev/null
+++ b/internal/mobius/test/config/Files/getFileNameListTestDir/testfile-1k
Binary files differ
diff --git a/internal/mobius/test/config/Files/test/testfile-1k b/internal/mobius/test/config/Files/test/testfile-1k
new file mode 100644
index 0000000..31758a0
--- /dev/null
+++ b/internal/mobius/test/config/Files/test/testfile-1k
Binary files differ
diff --git a/internal/mobius/test/config/Files/test/testfile-5k b/internal/mobius/test/config/Files/test/testfile-5k
new file mode 100644
index 0000000..c889187
--- /dev/null
+++ b/internal/mobius/test/config/Files/test/testfile-5k
Binary files differ
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
new file mode 100644
index 0000000..31758a0
--- /dev/null
+++ b/internal/mobius/test/config/Files/testfile-1k
Binary files differ
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))
+ })
+ }
+}