diff options
| author | Ruben Beltran del Rio <git@r.bdr.sh> | 2025-11-28 00:39:17 +0100 |
|---|---|---|
| committer | Ruben Beltran del Rio <git@r.bdr.sh> | 2025-11-28 00:39:17 +0100 |
| commit | 9f67542d8469db45c823e347b1868b3582d9e5a7 (patch) | |
| tree | 88741b3d8633758e4f6f5cbc292f338bc99602a0 /internal | |
| parent | 8f9edf2f3bb18f7ab1a04ead182a1daf2cfd41d9 (diff) | |
| parent | 8ddb9bb228389b198a76d6df21de005da4fad66b (diff) | |
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/mobius/account_manager.go | 38 | ||||
| -rw-r--r-- | internal/mobius/account_manager_test.go | 438 | ||||
| -rw-r--r-- | internal/mobius/api.go | 226 | ||||
| -rw-r--r-- | internal/mobius/ban.go | 6 | ||||
| -rw-r--r-- | internal/mobius/ban_test.go | 10 | ||||
| -rw-r--r-- | internal/mobius/config.go | 19 | ||||
| -rw-r--r-- | internal/mobius/config_test.go | 95 | ||||
| -rw-r--r-- | internal/mobius/news.go | 6 | ||||
| -rw-r--r-- | internal/mobius/news_test.go | 500 | ||||
| -rw-r--r-- | internal/mobius/threaded_news.go | 4 | ||||
| -rw-r--r-- | internal/mobius/threaded_news_test.go | 8 | ||||
| -rw-r--r-- | internal/mobius/transaction_handlers.go | 668 | ||||
| -rw-r--r-- | internal/mobius/transaction_handlers_test.go | 749 |
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) + } + }) + } +} |