aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorRuben Beltran del Rio <git@r.bdr.sh>2025-11-28 00:39:17 +0100
committerRuben Beltran del Rio <git@r.bdr.sh>2025-11-28 00:39:17 +0100
commit9f67542d8469db45c823e347b1868b3582d9e5a7 (patch)
tree88741b3d8633758e4f6f5cbc292f338bc99602a0 /internal
parent8f9edf2f3bb18f7ab1a04ead182a1daf2cfd41d9 (diff)
parent8ddb9bb228389b198a76d6df21de005da4fad66b (diff)
Merge branch 'master' of https://github.com/jhalter/mobiusHEADmain
Diffstat (limited to 'internal')
-rw-r--r--internal/mobius/account_manager.go38
-rw-r--r--internal/mobius/account_manager_test.go438
-rw-r--r--internal/mobius/api.go226
-rw-r--r--internal/mobius/ban.go6
-rw-r--r--internal/mobius/ban_test.go10
-rw-r--r--internal/mobius/config.go19
-rw-r--r--internal/mobius/config_test.go95
-rw-r--r--internal/mobius/news.go6
-rw-r--r--internal/mobius/news_test.go500
-rw-r--r--internal/mobius/threaded_news.go4
-rw-r--r--internal/mobius/threaded_news_test.go8
-rw-r--r--internal/mobius/transaction_handlers.go668
-rw-r--r--internal/mobius/transaction_handlers_test.go749
13 files changed, 2525 insertions, 242 deletions
diff --git a/internal/mobius/account_manager.go b/internal/mobius/account_manager.go
index 6bbc951..d9169c9 100644
--- a/internal/mobius/account_manager.go
+++ b/internal/mobius/account_manager.go
@@ -18,12 +18,14 @@ func loadFromYAMLFile(path string, data interface{}) error {
if err != nil {
return err
}
- defer fh.Close()
+ defer func() { _ = fh.Close() }()
decoder := yaml.NewDecoder(fh)
return decoder.Decode(data)
}
+// YAMLAccountManager implements AccountManager interface using YAML files for persistence.
+// It maintains an in-memory cache of accounts and synchronizes with YAML files on disk.
type YAMLAccountManager struct {
accounts map[string]hotline.Account
accountDir string
@@ -31,13 +33,16 @@ type YAMLAccountManager struct {
mu sync.Mutex
}
+// NewYAMLAccountManager creates a new YAML-based account manager that loads existing
+// accounts from .yaml files in the specified directory. It also performs migration
+// from old access flag format to new AccessBitmap format when needed.
func NewYAMLAccountManager(accountDir string) (*YAMLAccountManager, error) {
accountMgr := YAMLAccountManager{
accountDir: accountDir,
accounts: make(map[string]hotline.Account),
}
- matches, err := filepath.Glob(filepath.Join(accountDir, "*.yaml"))
+ matches, err := filepath.Glob(path.Join(accountDir, "*.yaml"))
if err != nil {
return nil, fmt.Errorf("list account files: %w", err)
}
@@ -71,19 +76,21 @@ func NewYAMLAccountManager(accountDir string) (*YAMLAccountManager, error) {
return &accountMgr, nil
}
+// Create adds a new account by writing it to a YAML file and updating the in-memory cache.
+// Returns an error if an account with the same login already exists.
func (am *YAMLAccountManager) Create(account hotline.Account) error {
am.mu.Lock()
defer am.mu.Unlock()
// Create account file, returning an error if one already exists.
file, err := os.OpenFile(
- filepath.Join(am.accountDir, path.Join("/", account.Login+".yaml")),
+ path.Join(am.accountDir, path.Join("/", account.Login+".yaml")),
os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644,
)
if err != nil {
return fmt.Errorf("create account file: %w", err)
}
- defer file.Close()
+ defer func() { _ = file.Close() }()
b, err := yaml.Marshal(account)
if err != nil {
@@ -100,6 +107,8 @@ func (am *YAMLAccountManager) Create(account hotline.Account) error {
return nil
}
+// Update modifies an existing account with new data and optionally renames it.
+// If newLogin differs from account.Login, the account file is renamed accordingly.
func (am *YAMLAccountManager) Update(account hotline.Account, newLogin string) error {
am.mu.Lock()
defer am.mu.Unlock()
@@ -107,17 +116,16 @@ func (am *YAMLAccountManager) Update(account hotline.Account, newLogin string) e
// 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"),
+ path.Join(am.accountDir, path.Join("/", account.Login)+".yaml"),
+ path.Join(am.accountDir, path.Join("/", newLogin)+".yaml"),
)
if err != nil {
return fmt.Errorf("error renaming account file: %w", err)
}
+ delete(am.accounts, account.Login)
account.Login = newLogin
am.accounts[newLogin] = account
-
- delete(am.accounts, account.Login)
}
out, err := yaml.Marshal(&account)
@@ -125,7 +133,7 @@ func (am *YAMLAccountManager) Update(account hotline.Account, newLogin string) e
return err
}
- if err := os.WriteFile(filepath.Join(am.accountDir, newLogin+".yaml"), out, 0644); err != nil {
+ if err := os.WriteFile(path.Join(am.accountDir, newLogin+".yaml"), out, 0644); err != nil {
return fmt.Errorf("error writing account file: %w", err)
}
@@ -134,6 +142,8 @@ func (am *YAMLAccountManager) Update(account hotline.Account, newLogin string) e
return nil
}
+// Get retrieves an account by login from the in-memory cache.
+// Returns nil if the account is not found.
func (am *YAMLAccountManager) Get(login string) *hotline.Account {
am.mu.Lock()
defer am.mu.Unlock()
@@ -146,6 +156,7 @@ func (am *YAMLAccountManager) Get(login string) *hotline.Account {
return &account
}
+// List returns all accounts from the in-memory cache as a slice.
func (am *YAMLAccountManager) List() []hotline.Account {
am.mu.Lock()
defer am.mu.Unlock()
@@ -158,11 +169,12 @@ func (am *YAMLAccountManager) List() []hotline.Account {
return accounts
}
+// Delete removes an account by deleting its YAML file and removing it from the cache.
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")))
+ err := os.Remove(path.Join(am.accountDir, path.Join("/", login+".yaml")))
if err != nil {
return fmt.Errorf("delete account file: %v", err)
}
@@ -172,34 +184,40 @@ func (am *YAMLAccountManager) Delete(login string) error {
return nil
}
+// MockAccountManager provides a test double implementation of AccountManager using testify/mock.
type MockAccountManager struct {
mock.Mock
}
+// Create mocks the Create method for testing.
func (m *MockAccountManager) Create(account hotline.Account) error {
args := m.Called(account)
return args.Error(0)
}
+// Update mocks the Update method for testing.
func (m *MockAccountManager) Update(account hotline.Account, newLogin string) error {
args := m.Called(account, newLogin)
return args.Error(0)
}
+// Get mocks the Get method for testing.
func (m *MockAccountManager) Get(login string) *hotline.Account {
args := m.Called(login)
return args.Get(0).(*hotline.Account)
}
+// List mocks the List method for testing.
func (m *MockAccountManager) List() []hotline.Account {
args := m.Called()
return args.Get(0).([]hotline.Account)
}
+// Delete mocks the Delete method for testing.
func (m *MockAccountManager) Delete(login string) error {
args := m.Called(login)
diff --git a/internal/mobius/account_manager_test.go b/internal/mobius/account_manager_test.go
index fe834a4..b0bfd79 100644
--- a/internal/mobius/account_manager_test.go
+++ b/internal/mobius/account_manager_test.go
@@ -1,89 +1,403 @@
package mobius
import (
- "fmt"
"github.com/jhalter/mobius/hotline"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "os"
+ "path"
"testing"
)
-func TestNewYAMLAccountManager(t *testing.T) {
- type args struct {
- accountDir string
+// copyTestFiles copies test config files to a temporary directory
+func copyTestFiles(t *testing.T, srcDir, dstDir string) {
+ files := []string{"admin.yaml", "guest.yaml", "user-with-old-access-format.yaml"}
+
+ for _, file := range files {
+ srcPath := path.Join(srcDir, file)
+ dstPath := path.Join(dstDir, file)
+
+ data, err := os.ReadFile(srcPath)
+ require.NoError(t, err, "Failed to read %s", srcPath)
+
+ err = os.WriteFile(dstPath, data, 0644)
+ require.NoError(t, err, "Failed to write %s", dstPath)
}
- tests := []struct {
- name string
- args args
- want *YAMLAccountManager
- wantErr assert.ErrorAssertionFunc
- }{
- {
- name: "loads accounts from a directory",
- args: args{
- accountDir: "test/config/Users",
+}
+
+func TestNewYAMLAccountManager(t *testing.T) {
+ t.Run("loads accounts from a directory", func(t *testing.T) {
+ // Create temporary directory and copy test files
+ tempDir := t.TempDir()
+ copyTestFiles(t, "test/config/Users", tempDir)
+
+ // Use temp directory instead of original test directory
+ got, err := NewYAMLAccountManager(tempDir)
+ require.NoError(t, err)
+
+ assert.Equal(t,
+ &hotline.Account{
+ Name: "admin",
+ Login: "admin",
+ Password: "$2a$04$2itGEYx8C1N5bsfRSoC9JuonS3I4YfnyVPZHLSwp7kEInRX0yoB.a",
+ Access: hotline.AccessBitmap{0xff, 0xff, 0xef, 0xff, 0xff, 0x80, 0x00, 0x00},
+ },
+ got.Get("admin"),
+ )
+
+ assert.Equal(t,
+ &hotline.Account{
+ Login: "guest",
+ Name: "guest",
+ Password: "$2a$04$6Yq/TIlgjSD.FbARwtYs9ODnkHawonu1TJ5W2jJKfhnHwBIQTk./y",
+ Access: hotline.AccessBitmap{0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00},
+ },
+ got.Get("guest"),
+ )
+
+ assert.Equal(t,
+ &hotline.Account{
+ Login: "test-user",
+ Name: "Test User Name",
+ Password: "$2a$04$9P/jgLn1fR9TjSoWL.rKxuN6g.1TSpf2o6Hw.aaRuBwrWIJNwsKkS",
+ Access: hotline.AccessBitmap{0x7d, 0xf0, 0x0c, 0xef, 0xab, 0x80, 0x00, 0x00},
+ },
+ got.Get("test-user"),
+ )
+ })
+}
+
+func TestYAMLAccountManager_Delete(t *testing.T) {
+ t.Run("successful deletions", func(t *testing.T) {
+ type args struct {
+ login string
+ }
+ tests := []struct {
+ name string
+ args args
+ }{
+ {
+ name: "deletes existing guest account",
+ args: args{
+ login: "guest",
+ },
+ },
+ {
+ name: "deletes existing admin account",
+ args: args{
+ login: "admin",
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create temporary directory and copy test files
+ tempDir := t.TempDir()
+ copyTestFiles(t, "test/config/Users", tempDir)
+
+ accountMgr, err := NewYAMLAccountManager(tempDir)
+ require.NoError(t, err)
+
+ // Get initial state
+ initialAccounts := accountMgr.List()
+ initialCount := len(initialAccounts)
+
+ // Verify account exists before deletion
+ account := accountMgr.Get(tt.args.login)
+ require.NotNilf(t, account, "Account %s should exist before deletion", tt.args.login)
+
+ // Perform deletion
+ err = accountMgr.Delete(tt.args.login)
+ assert.NoErrorf(t, err, "Delete(%v)", tt.args.login)
+
+ // Verify account no longer exists in memory
+ account = accountMgr.Get(tt.args.login)
+ assert.Nilf(t, account, "Delete(%v)", tt.args.login)
+
+ // Verify file was deleted
+ filePath := path.Join(tempDir, tt.args.login+".yaml")
+ _, fileErr := os.Stat(filePath)
+ assert.Truef(t, os.IsNotExist(fileErr), "Delete(%v) - file should be deleted", tt.args.login)
+
+ // Verify list count decreased
+ updatedAccounts := accountMgr.List()
+ expectedCount := initialCount - 1
+ assert.Equalf(t, expectedCount, len(updatedAccounts), "Delete(%v)", tt.args.login)
+
+ // Verify deleted account is not in the list
+ for _, account := range updatedAccounts {
+ assert.NotEqualf(t, tt.args.login, account.Login, "Delete(%v)", tt.args.login)
+ }
+ })
+ }
+ })
+
+ t.Run("error cases", func(t *testing.T) {
+ type args struct {
+ login string
+ }
+ tests := []struct {
+ name string
+ args args
+ }{
+ {
+ name: "returns error when deleting non-existent account",
+ args: args{
+ login: "nonexistent",
+ },
},
- want: &YAMLAccountManager{
- accountDir: "test/config/Users",
- accounts: map[string]hotline.Account{
- "admin": {
- Name: "admin",
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create temporary directory and copy test files
+ tempDir := t.TempDir()
+ copyTestFiles(t, "test/config/Users", tempDir)
+
+ accountMgr, err := NewYAMLAccountManager(tempDir)
+ require.NoError(t, err)
+
+ // Perform deletion
+ err = accountMgr.Delete(tt.args.login)
+ assert.Errorf(t, err, "Delete(%v)", tt.args.login)
+ })
+ }
+ })
+}
+
+func TestYAMLAccountManager_Create(t *testing.T) {
+ t.Run("successful creation", func(t *testing.T) {
+ type args struct {
+ account hotline.Account
+ }
+ tests := []struct {
+ name string
+ args args
+ }{
+ {
+ name: "creates new account with valid data",
+ args: args{
+ account: hotline.Account{
+ Login: "newuser",
+ Name: "New User",
+ Password: "hashedpassword",
+ Access: hotline.AccessBitmap{0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00},
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir := t.TempDir()
+ copyTestFiles(t, "test/config/Users", tempDir)
+
+ accountMgr, err := NewYAMLAccountManager(tempDir)
+ require.NoError(t, err)
+
+ initialCount := len(accountMgr.List())
+
+ err = accountMgr.Create(tt.args.account)
+ assert.NoErrorf(t, err, "Create(%v)", tt.args.account)
+
+ // Verify account exists in memory
+ retrievedAccount := accountMgr.Get(tt.args.account.Login)
+ assert.NotNilf(t, retrievedAccount, "Create(%v)", tt.args.account)
+ assert.Equalf(t, &tt.args.account, retrievedAccount, "Create(%v)", tt.args.account)
+
+ // Verify file was created
+ filePath := path.Join(tempDir, tt.args.account.Login+".yaml")
+ _, err = os.Stat(filePath)
+ assert.NoErrorf(t, err, "Create(%v) - file should exist", tt.args.account)
+
+ // Verify list count increased
+ assert.Equalf(t, initialCount+1, len(accountMgr.List()), "Create(%v)", tt.args.account)
+ })
+ }
+ })
+
+ t.Run("error cases", func(t *testing.T) {
+ type args struct {
+ account hotline.Account
+ }
+ tests := []struct {
+ name string
+ args args
+ }{
+ {
+ name: "returns error when creating account with existing login",
+ args: args{
+ account: hotline.Account{
Login: "admin",
- Password: "$2a$04$2itGEYx8C1N5bsfRSoC9JuonS3I4YfnyVPZHLSwp7kEInRX0yoB.a",
- Access: hotline.AccessBitmap{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00},
+ Name: "Duplicate Admin",
+ Password: "password",
+ Access: hotline.AccessBitmap{},
},
- "Test User Name": {
- Name: "test-user",
- Login: "Test User Name",
- Password: "$2a$04$9P/jgLn1fR9TjSoWL.rKxuN6g.1TSpf2o6Hw.aaRuBwrWIJNwsKkS",
- Access: hotline.AccessBitmap{0x7d, 0xf0, 0x0c, 0xef, 0xab, 0x80, 0x00, 0x00},
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir := t.TempDir()
+ copyTestFiles(t, "test/config/Users", tempDir)
+
+ accountMgr, err := NewYAMLAccountManager(tempDir)
+ require.NoError(t, err)
+
+ err = accountMgr.Create(tt.args.account)
+ assert.Errorf(t, err, "Create(%v)", tt.args.account)
+ })
+ }
+ })
+}
+
+func TestYAMLAccountManager_Update(t *testing.T) {
+ t.Run("successful updates", func(t *testing.T) {
+ type args struct {
+ account hotline.Account
+ newLogin string
+ }
+ tests := []struct {
+ name string
+ args args
+ }{
+ {
+ name: "updates account without changing login",
+ args: args{
+ account: hotline.Account{
+ Login: "guest",
+ Name: "Updated Guest",
+ Password: "newpassword",
+ Access: hotline.AccessBitmap{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88},
},
- "guest": {
- Name: "guest",
+ newLogin: "guest",
+ },
+ },
+ {
+ name: "updates account with login change",
+ args: args{
+ account: hotline.Account{
Login: "guest",
- Password: "$2a$04$6Yq/TIlgjSD.FbARwtYs9ODnkHawonu1TJ5W2jJKfhnHwBIQTk./y",
- Access: hotline.AccessBitmap{0x7d, 0xf0, 0x0c, 0xef, 0xab, 0x80, 0x00, 0x00},
+ Name: "Renamed Guest",
+ Password: "password",
+ Access: hotline.AccessBitmap{0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00},
},
+ newLogin: "renamedguest",
},
},
- wantErr: assert.NoError,
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir := t.TempDir()
+ copyTestFiles(t, "test/config/Users", tempDir)
+
+ accountMgr, err := NewYAMLAccountManager(tempDir)
+ require.NoError(t, err)
+
+ err = accountMgr.Update(tt.args.account, tt.args.newLogin)
+ assert.NoErrorf(t, err, "Update(%v, %v)", tt.args.account, tt.args.newLogin)
+
+ // Verify updated account exists with new login
+ retrievedAccount := accountMgr.Get(tt.args.newLogin)
+ assert.NotNilf(t, retrievedAccount, "Update(%v, %v)", tt.args.account, tt.args.newLogin)
+
+ expectedAccount := tt.args.account
+ expectedAccount.Login = tt.args.newLogin
+ assert.Equalf(t, &expectedAccount, retrievedAccount, "Update(%v, %v)", tt.args.account, tt.args.newLogin)
+
+ // If login changed, verify old login no longer exists
+ if tt.args.account.Login != tt.args.newLogin {
+ oldAccount := accountMgr.Get(tt.args.account.Login)
+ assert.Nilf(t, oldAccount, "Update(%v, %v) - old login should not exist", tt.args.account, tt.args.newLogin)
+
+ // Verify old file was renamed
+ oldFilePath := path.Join(tempDir, tt.args.account.Login+".yaml")
+ _, err = os.Stat(oldFilePath)
+ assert.Truef(t, os.IsNotExist(err), "Update(%v, %v) - old file should not exist", tt.args.account, tt.args.newLogin)
+ }
+
+ // Verify new file exists
+ newFilePath := path.Join(tempDir, tt.args.newLogin+".yaml")
+ _, err = os.Stat(newFilePath)
+ assert.NoErrorf(t, err, "Update(%v, %v) - new file should exist", tt.args.account, tt.args.newLogin)
+ })
+ }
+ })
+}
+
+func TestYAMLAccountManager_Get(t *testing.T) {
+ type args struct {
+ login string
+ }
+ tests := []struct {
+ name string
+ args args
+ want *hotline.Account
+ }{
+ {
+ name: "returns existing account",
+ args: args{
+ login: "admin",
+ },
+ want: &hotline.Account{
+ Name: "admin",
+ Login: "admin",
+ Password: "$2a$04$2itGEYx8C1N5bsfRSoC9JuonS3I4YfnyVPZHLSwp7kEInRX0yoB.a",
+ Access: hotline.AccessBitmap{0xff, 0xff, 0xef, 0xff, 0xff, 0x80, 0x00, 0x00},
+ },
+ },
+ {
+ name: "returns nil for non-existent account",
+ args: args{
+ login: "nonexistent",
+ },
+ want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- got, err := NewYAMLAccountManager(tt.args.accountDir)
- if !tt.wantErr(t, err, fmt.Sprintf("NewYAMLAccountManager(%v)", tt.args.accountDir)) {
- return
- }
+ tempDir := t.TempDir()
+ copyTestFiles(t, "test/config/Users", tempDir)
- assert.Equal(t,
- &hotline.Account{
- Name: "admin",
- Login: "admin",
- Password: "$2a$04$2itGEYx8C1N5bsfRSoC9JuonS3I4YfnyVPZHLSwp7kEInRX0yoB.a",
- Access: hotline.AccessBitmap{0xff, 0xff, 0xef, 0xff, 0xff, 0x80, 0x00, 0x00},
- },
- got.Get("admin"),
- )
+ accountMgr, err := NewYAMLAccountManager(tempDir)
+ require.NoError(t, err)
- assert.Equal(t,
- &hotline.Account{
- Login: "guest",
- Name: "guest",
- Password: "$2a$04$6Yq/TIlgjSD.FbARwtYs9ODnkHawonu1TJ5W2jJKfhnHwBIQTk./y",
- Access: hotline.AccessBitmap{0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00},
- },
- got.Get("guest"),
- )
+ got := accountMgr.Get(tt.args.login)
+ assert.Equalf(t, tt.want, got, "Get(%v)", tt.args.login)
+ })
+ }
+}
- assert.Equal(t,
- &hotline.Account{
- Login: "test-user",
- Name: "Test User Name",
- Password: "$2a$04$9P/jgLn1fR9TjSoWL.rKxuN6g.1TSpf2o6Hw.aaRuBwrWIJNwsKkS",
- Access: hotline.AccessBitmap{0x7d, 0xf0, 0x0c, 0xef, 0xab, 0x80, 0x00, 0x00},
- },
- got.Get("test-user"),
- )
+func TestYAMLAccountManager_List(t *testing.T) {
+ tests := []struct {
+ name string
+ wantCount int
+ wantLogins []string
+ }{
+ {
+ name: "returns all accounts",
+ wantCount: 3,
+ wantLogins: []string{"admin", "guest", "test-user"},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir := t.TempDir()
+ copyTestFiles(t, "test/config/Users", tempDir)
+
+ accountMgr, err := NewYAMLAccountManager(tempDir)
+ require.NoError(t, err)
+
+ got := accountMgr.List()
+ assert.Equalf(t, tt.wantCount, len(got), "List() count")
+
+ // Check that all expected logins are present
+ gotLogins := make([]string, len(got))
+ for i, account := range got {
+ gotLogins[i] = account.Login
+ }
+
+ for _, expectedLogin := range tt.wantLogins {
+ assert.Containsf(t, gotLogins, expectedLogin, "List() should contain login %s", expectedLogin)
+ }
})
}
}
diff --git a/internal/mobius/api.go b/internal/mobius/api.go
index 31755b8..2bce8f8 100644
--- a/internal/mobius/api.go
+++ b/internal/mobius/api.go
@@ -2,12 +2,16 @@ package mobius
import (
"bytes"
+ "context"
"encoding/json"
- "github.com/jhalter/mobius/hotline"
"io"
"log"
"log/slog"
"net/http"
+ "strings"
+
+ "github.com/jhalter/mobius/hotline"
+ "github.com/redis/go-redis/v9"
)
type logResponseWriter struct {
@@ -30,35 +34,234 @@ func (lrw *logResponseWriter) Write(b []byte) (int, error) {
return lrw.ResponseWriter.Write(b)
}
+// APIServer provides REST API endpoints for managing a Hotline server.
+// It supports user management, banning operations, and server administration.
type APIServer struct {
hlServer *hotline.Server
logger *slog.Logger
mux *http.ServeMux
+ apiKey string
+ redis *redis.Client
+}
+
+func (srv *APIServer) authMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if srv.apiKey != "" && r.Header.Get("X-API-Key") != srv.apiKey {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"error":"unauthorized"}`))
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
}
func (srv *APIServer) logMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lrw := NewLogResponseWriter(w)
next.ServeHTTP(lrw, r)
-
srv.logger.Info("req", "method", r.Method, "url", r.URL.Path, "remoteAddr", r.RemoteAddr, "response_code", lrw.statusCode)
})
}
-func NewAPIServer(hlServer *hotline.Server, reloadFunc func(), logger *slog.Logger) *APIServer {
+// NewAPIServer creates a new APIServer instance with the specified configuration.
+// It sets up all API routes and middleware, and optionally connects to Redis for persistent storage.
+func NewAPIServer(hlServer *hotline.Server, reloadFunc func(), logger *slog.Logger, apiKey string, redisAddr string, redisPassword string, redisDB int) *APIServer {
srv := APIServer{
hlServer: hlServer,
logger: logger,
mux: http.NewServeMux(),
+ apiKey: apiKey,
+ }
+ if redisAddr != "" {
+ srv.redis = redis.NewClient(&redis.Options{
+ Addr: redisAddr,
+ Password: redisPassword,
+ DB: redisDB,
+ })
+ hlServer.Redis = srv.redis
}
- srv.mux.Handle("/api/v1/reload", srv.logMiddleware(http.HandlerFunc(srv.ReloadHandler(reloadFunc))))
- srv.mux.Handle("/api/v1/shutdown", srv.logMiddleware(http.HandlerFunc(srv.ShutdownHandler)))
- srv.mux.Handle("/api/v1/stats", srv.logMiddleware(http.HandlerFunc(srv.RenderStats)))
+ srv.mux.Handle("/api/v1/online", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.OnlineHandler))))
+ srv.mux.Handle("/api/v1/ban", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.BanHandler))))
+ srv.mux.Handle("/api/v1/unban", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.UnbanHandler))))
+ srv.mux.Handle("/api/v1/banned/ips", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ListBannedIPsHandler))))
+ srv.mux.Handle("/api/v1/banned/usernames", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ListBannedUsernamesHandler))))
+ srv.mux.Handle("/api/v1/banned/nicknames", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ListBannedNicknamesHandler))))
+ srv.mux.Handle("/api/v1/reload", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ReloadHandler(reloadFunc)))))
+ srv.mux.Handle("/api/v1/shutdown", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ShutdownHandler))))
+ srv.mux.Handle("/api/v1/stats", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.RenderStats))))
+
+ if srv.redis != nil {
+ if err := srv.redis.Del(context.Background(), "mobius:online").Err(); err != nil {
+ srv.logger.Warn("Failed to clear mobius:online in Redis", "err", err)
+ } else {
+ srv.logger.Info("Cleared mobius:online in Redis on startup")
+ }
+ }
return &srv
}
+// OnlineHandler returns a list of currently online users with their login, nickname, and IP address.
+// GET /api/v1/online
+func (srv *APIServer) OnlineHandler(w http.ResponseWriter, r *http.Request) {
+ var users []map[string]string
+
+ if srv.redis != nil {
+ members, err := srv.redis.SMembers(r.Context(), "mobius:online").Result()
+ if err == nil {
+ for _, m := range members {
+ parts := strings.SplitN(m, ":", 3)
+ if len(parts) == 3 {
+ users = append(users, map[string]string{
+ "login": parts[0],
+ "nickname": parts[1],
+ "ip": parts[2],
+ })
+ }
+ }
+ }
+ } else {
+ for _, c := range srv.hlServer.ClientMgr.List() {
+ users = append(users, map[string]string{
+ "login": string(c.Account.Login),
+ "nickname": string(c.UserName),
+ "ip": c.RemoteAddr,
+ })
+ }
+ }
+
+ _ = json.NewEncoder(w).Encode(users)
+}
+
+// BanRequest represents a ban/unban request payload.
+// At least one field (Username, Nickname, or IP) must be provided.
+type BanRequest struct {
+ Username string `json:"username,omitempty"`
+ Nickname string `json:"nickname,omitempty"`
+ IP string `json:"ip,omitempty"`
+}
+
+// BanHandler bans a user by username, nickname, or IP address.
+// The user will be disconnected if currently online and added to the ban list.
+// POST /api/v1/ban
+func (srv *APIServer) BanHandler(w http.ResponseWriter, r *http.Request) {
+ var req BanRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "invalid request", http.StatusBadRequest)
+ return
+ }
+
+ if req.Username == "" && req.Nickname == "" && req.IP == "" {
+ http.Error(w, "username, nickname, or ip required", http.StatusBadRequest)
+ return
+ }
+
+ if srv.redis != nil {
+ if req.Username != "" {
+ srv.redis.SAdd(r.Context(), "mobius:banned:users", req.Username)
+ }
+ if req.Nickname != "" {
+ srv.redis.SAdd(r.Context(), "mobius:banned:nicknames", req.Nickname)
+ }
+ if req.IP != "" {
+ srv.redis.SAdd(r.Context(), "mobius:banned:ips", req.IP)
+ }
+ } else {
+ // TODO: Fallback
+ }
+
+ // Disconnect user if online
+ for _, c := range srv.hlServer.ClientMgr.List() {
+ if (req.Username != "" && string(c.Account.Login) == req.Username) ||
+ (req.Nickname != "" && string(c.UserName) == req.Nickname) ||
+ (req.IP != "" && c.RemoteAddr == req.IP) {
+ c.Disconnect()
+ }
+ }
+
+ _, _ = w.Write([]byte(`{"msg":"banned"}`))
+}
+
+// UnbanHandler removes a ban for a user by username, nickname, or IP address.
+// POST /api/v1/unban
+func (srv *APIServer) UnbanHandler(w http.ResponseWriter, r *http.Request) {
+ var req BanRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "invalid request", http.StatusBadRequest)
+ return
+ }
+
+ if req.Username == "" && req.Nickname == "" && req.IP == "" {
+ http.Error(w, "username, nickname, or ip required", http.StatusBadRequest)
+ return
+ }
+
+ if srv.redis != nil {
+ if req.Username != "" {
+ srv.redis.SRem(r.Context(), "mobius:banned:users", req.Username)
+ }
+ if req.Nickname != "" {
+ srv.redis.SRem(r.Context(), "mobius:banned:nicknames", req.Nickname)
+ }
+ if req.IP != "" {
+ srv.redis.SRem(r.Context(), "mobius:banned:ips", req.IP)
+ }
+ } else {
+ // TODO: Fallback
+ }
+
+ _, _ = w.Write([]byte(`{"msg":"unbanned"}`))
+}
+
+// ListBannedIPsHandler returns a list of all banned IP addresses.
+// GET /api/v1/banned/ips
+func (srv *APIServer) ListBannedIPsHandler(w http.ResponseWriter, r *http.Request) {
+ if srv.redis != nil {
+ ips, err := srv.redis.SMembers(r.Context(), "mobius:banned:ips").Result()
+ if err != nil {
+ http.Error(w, "failed to fetch banned IPs", http.StatusInternalServerError)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(ips)
+ } else {
+ // TODO: Fallback
+ }
+}
+
+// ListBannedUsernamesHandler returns a list of all banned usernames.
+// GET /api/v1/banned/usernames
+func (srv *APIServer) ListBannedUsernamesHandler(w http.ResponseWriter, r *http.Request) {
+ if srv.redis != nil {
+ users, err := srv.redis.SMembers(r.Context(), "mobius:banned:users").Result()
+ if err != nil {
+ http.Error(w, "failed to fetch banned usernames", http.StatusInternalServerError)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(users)
+ } else {
+ // TODO: Fallback
+ }
+}
+
+// ListBannedNicknamesHandler returns a list of all banned nicknames.
+// GET /api/v1/banned/nicknames
+func (srv *APIServer) ListBannedNicknamesHandler(w http.ResponseWriter, r *http.Request) {
+ if srv.redis != nil {
+ nicks, err := srv.redis.SMembers(r.Context(), "mobius:banned:nicknames").Result()
+ if err != nil {
+ http.Error(w, "failed to fetch banned nicknames", http.StatusInternalServerError)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(nicks)
+ } else {
+ // TODO: Fallback
+ }
+}
+
+// ShutdownHandler gracefully shuts down the server with a custom message.
+// The message is sent to all connected clients before shutdown.
+// POST /api/v1/shutdown
func (srv *APIServer) ShutdownHandler(w http.ResponseWriter, r *http.Request) {
msg, err := io.ReadAll(r.Body)
if err != nil || len(msg) == 0 {
@@ -71,6 +274,8 @@ func (srv *APIServer) ShutdownHandler(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{ "msg": "server shutting down" }`)
}
+// ReloadHandler triggers a reload of the server configuration.
+// POST /api/v1/reload
func (srv *APIServer) ReloadHandler(reloadFunc func()) func(w http.ResponseWriter, _ *http.Request) {
return func(w http.ResponseWriter, _ *http.Request) {
reloadFunc()
@@ -79,15 +284,20 @@ func (srv *APIServer) ReloadHandler(reloadFunc func()) func(w http.ResponseWrite
}
}
+// RenderStats returns current server statistics and metrics in JSON format.
+// GET /api/v1/stats
func (srv *APIServer) RenderStats(w http.ResponseWriter, _ *http.Request) {
u, err := json.Marshal(srv.hlServer.CurrentStats())
if err != nil {
- panic(err)
+ http.Error(w, "failed to marshal stats", http.StatusInternalServerError)
+ return
}
- _, _ = io.WriteString(w, string(u))
+ _, _ = w.Write(u)
}
+// Serve starts the API server on the specified port.
+// This is a blocking call that will run until the server is shut down.
func (srv *APIServer) Serve(port string) {
err := http.ListenAndServe(port, srv.mux)
if err != nil {
diff --git a/internal/mobius/ban.go b/internal/mobius/ban.go
index e73b14a..b4fde95 100644
--- a/internal/mobius/ban.go
+++ b/internal/mobius/ban.go
@@ -4,7 +4,7 @@ import (
"fmt"
"gopkg.in/yaml.v3"
"os"
- "path/filepath"
+ "path"
"sync"
"time"
)
@@ -43,7 +43,7 @@ func (bf *BanFile) Load() error {
if err != nil {
return fmt.Errorf("open file: %v", err)
}
- defer fh.Close()
+ defer func() { _ = fh.Close() }()
err = yaml.NewDecoder(fh).Decode(&bf.banList)
if err != nil {
@@ -64,7 +64,7 @@ func (bf *BanFile) Add(ip string, until *time.Time) error {
return fmt.Errorf("marshal yaml: %v", err)
}
- err = os.WriteFile(filepath.Join(bf.filePath), out, 0644)
+ err = os.WriteFile(path.Join(bf.filePath), out, 0644)
if err != nil {
return fmt.Errorf("write file: %v", err)
}
diff --git a/internal/mobius/ban_test.go b/internal/mobius/ban_test.go
index f03f214..9f1f5d8 100644
--- a/internal/mobius/ban_test.go
+++ b/internal/mobius/ban_test.go
@@ -4,7 +4,7 @@ import (
"fmt"
"github.com/stretchr/testify/assert"
"os"
- "path/filepath"
+ "path"
"sync"
"testing"
"time"
@@ -26,9 +26,9 @@ func TestNewBanFile(t *testing.T) {
}{
{
name: "Valid path with valid content",
- args: args{path: filepath.Join(cwd, "test", "config", "Banlist.yaml")},
+ args: args{path: path.Join(cwd, "test", "config", "Banlist.yaml")},
want: &BanFile{
- filePath: filepath.Join(cwd, "test", "config", "Banlist.yaml"),
+ filePath: path.Join(cwd, "test", "config", "Banlist.yaml"),
banList: map[string]*time.Time{"192.168.86.29": &testTime},
},
wantErr: assert.NoError,
@@ -52,10 +52,10 @@ func TestAdd(t *testing.T) {
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
- defer os.RemoveAll(tmpDir) // Clean up the temporary directory.
+ defer func() { _ = os.RemoveAll(tmpDir) }() // Clean up the temporary directory.
// Path to the temporary ban file.
- tmpFilePath := filepath.Join(tmpDir, "banfile.yaml")
+ tmpFilePath := path.Join(tmpDir, "banfile.yaml")
// Initialize BanFile.
bf := &BanFile{
diff --git a/internal/mobius/config.go b/internal/mobius/config.go
index d04b14d..c798e54 100644
--- a/internal/mobius/config.go
+++ b/internal/mobius/config.go
@@ -7,6 +7,7 @@ import (
"gopkg.in/yaml.v3"
"os"
"path/filepath"
+ "strings"
)
var ConfigSearchOrder = []string{
@@ -28,7 +29,25 @@ func LoadConfig(path string) (*hotline.Config, error) {
}
validate := validator.New()
+ if err = validate.RegisterValidation("bannerext", func(fl validator.FieldLevel) bool {
+ filename := fl.Field().String()
+ if filename == "" {
+ return true // Allow empty since BannerFile is optional
+ }
+ ext := strings.ToLower(filepath.Ext(filename))
+ return ext == ".jpg" || ext == ".jpeg" || ext == ".gif"
+ }); err != nil {
+ return nil, fmt.Errorf("register validation: %v", err)
+ }
if err = validate.Struct(config); err != nil {
+ // Check if this is a BannerFile validation error and provide a better message
+ if validationErrs, ok := err.(validator.ValidationErrors); ok {
+ for _, fieldErr := range validationErrs {
+ if fieldErr.Field() == "BannerFile" && fieldErr.Tag() == "bannerext" {
+ return nil, fmt.Errorf("BannerFile must have a .jpg, .jpeg, or .gif extension (got: %s)", config.BannerFile)
+ }
+ }
+ }
return nil, fmt.Errorf("validate config: %v", err)
}
diff --git a/internal/mobius/config_test.go b/internal/mobius/config_test.go
new file mode 100644
index 0000000..b76b16d
--- /dev/null
+++ b/internal/mobius/config_test.go
@@ -0,0 +1,95 @@
+package mobius
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestLoadConfig_InvalidBannerFileExtension(t *testing.T) {
+ // Create a temporary directory for test files
+ tmpDir, err := os.MkdirTemp("", "mobius-config-test")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Create a test config file with an invalid banner file extension
+ configContent := `
+Name: "Test Server"
+Description: "Test Description"
+BannerFile: "banner.png"
+FileRoot: "files"
+`
+ configPath := filepath.Join(tmpDir, "config.yaml")
+ if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
+ t.Fatalf("Failed to write config file: %v", err)
+ }
+
+ // Attempt to load the config
+ _, err = LoadConfig(configPath)
+
+ // Verify that we get the improved error message
+ if err == nil {
+ t.Fatal("Expected error for invalid banner file extension, got nil")
+ }
+
+ expectedMsg := "BannerFile must have a .jpg, .jpeg, or .gif extension (got: banner.png)"
+ if !strings.Contains(err.Error(), expectedMsg) {
+ t.Errorf("Expected error message to contain %q, got: %v", expectedMsg, err)
+ }
+}
+
+func TestLoadConfig_ValidBannerFileExtensions(t *testing.T) {
+ tests := []struct {
+ name string
+ bannerFile string
+ }{
+ {"jpg extension", "banner.jpg"},
+ {"jpeg extension", "banner.jpeg"},
+ {"gif extension", "banner.gif"},
+ {"empty banner", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create a temporary directory for test files
+ tmpDir, err := os.MkdirTemp("", "mobius-config-test")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Create files subdirectory
+ filesDir := filepath.Join(tmpDir, "files")
+ if err := os.Mkdir(filesDir, 0755); err != nil {
+ t.Fatalf("Failed to create files dir: %v", err)
+ }
+
+ // Create a test config file with a valid banner file extension
+ configContent := `
+Name: "Test Server"
+Description: "Test Description"
+BannerFile: "` + tt.bannerFile + `"
+FileRoot: "files"
+`
+ configPath := filepath.Join(tmpDir, "config.yaml")
+ if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
+ t.Fatalf("Failed to write config file: %v", err)
+ }
+
+ // Attempt to load the config
+ config, err := LoadConfig(configPath)
+
+ // Verify that we don't get a validation error
+ if err != nil {
+ t.Errorf("Expected no error for %s, got: %v", tt.bannerFile, err)
+ }
+
+ if config.BannerFile != tt.bannerFile {
+ t.Errorf("Expected BannerFile to be %q, got %q", tt.bannerFile, config.BannerFile)
+ }
+ })
+ }
+}
diff --git a/internal/mobius/news.go b/internal/mobius/news.go
index 13a728a..c63c7f7 100644
--- a/internal/mobius/news.go
+++ b/internal/mobius/news.go
@@ -66,6 +66,7 @@ func (f *FlatNews) Write(p []byte) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
+ // Prepend the new post to the existing news posts.
f.data = slices.Concat(p, f.data)
tempFilePath := f.filePath + ".tmp"
@@ -79,10 +80,13 @@ func (f *FlatNews) Write(p []byte) (int, error) {
return 0, fmt.Errorf("rename temporary file to final file: %v", err)
}
- return len(p), os.WriteFile(f.filePath, f.data, 0644)
+ return len(p), nil
}
func (f *FlatNews) Seek(offset int64, _ int) (int64, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+
f.readOffset = int(offset)
return 0, nil
diff --git a/internal/mobius/news_test.go b/internal/mobius/news_test.go
new file mode 100644
index 0000000..b5d5ccd
--- /dev/null
+++ b/internal/mobius/news_test.go
@@ -0,0 +1,500 @@
+package mobius
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+)
+
+func TestNewFlatNews(t *testing.T) {
+ tests := []struct {
+ name string
+ setupFile func(string) error
+ filePath string
+ wantErr bool
+ wantErrMsg string
+ }{
+ {
+ name: "valid file with content",
+ setupFile: func(path string) error {
+ return os.WriteFile(path, []byte("test news content\nwith newlines"), 0644)
+ },
+ filePath: "test_news.txt",
+ wantErr: false,
+ },
+ {
+ name: "valid empty file",
+ setupFile: func(path string) error {
+ return os.WriteFile(path, []byte(""), 0644)
+ },
+ filePath: "empty_news.txt",
+ wantErr: false,
+ },
+ {
+ name: "nonexistent file",
+ setupFile: func(path string) error { return nil },
+ filePath: "nonexistent.txt",
+ wantErr: true,
+ wantErrMsg: "reload:",
+ },
+ {
+ name: "file with mixed line endings",
+ setupFile: func(path string) error {
+ return os.WriteFile(path, []byte("line1\nline2\r\nline3\r"), 0644)
+ },
+ filePath: "mixed_endings.txt",
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir := t.TempDir()
+ fullPath := filepath.Join(tempDir, tt.filePath)
+
+ if err := tt.setupFile(fullPath); err != nil {
+ t.Fatalf("Failed to setup test file: %v", err)
+ }
+
+ flatNews, err := NewFlatNews(fullPath)
+
+ if tt.wantErr {
+ if err == nil {
+ t.Error("Expected error but got none")
+ } else if tt.wantErrMsg != "" && !containsSubstring(err.Error(), tt.wantErrMsg) {
+ t.Errorf("Expected error to contain %q, got %q", tt.wantErrMsg, err.Error())
+ }
+ return
+ }
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if flatNews == nil {
+ t.Error("Expected FlatNews instance but got nil")
+ return
+ }
+
+ if flatNews.filePath != fullPath {
+ t.Errorf("Expected filePath %q, got %q", fullPath, flatNews.filePath)
+ }
+ })
+ }
+}
+
+func TestFlatNews_Reload(t *testing.T) {
+ tests := []struct {
+ name string
+ initialData string
+ newData string
+ expectData string
+ wantErr bool
+ deleteFile bool
+ }{
+ {
+ name: "reload with new content",
+ initialData: "initial content",
+ newData: "new content\nwith newlines",
+ expectData: "new content\rwith newlines",
+ wantErr: false,
+ },
+ {
+ name: "reload with empty content",
+ initialData: "some content",
+ newData: "",
+ expectData: "",
+ wantErr: false,
+ },
+ {
+ name: "reload with mixed line endings",
+ initialData: "old",
+ newData: "line1\nline2\r\nline3\r",
+ expectData: "line1\rline2\r\rline3\r",
+ wantErr: false,
+ },
+ {
+ name: "reload after file deletion",
+ initialData: "content",
+ newData: "",
+ expectData: "",
+ wantErr: true,
+ deleteFile: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir := t.TempDir()
+ filePath := filepath.Join(tempDir, "test.txt")
+
+ if err := os.WriteFile(filePath, []byte(tt.initialData), 0644); err != nil {
+ t.Fatalf("Failed to create initial file: %v", err)
+ }
+
+ flatNews, err := NewFlatNews(filePath)
+ if err != nil {
+ t.Fatalf("Failed to create FlatNews: %v", err)
+ }
+
+ if tt.deleteFile {
+ if err := os.Remove(filePath); err != nil {
+ t.Fatalf("Failed to delete file: %v", err)
+ }
+ } else {
+ if err := os.WriteFile(filePath, []byte(tt.newData), 0644); err != nil {
+ t.Fatalf("Failed to write new data: %v", err)
+ }
+ }
+
+ err = flatNews.Reload()
+
+ if tt.wantErr {
+ if err == nil {
+ t.Error("Expected error but got none")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if string(flatNews.data) != tt.expectData {
+ t.Errorf("Expected data %q, got %q", tt.expectData, string(flatNews.data))
+ }
+ })
+ }
+}
+
+func TestFlatNews_Read(t *testing.T) {
+ tests := []struct {
+ name string
+ fileContent string
+ bufferSize int
+ expectedReads []readResult
+ }{
+ {
+ name: "read all at once",
+ fileContent: "test content\nwith newlines",
+ bufferSize: 100,
+ expectedReads: []readResult{
+ {data: "test content\rwith newlines", n: 26, err: nil},
+ {data: "", n: 0, err: io.EOF},
+ },
+ },
+ {
+ name: "read in chunks",
+ fileContent: "hello world",
+ bufferSize: 5,
+ expectedReads: []readResult{
+ {data: "hello", n: 5, err: nil},
+ {data: " worl", n: 5, err: nil},
+ {data: "d", n: 1, err: nil},
+ {data: "", n: 0, err: io.EOF},
+ },
+ },
+ {
+ name: "read empty file",
+ fileContent: "",
+ bufferSize: 10,
+ expectedReads: []readResult{
+ {data: "", n: 0, err: io.EOF},
+ },
+ },
+ {
+ name: "small buffer large content",
+ fileContent: "abcdefghij",
+ bufferSize: 3,
+ expectedReads: []readResult{
+ {data: "abc", n: 3, err: nil},
+ {data: "def", n: 3, err: nil},
+ {data: "ghi", n: 3, err: nil},
+ {data: "j", n: 1, err: nil},
+ {data: "", n: 0, err: io.EOF},
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir := t.TempDir()
+ filePath := filepath.Join(tempDir, "test.txt")
+
+ if err := os.WriteFile(filePath, []byte(tt.fileContent), 0644); err != nil {
+ t.Fatalf("Failed to create test file: %v", err)
+ }
+
+ flatNews, err := NewFlatNews(filePath)
+ if err != nil {
+ t.Fatalf("Failed to create FlatNews: %v", err)
+ }
+
+ for i, expected := range tt.expectedReads {
+ buf := make([]byte, tt.bufferSize)
+ n, err := flatNews.Read(buf)
+
+ if err != expected.err {
+ t.Errorf("Read %d: expected error %v, got %v", i, expected.err, err)
+ }
+
+ if n != expected.n {
+ t.Errorf("Read %d: expected n %d, got %d", i, expected.n, n)
+ }
+
+ actualData := string(buf[:n])
+ if actualData != expected.data {
+ t.Errorf("Read %d: expected data %q, got %q", i, expected.data, actualData)
+ }
+ }
+ })
+ }
+}
+
+func TestFlatNews_Write(t *testing.T) {
+ tests := []struct {
+ name string
+ initialData string
+ writeData string
+ expectedData string
+ wantErr bool
+ }{
+ {
+ name: "write to empty file",
+ initialData: "",
+ writeData: "new content",
+ expectedData: "new content",
+ wantErr: false,
+ },
+ {
+ name: "prepend to existing content",
+ initialData: "existing",
+ writeData: "new ",
+ expectedData: "new existing",
+ wantErr: false,
+ },
+ {
+ name: "write empty data",
+ initialData: "content",
+ writeData: "",
+ expectedData: "content",
+ wantErr: false,
+ },
+ {
+ name: "write binary data",
+ initialData: "text",
+ writeData: "\x00\x01\x02",
+ expectedData: "\x00\x01\x02text",
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir := t.TempDir()
+ filePath := filepath.Join(tempDir, "test.txt")
+
+ if err := os.WriteFile(filePath, []byte(tt.initialData), 0644); err != nil {
+ t.Fatalf("Failed to create initial file: %v", err)
+ }
+
+ flatNews, err := NewFlatNews(filePath)
+ if err != nil {
+ t.Fatalf("Failed to create FlatNews: %v", err)
+ }
+
+ n, err := flatNews.Write([]byte(tt.writeData))
+
+ if tt.wantErr {
+ if err == nil {
+ t.Error("Expected error but got none")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if n != len(tt.writeData) {
+ t.Errorf("Expected n %d, got %d", len(tt.writeData), n)
+ }
+
+ if string(flatNews.data) != tt.expectedData {
+ t.Errorf("Expected data %q, got %q", tt.expectedData, string(flatNews.data))
+ }
+
+ fileData, err := os.ReadFile(filePath)
+ if err != nil {
+ t.Errorf("Failed to read file: %v", err)
+ return
+ }
+
+ if string(fileData) != tt.expectedData {
+ t.Errorf("Expected file data %q, got %q", tt.expectedData, string(fileData))
+ }
+ })
+ }
+}
+
+func TestFlatNews_Seek(t *testing.T) {
+ tests := []struct {
+ name string
+ fileContent string
+ offset int64
+ whence int
+ expectOffset int64
+ expectErr bool
+ }{
+ {
+ name: "seek to beginning",
+ fileContent: "test content",
+ offset: 0,
+ whence: 0,
+ expectOffset: 0,
+ expectErr: false,
+ },
+ {
+ name: "seek to middle",
+ fileContent: "test content",
+ offset: 5,
+ whence: 0,
+ expectOffset: 0,
+ expectErr: false,
+ },
+ {
+ name: "seek beyond end",
+ fileContent: "test",
+ offset: 10,
+ whence: 0,
+ expectOffset: 0,
+ expectErr: false,
+ },
+ {
+ name: "negative offset",
+ fileContent: "test",
+ offset: -5,
+ whence: 0,
+ expectOffset: 0,
+ expectErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir := t.TempDir()
+ filePath := filepath.Join(tempDir, "test.txt")
+
+ if err := os.WriteFile(filePath, []byte(tt.fileContent), 0644); err != nil {
+ t.Fatalf("Failed to create test file: %v", err)
+ }
+
+ flatNews, err := NewFlatNews(filePath)
+ if err != nil {
+ t.Fatalf("Failed to create FlatNews: %v", err)
+ }
+
+ offset, err := flatNews.Seek(tt.offset, tt.whence)
+
+ if tt.expectErr {
+ if err == nil {
+ t.Error("Expected error but got none")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if offset != tt.expectOffset {
+ t.Errorf("Expected offset %d, got %d", tt.expectOffset, offset)
+ }
+
+ expectedReadOffset := int(tt.offset)
+ if flatNews.readOffset != expectedReadOffset {
+ t.Errorf("Expected readOffset %d, got %d", expectedReadOffset, flatNews.readOffset)
+ }
+ })
+ }
+}
+
+func TestFlatNews_ConcurrentOperations(t *testing.T) {
+ tempDir := t.TempDir()
+ filePath := filepath.Join(tempDir, "concurrent_test.txt")
+
+ if err := os.WriteFile(filePath, []byte("initial content"), 0644); err != nil {
+ t.Fatalf("Failed to create test file: %v", err)
+ }
+
+ flatNews, err := NewFlatNews(filePath)
+ if err != nil {
+ t.Fatalf("Failed to create FlatNews: %v", err)
+ }
+
+ var wg sync.WaitGroup
+ errors := make(chan error, 10)
+
+ for i := 0; i < 5; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+
+ buf := make([]byte, 10)
+ _, err := flatNews.Read(buf)
+ if err != nil && err != io.EOF {
+ errors <- fmt.Errorf("read goroutine %d: %w", id, err)
+ }
+ }(i)
+ }
+
+ for i := 0; i < 3; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+
+ if err := flatNews.Reload(); err != nil {
+ errors <- fmt.Errorf("reload goroutine %d: %w", id, err)
+ }
+ }(i)
+ }
+
+ for i := 0; i < 2; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+
+ data := fmt.Sprintf("data%d", id)
+ if _, err := flatNews.Write([]byte(data)); err != nil {
+ errors <- fmt.Errorf("write goroutine %d: %w", id, err)
+ }
+ }(i)
+ }
+
+ wg.Wait()
+ close(errors)
+
+ for err := range errors {
+ t.Errorf("Concurrent operation error: %v", err)
+ }
+}
+
+type readResult struct {
+ data string
+ n int
+ err error
+}
+
+func containsSubstring(s, substr string) bool {
+ return len(s) >= len(substr) &&
+ (len(substr) == 0 ||
+ strings.Contains(s, substr))
+} \ No newline at end of file
diff --git a/internal/mobius/threaded_news.go b/internal/mobius/threaded_news.go
index e9d9c22..c7daea4 100644
--- a/internal/mobius/threaded_news.go
+++ b/internal/mobius/threaded_news.go
@@ -195,7 +195,7 @@ func (n *ThreadedNewsYAML) DeleteArticle(newsPath []string, articleID uint32, _
return n.writeFile()
}
-func (n *ThreadedNewsYAML) ListArticles(newsPath []string) hotline.NewsArtListData {
+func (n *ThreadedNewsYAML) ListArticles(newsPath []string) (hotline.NewsArtListData, error) {
n.mu.Lock()
defer n.mu.Unlock()
@@ -218,7 +218,7 @@ func (n *ThreadedNewsYAML) Load() error {
if err != nil {
return err
}
- defer fh.Close()
+ defer func() { _ = fh.Close() }()
n.ThreadedNews = hotline.ThreadedNews{}
diff --git a/internal/mobius/threaded_news_test.go b/internal/mobius/threaded_news_test.go
index dd06a28..2ff8a84 100644
--- a/internal/mobius/threaded_news_test.go
+++ b/internal/mobius/threaded_news_test.go
@@ -5,7 +5,7 @@ import (
"github.com/jhalter/mobius/hotline"
"github.com/stretchr/testify/assert"
"os"
- "path/filepath"
+ "path"
"sync"
"testing"
)
@@ -52,7 +52,7 @@ func TestLoadFromYAMLFile(t *testing.T) {
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
+ defer func() { _ = os.Remove(tt.fileName) }() // Cleanup the file after the test
}
var data TestData
@@ -161,10 +161,10 @@ func TestThreadedNewsYAML_CreateGrouping(t *testing.T) {
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
- defer os.RemoveAll(tmpDir) // Clean up the temporary directory.
+ defer func() { _ = os.RemoveAll(tmpDir) }() // Clean up the temporary directory.
// Path to the temporary ban file.
- tmpFilePath := filepath.Join(tmpDir, "ThreadedNews.yaml")
+ tmpFilePath := path.Join(tmpDir, "ThreadedNews.yaml")
type fields struct {
ThreadedNews hotline.ThreadedNews
diff --git a/internal/mobius/transaction_handlers.go b/internal/mobius/transaction_handlers.go
index 6451212..9916b6c 100644
--- a/internal/mobius/transaction_handlers.go
+++ b/internal/mobius/transaction_handlers.go
@@ -3,17 +3,91 @@ package mobius
import (
"bufio"
"bytes"
+ "context"
"encoding/binary"
"fmt"
- "github.com/jhalter/mobius/hotline"
- "golang.org/x/text/encoding/charmap"
"io"
"math/big"
+ "net"
"os"
"path"
- "path/filepath"
"strings"
"time"
+
+ "github.com/jhalter/mobius/hotline"
+ "golang.org/x/text/encoding/charmap"
+)
+
+// Public error message constants for reuse by other packages
+const (
+ // Authorization error messages
+ ErrMsgNotAllowedParticipateChat = "You are not allowed to participate in chat."
+ ErrMsgNotAllowedSendPrivateMsg = "You are not allowed to send private messages."
+ ErrMsgNotAllowedReadNews = "You are not allowed to read news."
+ ErrMsgNotAllowedPostNews = "You are not allowed to post news."
+ ErrMsgNotAllowedCreateAccounts = "You are not allowed to create new accounts."
+ ErrMsgNotAllowedViewAccounts = "You are not allowed to view accounts."
+ ErrMsgNotAllowedModifyAccounts = "You are not allowed to modify accounts."
+ ErrMsgNotAllowedDeleteAccounts = "You are not allowed to delete accounts."
+ ErrMsgNotAllowedRequestPrivateChat = "You are not allowed to request private chat."
+ ErrMsgNotAllowedCreateNewsCategories = "You are not allowed to create news categories."
+ ErrMsgNotAllowedDeleteNewsArticles = "You are not allowed to delete news articles."
+ ErrMsgNotAllowedSetCommentsFiles = "You are not allowed to set comments for files."
+ ErrMsgNotAllowedSetCommentsFolders = "You are not allowed to set comments for folders."
+ ErrMsgNotAllowedRenameFiles = "You are not allowed to rename files."
+ ErrMsgNotAllowedRenameFolders = "You are not allowed to rename folders."
+ ErrMsgNotAllowedDeleteFiles = "You are not allowed to delete files."
+ ErrMsgNotAllowedDeleteFolders = "You are not allowed to delete folders."
+ ErrMsgNotAllowedMoveFiles = "You are not allowed to move files."
+ ErrMsgNotAllowedMoveFolders = "You are not allowed to move folders."
+ ErrMsgNotAllowedCreateFolders = "You are not allowed to create folders."
+ ErrMsgNotAllowedSendBroadcast = "You are not allowed to send broadcast messages."
+ ErrMsgNotAllowedGetClientInfo = "You are not allowed to get client info."
+ ErrMsgNotAllowedDisconnectUsers = "You are not allowed to disconnect users."
+ ErrMsgNotAllowedCreateNewsfolders = "You are not allowed to create news folders."
+ ErrMsgNotAllowedDeleteNewsCategories = "You are not allowed to delete news categories."
+ ErrMsgNotAllowedDeleteNewsFolders = "You are not allowed to delete news folders."
+ ErrMsgNotAllowedPostNewsArticles = "You are not allowed to post news articles."
+ ErrMsgNotAllowedDownloadFiles = "You are not allowed to download files."
+ ErrMsgNotAllowedDownloadFolders = "You are not allowed to download folders."
+ ErrMsgNotAllowedUploadFiles = "You are not allowed to upload files."
+ ErrMsgNotAllowedUploadFolders = "You are not allowed to upload folders."
+ ErrMsgNotAllowedViewDropBoxes = "You are not allowed to view drop boxes."
+ ErrMsgNotAllowedMakeAliases = "You are not allowed to make aliases."
+
+ // Account error messages
+ ErrMsgAccountDeleted = "You are logged in with an account which was deleted."
+ ErrMsgAccountExists = "Cannot create account because there is already an account with that login."
+ ErrMsgAccountMoreAccess = "Cannot create account with more access than yourself."
+ ErrMsgAccountNotExist = "Account does not exist."
+
+ // Account error templates (for dynamic content)
+ ErrMsgAccountExistsTemplate = "Cannot create account %s because there is already an account with that login."
+
+ // File operation error templates
+ ErrMsgCannotRenameFileNotFound = "Cannot rename file %s because it does not exist or cannot be found."
+ ErrMsgCannotRenameFolderNotFound = "Cannot rename folder %s because it does not exist or cannot be found."
+ ErrMsgCannotDeleteFileNotFound = "Cannot delete file %s because it does not exist or cannot be found."
+
+ // File operation error templates (for dynamic content)
+ ErrMsgFolderCreateConflictTemplate = "Cannot create folder \"%s\" because there is already a file or folder with that Name."
+ ErrMsgFolderCreateErrorTemplate = "Cannot create folder \"%s\" because an error occurred."
+
+ // Upload restriction templates (these need dynamic content)
+ ErrMsgUploadRestrictedTemplate = "Cannot accept upload of the %s \"%v\" because you are only allowed to upload to the \"Uploads\" folder."
+ ErrMsgFileUploadConflictTemplate = "Cannot accept upload because there is already a file named \"%v\". Try choosing a different Name."
+
+ // Chat/messaging templates (these need dynamic content)
+ ErrMsgDoesNotAcceptTemplate = "%s does not accept %s."
+
+ // Ban messages
+ ErrMsgTemporaryBan = "You are temporarily banned on this server"
+ ErrMsgPermanentBan = "You are permanently banned on this server"
+
+ // General error messages
+ ErrMsgAccountNotFound = "Account not found."
+ ErrMsgUserNotFound = "User not found."
+ ErrMsgCreateAlias = "Error creating alias"
)
// Converts bytes from Mac Roman encoding to UTF-8
@@ -69,9 +143,17 @@ func RegisterHandlers(srv *hotline.Server) {
srv.HandleFunc(hotline.TranDownloadBanner, HandleDownloadBanner)
}
+// HandleChatSend processes chat messages and distributes them to appropriate clients.
+//
+// Fields used in the request:
+// * 101 Data Required - Chat message content
+// * 109 Chat Options Optional - Set to [0,1] for /me formatted messages
+// * 114 Chat ID Optional - Private chat ID (omitted for public chat)
+//
+// Fields used in the reply:
func HandleChatSend(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessSendChat) {
- return cc.NewErrReply(t, "You are not allowed to participate in chat.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedParticipateChat)
}
// Truncate long usernames
@@ -139,7 +221,7 @@ func HandleChatSend(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotli
// None
func HandleSendInstantMsg(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessSendPrivMsg) {
- return cc.NewErrReply(t, "You are not allowed to send private messages.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedSendPrivateMsg)
}
msg := t.GetField(hotline.FieldData)
@@ -171,7 +253,7 @@ func HandleSendInstantMsg(cc *hotline.ClientConn, t *hotline.Transaction) (res [
hotline.NewTransaction(
hotline.TranServerMsg,
cc.ID,
- hotline.NewField(hotline.FieldData, []byte(string(otherClient.UserName)+" does not accept private messages.")),
+ hotline.NewField(hotline.FieldData, []byte(fmt.Sprintf(ErrMsgDoesNotAcceptTemplate, string(otherClient.UserName), "private messages"))),
hotline.NewField(hotline.FieldUserName, otherClient.UserName),
hotline.NewField(hotline.FieldUserID, otherClient.ID[:]),
hotline.NewField(hotline.FieldOptions, []byte{0, 2}),
@@ -200,6 +282,21 @@ func HandleSendInstantMsg(cc *hotline.ClientConn, t *hotline.Transaction) (res [
var fileTypeFLDR = [4]byte{0x66, 0x6c, 0x64, 0x72}
+// HandleGetFileInfo returns detailed information about a file or folder.
+//
+// Fields used in the request:
+// * 201 File Name Required - Name of the file or folder
+// * 202 File Path Optional - Path to the file or folder
+//
+// Fields used in the reply:
+// * 201 File Name File name (encoded)
+// * 205 File Type String Friendly file type description
+// * 206 File Creator String Friendly creator description
+// * 213 File Type File type signature
+// * 208 File Create Date File creation date
+// * 209 File Modify Date File modification date
+// * 210 File Comment Optional - File comment if present
+// * 207 File Size Optional - File size (only for files, not folders)
func HandleGetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
fileName := t.GetField(hotline.FieldFileName).Data
filePath := t.GetField(hotline.FieldFilePath).Data
@@ -209,7 +306,7 @@ func HandleGetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho
return res
}
- fw, err := hotline.NewFileWrapper(cc.Server.FS, fullFilePath, 0)
+ fw, err := hotline.NewFile(cc.Server.FS, fullFilePath, 0)
if err != nil {
return res
}
@@ -263,7 +360,7 @@ func HandleSetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho
return res
}
- hlFile, err := hotline.NewFileWrapper(cc.Server.FS, fullFilePath, 0)
+ hlFile, err := hotline.NewFile(cc.Server.FS, fullFilePath, 0)
if err != nil {
return res
}
@@ -271,11 +368,11 @@ func HandleSetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho
switch mode := fi.Mode(); {
case mode.IsDir():
if !cc.Authorize(hotline.AccessSetFolderComment) {
- return cc.NewErrReply(t, "You are not allowed to set comments for folders.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedSetCommentsFolders)
}
case mode.IsRegular():
if !cc.Authorize(hotline.AccessSetFileComment) {
- return cc.NewErrReply(t, "You are not allowed to set comments for files.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedSetCommentsFiles)
}
}
@@ -303,16 +400,16 @@ func HandleSetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho
switch mode := fi.Mode(); {
case mode.IsDir():
if !cc.Authorize(hotline.AccessRenameFolder) {
- return cc.NewErrReply(t, "You are not allowed to rename folders.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedRenameFolders)
}
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.")
+ return cc.NewErrReply(t, fmt.Sprintf(ErrMsgCannotRenameFolderNotFound, string(fileName)))
}
case mode.IsRegular():
if !cc.Authorize(hotline.AccessRenameFile) {
- return cc.NewErrReply(t, "You are not allowed to rename files.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedRenameFiles)
}
fileDir, err := hotline.ReadPath(cc.FileRoot(), filePath, []byte{})
if err != nil {
@@ -325,7 +422,7 @@ func HandleSetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho
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.")
+ return cc.NewErrReply(t, fmt.Sprintf(ErrMsgCannotRenameFileNotFound, string(fileName)))
}
if err != nil {
return res
@@ -358,24 +455,24 @@ func HandleDeleteFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
return res
}
- hlFile, err := hotline.NewFileWrapper(cc.Server.FS, fullFilePath, 0)
+ hlFile, err := hotline.NewFile(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.")
+ return cc.NewErrReply(t, fmt.Sprintf(ErrMsgCannotDeleteFileNotFound, string(fileName)))
}
switch mode := fi.Mode(); {
case mode.IsDir():
if !cc.Authorize(hotline.AccessDeleteFolder) && !fp.IsUserDir() {
- return cc.NewErrReply(t, "You are not allowed to delete folders.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedDeleteFolders)
}
case mode.IsRegular():
if !cc.Authorize(hotline.AccessDeleteFile) && !fp.IsUserDir() {
- return cc.NewErrReply(t, "You are not allowed to delete files.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedDeleteFiles)
}
}
@@ -403,23 +500,23 @@ func HandleMoveFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotli
cc.Logger.Info("Move file", "src", filePath+"/"+fileName, "dst", fileNewPath+"/"+fileName)
- hlFile, err := hotline.NewFileWrapper(cc.Server.FS, filePath, 0)
+ hlFile, err := hotline.NewFile(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.")
+ return cc.NewErrReply(t, fmt.Sprintf(ErrMsgCannotDeleteFileNotFound, fileName))
}
switch mode := fi.Mode(); {
case mode.IsDir():
if !cc.Authorize(hotline.AccessMoveFolder) {
- return cc.NewErrReply(t, "You are not allowed to move folders.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedMoveFolders)
}
case mode.IsRegular():
if !cc.Authorize(hotline.AccessMoveFile) {
- return cc.NewErrReply(t, "You are not allowed to move files.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedMoveFiles)
}
}
if err := hlFile.Move(fileNewPath); err != nil {
@@ -431,9 +528,17 @@ func HandleMoveFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotli
return res
}
+// HandleNewFolder creates a new folder at the specified path.
+//
+// Fields used in the request:
+// * 201 File Name Required - Name of the new folder
+// * 202 File Path Optional - Path where the folder should be created
+//
+// Fields used in the reply:
+// None
func HandleNewFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessCreateFolder) {
- return cc.NewErrReply(t, "You are not allowed to create folders.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedCreateFolders)
}
folderName := string(t.GetField(hotline.FieldFileName).Data)
@@ -450,7 +555,7 @@ func HandleNewFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotl
}
for _, pathItem := range newFp.Items {
- subPath = filepath.Join("/", subPath, string(pathItem.Name))
+ subPath = path.Join("/", subPath, string(pathItem.Name))
}
}
newFolderPath := path.Join(cc.FileRoot(), subPath, folderName)
@@ -462,21 +567,31 @@ func HandleNewFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotl
// 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)
+ msg := fmt.Sprintf(ErrMsgFolderCreateConflictTemplate, 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)
+ msg := fmt.Sprintf(ErrMsgFolderCreateErrorTemplate, folderName)
return cc.NewErrReply(t, msg)
}
return append(res, cc.NewReply(t))
}
+// HandleSetUser modifies an existing user account's properties.
+//
+// Fields used in the request:
+// * 105 User Login Required - Login name of the account to modify
+// * 102 User Name Required - Display name for the account
+// * 110 User Access Required - Access permissions bitmap
+// * 106 User Password Optional - New password (omitted to clear password)
+//
+// Fields used in the reply:
+// None
func HandleSetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessModifyUser) {
- return cc.NewErrReply(t, "You are not allowed to modify accounts.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedModifyAccounts)
}
login := t.GetField(hotline.FieldUserLogin).DecodeObfuscatedString()
@@ -486,7 +601,7 @@ func HandleSetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotlin
account := cc.Server.AccountManager.Get(login)
if account == nil {
- return cc.NewErrReply(t, "Account not found.")
+ return cc.NewErrReply(t, ErrMsgAccountNotFound)
}
account.Name = userName
copy(account.Access[:], newAccessLvl)
@@ -533,14 +648,24 @@ func HandleSetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotlin
return append(res, cc.NewReply(t))
}
+// HandleGetUser retrieves account information for a specific user.
+//
+// Fields used in the request:
+// * 105 User Login Required - Login name of the account to retrieve
+//
+// Fields used in the reply:
+// * 102 User Name Account display name
+// * 105 User Login Account login name (encoded)
+// * 106 User Password Account password hash
+// * 110 User Access Access permissions bitmap
func HandleGetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessOpenUser) {
- return cc.NewErrReply(t, "You are not allowed to view accounts.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedViewAccounts)
}
account := cc.Server.AccountManager.Get(string(t.GetField(hotline.FieldUserLogin).Data))
if account == nil {
- return cc.NewErrReply(t, "Account does not exist.")
+ return cc.NewErrReply(t, ErrMsgAccountNotExist)
}
return append(res, cc.NewReply(t,
@@ -551,9 +676,16 @@ func HandleGetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotlin
))
}
+// HandleListUsers returns a list of all user accounts on the server.
+//
+// Fields used in the request:
+// None
+//
+// Fields used in the reply:
+// * 101 Data Repeated - Serialized account data for each user
func HandleListUsers(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessOpenUser) {
- return cc.NewErrReply(t, "You are not allowed to view accounts.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedViewAccounts)
}
var userFields []hotline.Field
@@ -579,6 +711,21 @@ func HandleListUsers(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotl
// 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.
+// HandleUpdateUser processes batch user account operations from the v1.5+ multi-user editor.
+// This handler supports creating, deleting, and modifying multiple user accounts in a single transaction.
+//
+// Fields used in the request:
+// * 101 Data Repeated - Each contains encoded sub-fields for one user operation
+//
+// Sub-fields for user operations:
+// * 101 Data Optional - Original login name (for rename operations)
+// * 105 User Login Required - Login name (new name for renames)
+// * 102 User Name Optional - Display name (for create/modify)
+// * 106 User Password Optional - Password (for create/modify)
+// * 110 User Access Optional - Access permissions (for create/modify)
+//
+// Fields used in the reply:
+// None
func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
for _, field := range t.Fields {
var subFields []hotline.Field
@@ -600,7 +747,7 @@ func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
// If there's only one subfield, that indicates this is a delete operation for the login in FieldData
if len(subFields) == 1 {
if !cc.Authorize(hotline.AccessDeleteUser) {
- return cc.NewErrReply(t, "You are not allowed to delete accounts.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedDeleteAccounts)
}
login := string(hotline.EncodeString(hotline.GetField(hotline.FieldData, &subFields).Data))
@@ -618,7 +765,7 @@ func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
res = append(res,
hotline.NewTransaction(hotline.TranServerMsg, [2]byte{},
- hotline.NewField(hotline.FieldData, []byte("You are logged in with an account which was deleted.")),
+ hotline.NewField(hotline.FieldData, []byte(ErrMsgAccountDeleted)),
hotline.NewField(hotline.FieldChatOptions, []byte{0}),
),
)
@@ -658,7 +805,7 @@ func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
// Account exists, so this is an update action.
if !cc.Authorize(hotline.AccessModifyUser) {
- return cc.NewErrReply(t, "You are not allowed to modify accounts.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedModifyAccounts)
}
// This part is a bit tricky. There are three possibilities:
@@ -690,7 +837,7 @@ func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
}
} else {
if !cc.Authorize(hotline.AccessCreateUser) {
- return cc.NewErrReply(t, "You are not allowed to create new accounts.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedCreateAccounts)
}
cc.Logger.Info("CreateUser", "login", userLogin)
@@ -716,7 +863,7 @@ func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
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 cc.NewErrReply(t, ErrMsgAccountExists)
}
}
}
@@ -724,17 +871,26 @@ func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
return append(res, cc.NewReply(t))
}
-// HandleNewUser creates a new user account
+// HandleNewUser creates a new user account.
+//
+// Fields used in the request:
+// * 105 User Login Required - Login name for the new account
+// * 102 User Name Required - Display name for the account
+// * 106 User Password Required - Password for the account
+// * 110 User Access Required - Access permissions bitmap
+//
+// Fields used in the reply:
+// None
func HandleNewUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessCreateUser) {
- return cc.NewErrReply(t, "You are not allowed to create new accounts.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedCreateAccounts)
}
login := t.GetField(hotline.FieldUserLogin).DecodeObfuscatedString()
// If the account already exists, reply with an error.
if account := cc.Server.AccountManager.Get(login); account != nil {
- return cc.NewErrReply(t, "Cannot create account "+login+" because there is already an account with that login.")
+ return cc.NewErrReply(t, fmt.Sprintf(ErrMsgAccountExistsTemplate, login))
}
var newAccess hotline.AccessBitmap
@@ -744,7 +900,7 @@ func HandleNewUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotlin
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.")
+ return cc.NewErrReply(t, ErrMsgAccountMoreAccess)
}
}
}
@@ -753,15 +909,22 @@ func HandleNewUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotlin
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 cc.NewErrReply(t, ErrMsgAccountExists)
}
return append(res, cc.NewReply(t))
}
+// HandleDeleteUser deletes a user account and disconnects any logged-in sessions.
+//
+// Fields used in the request:
+// * 105 User Login Required - Login name of the account to delete
+//
+// Fields used in the reply:
+// None
func HandleDeleteUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessDeleteUser) {
- return cc.NewErrReply(t, "You are not allowed to delete accounts.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedDeleteAccounts)
}
login := t.GetField(hotline.FieldUserLogin).DecodeObfuscatedString()
@@ -775,7 +938,7 @@ func HandleDeleteUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
if client.Account.Login == login {
res = append(res,
hotline.NewTransaction(hotline.TranServerMsg, client.ID,
- hotline.NewField(hotline.FieldData, []byte("You are logged in with an account which was deleted.")),
+ hotline.NewField(hotline.FieldData, []byte(ErrMsgAccountDeleted)),
hotline.NewField(hotline.FieldChatOptions, []byte{2}),
),
)
@@ -790,10 +953,16 @@ func HandleDeleteUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
return append(res, cc.NewReply(t))
}
-// HandleUserBroadcast sends an Administrator Message to all connected clients of the server
+// HandleUserBroadcast sends an administrator message to all connected clients.
+//
+// Fields used in the request:
+// * 101 Data Required - Broadcast message content
+//
+// Fields used in the reply:
+// None
func HandleUserBroadcast(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessBroadcast) {
- return cc.NewErrReply(t, "You are not allowed to send broadcast messages.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedSendBroadcast)
}
cc.SendAll(
@@ -815,14 +984,14 @@ func HandleUserBroadcast(cc *hotline.ClientConn, t *hotline.Transaction) (res []
// 101 Data User info text string
func HandleGetClientInfoText(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessGetClientInfo) {
- return cc.NewErrReply(t, "You are not allowed to get client info.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedGetClientInfo)
}
clientID := t.GetField(hotline.FieldUserID).Data
clientConn := cc.Server.ClientMgr.Get(hotline.ClientID(clientID))
if clientConn == nil {
- return cc.NewErrReply(t, "User not found.")
+ return cc.NewErrReply(t, ErrMsgUserNotFound)
}
return append(res, cc.NewReply(t,
@@ -831,6 +1000,13 @@ func HandleGetClientInfoText(cc *hotline.ClientConn, t *hotline.Transaction) (re
))
}
+// HandleGetUserNameList returns a list of all currently connected users.
+//
+// Fields used in the request:
+// None
+//
+// Fields used in the reply:
+// * 300 Username With Info Repeated - User information for each connected client
func HandleGetUserNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
var fields []hotline.Field
for _, c := range cc.Server.ClientMgr.List() {
@@ -850,6 +1026,17 @@ func HandleGetUserNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res
return []hotline.Transaction{cc.NewReply(t, fields...)}
}
+// HandleTranAgreed completes the login process after the client agrees to server terms.
+// This handler finalizes user authentication and notifies other clients of the new user.
+//
+// Fields used in the request:
+// * 102 User Name Optional - Desired display name
+// * 104 User Icon ID Optional - User icon identifier
+// * 113 Options Optional - User preference flags (refuse PM, refuse chat, auto-reply)
+// * 215 Automatic Response Optional - Auto-reply message text
+//
+// Fields used in the reply:
+// None
func HandleTranAgreed(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if t.GetField(hotline.FieldUserName).Data != nil {
if cc.Authorize(hotline.AccessAnyName) {
@@ -859,6 +1046,27 @@ func HandleTranAgreed(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
}
}
+ if cc.Server.Redis != nil {
+ login := cc.Account.Login
+ ip, _, _ := net.SplitHostPort(cc.RemoteAddr)
+ // Remove old entry (login::ip)
+ cc.Server.Redis.SRem(context.Background(), "mobius:online", login+"::"+ip)
+ // Add new entry with login, nickname, ip
+ cc.Server.Redis.SAdd(context.Background(), "mobius:online", login+":"+string(cc.UserName)+":"+ip)
+ // Ban check for nickname
+ bannedNick, _ := cc.Server.Redis.SIsMember(context.Background(), "mobius:banned:nicknames", string(cc.UserName)).Result()
+ if bannedNick {
+ // Remove all possible online entries for this login and IP
+ cc.Server.Redis.SRem(context.Background(), "mobius:online", login+"::"+ip)
+ cc.Server.Redis.SRem(context.Background(), "mobius:online", login+":"+string(cc.UserName)+":"+ip)
+ // If we track the previous nickname, remove that too:
+ // cc.Server.Redis.SRem(context.Background(), "mobius:online", login+":"+oldNickname+":"+ip)
+ cc.Server.Redis.SAdd(context.Background(), "mobius:banned:ips", ip)
+ cc.Disconnect()
+ return res
+ }
+ }
+
cc.Icon = t.GetField(hotline.FieldUserIconID).Data
cc.Logger = cc.Logger.With("Name", string(cc.UserName))
@@ -893,7 +1101,8 @@ func HandleTranAgreed(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
res = append(res, trans...)
if cc.Server.Config.BannerFile != "" {
- res = append(res, hotline.NewTransaction(hotline.TranServerBanner, cc.ID, hotline.NewField(hotline.FieldBannerType, []byte("JPEG"))))
+ bannerType := hotline.FileTypeFromFilename(cc.Server.Config.BannerFile).TypeCode
+ res = append(res, hotline.NewTransaction(hotline.TranServerBanner, cc.ID, hotline.NewField(hotline.FieldBannerType, []byte(bannerType))))
}
res = append(res, cc.NewReply(t))
@@ -906,7 +1115,7 @@ func HandleTranAgreed(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
// 101 Data
func HandleTranOldPostNews(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessNewsPostArt) {
- return cc.NewErrReply(t, "You are not allowed to post news.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedPostNews)
}
newsDateTemplate := hotline.NewsDateFormat
@@ -937,9 +1146,17 @@ func HandleTranOldPostNews(cc *hotline.ClientConn, t *hotline.Transaction) (res
return append(res, cc.NewReply(t))
}
+// HandleDisconnectUser disconnects a specified user, optionally with a ban.
+//
+// Fields used in the request:
+// * 103 User ID Required - ID of the user to disconnect
+// * 113 Options Optional - Ban options ([0,1]=temporary ban, [0,2]=permanent ban)
+//
+// Fields used in the reply:
+// None
func HandleDisconnectUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessDisconUser) {
- return cc.NewErrReply(t, "You are not allowed to disconnect users.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedDisconnectUsers)
}
clientID := [2]byte(t.GetField(hotline.FieldUserID).Data)
@@ -961,12 +1178,12 @@ func HandleDisconnectUser(cc *hotline.ClientConn, t *hotline.Transaction) (res [
res = append(res, hotline.NewTransaction(
hotline.TranServerMsg,
clientConn.ID,
- hotline.NewField(hotline.FieldData, []byte("You are temporarily banned on this server")),
+ hotline.NewField(hotline.FieldData, []byte(ErrMsgTemporaryBan)),
hotline.NewField(hotline.FieldChatOptions, []byte{0, 0}),
))
banUntil := time.Now().Add(hotline.BanDuration)
- ip := strings.Split(clientConn.RemoteAddr, ":")[0]
+ ip, _, _ := net.SplitHostPort(clientConn.RemoteAddr)
err := cc.Server.BanList.Add(ip, &banUntil)
if err != nil {
@@ -980,11 +1197,11 @@ func HandleDisconnectUser(cc *hotline.ClientConn, t *hotline.Transaction) (res [
res = append(res, hotline.NewTransaction(
hotline.TranServerMsg,
clientConn.ID,
- hotline.NewField(hotline.FieldData, []byte("You are permanently banned on this server")),
+ hotline.NewField(hotline.FieldData, []byte(ErrMsgPermanentBan)),
hotline.NewField(hotline.FieldChatOptions, []byte{0, 0}),
))
- ip := strings.Split(clientConn.RemoteAddr, ":")[0]
+ ip, _, _ := net.SplitHostPort(clientConn.RemoteAddr)
err := cc.Server.BanList.Add(ip, nil)
if err != nil {
@@ -1001,12 +1218,16 @@ func HandleDisconnectUser(cc *hotline.ClientConn, t *hotline.Transaction) (res [
return append(res, cc.NewReply(t))
}
-// HandleGetNewsCatNameList returns a list of news categories for a path
+// HandleGetNewsCatNameList returns a list of news categories for the specified path.
+//
// Fields used in the request:
-// 325 News path (Optional)
+// * 325 News Path Optional - Path to the news category (root if omitted)
+//
+// Fields used in the reply:
+// * 323 News Category List Data Repeated - Category information for each subcategory
func HandleGetNewsCatNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessNewsReadArt) {
- return cc.NewErrReply(t, "You are not allowed to read news.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedReadNews)
}
pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
@@ -1028,9 +1249,17 @@ func HandleGetNewsCatNameList(cc *hotline.ClientConn, t *hotline.Transaction) (r
return append(res, cc.NewReply(t, fields...))
}
+// HandleNewNewsCat creates a new news category.
+//
+// Fields used in the request:
+// * 322 News Category Name Required - Name of the new category
+// * 325 News Path Optional - Parent path for the new category
+//
+// Fields used in the reply:
+// None
func HandleNewNewsCat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessNewsCreateCat) {
- return cc.NewErrReply(t, "You are not allowed to create news categories.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedCreateNewsCategories)
}
name := string(t.GetField(hotline.FieldNewsCatName).Data)
@@ -1047,12 +1276,17 @@ func HandleNewNewsCat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
return []hotline.Transaction{cc.NewReply(t)}
}
+// HandleNewNewsFldr creates a new news folder (bundle).
+//
// Fields used in the request:
-// 322 News category Name
-// 325 News path
+// * 201 File Name Required - Name of the new news folder
+// * 325 News Path Optional - Parent path for the new folder
+//
+// Fields used in the reply:
+// None
func HandleNewNewsFldr(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessNewsCreateFldr) {
- return cc.NewErrReply(t, "You are not allowed to create news folders.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedCreateNewsfolders)
}
name := string(t.GetField(hotline.FieldFileName).Data)
@@ -1069,16 +1303,16 @@ func HandleNewNewsFldr(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho
return append(res, cc.NewReply(t))
}
-// HandleGetNewsArtData gets the list of article names at the specified news path.
-
+// HandleGetNewsArtNameList returns a list of article names at the specified news path.
+//
// Fields used in the request:
-// 325 News path Optional
-
+// * 325 News Path Optional - Path to the news category
+//
// Fields used in the reply:
-// 321 News article list data Optional
+// * 321 News Article List Data Optional - List of articles in the category
func HandleGetNewsArtNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessNewsReadArt) {
- return cc.NewErrReply(t, "You are not allowed to read news.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedReadNews)
}
pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
@@ -1086,7 +1320,10 @@ func HandleGetNewsArtNameList(cc *hotline.ClientConn, t *hotline.Transaction) (r
return res
}
- nald := cc.Server.ThreadedNewsMgr.ListArticles(pathStrs)
+ nald, err := cc.Server.ThreadedNewsMgr.ListArticles(pathStrs)
+ if err != nil {
+ return res
+ }
b, err := io.ReadAll(&nald)
if err != nil {
@@ -1096,27 +1333,26 @@ func HandleGetNewsArtNameList(cc *hotline.ClientConn, t *hotline.Transaction) (r
return append(res, cc.NewReply(t, hotline.NewField(hotline.FieldNewsArtListData, b)))
}
-// HandleGetNewsArtData requests information about the specific news article.
-// Fields used in the request:
+// HandleGetNewsArtData retrieves the content and metadata of a specific news article.
//
-// Request fields
-// 325 News path
-// 326 News article Type
-// 327 News article data flavor
+// Fields used in the request:
+// * 325 News Path Required - Path to the news category
+// * 326 News Article ID Required - ID of the article to retrieve
+// * 327 News Article Data Flavor Optional - Data format ("text/plain")
//
// 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”)
+// * 328 News Article Title Article title
+// * 329 News Article Poster Author of the article
+// * 330 News Article Date Publication date
+// * 331 Previous Article ID ID of previous article in thread
+// * 332 Next Article ID ID of next article in thread
+// * 335 Parent Article ID ID of parent article
+// * 336 First Child Article ID ID of first reply article
+// * 327 News Article Data Flavor Data format ("text/plain")
+// * 333 News Article Data Optional - Article content (if flavor is "text/plain")
func HandleGetNewsArtData(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessNewsReadArt) {
- return cc.NewErrReply(t, "You are not allowed to read news.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedReadNews)
}
newsPath, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
@@ -1149,8 +1385,10 @@ func HandleGetNewsArtData(cc *hotline.ClientConn, t *hotline.Transaction) (res [
}
// HandleDelNewsItem deletes a threaded news folder or category.
+//
// Fields used in the request:
-// 325 News path
+// * 325 News Path Required - Path to the news item to delete
+//
// Fields used in the reply:
// None
func HandleDelNewsItem(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
@@ -1164,11 +1402,11 @@ func HandleDelNewsItem(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho
if item.Type == [2]byte{0, 3} {
if !cc.Authorize(hotline.AccessNewsDeleteCat) {
- return cc.NewErrReply(t, "You are not allowed to delete news categories.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedDeleteNewsCategories)
}
} else {
if !cc.Authorize(hotline.AccessNewsDeleteFldr) {
- return cc.NewErrReply(t, "You are not allowed to delete news folders.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedDeleteNewsFolders)
}
}
@@ -1181,13 +1419,17 @@ func HandleDelNewsItem(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho
}
// 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)
+//
+// Fields used in the request:
+// * 325 News Path Required - Path to the news category
+// * 326 News Article ID Required - ID of the article to delete
+// * 337 News Article Recursive Delete Optional - Delete child articles (1) or not (0)
+//
+// Fields used in the reply:
+// None
func HandleDelNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessNewsDeleteArt) {
- return cc.NewErrReply(t, "You are not allowed to delete news articles.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedDeleteNewsArticles)
}
@@ -1212,16 +1454,21 @@ func HandleDelNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
return []hotline.Transaction{cc.NewReply(t)}
}
-// Request fields
-// 325 News path
-// 326 News article Type Type of the parent article?
-// 328 News article title
-// 334 News article flags
-// 327 News article data flavor Currently “text/plain”
-// 333 News article data
+// HandlePostNewsArt creates a new threaded news article.
+//
+// Fields used in the request:
+// * 325 News Path Required - Path to the news category
+// * 326 News Article ID Optional - ID of parent article (0 for new thread)
+// * 328 News Article Title Required - Article title
+// * 334 News Article Flags Optional - Article flags
+// * 327 News Article Data Flavor Required - Data format ("text/plain")
+// * 333 News Article Data Required - Article content
+//
+// Fields used in the reply:
+// None
func HandlePostNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessNewsPostArt) {
- return cc.NewErrReply(t, "You are not allowed to post news articles.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedPostNewsArticles)
}
pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
@@ -1253,10 +1500,16 @@ func HandlePostNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho
return append(res, cc.NewReply(t))
}
-// HandleGetMsgs returns the flat news data
+// HandleGetMsgs returns the flat news data (message board content).
+//
+// Fields used in the request:
+// None
+//
+// Fields used in the reply:
+// * 101 Data Complete message board content
func HandleGetMsgs(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessNewsReadArt) {
- return cc.NewErrReply(t, "You are not allowed to read news.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedReadNews)
}
_, _ = cc.Server.MessageBoard.Seek(0, 0)
@@ -1269,9 +1522,22 @@ func HandleGetMsgs(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotlin
return append(res, cc.NewReply(t, hotline.NewField(hotline.FieldData, newsData)))
}
+// HandleDownloadFile initiates a file download transfer.
+//
+// Fields used in the request:
+// * 201 File Name Required - Name of the file to download
+// * 202 File Path Optional - Path to the file
+// * 203 File Resume Data Optional - Resume information for partial downloads
+// * 204 File Transfer Options Optional - Set to 2 for file preview
+//
+// Fields used in the reply:
+// * 107 Ref Num Transfer reference number
+// * 116 Waiting Count Number of users ahead in download queue
+// * 108 Transfer Size Total bytes to transfer
+// * 207 File Size Actual file size
func HandleDownloadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessDownloadFile) {
- return cc.NewErrReply(t, "You are not allowed to download files.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedDownloadFiles)
}
fileName := t.GetField(hotline.FieldFileName).Data
@@ -1293,7 +1559,7 @@ func HandleDownloadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []h
return res
}
- hlFile, err := hotline.NewFileWrapper(cc.Server.FS, fullFilePath, dataOffset)
+ hlFile, err := hotline.NewFile(cc.Server.FS, fullFilePath, dataOffset)
if err != nil {
return res
}
@@ -1335,9 +1601,20 @@ func HandleDownloadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []h
}
// Download all files from the specified folder and sub-folders
+// HandleDownloadFolder initiates a folder download transfer (all files and subfolders).
+//
+// Fields used in the request:
+// * 201 File Name Required - Name of the folder to download
+// * 202 File Path Optional - Path to the folder
+//
+// Fields used in the reply:
+// * 107 Ref Num Transfer reference number
+// * 108 Transfer Size Total bytes to transfer
+// * 220 Folder Item Count Number of items in the folder
+// * 116 Waiting Count Number of users ahead in download queue
func HandleDownloadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessDownloadFolder) {
- return cc.NewErrReply(t, "You are not allowed to download folders.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedDownloadFolders)
}
fullFilePath, err := hotline.ReadPath(cc.FileRoot(), t.GetField(hotline.FieldFilePath).Data, t.GetField(hotline.FieldFileName).Data)
@@ -1378,9 +1655,20 @@ func HandleDownloadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res [
// 108 hotline.Transfer size Total size of all items in the folder
// 220 Folder item count
// 204 File transfer options "Optional Currently set to 1" (TODO: ??)
+// HandleUploadFolder initiates a folder upload transfer.
+//
+// Fields used in the request:
+// * 201 File Name Required - Name of the folder to upload
+// * 202 File Path Optional - Destination path on server
+// * 108 Transfer Size Required - Total size of all items in the folder
+// * 220 Folder Item Count Required - Number of items in the folder
+// * 204 File Transfer Options Optional - Currently set to 1
+//
+// Fields used in the reply:
+// * 107 Ref Num Transfer reference number
func HandleUploadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessUploadFolder) {
- return cc.NewErrReply(t, "You are not allowed to upload folders.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedUploadFolders)
}
var fp hotline.FilePath
@@ -1393,7 +1681,7 @@ func HandleUploadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []h
// Handle special cases for Upload and Drop Box folders
if !cc.Authorize(hotline.AccessUploadAnywhere) {
if !fp.IsUploadDir() && !fp.IsDropbox() && !fp.IsUserDir() {
- return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload of the folder \"%v\" because you are only allowed to upload to the \"Uploads\" folder.", string(t.GetField(hotline.FieldFileName).Data)))
+ return cc.NewErrReply(t, fmt.Sprintf(ErrMsgUploadRestrictedTemplate, "folder", string(t.GetField(hotline.FieldFileName).Data)))
}
}
@@ -1409,16 +1697,20 @@ func HandleUploadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []h
return append(res, cc.NewReply(t, hotline.NewField(hotline.FieldRefNum, fileTransfer.RefNum[:])))
}
-// HandleUploadFile
+// HandleUploadFile initiates a file upload transfer.
+//
// 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"
+// * 201 File Name Required - Name of the file to upload
+// * 202 File Path Optional - Destination path on server
+// * 204 File Transfer Options Optional - Set to 2 for resume upload
+// * 108 Transfer Size Optional - File size (not sent for resume)
+//
+// Fields used in the reply:
+// * 107 Ref Num Transfer reference number
+// * 203 File Resume Data Optional - Resume information (for resumed uploads)
func HandleUploadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessUploadFile) {
- return cc.NewErrReply(t, "You are not allowed to upload files.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedUploadFiles)
}
fileName := t.GetField(hotline.FieldFileName).Data
@@ -1436,7 +1728,7 @@ func HandleUploadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
// Handle special cases for Upload and Drop Box folders
if !cc.Authorize(hotline.AccessUploadAnywhere) {
if !fp.IsUploadDir() && !fp.IsDropbox() && !fp.IsUserDir() {
- 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)))
+ return cc.NewErrReply(t, fmt.Sprintf(ErrMsgUploadRestrictedTemplate, "file", string(fileName)))
}
}
fullFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, fileName)
@@ -1446,7 +1738,7 @@ func HandleUploadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
if _, err := cc.Server.FS.Stat(fullFilePath); err == nil {
if !fp.IsUserDir() {
- return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload because there is already a file named \"%v\". Try choosing a different Name.", string(fileName)))
+ return cc.NewErrReply(t, fmt.Sprintf(ErrMsgFileUploadConflictTemplate, string(fileName)))
}
}
@@ -1479,6 +1771,16 @@ func HandleUploadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
return res
}
+// HandleSetClientUserInfo updates the current client's user information and preferences.
+//
+// Fields used in the request:
+// * 104 User Icon ID Optional - New user icon
+// * 102 User Name Optional - New display name (requires appropriate access)
+// * 113 Options Optional - User preference flags (refuse PM, refuse chat, auto-reply)
+// * 215 Automatic Response Optional - Auto-reply message text
+//
+// Fields used in the reply:
+// None
func HandleSetClientUserInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if len(t.GetField(hotline.FieldUserIconID).Data) == 4 {
cc.Icon = t.GetField(hotline.FieldUserIconID).Data[2:]
@@ -1486,7 +1788,33 @@ func HandleSetClientUserInfo(cc *hotline.ClientConn, t *hotline.Transaction) (re
cc.Icon = t.GetField(hotline.FieldUserIconID).Data
}
if cc.Authorize(hotline.AccessAnyName) {
+ oldNickname := string(cc.UserName)
+ newNickname := string(t.GetField(hotline.FieldUserName).Data)
cc.UserName = t.GetField(hotline.FieldUserName).Data
+ if cc.Server.Redis != nil {
+ login := cc.Account.Login
+ ip, _, _ := net.SplitHostPort(cc.RemoteAddr)
+ // Remove old entry (login:oldnickname:ip) and (login::ip)
+ cc.Server.Redis.SRem(context.Background(), "mobius:online", login+"::"+ip)
+ if oldNickname != "" {
+ cc.Server.Redis.SRem(context.Background(), "mobius:online", login+":"+oldNickname+":"+ip)
+ }
+ // Add new entry
+ cc.Server.Redis.SAdd(context.Background(), "mobius:online", login+":"+newNickname+":"+ip)
+ // Ban check for nickname
+ bannedNick, _ := cc.Server.Redis.SIsMember(context.Background(), "mobius:banned:nicknames", newNickname).Result()
+ if bannedNick {
+ // Remove all possible online entries for this login and IP
+ cc.Server.Redis.SRem(context.Background(), "mobius:online", login+"::"+ip)
+ cc.Server.Redis.SRem(context.Background(), "mobius:online", login+":"+newNickname+":"+ip)
+ if oldNickname != "" {
+ cc.Server.Redis.SRem(context.Background(), "mobius:online", login+":"+oldNickname+":"+ip)
+ }
+ cc.Server.Redis.SAdd(context.Background(), "mobius:banned:ips", ip)
+ cc.Disconnect()
+ return res
+ }
+ }
}
// the options field is only passed by the client versions > 1.2.3.
@@ -1519,15 +1847,27 @@ func HandleSetClientUserInfo(cc *hotline.ClientConn, t *hotline.Transaction) (re
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
+// HandleKeepAlive responds to client keepalive messages to maintain the connection.
+// HL 1.9.2 clients send keepalive messages every 3 minutes, while HL 1.2.3 clients do not.
+//
+// Fields used in the request:
+// None
+//
+// Fields used in the reply:
+// None
func HandleKeepAlive(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
res = append(res, cc.NewReply(t))
return res
}
+// HandleGetFileNameList returns a list of files and folders in the specified directory.
+//
+// Fields used in the request:
+// * 202 File Path Optional - Path to list (root if omitted)
+//
+// Fields used in the reply:
+// * 200 File Name With Info Repeated - File information for each item
func HandleGetFileNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
fullPath, err := hotline.ReadPath(
cc.FileRoot(),
@@ -1547,7 +1887,7 @@ func HandleGetFileNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res
// Handle special case for drop box folders
if fp.IsDropbox() && !cc.Authorize(hotline.AccessViewDropBoxes) {
- return cc.NewErrReply(t, "You are not allowed to view drop boxes.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedViewDropBoxes)
}
fileNames, err := hotline.GetFileNameList(fullPath, cc.Server.Config.IgnoreFiles)
@@ -1572,10 +1912,20 @@ func HandleGetFileNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res
// If Accepted is clicked:
// 1. ClientB sends TranJoinChat with FieldChatID
-// HandleInviteNewChat invites users to new private chat
+// HandleInviteNewChat creates a new private chat and invites a user to join.
+//
+// Fields used in the request:
+// * 103 User ID Required - ID of the user to invite
+//
+// Fields used in the reply:
+// * 114 Chat ID New chat room identifier
+// * 102 User Name Inviting user's name
+// * 103 User ID Inviting user's ID
+// * 104 User Icon ID Inviting user's icon
+// * 112 User Flags Inviting user's flags
func HandleInviteNewChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessOpenChat) {
- return cc.NewErrReply(t, "You are not allowed to request private chat.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedRequestPrivateChat)
}
// Client to Invite
@@ -1592,7 +1942,7 @@ func HandleInviteNewChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []
hotline.NewTransaction(
hotline.TranServerMsg,
cc.ID,
- hotline.NewField(hotline.FieldData, []byte(string(targetClient.UserName)+" does not accept private chats.")),
+ hotline.NewField(hotline.FieldData, []byte(fmt.Sprintf(ErrMsgDoesNotAcceptTemplate, string(targetClient.UserName), "private chats"))),
hotline.NewField(hotline.FieldUserName, targetClient.UserName),
hotline.NewField(hotline.FieldUserID, targetClient.ID[:]),
hotline.NewField(hotline.FieldOptions, []byte{0, 2}),
@@ -1622,9 +1972,21 @@ func HandleInviteNewChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []
)
}
+// HandleInviteToChat invites a user to an existing private chat.
+//
+// Fields used in the request:
+// * 103 User ID Required - ID of the user to invite
+// * 114 Chat ID Required - Existing chat room identifier
+//
+// Fields used in the reply:
+// * 114 Chat ID Chat room identifier
+// * 102 User Name Inviting user's name
+// * 103 User ID Inviting user's ID
+// * 104 User Icon ID Inviting user's icon
+// * 112 User Flags Inviting user's flags
func HandleInviteToChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessOpenChat) {
- return cc.NewErrReply(t, "You are not allowed to request private chat.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedRequestPrivateChat)
}
// Client to Invite
@@ -1650,6 +2012,13 @@ func HandleInviteToChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []h
}
}
+// HandleRejectChatInvite processes a user's rejection of a private chat invitation.
+//
+// Fields used in the request:
+// * 114 Chat ID Required - Chat room identifier of the rejected invitation
+//
+// Fields used in the reply:
+// None
func HandleRejectChatInvite(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
chatID := [4]byte(t.GetField(hotline.FieldChatID).Data)
@@ -1667,11 +2036,14 @@ func HandleRejectChatInvite(cc *hotline.ClientConn, t *hotline.Transaction) (res
return res
}
-// HandleJoinChat is sent from a v1.8+ Hotline client when the joins a private chat
+// HandleJoinChat processes a user joining a private chat room.
+//
+// Fields used in the request:
+// * 114 Chat ID Required - Chat room identifier to join
+//
// Fields used in the reply:
-// * 115 Chat subject
-// * 300 User Name with info (Optional)
-// * 300 (more user names with info)
+// * 115 Chat Subject Current chat room subject
+// * 300 Username With Info Repeated - Information for each user in the chat
func HandleJoinChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
chatID := t.GetField(hotline.FieldChatID).Data
@@ -1711,11 +2083,13 @@ func HandleJoinChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotli
return append(res, cc.NewReply(t, replyFields...))
}
-// HandleLeaveChat is sent from a v1.8+ Hotline client when the user exits a private chat
+// HandleLeaveChat processes a user leaving a private chat room.
+//
// Fields used in the request:
-// - 114 FieldChatID
+// * 114 Chat ID Required - Chat room identifier to leave
//
-// Reply is not expected.
+// Fields used in the reply:
+// None
func HandleLeaveChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
chatID := t.GetField(hotline.FieldChatID).Data
@@ -1736,11 +2110,14 @@ func HandleLeaveChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotl
return res
}
-// HandleSetChatSubject is sent from a v1.8+ Hotline client when the user sets a private chat subject
+// HandleSetChatSubject sets the subject/topic for a private chat room.
+//
// Fields used in the request:
-// * 114 Chat Type
-// * 115 Chat subject
-// Reply is not expected.
+// * 114 Chat ID Required - Chat room identifier
+// * 115 Chat Subject Required - New chat room subject
+//
+// Fields used in the reply:
+// None
func HandleSetChatSubject(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
chatID := t.GetField(hotline.FieldChatID).Data
@@ -1761,17 +2138,18 @@ func HandleSetChatSubject(cc *hotline.ClientConn, t *hotline.Transaction) (res [
return res
}
-// HandleMakeAlias makes a file alias using the specified path.
+// HandleMakeAlias creates a symbolic link (alias) to a file or folder.
+//
// Fields used in the request:
-// 201 File Name
-// 202 File path
-// 212 File new path Destination path
+// * 201 File Name Required - Name of the file to create an alias of
+// * 202 File Path Required - Path to the source file
+// * 212 File New Path Required - Destination path for the alias
//
// Fields used in the reply:
// None
func HandleMakeAlias(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessMakeAlias) {
- return cc.NewErrReply(t, "You are not allowed to make aliases.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedMakeAliases)
}
fileName := t.GetField(hotline.FieldFileName).Data
filePath := t.GetField(hotline.FieldFilePath).Data
@@ -1788,19 +2166,21 @@ func HandleMakeAlias(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotl
}
if err := cc.Server.FS.Symlink(fullFilePath, fullNewFilePath); err != nil {
- return cc.NewErrReply(t, "Error creating alias")
+ return cc.NewErrReply(t, ErrMsgCreateAlias)
}
res = append(res, cc.NewReply(t))
return res
}
-// HandleDownloadBanner handles requests for a new banner from the server
+// HandleDownloadBanner initiates a download of the server banner image.
+//
// 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
+// * 107 Ref Num Transfer reference number
+// * 108 Transfer Size Size of banner data to download
func HandleDownloadBanner(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
ft := cc.NewFileTransfer(hotline.BannerDownload, "", []byte{}, []byte{}, make([]byte, 4))
binary.BigEndian.PutUint32(ft.TransferSize, uint32(len(cc.Server.Banner)))
diff --git a/internal/mobius/transaction_handlers_test.go b/internal/mobius/transaction_handlers_test.go
index 43c033f..d943649 100644
--- a/internal/mobius/transaction_handlers_test.go
+++ b/internal/mobius/transaction_handlers_test.go
@@ -1917,11 +1917,11 @@ func TestHandleDownloadFile(t *testing.T) {
ForkCount: [2]byte{0, 2},
ForkInfoList: []hotline.ForkInfoList{
{
- Fork: [4]byte{0x44, 0x41, 0x54, 0x41}, // "DATA"
- DataSize: [4]byte{0, 0, 0x01, 0x00}, // request offset 256
+ Fork: hotline.ForkTypeDATA,
+ DataSize: [4]byte{0, 0, 0x01, 0x00}, // request offset 256
},
{
- Fork: [4]byte{0x4d, 0x41, 0x43, 0x52}, // "MACR"
+ Fork: hotline.ForkTypeMACR,
DataSize: [4]byte{0, 0, 0, 0},
},
},
@@ -2873,6 +2873,56 @@ func TestHandleTranAgreed(t *testing.T) {
},
},
},
+ {
+ name: "with gif banner",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessDisconUser)
+ bits.Set(hotline.AccessAnyName)
+ return bits
+ }()},
+ Icon: []byte{0, 1},
+ Flags: [2]byte{0, 1},
+ Version: []byte{0, 1},
+ ID: [2]byte{0, 1},
+ Logger: NewTestLogger(),
+ Server: &hotline.Server{
+ Config: hotline.Config{
+ BannerFile: "Banner.gif",
+ },
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{},
+ )
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranAgreed, [2]byte{},
+ hotline.NewField(hotline.FieldUserName, []byte("username")),
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldOptions, []byte{0, 0}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0, 0x7a},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldBannerType, []byte("GIFf")),
+ },
+ },
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ Fields: []hotline.Field{},
+ },
+ },
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -2935,6 +2985,220 @@ func TestHandleSetClientUserInfo(t *testing.T) {
},
},
},
+ {
+ name: "when client has AccessAnyName and changes username",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessAnyName)
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ UserName: []byte("Guest"),
+ Flags: [2]byte{0, 1},
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ ID: [2]byte{0, 1},
+ },
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranSetClientUserInfo, [2]byte{},
+ hotline.NewField(hotline.FieldUserName, []byte("NewName")),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0x01, 0x2d},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserIconID, []byte{}),
+ hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserName, []byte("NewName"))},
+ },
+ },
+ },
+ {
+ name: "when client updates icon with 4-byte data",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ UserName: []byte("Guest"),
+ Flags: [2]byte{0, 1},
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ ID: [2]byte{0, 1},
+ },
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranSetClientUserInfo, [2]byte{},
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 1, 0, 5}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0x01, 0x2d},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 5}),
+ hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserName, []byte("Guest"))},
+ },
+ },
+ },
+ {
+ name: "when client updates icon with 2-byte data",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ UserName: []byte("Guest"),
+ Flags: [2]byte{0, 1},
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ ID: [2]byte{0, 1},
+ },
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranSetClientUserInfo, [2]byte{},
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 3}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0x01, 0x2d},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 3}),
+ hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserName, []byte("Guest"))},
+ },
+ },
+ },
+ {
+ name: "when client sets user options with auto-reply",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ UserName: []byte("Guest"),
+ Flags: [2]byte{0, 1},
+ Version: []byte{0x01, 0x03},
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ ID: [2]byte{0, 1},
+ },
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranSetClientUserInfo, [2]byte{},
+ hotline.NewField(hotline.FieldOptions, []byte{0, 4}),
+ hotline.NewField(hotline.FieldAutomaticResponse, []byte("Away message")),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0x01, 0x2d},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserIconID, []byte{}),
+ hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserName, []byte("Guest"))},
+ },
+ },
+ },
+ {
+ name: "when client sets refuse private messages flag",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ UserName: []byte("Guest"),
+ Flags: [2]byte{0, 1},
+ Version: []byte{0x01, 0x03},
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ ID: [2]byte{0, 1},
+ },
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranSetClientUserInfo, [2]byte{},
+ hotline.NewField(hotline.FieldOptions, []byte{0, 2}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0x01, 0x2d},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserIconID, []byte{}),
+ hotline.NewField(hotline.FieldUserFlags, []byte{0, 9}),
+ hotline.NewField(hotline.FieldUserName, []byte("Guest"))},
+ },
+ },
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -3868,3 +4132,482 @@ func TestHandleDownloadFolder(t *testing.T) {
})
}
}
+
+func TestHandleJoinChat(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ want []hotline.Transaction
+ }{
+ {
+ name: "joins private chat and notifies existing members",
+ args: args{
+ cc: &hotline.ClientConn{
+ UserName: []byte("NewUser"),
+ ID: [2]byte{0, 3},
+ Icon: []byte{0, 5},
+ Flags: [2]byte{0, 1},
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ Server: &hotline.Server{
+ ChatMgr: func() *hotline.MockChatManager {
+ m := hotline.MockChatManager{}
+ // Mock existing members before join
+ m.On("Members", hotline.ChatID{0, 0, 0, 1}).Return([]*hotline.ClientConn{
+ {
+ UserName: []byte("User1"),
+ ID: [2]byte{0, 1},
+ Icon: []byte{0, 2},
+ Flags: [2]byte{0, 0},
+ },
+ {
+ UserName: []byte("User2"),
+ ID: [2]byte{0, 2},
+ Icon: []byte{0, 3},
+ Flags: [2]byte{0, 0},
+ },
+ })
+ // Mock join operation
+ m.On("Join", hotline.ChatID{0, 0, 0, 1}, mock.AnythingOfType("*hotline.ClientConn")).Return()
+ // Mock members after join (including new user)
+ m.On("Members", hotline.ChatID{0, 0, 0, 1}).Return([]*hotline.ClientConn{
+ {
+ UserName: []byte("User1"),
+ ID: [2]byte{0, 1},
+ Icon: []byte{0, 2},
+ Flags: [2]byte{0, 0},
+ },
+ {
+ UserName: []byte("User2"),
+ ID: [2]byte{0, 2},
+ Icon: []byte{0, 3},
+ Flags: [2]byte{0, 0},
+ },
+ {
+ UserName: []byte("NewUser"),
+ ID: [2]byte{0, 3},
+ Icon: []byte{0, 5},
+ Flags: [2]byte{0, 1},
+ },
+ })
+ // Mock chat subject
+ m.On("GetSubject", hotline.ChatID{0, 0, 0, 1}).Return("Test Chat Room")
+ return &m
+ }(),
+ },
+ },
+ t: hotline.Transaction{
+ Type: [2]byte{0, 0x74}, // TranJoinChat
+ ID: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ // Notification to existing member 1
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0, 0x75}, // TranNotifyChatChangeUser
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserName, []byte("NewUser")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 3}),
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 5}),
+ hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}),
+ },
+ },
+ // Notification to existing member 2
+ {
+ ClientID: [2]byte{0, 2},
+ Type: [2]byte{0, 0x75}, // TranNotifyChatChangeUser
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserName, []byte("NewUser")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 3}),
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 5}),
+ hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}),
+ },
+ },
+ // Reply to joining user
+ {
+ ClientID: [2]byte{0, 3},
+ IsReply: 0x01,
+ Type: [2]byte{0, 0}, // Reply type is [0, 0]
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatSubject, []byte("Test Chat Room")),
+ // User1 info
+ hotline.NewField(hotline.FieldUsernameWithInfo, []byte{
+ 0x00, 0x01, // User ID
+ 0x00, 0x02, // Icon ID
+ 0x00, 0x00, // Flags
+ 0x00, 0x05, // Username length
+ 0x55, 0x73, 0x65, 0x72, 0x31, // "User1"
+ }),
+ // User2 info
+ hotline.NewField(hotline.FieldUsernameWithInfo, []byte{
+ 0x00, 0x02, // User ID
+ 0x00, 0x03, // Icon ID
+ 0x00, 0x00, // Flags
+ 0x00, 0x05, // Username length
+ 0x55, 0x73, 0x65, 0x72, 0x32, // "User2"
+ }),
+ },
+ },
+ },
+ },
+ {
+ name: "joins empty private chat",
+ args: args{
+ cc: &hotline.ClientConn{
+ UserName: []byte("OnlyUser"),
+ ID: [2]byte{0, 1},
+ Icon: []byte{0, 1},
+ Flags: [2]byte{0, 0},
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ Server: &hotline.Server{
+ ChatMgr: func() *hotline.MockChatManager {
+ m := hotline.MockChatManager{}
+ // Mock empty chat before join
+ m.On("Members", hotline.ChatID{0, 0, 0, 2}).Return([]*hotline.ClientConn{})
+ // Mock join operation
+ m.On("Join", hotline.ChatID{0, 0, 0, 2}, mock.AnythingOfType("*hotline.ClientConn")).Return()
+ // Mock members after join
+ m.On("Members", hotline.ChatID{0, 0, 0, 2}).Return([]*hotline.ClientConn{
+ {
+ UserName: []byte("OnlyUser"),
+ ID: [2]byte{0, 1},
+ Icon: []byte{0, 1},
+ Flags: [2]byte{0, 0},
+ },
+ })
+ // Mock chat subject
+ m.On("GetSubject", hotline.ChatID{0, 0, 0, 2}).Return("Empty Chat")
+ return &m
+ }(),
+ },
+ },
+ t: hotline.Transaction{
+ Type: [2]byte{0, 0x74}, // TranJoinChat
+ ID: [4]byte{0, 0, 0, 2},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 2}),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ // Only reply to joining user (no existing members to notify)
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ Type: [2]byte{0, 0}, // Reply type is [0, 0]
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatSubject, []byte("Empty Chat")),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := HandleJoinChat(tt.args.cc, &tt.args.t)
+ if !TranAssertEqual(t, tt.want, got) {
+ t.Errorf("HandleJoinChat() got = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestHandleRejectChatInvite(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ want []hotline.Transaction
+ }{
+ {
+ name: "rejects invite and notifies chat members",
+ args: args{
+ cc: &hotline.ClientConn{
+ UserName: []byte("RejectUser"),
+ ID: [2]byte{0, 3},
+ Server: &hotline.Server{
+ ChatMgr: func() *hotline.MockChatManager {
+ m := hotline.MockChatManager{}
+ // Mock current members of the chat
+ m.On("Members", hotline.ChatID{0, 0, 0, 1}).Return([]*hotline.ClientConn{
+ {
+ UserName: []byte("Member1"),
+ ID: [2]byte{0, 1},
+ },
+ {
+ UserName: []byte("Member2"),
+ ID: [2]byte{0, 2},
+ },
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.Transaction{
+ Type: [2]byte{0, 0x72}, // TranRejectChatInvite
+ ID: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ // Notification to member 1
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0, 0x6A}, // TranChatMsg
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte("RejectUser declined invitation to chat")),
+ },
+ },
+ // Notification to member 2
+ {
+ ClientID: [2]byte{0, 2},
+ Type: [2]byte{0, 0x6A}, // TranChatMsg
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte("RejectUser declined invitation to chat")),
+ },
+ },
+ },
+ },
+ {
+ name: "rejects invite to empty chat",
+ args: args{
+ cc: &hotline.ClientConn{
+ UserName: []byte("LoneRejecter"),
+ ID: [2]byte{0, 1},
+ Server: &hotline.Server{
+ ChatMgr: func() *hotline.MockChatManager {
+ m := hotline.MockChatManager{}
+ // Mock empty chat (no members)
+ m.On("Members", hotline.ChatID{0, 0, 0, 2}).Return([]*hotline.ClientConn{})
+ return &m
+ }(),
+ },
+ },
+ t: hotline.Transaction{
+ Type: [2]byte{0, 0x72}, // TranRejectChatInvite
+ ID: [4]byte{0, 0, 0, 2},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 2}),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ // No notifications (no members to notify)
+ },
+ },
+ {
+ name: "rejects invite with single member",
+ args: args{
+ cc: &hotline.ClientConn{
+ UserName: []byte("Shy"),
+ ID: [2]byte{0, 2},
+ Server: &hotline.Server{
+ ChatMgr: func() *hotline.MockChatManager {
+ m := hotline.MockChatManager{}
+ // Mock chat with single member
+ m.On("Members", hotline.ChatID{0, 0, 0, 3}).Return([]*hotline.ClientConn{
+ {
+ UserName: []byte("OnlyMember"),
+ ID: [2]byte{0, 1},
+ },
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.Transaction{
+ Type: [2]byte{0, 0x72}, // TranRejectChatInvite
+ ID: [4]byte{0, 0, 0, 3},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 3}),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ // Notification to the single member
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0, 0x6A}, // TranChatMsg
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte("Shy declined invitation to chat")),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := HandleRejectChatInvite(tt.args.cc, &tt.args.t)
+ if !TranAssertEqual(t, tt.want, got) {
+ t.Errorf("HandleRejectChatInvite() got = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestHandleInviteToChat(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ want []hotline.Transaction
+ }{
+ {
+ name: "invites user to chat successfully",
+ args: args{
+ cc: &hotline.ClientConn{
+ UserName: []byte("Inviter"),
+ ID: [2]byte{0, 1},
+ Icon: []byte{0, 2},
+ Flags: [2]byte{0, 3},
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ access := hotline.AccessBitmap{}
+ access.Set(hotline.AccessOpenChat)
+ return access
+ }(),
+ },
+ },
+ t: hotline.Transaction{
+ Type: [2]byte{0, 0x71}, // TranInviteToChat
+ ID: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserID, []byte{0, 5}),
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 10}),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ // Invite sent to target user
+ {
+ ClientID: [2]byte{0, 5},
+ Type: [2]byte{0, 0x71}, // TranInviteToChat
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserName, []byte("Inviter")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ },
+ },
+ // Reply to inviting user
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ Type: [2]byte{0, 0}, // Reply type is [0, 0]
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserName, []byte("Inviter")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 2}),
+ hotline.NewField(hotline.FieldUserFlags, []byte{0, 3}),
+ },
+ },
+ },
+ },
+ {
+ name: "returns error when user lacks permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ UserName: []byte("NoPermUser"),
+ ID: [2]byte{0, 2},
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{}, // No permissions
+ },
+ },
+ t: hotline.Transaction{
+ Type: [2]byte{0, 0x71}, // TranInviteToChat
+ ID: [4]byte{0, 0, 0, 2},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserID, []byte{0, 3}),
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 5}),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ // Error reply to requesting user
+ {
+ ClientID: [2]byte{0, 2},
+ IsReply: 0x01,
+ Type: [2]byte{0, 0}, // Reply type is [0, 0]
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to request private chat.")),
+ },
+ },
+ },
+ },
+ {
+ name: "invites to different chat room",
+ args: args{
+ cc: &hotline.ClientConn{
+ UserName: []byte("Host"),
+ ID: [2]byte{0, 10},
+ Icon: []byte{0, 15},
+ Flags: [2]byte{0, 20},
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ access := hotline.AccessBitmap{}
+ access.Set(hotline.AccessOpenChat)
+ return access
+ }(),
+ },
+ },
+ t: hotline.Transaction{
+ Type: [2]byte{0, 0x71}, // TranInviteToChat
+ ID: [4]byte{0, 0, 0, 3},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserID, []byte{0, 99}),
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 25}),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ // Invite sent to target user
+ {
+ ClientID: [2]byte{0, 99},
+ Type: [2]byte{0, 0x71}, // TranInviteToChat
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserName, []byte("Host")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 10}),
+ },
+ },
+ // Reply to inviting user
+ {
+ ClientID: [2]byte{0, 10},
+ IsReply: 0x01,
+ Type: [2]byte{0, 0}, // Reply type is [0, 0]
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserName, []byte("Host")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 10}),
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 15}),
+ hotline.NewField(hotline.FieldUserFlags, []byte{0, 20}),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := HandleInviteToChat(tt.args.cc, &tt.args.t)
+ if !TranAssertEqual(t, tt.want, got) {
+ t.Errorf("HandleInviteToChat() got = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}