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