From 94fd21cb533689a1f9ca18c1404b63f2fedb674c Mon Sep 17 00:00:00 2001 From: Theo Knez <27211475+Knezzen@users.noreply.github.com> Date: Sat, 10 May 2025 20:36:59 +0200 Subject: Update api.go --- internal/mobius/api.go | 195 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 188 insertions(+), 7 deletions(-) (limited to 'internal') diff --git a/internal/mobius/api.go b/internal/mobius/api.go index 31755b8..4dfc575 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 { @@ -34,31 +38,208 @@ 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 { +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 } +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) +} + +type BanRequest struct { + Username string `json:"username,omitempty"` + Nickname string `json:"nickname,omitempty"` + IP string `json:"ip,omitempty"` +} + +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"}`)) +} + +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"}`)) +} + +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 + } +} + +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 + } +} + +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 + } +} + func (srv *APIServer) ShutdownHandler(w http.ResponseWriter, r *http.Request) { msg, err := io.ReadAll(r.Body) if err != nil || len(msg) == 0 { @@ -85,7 +266,7 @@ func (srv *APIServer) RenderStats(w http.ResponseWriter, _ *http.Request) { panic(err) } - _, _ = io.WriteString(w, string(u)) + _, _ = w.Write(u) } func (srv *APIServer) Serve(port string) { -- cgit From 8e563cf7f9bda27105383a68ec2e902a62bfd50d Mon Sep 17 00:00:00 2001 From: Theo Knez <27211475+Knezzen@users.noreply.github.com> Date: Sat, 10 May 2025 20:37:32 +0200 Subject: Update transaction_handlers.go --- internal/mobius/transaction_handlers.go | 61 +++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 2 deletions(-) (limited to 'internal') diff --git a/internal/mobius/transaction_handlers.go b/internal/mobius/transaction_handlers.go index cf2357c..c03704a 100644 --- a/internal/mobius/transaction_handlers.go +++ b/internal/mobius/transaction_handlers.go @@ -3,10 +3,9 @@ package mobius import ( "bufio" "bytes" + "context" "encoding/binary" "fmt" - "github.com/jhalter/mobius/hotline" - "golang.org/x/text/encoding/charmap" "io" "math/big" "os" @@ -14,8 +13,19 @@ import ( "path/filepath" "strings" "time" + + "github.com/jhalter/mobius/hotline" + "golang.org/x/text/encoding/charmap" ) +// This function is used to extract the IP address from a given address string, exluding the port. +func extractIP(addr string) string { + if idx := strings.LastIndex(addr, ":"); idx != -1 { + return addr[:idx] + } + return addr +} + // Converts bytes from Mac Roman encoding to UTF-8 var txtDecoder = charmap.Macintosh.NewDecoder() @@ -852,6 +862,27 @@ func HandleTranAgreed(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot } } + if cc.Server.Redis != nil { + login := cc.Account.Login + ip := extractIP(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)) @@ -1477,7 +1508,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 := extractIP(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. -- cgit From 5ec29c573bde2417b5d8788966af2577332ce0fc Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Wed, 25 Jun 2025 20:56:36 -0700 Subject: Fix critical bug in YAMLAccountManager Update method Move delete operation before modifying account.Login to prevent deleting wrong key from accounts map. --- internal/mobius/account_manager.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'internal') diff --git a/internal/mobius/account_manager.go b/internal/mobius/account_manager.go index 6bbc951..72984b7 100644 --- a/internal/mobius/account_manager.go +++ b/internal/mobius/account_manager.go @@ -114,10 +114,9 @@ func (am *YAMLAccountManager) Update(account hotline.Account, newLogin string) e 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) -- cgit From 55b8e77c409761639e95168c77dc22c13e858b6b Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Thu, 26 Jun 2025 14:01:22 -0700 Subject: Replace filepath.Join with path.Join for Windows compatibility Replace all instances of filepath.Join with path.Join across the codebase to improve Windows compatibility following the guidance from https://github.com/golang/go/issues/44305. Key changes: - Replaced filepath.Join with path.Join in 14 files - Updated import statements appropriately - Resolved variable shadowing issues where function parameters named 'path' were conflicting with the path package - Maintained filepath imports where needed for OS-specific functions like filepath.Walk, filepath.IsAbs, filepath.Dir, and filepath.Base All tests pass, confirming the changes maintain functionality while improving cross-platform compatibility. --- cmd/mobius-hotline-server/main.go | 7 +++---- cmd/mobius-hotline-server/main_test.go | 16 ++++++++-------- hotline/file_path.go | 8 ++++---- hotline/file_transfer.go | 13 +++++++------ internal/mobius/account_manager.go | 12 ++++++------ internal/mobius/ban.go | 4 ++-- internal/mobius/ban_test.go | 8 ++++---- internal/mobius/threaded_news_test.go | 4 ++-- internal/mobius/transaction_handlers.go | 3 +-- 9 files changed, 37 insertions(+), 38 deletions(-) (limited to 'internal') diff --git a/cmd/mobius-hotline-server/main.go b/cmd/mobius-hotline-server/main.go index e230771..afad4d1 100644 --- a/cmd/mobius-hotline-server/main.go +++ b/cmd/mobius-hotline-server/main.go @@ -10,7 +10,6 @@ import ( "os" "os/signal" "path" - "path/filepath" "syscall" "github.com/jhalter/mobius/hotline" @@ -108,7 +107,7 @@ func main() { os.Exit(1) } - srv.AccountManager, err = mobius.NewYAMLAccountManager(filepath.Join(*configDir, "Users/")) + srv.AccountManager, err = mobius.NewYAMLAccountManager(path.Join(*configDir, "Users/")) if err != nil { slogger.Error(fmt.Sprintf("Error loading accounts: %v", err)) os.Exit(1) @@ -120,7 +119,7 @@ func main() { os.Exit(1) } - bannerPath := filepath.Join(*configDir, config.BannerFile) + bannerPath := path.Join(*configDir, config.BannerFile) srv.Banner, err = os.ReadFile(bannerPath) if err != nil { slogger.Error(fmt.Sprintf("Error loading accounts: %v", err)) @@ -146,7 +145,7 @@ func main() { } // Let's try to reload the banner - bannerPath := filepath.Join(*configDir, config.BannerFile) + bannerPath := path.Join(*configDir, config.BannerFile) srv.Banner, err = os.ReadFile(bannerPath) if err != nil { slogger.Error(fmt.Sprintf("Error reloading banner: %v", err)) diff --git a/cmd/mobius-hotline-server/main_test.go b/cmd/mobius-hotline-server/main_test.go index 17a2cb4..ed63065 100644 --- a/cmd/mobius-hotline-server/main_test.go +++ b/cmd/mobius-hotline-server/main_test.go @@ -2,7 +2,7 @@ package main import ( "os" - "path/filepath" + "path" "testing" "github.com/jhalter/mobius/internal/mobius" @@ -30,7 +30,7 @@ func TestCopyDir(t *testing.T) { } for _, expectedFile := range expectedFiles { - fullPath := filepath.Join(dstDir, expectedFile) + fullPath := path.Join(dstDir, expectedFile) assert.FileExists(t, fullPath, "Expected file %s to exist", expectedFile) // Verify file is not empty @@ -46,7 +46,7 @@ func TestCopyDir(t *testing.T) { } for _, expectedDir := range expectedDirs { - fullPath := filepath.Join(dstDir, expectedDir) + fullPath := path.Join(dstDir, expectedDir) info, err := os.Stat(fullPath) require.NoError(t, err) assert.True(t, info.IsDir(), "Expected %s to be a directory", expectedDir) @@ -69,11 +69,11 @@ func TestCopyDirRecursive(t *testing.T) { require.NoError(t, err) // Verify nested structure is copied correctly - nestedPath := filepath.Join(dstDir, "Users", "admin.yaml") + nestedPath := path.Join(dstDir, "Users", "admin.yaml") assert.FileExists(t, nestedPath) // Verify nested Files directory - filesDir := filepath.Join(dstDir, "Files") + filesDir := path.Join(dstDir, "Files") info, err := os.Stat(filesDir) require.NoError(t, err) assert.True(t, info.IsDir()) @@ -81,7 +81,7 @@ func TestCopyDirRecursive(t *testing.T) { func TestCopyFile(t *testing.T) { dstDir := t.TempDir() - dstFile := filepath.Join(dstDir, "copied.yaml") + dstFile := path.Join(dstDir, "copied.yaml") // Copy a single file from embedded config err := copyFile("mobius/config/config.yaml", dstFile) @@ -99,7 +99,7 @@ func TestCopyFile(t *testing.T) { func TestCopyFileErrors(t *testing.T) { t.Run("source file does not exist", func(t *testing.T) { dstDir := t.TempDir() - err := copyFile("nonexistent.txt", filepath.Join(dstDir, "dest.txt")) + err := copyFile("nonexistent.txt", path.Join(dstDir, "dest.txt")) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to open source file") }) @@ -118,7 +118,7 @@ func TestCopyDirPermissions(t *testing.T) { require.NoError(t, err) // Check directory permissions - info, err := os.Stat(filepath.Join(dstDir, "Users")) + info, err := os.Stat(path.Join(dstDir, "Users")) require.NoError(t, err) assert.True(t, info.IsDir()) diff --git a/hotline/file_path.go b/hotline/file_path.go index f4a27cc..a3a13f1 100644 --- a/hotline/file_path.go +++ b/hotline/file_path.go @@ -7,7 +7,7 @@ import ( "errors" "fmt" "io" - "path/filepath" + "path" "strings" ) @@ -112,13 +112,13 @@ func ReadPath(fileRoot string, filePath, fileName []byte) (fullPath string, err var subPath string for _, pathItem := range fp.Items { - subPath = filepath.Join("/", subPath, string(pathItem.Name)) + subPath = path.Join("/", subPath, string(pathItem.Name)) } - fullPath = filepath.Join( + fullPath = path.Join( fileRoot, subPath, - filepath.Join("/", string(fileName)), + path.Join("/", string(fileName)), ) fullPath, err = txtDecoder.String(fullPath) if err != nil { diff --git a/hotline/file_transfer.go b/hotline/file_transfer.go index f09e995..0ddac0b 100644 --- a/hotline/file_transfer.go +++ b/hotline/file_transfer.go @@ -11,6 +11,7 @@ import ( "log/slog" "math" "os" + "path" "path/filepath" "slices" "strings" @@ -200,7 +201,7 @@ func (fu *folderUpload) FormattedPath() string { pathData = pathData[3+segLen:] } - return filepath.Join(pathSegments...) + return path.Join(pathSegments...) } type FileHeader struct { @@ -566,8 +567,8 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT } if fu.IsFolder == [2]byte{0, 1} { - if _, err := os.Stat(filepath.Join(fullPath, fu.FormattedPath())); os.IsNotExist(err) { - if err := os.Mkdir(filepath.Join(fullPath, fu.FormattedPath()), 0777); err != nil { + if _, err := os.Stat(path.Join(fullPath, fu.FormattedPath())); os.IsNotExist(err) { + if err := os.Mkdir(path.Join(fullPath, fu.FormattedPath()), 0777); err != nil { return err } } @@ -580,7 +581,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT nextAction := DlFldrActionSendFile // Check if we have the full file already. If so, send dlFldrAction_NextFile to client to skip. - _, err := os.Stat(filepath.Join(fullPath, fu.FormattedPath())) + _, err := os.Stat(path.Join(fullPath, fu.FormattedPath())) if err != nil && !errors.Is(err, fs.ErrNotExist) { return err } @@ -589,7 +590,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT } // Check if we have a partial file already. If so, send dlFldrAction_ResumeFile to client to resume upload. - incompleteFile, err := os.Stat(filepath.Join(fullPath, fu.FormattedPath()+IncompleteFileSuffix)) + incompleteFile, err := os.Stat(path.Join(fullPath, fu.FormattedPath()+IncompleteFileSuffix)) if err != nil && !errors.Is(err, fs.ErrNotExist) { return err } @@ -642,7 +643,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT return err } - filePath := filepath.Join(fullPath, fu.FormattedPath()) + filePath := path.Join(fullPath, fu.FormattedPath()) hlFile, err := NewFileWrapper(fileStore, filePath, 0) if err != nil { diff --git a/internal/mobius/account_manager.go b/internal/mobius/account_manager.go index 72984b7..2558d3a 100644 --- a/internal/mobius/account_manager.go +++ b/internal/mobius/account_manager.go @@ -37,7 +37,7 @@ func NewYAMLAccountManager(accountDir string) (*YAMLAccountManager, error) { 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) } @@ -77,7 +77,7 @@ func (am *YAMLAccountManager) Create(account hotline.Account) error { // 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 { @@ -107,8 +107,8 @@ 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) @@ -124,7 +124,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) } @@ -161,7 +161,7 @@ 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) } diff --git a/internal/mobius/ban.go b/internal/mobius/ban.go index e73b14a..781052b 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" ) @@ -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..1bf68a4 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, @@ -55,7 +55,7 @@ func TestAdd(t *testing.T) { defer 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/threaded_news_test.go b/internal/mobius/threaded_news_test.go index dd06a28..7f5cdaa 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" ) @@ -164,7 +164,7 @@ func TestThreadedNewsYAML_CreateGrouping(t *testing.T) { defer 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 c03704a..6553887 100644 --- a/internal/mobius/transaction_handlers.go +++ b/internal/mobius/transaction_handlers.go @@ -10,7 +10,6 @@ import ( "math/big" "os" "path" - "path/filepath" "strings" "time" @@ -453,7 +452,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) -- cgit From dbeb4f0361acbeac799f9da940daeba6e26b80c9 Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Sat, 28 Jun 2025 11:13:28 -0700 Subject: Add comprehensive documentation to transaction handler functions - Document all transaction handler functions with detailed field specifications - Include request and reply field descriptions with required/optional indicators - Add field numbers and descriptions for easy protocol reference - Remove duplicate comments while preserving important context - Standardize documentation format across all handler functions --- internal/mobius/transaction_handlers.go | 389 ++++++++++++++++++++++++++------ 1 file changed, 322 insertions(+), 67 deletions(-) (limited to 'internal') diff --git a/internal/mobius/transaction_handlers.go b/internal/mobius/transaction_handlers.go index 6553887..b0b1eac 100644 --- a/internal/mobius/transaction_handlers.go +++ b/internal/mobius/transaction_handlers.go @@ -78,6 +78,14 @@ 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.") @@ -209,6 +217,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 @@ -433,6 +456,14 @@ 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.") @@ -476,6 +507,16 @@ func HandleNewFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotl 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.") @@ -535,6 +576,16 @@ 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.") @@ -553,6 +604,13 @@ 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.") @@ -581,6 +639,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 @@ -726,7 +799,16 @@ 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.") @@ -761,6 +843,13 @@ func HandleNewUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotlin 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.") @@ -792,7 +881,13 @@ 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.") @@ -833,6 +928,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() { @@ -852,6 +954,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) { @@ -960,6 +1073,14 @@ 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.") @@ -1024,9 +1145,13 @@ 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.") @@ -1051,6 +1176,14 @@ 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.") @@ -1070,9 +1203,14 @@ 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.") @@ -1092,13 +1230,13 @@ 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.") @@ -1119,24 +1257,23 @@ 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.") @@ -1172,8 +1309,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) { @@ -1204,10 +1343,14 @@ 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.") @@ -1235,13 +1378,18 @@ 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.") @@ -1276,7 +1424,13 @@ 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.") @@ -1292,6 +1446,19 @@ 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.") @@ -1358,6 +1525,17 @@ 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.") @@ -1401,6 +1579,17 @@ 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.") @@ -1432,13 +1621,17 @@ 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.") @@ -1500,6 +1693,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:] @@ -1566,15 +1769,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(), @@ -1619,7 +1834,17 @@ 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.") @@ -1669,6 +1894,18 @@ 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.") @@ -1697,6 +1934,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) @@ -1714,11 +1958,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 @@ -1758,11 +2005,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 @@ -1783,11 +2032,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 @@ -1808,11 +2060,12 @@ 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 @@ -1842,12 +2095,14 @@ func HandleMakeAlias(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotl 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))) -- cgit From 4a0c7c345c74f78ab0fda22f56c973c00bd50b7d Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Sun, 29 Jun 2025 16:14:06 -0700 Subject: Extract hardcoded error strings into comprehensive public constants Replace 60+ hardcoded error strings throughout transaction handlers with public constants and templates for consistent error handling across the Hotline protocol implementation. Features: - 33 authorization error constants (ErrMsgNotAllowed*) - Account operation error constants (ErrMsgAccount*) - File operation error templates with filename parameters (ErrMsgCannot*) - Upload/download restriction templates (ErrMsgUpload*) - Ban message constants (ErrMsgTemporaryBan, ErrMsgPermanentBan) - General error constants (ErrMsgUserNotFound, ErrMsgCreateAlias) - Chat/messaging templates (ErrMsgDoesNotAcceptTemplate) Benefits: - Single source of truth for all error messages - Public API for other packages to import standard error constants - Sprintf-style templates for dynamic content (filenames, usernames) - Clear distinction between protocol errors (ErrMsg*) and golang errors - Improved maintainability and consistency across transaction handlers All error messages are now centralized, making future modifications easier and ensuring consistent user experience across all operations. --- internal/mobius/transaction_handlers.go | 200 ++++++++++++++++++++++---------- 1 file changed, 136 insertions(+), 64 deletions(-) (limited to 'internal') diff --git a/internal/mobius/transaction_handlers.go b/internal/mobius/transaction_handlers.go index b0b1eac..d8c4cb5 100644 --- a/internal/mobius/transaction_handlers.go +++ b/internal/mobius/transaction_handlers.go @@ -17,6 +17,78 @@ import ( "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" +) + // This function is used to extract the IP address from a given address string, exluding the port. func extractIP(addr string) string { if idx := strings.LastIndex(addr, ":"); idx != -1 { @@ -88,7 +160,7 @@ func RegisterHandlers(srv *hotline.Server) { // 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 @@ -156,7 +228,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) @@ -188,7 +260,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}), @@ -303,11 +375,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) } } @@ -335,16 +407,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 { @@ -357,7 +429,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 @@ -390,17 +462,17 @@ func HandleDeleteFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot 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) { - return cc.NewErrReply(t, "You are not allowed to delete folders.") + return cc.NewErrReply(t, ErrMsgNotAllowedDeleteFolders) } case mode.IsRegular(): if !cc.Authorize(hotline.AccessDeleteFile) { - return cc.NewErrReply(t, "You are not allowed to delete files.") + return cc.NewErrReply(t, ErrMsgNotAllowedDeleteFiles) } } @@ -435,16 +507,16 @@ func HandleMoveFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotli 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 { @@ -466,7 +538,7 @@ func HandleMoveFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotli // 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) @@ -495,12 +567,12 @@ 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) } @@ -519,7 +591,7 @@ func HandleNewFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotl // 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() @@ -529,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) @@ -588,12 +660,12 @@ func HandleSetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotlin // * 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, @@ -613,7 +685,7 @@ func HandleGetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotlin // * 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 @@ -675,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)) @@ -693,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}), ), ) @@ -733,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: @@ -765,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) @@ -791,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) } } } @@ -811,14 +883,14 @@ func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot // 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 @@ -828,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) } } } @@ -837,7 +909,7 @@ 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)) @@ -852,7 +924,7 @@ func HandleNewUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotlin // 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() @@ -866,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}), ), ) @@ -890,7 +962,7 @@ func HandleDeleteUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot // 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( @@ -912,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, @@ -1042,7 +1114,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 @@ -1083,7 +1155,7 @@ func HandleTranOldPostNews(cc *hotline.ClientConn, t *hotline.Transaction) (res // 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) @@ -1105,7 +1177,7 @@ 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}), )) @@ -1124,7 +1196,7 @@ 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}), )) @@ -1154,7 +1226,7 @@ func HandleDisconnectUser(cc *hotline.ClientConn, t *hotline.Transaction) (res [ // * 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() @@ -1186,7 +1258,7 @@ func HandleGetNewsCatNameList(cc *hotline.ClientConn, t *hotline.Transaction) (r // 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) @@ -1213,7 +1285,7 @@ func HandleNewNewsCat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot // 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) @@ -1239,7 +1311,7 @@ func HandleNewNewsFldr(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho // * 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() @@ -1276,7 +1348,7 @@ func HandleGetNewsArtNameList(cc *hotline.ClientConn, t *hotline.Transaction) (r // * 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() @@ -1326,11 +1398,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) } } @@ -1353,7 +1425,7 @@ func HandleDelNewsItem(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho // 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) } @@ -1392,7 +1464,7 @@ func HandleDelNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot // 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() @@ -1433,7 +1505,7 @@ func HandlePostNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho // * 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) @@ -1461,7 +1533,7 @@ func HandleGetMsgs(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotlin // * 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 @@ -1538,7 +1610,7 @@ func HandleDownloadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []h // * 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) @@ -1592,7 +1664,7 @@ func HandleDownloadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res [ // * 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 @@ -1605,7 +1677,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() { - 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))) } } @@ -1634,7 +1706,7 @@ func HandleUploadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []h // * 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 @@ -1652,7 +1724,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() { - 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) @@ -1661,7 +1733,7 @@ func HandleUploadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot } if _, err := cc.Server.FS.Stat(fullFilePath); err == nil { - return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload because there is already a file named \"%v\". Try choosing a different Name.", string(fileName))) + return cc.NewErrReply(t, fmt.Sprintf(ErrMsgFileUploadConflictTemplate, string(fileName))) } ft := cc.NewFileTransfer(hotline.FileUpload, cc.FileRoot(), fileName, filePath, transferSize) @@ -1809,7 +1881,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) @@ -1847,7 +1919,7 @@ func HandleGetFileNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res // * 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 @@ -1864,7 +1936,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}), @@ -1908,7 +1980,7 @@ func HandleInviteNewChat(cc *hotline.ClientConn, t *hotline.Transaction) (res [] // * 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 @@ -2071,7 +2143,7 @@ func HandleSetChatSubject(cc *hotline.ClientConn, t *hotline.Transaction) (res [ // 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 @@ -2088,7 +2160,7 @@ 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)) -- cgit From aeb920895dc2d624d9c68d1cde3e85f7456bf04c Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Sun, 29 Jun 2025 16:25:30 -0700 Subject: Fix tests modifying test fixture files by using temporary directories Previously, running tests would modify test/config/Users/guest.yaml and create test/config/Users/test-user.yaml due to the automatic migration logic in YAMLAccountManager. This caused git diff to show changes after running tests. Solution: - Modified TestNewYAMLAccountManager to use t.TempDir() for test isolation - Added copyTestFiles helper to copy test fixtures to temporary directory - Tests now run against copies, leaving original fixtures untouched This ensures tests are properly isolated and don't have side effects on the repository's test fixture files. --- internal/mobius/account_manager_test.go | 131 ++++++++++++++------------------ 1 file changed, 55 insertions(+), 76 deletions(-) (limited to 'internal') diff --git a/internal/mobius/account_manager_test.go b/internal/mobius/account_manager_test.go index fe834a4..a6f39ca 100644 --- a/internal/mobius/account_manager_test.go +++ b/internal/mobius/account_manager_test.go @@ -1,89 +1,68 @@ package mobius import ( - "fmt" + "os" + "path" "github.com/jhalter/mobius/hotline" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "testing" ) -func TestNewYAMLAccountManager(t *testing.T) { - type args struct { - accountDir string - } - tests := []struct { - name string - args args - want *YAMLAccountManager - wantErr assert.ErrorAssertionFunc - }{ - { - name: "loads accounts from a directory", - args: args{ - accountDir: "test/config/Users", - }, - want: &YAMLAccountManager{ - accountDir: "test/config/Users", - accounts: map[string]hotline.Account{ - "admin": { - Name: "admin", - Login: "admin", - Password: "$2a$04$2itGEYx8C1N5bsfRSoC9JuonS3I4YfnyVPZHLSwp7kEInRX0yoB.a", - Access: hotline.AccessBitmap{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00}, - }, - "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}, - }, - "guest": { - Name: "guest", - Login: "guest", - Password: "$2a$04$6Yq/TIlgjSD.FbARwtYs9ODnkHawonu1TJ5W2jJKfhnHwBIQTk./y", - Access: hotline.AccessBitmap{0x7d, 0xf0, 0x0c, 0xef, 0xab, 0x80, 0x00, 0x00}, - }, - }, - }, - wantErr: assert.NoError, - }, +// 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) } - 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 - } +} - 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"), - ) +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{ - 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{ + 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: "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"), - ) - }) - } + 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"), + ) + }) } -- cgit From a9c6485fb00dbf2e4b55351c1ae74d4eaca3eec9 Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Mon, 30 Jun 2025 15:24:19 -0700 Subject: Replace inappropriate panic calls with proper error handling - Convert panic in news article processing to return error instead - Replace panic in API stats endpoint with HTTP error response - Update ThreadedNewsMgr interface to return errors from ListArticles - Ensure server stability by handling errors gracefully --- hotline/news.go | 13 ++++++------- internal/mobius/api.go | 3 ++- internal/mobius/threaded_news.go | 2 +- internal/mobius/transaction_handlers.go | 5 ++++- 4 files changed, 13 insertions(+), 10 deletions(-) (limited to 'internal') diff --git a/hotline/news.go b/hotline/news.go index a490a89..c61a76f 100644 --- a/hotline/news.go +++ b/hotline/news.go @@ -14,7 +14,7 @@ var ( ) type ThreadedNewsMgr interface { - ListArticles(newsPath []string) NewsArtListData + ListArticles(newsPath []string) (NewsArtListData, error) GetArticle(newsPath []string, articleID uint32) *NewsArtData DeleteArticle(newsPath []string, articleID uint32, recursive bool) error PostArticle(newsPath []string, parentArticleID uint32, article NewsArtData) error @@ -41,7 +41,7 @@ type NewsCategoryListData15 struct { readOffset int // Internal offset to track read progress } -func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData { +func (newscat *NewsCategoryListData15) GetNewsArtListData() (NewsArtListData, error) { var newsArts []NewsArtList var newsArtsPayload []byte @@ -70,8 +70,7 @@ func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData { for _, v := range newsArts { b, err := io.ReadAll(&v) if err != nil { - // TODO - panic(err) + return NewsArtListData{}, err } newsArtsPayload = append(newsArtsPayload, b...) } @@ -81,7 +80,7 @@ func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData { Name: []byte{}, Description: []byte{}, NewsArtList: newsArtsPayload, - } + }, nil } // NewsArtData represents an individual news article. @@ -242,10 +241,10 @@ type MockThreadNewsMgr struct { mock.Mock } -func (m *MockThreadNewsMgr) ListArticles(newsPath []string) NewsArtListData { +func (m *MockThreadNewsMgr) ListArticles(newsPath []string) (NewsArtListData, error) { args := m.Called(newsPath) - return args.Get(0).(NewsArtListData) + return args.Get(0).(NewsArtListData), args.Error(1) } func (m *MockThreadNewsMgr) GetArticle(newsPath []string, articleID uint32) *NewsArtData { diff --git a/internal/mobius/api.go b/internal/mobius/api.go index 4dfc575..0a20d01 100644 --- a/internal/mobius/api.go +++ b/internal/mobius/api.go @@ -263,7 +263,8 @@ func (srv *APIServer) ReloadHandler(reloadFunc func()) func(w http.ResponseWrite 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 } _, _ = w.Write(u) diff --git a/internal/mobius/threaded_news.go b/internal/mobius/threaded_news.go index e9d9c22..67e8282 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() diff --git a/internal/mobius/transaction_handlers.go b/internal/mobius/transaction_handlers.go index d8c4cb5..5181e15 100644 --- a/internal/mobius/transaction_handlers.go +++ b/internal/mobius/transaction_handlers.go @@ -1319,7 +1319,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 { -- cgit From 262f66351484369fa65c2e23df81ef682ea06d89 Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Tue, 1 Jul 2025 16:03:57 -0700 Subject: Add documentation and comprehensive test coverage for AccountManager - Add GoDoc comments to AccountManager interface and all YAMLAccountManager methods - Add complete table-driven test coverage for Create, Update, Get, and List methods - Tests follow project conventions with proper error handling and temporary directories - All tests pass with comprehensive verification of both memory state and file operations --- hotline/account_manager.go | 2 + internal/mobius/account_manager.go | 19 ++ internal/mobius/account_manager_test.go | 347 +++++++++++++++++++++++++++++++- 3 files changed, 362 insertions(+), 6 deletions(-) (limited to 'internal') diff --git a/hotline/account_manager.go b/hotline/account_manager.go index 03621df..52c24f8 100644 --- a/hotline/account_manager.go +++ b/hotline/account_manager.go @@ -1,5 +1,7 @@ package hotline +// AccountManager provides an interface for managing user accounts, +// including creation, retrieval, updates, and deletion operations. type AccountManager interface { Create(account Account) error Update(account Account, newLogin string) error diff --git a/internal/mobius/account_manager.go b/internal/mobius/account_manager.go index 2558d3a..8859c58 100644 --- a/internal/mobius/account_manager.go +++ b/internal/mobius/account_manager.go @@ -24,6 +24,8 @@ func loadFromYAMLFile(path string, data interface{}) error { 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,6 +33,9 @@ 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, @@ -71,6 +76,8 @@ 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() @@ -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() @@ -133,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() @@ -145,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() @@ -157,6 +169,7 @@ 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() @@ -171,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 a6f39ca..b0bfd79 100644 --- a/internal/mobius/account_manager_test.go +++ b/internal/mobius/account_manager_test.go @@ -1,25 +1,25 @@ package mobius import ( - "os" - "path" "github.com/jhalter/mobius/hotline" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "os" + "path" "testing" ) // 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) } @@ -30,7 +30,7 @@ func TestNewYAMLAccountManager(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) @@ -66,3 +66,338 @@ func TestNewYAMLAccountManager(t *testing.T) { ) }) } + +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", + }, + }, + } + 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", + Name: "Duplicate Admin", + Password: "password", + Access: hotline.AccessBitmap{}, + }, + }, + }, + } + 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}, + }, + newLogin: "guest", + }, + }, + { + name: "updates account with login change", + args: args{ + account: hotline.Account{ + Login: "guest", + Name: "Renamed Guest", + Password: "password", + Access: hotline.AccessBitmap{0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00}, + }, + newLogin: "renamedguest", + }, + }, + } + 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) { + tempDir := t.TempDir() + copyTestFiles(t, "test/config/Users", tempDir) + + accountMgr, err := NewYAMLAccountManager(tempDir) + require.NoError(t, err) + + got := accountMgr.Get(tt.args.login) + assert.Equalf(t, tt.want, got, "Get(%v)", tt.args.login) + }) + } +} + +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) + } + }) + } +} -- cgit From ee6629ad78ac62fa14371ea5ddb7474c1fe9c979 Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Fri, 4 Jul 2025 17:24:02 -0700 Subject: Fix file handle close warnings by ignoring return values Updated all file close operations to use anonymous functions that ignore return values to satisfy golangci-lint errcheck warnings. --- cmd/mobius-hotline-server/main.go | 4 ++-- cmd/mobius-hotline-server/main_test.go | 2 +- hotline/files_test.go | 2 +- hotline/server.go | 4 ++-- hotline/tracker.go | 2 +- internal/mobius/account_manager.go | 4 ++-- internal/mobius/api.go | 12 ++++++------ internal/mobius/ban.go | 2 +- internal/mobius/ban_test.go | 2 +- internal/mobius/threaded_news.go | 2 +- internal/mobius/threaded_news_test.go | 4 ++-- 11 files changed, 20 insertions(+), 20 deletions(-) (limited to 'internal') diff --git a/cmd/mobius-hotline-server/main.go b/cmd/mobius-hotline-server/main.go index 1ba11ae..aa41279 100644 --- a/cmd/mobius-hotline-server/main.go +++ b/cmd/mobius-hotline-server/main.go @@ -246,13 +246,13 @@ func copyFile(src, dst string) error { if err != nil { return fmt.Errorf("failed to open source file: %w", err) } - defer srcFile.Close() + defer func() { _ = srcFile.Close() }() dstFile, err := os.Create(dst) if err != nil { return fmt.Errorf("failed to create destination file: %w", err) } - defer dstFile.Close() + defer func() { _ = dstFile.Close() }() if _, err := io.Copy(dstFile, srcFile); err != nil { return fmt.Errorf("failed to copy file contents: %w", err) diff --git a/cmd/mobius-hotline-server/main_test.go b/cmd/mobius-hotline-server/main_test.go index ed63065..dec2ebf 100644 --- a/cmd/mobius-hotline-server/main_test.go +++ b/cmd/mobius-hotline-server/main_test.go @@ -166,7 +166,7 @@ func TestFindConfigPath(t *testing.T) { tmpDir := t.TempDir() originalDir, err := os.Getwd() require.NoError(t, err) - defer os.Chdir(originalDir) + defer func() { _ = os.Chdir(originalDir) }() err = os.Chdir(tmpDir) require.NoError(t, err) diff --git a/hotline/files_test.go b/hotline/files_test.go index 0a7eb7b..9bed670 100644 --- a/hotline/files_test.go +++ b/hotline/files_test.go @@ -148,7 +148,7 @@ func TestCalcItemCount(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tempDir) + defer func() { _ = os.RemoveAll(tempDir) }() // Create the test directory structure if err := createTestDirStructure(tempDir, tt.structure); err != nil { diff --git a/hotline/server.go b/hotline/server.go index 98b6132..19c7acc 100644 --- a/hotline/server.go +++ b/hotline/server.go @@ -235,7 +235,7 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error { }) s.Logger.Info("Connection established", "ip", ipAddr) - defer conn.Close() + defer func() { _ = conn.Close() }() // Check if we have an existing rate limit for the IP and create one if we do not. rl, ok := s.rateLimiters[ipAddr] @@ -247,7 +247,7 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error { // Check if the rate limit is exceeded and close the connection if so. if !rl.Allow() { s.Logger.Info("Rate limit exceeded", "RemoteAddr", conn.RemoteAddr()) - conn.Close() + _ = conn.Close() return } diff --git a/hotline/tracker.go b/hotline/tracker.go index 52963bf..edd973d 100644 --- a/hotline/tracker.go +++ b/hotline/tracker.go @@ -69,7 +69,7 @@ func register(dialer Dialer, tracker string, tr io.Reader) error { if err != nil { return fmt.Errorf("failed to dial tracker: %v", err) } - defer conn.Close() + defer func() { _ = conn.Close() }() if _, err := io.Copy(conn, tr); err != nil { return fmt.Errorf("failed to write to connection: %w", err) diff --git a/internal/mobius/account_manager.go b/internal/mobius/account_manager.go index 8859c58..d9169c9 100644 --- a/internal/mobius/account_manager.go +++ b/internal/mobius/account_manager.go @@ -18,7 +18,7 @@ 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) @@ -90,7 +90,7 @@ func (am *YAMLAccountManager) Create(account hotline.Account) error { 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 { diff --git a/internal/mobius/api.go b/internal/mobius/api.go index 0a20d01..f912f60 100644 --- a/internal/mobius/api.go +++ b/internal/mobius/api.go @@ -125,7 +125,7 @@ func (srv *APIServer) OnlineHandler(w http.ResponseWriter, r *http.Request) { } } - json.NewEncoder(w).Encode(users) + _ = json.NewEncoder(w).Encode(users) } type BanRequest struct { @@ -169,7 +169,7 @@ func (srv *APIServer) BanHandler(w http.ResponseWriter, r *http.Request) { } } - w.Write([]byte(`{"msg":"banned"}`)) + _, _ = w.Write([]byte(`{"msg":"banned"}`)) } func (srv *APIServer) UnbanHandler(w http.ResponseWriter, r *http.Request) { @@ -198,7 +198,7 @@ func (srv *APIServer) UnbanHandler(w http.ResponseWriter, r *http.Request) { // TODO: Fallback } - w.Write([]byte(`{"msg":"unbanned"}`)) + _, _ = w.Write([]byte(`{"msg":"unbanned"}`)) } func (srv *APIServer) ListBannedIPsHandler(w http.ResponseWriter, r *http.Request) { @@ -208,7 +208,7 @@ func (srv *APIServer) ListBannedIPsHandler(w http.ResponseWriter, r *http.Reques http.Error(w, "failed to fetch banned IPs", http.StatusInternalServerError) return } - json.NewEncoder(w).Encode(ips) + _ = json.NewEncoder(w).Encode(ips) } else { // TODO: Fallback } @@ -221,7 +221,7 @@ func (srv *APIServer) ListBannedUsernamesHandler(w http.ResponseWriter, r *http. http.Error(w, "failed to fetch banned usernames", http.StatusInternalServerError) return } - json.NewEncoder(w).Encode(users) + _ = json.NewEncoder(w).Encode(users) } else { // TODO: Fallback } @@ -234,7 +234,7 @@ func (srv *APIServer) ListBannedNicknamesHandler(w http.ResponseWriter, r *http. http.Error(w, "failed to fetch banned nicknames", http.StatusInternalServerError) return } - json.NewEncoder(w).Encode(nicks) + _ = json.NewEncoder(w).Encode(nicks) } else { // TODO: Fallback } diff --git a/internal/mobius/ban.go b/internal/mobius/ban.go index 781052b..b4fde95 100644 --- a/internal/mobius/ban.go +++ b/internal/mobius/ban.go @@ -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 { diff --git a/internal/mobius/ban_test.go b/internal/mobius/ban_test.go index 1bf68a4..9f1f5d8 100644 --- a/internal/mobius/ban_test.go +++ b/internal/mobius/ban_test.go @@ -52,7 +52,7 @@ 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 := path.Join(tmpDir, "banfile.yaml") diff --git a/internal/mobius/threaded_news.go b/internal/mobius/threaded_news.go index 67e8282..c7daea4 100644 --- a/internal/mobius/threaded_news.go +++ b/internal/mobius/threaded_news.go @@ -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 7f5cdaa..2ff8a84 100644 --- a/internal/mobius/threaded_news_test.go +++ b/internal/mobius/threaded_news_test.go @@ -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,7 +161,7 @@ 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 := path.Join(tmpDir, "ThreadedNews.yaml") -- cgit From f00eb8b5ea470f322bd437e9f649d60c9dbc9c4d Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Fri, 4 Jul 2025 17:58:35 -0700 Subject: Add comprehensive API documentation and improve code documentation - Add OpenAPI specification (api.yaml) with complete endpoint documentation - Update README with comprehensive API section including authentication and examples - Add godoc comments to all API handlers and types for better code documentation --- README.md | 87 +++++++++------- api.yaml | 278 +++++++++++++++++++++++++++++++++++++++++++++++++ internal/mobius/api.go | 28 +++++ 3 files changed, 355 insertions(+), 38 deletions(-) create mode 100644 api.yaml (limited to 'internal') diff --git a/README.md b/README.md index 2c9772b..9e7e46f 100644 --- a/README.md +++ b/README.md @@ -170,62 +170,73 @@ Usage of mobius-hotline-server: To run as a systemd service, refer to this sample unit file: [mobius-hotline-server.service](https://github.com/jhalter/mobius/blob/master/cmd/mobius-hotline-server/mobius-hotline-server.service) -## (Optional) HTTP API +## HTTP API -The Mobius server includes an optional HTTP API to perform out-of-band administrative functions. +The Mobius server includes an optional HTTP API for server administration and user management. -To enable it, include the `--api-addr` flag with a string defining the IP and port to listen on in the form of `:`. +### Configuration -Example: `--api-addr=127.0.0.1:5503` +To enable the API, use the `--api-addr` flag with an IP and port: -⚠️ The API has no authentication, so binding it to localhost is a good idea! +```bash +mobius-hotline-server --api-addr=127.0.0.1:5503 +``` -#### GET /api/v1/stats +### Authentication -The stats endpoint returns server runtime statistics and counters. +The API supports optional authentication via API key. Set the `--api-key` flag: +```bash +mobius-hotline-server --api-addr=127.0.0.1:5503 --api-key=your-secret-key ``` -❯ curl -s localhost:5503/api/v1/stats | jq . -{ - "ConnectionCounter": 0, - "ConnectionPeak": 0, - "CurrentlyConnected": 0, - "DownloadCounter": 0, - "DownloadsInProgress": 0, - "Since": "2024-07-18T15:36:42.426156-07:00", - "UploadCounter": 0, - "UploadsInProgress": 0, - "WaitingDownloads": 0 -} + +Include the API key in the `X-API-Key` header: + +```bash +curl -H "X-API-Key: your-secret-key" localhost:5503/api/v1/stats ``` -#### GET /api/v1/reload +### API Documentation -The reload endpoint reloads the following configuration files from disk: +Complete API documentation is available in the [OpenAPI specification](api.yaml). -* Agreement.txt -* News.txt -* Users/*.yaml -* ThreadedNews.yaml -* banner.jpg +### Endpoints -Example: +#### User Management -``` -❯ curl -s localhost:5503/api/v1/reload | jq . -{ - "msg": "config reloaded" -} -``` +- `GET /api/v1/online` - List currently online users +- `POST /api/v1/ban` - Ban a user by username, nickname, or IP +- `POST /api/v1/unban` - Remove a ban +- `GET /api/v1/banned/ips` - List banned IP addresses +- `GET /api/v1/banned/usernames` - List banned usernames +- `GET /api/v1/banned/nicknames` - List banned nicknames -#### POST /api/v1/shutdown +#### Server Administration -The shutdown endpoint accepts a shutdown message from POST payload, sends it to to all connected Hotline clients, then gracefully shuts down the server. +- `GET /api/v1/stats` - Get server statistics +- `POST /api/v1/reload` - Reload configuration +- `POST /api/v1/shutdown` - Shutdown server -Example: +### Examples + +**Get online users:** +```bash +curl localhost:5503/api/v1/online +``` + +**Ban a user:** +```bash +curl -X POST -H "Content-Type: application/json" \ + -d '{"username":"baduser"}' \ + localhost:5503/api/v1/ban +``` +**Get server stats:** +```bash +curl localhost:5503/api/v1/stats | jq . ``` -❯ curl -d 'Server rebooting' localhost:5503/api/v1/shutdown -{ "msg": "server shutting down" } +**Shutdown server:** +```bash +curl -X POST -d 'Server maintenance' localhost:5503/api/v1/shutdown ``` diff --git a/api.yaml b/api.yaml new file mode 100644 index 0000000..fe6060d --- /dev/null +++ b/api.yaml @@ -0,0 +1,278 @@ +openapi: 3.0.3 +info: + title: Mobius API + description: REST API for managing a Hotline server + version: 1.0.0 + contact: + name: Mobius + url: https://github.com/jhalter/mobius +servers: + - url: http://localhost:5603 + description: Local development server +security: + - ApiKeyAuth: [] +paths: + /api/v1/online: + get: + summary: Get online users + description: Returns a list of currently online users with their login, nickname, and IP address + responses: + '200': + description: List of online users + content: + application/json: + schema: + type: array + items: + type: object + properties: + login: + type: string + description: User's login name + nickname: + type: string + description: User's display nickname + ip: + type: string + description: User's IP address + example: + login: "admin" + nickname: "Administrator" + ip: "192.168.1.100" + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/ban: + post: + summary: Ban a user + description: Ban a user by username, nickname, or IP address. At least one field must be provided. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + description: Username to ban + nickname: + type: string + description: Nickname to ban + ip: + type: string + description: IP address to ban + example: + username: "baduser" + responses: + '200': + description: User banned successfully + content: + application/json: + schema: + type: object + properties: + msg: + type: string + example: "banned" + '400': + description: Bad request - missing required fields + content: + text/plain: + schema: + type: string + example: "username, nickname, or ip required" + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/unban: + post: + summary: Unban a user + description: Remove a ban for a user by username, nickname, or IP address. At least one field must be provided. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + description: Username to unban + nickname: + type: string + description: Nickname to unban + ip: + type: string + description: IP address to unban + example: + username: "baduser" + responses: + '200': + description: User unbanned successfully + content: + application/json: + schema: + type: object + properties: + msg: + type: string + example: "unbanned" + '400': + description: Bad request - missing required fields + content: + text/plain: + schema: + type: string + example: "username, nickname, or ip required" + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/banned/ips: + get: + summary: List banned IP addresses + description: Returns a list of all banned IP addresses + responses: + '200': + description: List of banned IP addresses + content: + application/json: + schema: + type: array + items: + type: string + example: ["192.168.1.100", "10.0.0.5"] + '401': + $ref: '#/components/responses/Unauthorized' + '500': + description: Internal server error + content: + text/plain: + schema: + type: string + example: "failed to fetch banned IPs" + /api/v1/banned/usernames: + get: + summary: List banned usernames + description: Returns a list of all banned usernames + responses: + '200': + description: List of banned usernames + content: + application/json: + schema: + type: array + items: + type: string + example: ["baduser", "spammer"] + '401': + $ref: '#/components/responses/Unauthorized' + '500': + description: Internal server error + content: + text/plain: + schema: + type: string + example: "failed to fetch banned usernames" + /api/v1/banned/nicknames: + get: + summary: List banned nicknames + description: Returns a list of all banned nicknames + responses: + '200': + description: List of banned nicknames + content: + application/json: + schema: + type: array + items: + type: string + example: ["BadNick", "Spammer123"] + '401': + $ref: '#/components/responses/Unauthorized' + '500': + description: Internal server error + content: + text/plain: + schema: + type: string + example: "failed to fetch banned nicknames" + /api/v1/reload: + post: + summary: Reload server configuration + description: Triggers a reload of the server configuration + responses: + '200': + description: Configuration reloaded successfully + content: + application/json: + schema: + type: object + properties: + msg: + type: string + example: "config reloaded" + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/shutdown: + post: + summary: Shutdown server + description: Gracefully shutdown the server with a message + requestBody: + required: true + content: + text/plain: + schema: + type: string + example: "Server maintenance" + responses: + '200': + description: Server shutting down + content: + application/json: + schema: + type: object + properties: + msg: + type: string + example: "server shutting down" + '400': + description: Bad request - missing shutdown message + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/stats: + get: + summary: Get server statistics + description: Returns current server statistics and metrics + responses: + '200': + description: Server statistics + content: + application/json: + schema: + type: object + description: Server statistics object (structure depends on implementation) + '401': + $ref: '#/components/responses/Unauthorized' + '500': + description: Internal server error + content: + text/plain: + schema: + type: string + example: "failed to marshal stats" +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + description: API key for authentication + responses: + Unauthorized: + description: Authentication required + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: "unauthorized" \ No newline at end of file diff --git a/internal/mobius/api.go b/internal/mobius/api.go index f912f60..2bce8f8 100644 --- a/internal/mobius/api.go +++ b/internal/mobius/api.go @@ -34,6 +34,8 @@ 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 @@ -61,6 +63,8 @@ func (srv *APIServer) logMiddleware(next http.Handler) http.Handler { }) } +// 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, @@ -98,6 +102,8 @@ func NewAPIServer(hlServer *hotline.Server, reloadFunc func(), logger *slog.Logg 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 @@ -128,12 +134,17 @@ func (srv *APIServer) OnlineHandler(w http.ResponseWriter, r *http.Request) { _ = 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 { @@ -172,6 +183,8 @@ func (srv *APIServer) BanHandler(w http.ResponseWriter, r *http.Request) { _, _ = 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 { @@ -201,6 +214,8 @@ func (srv *APIServer) UnbanHandler(w http.ResponseWriter, r *http.Request) { _, _ = 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() @@ -214,6 +229,8 @@ func (srv *APIServer) ListBannedIPsHandler(w http.ResponseWriter, r *http.Reques } } +// 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() @@ -227,6 +244,8 @@ func (srv *APIServer) ListBannedUsernamesHandler(w http.ResponseWriter, r *http. } } +// 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() @@ -240,6 +259,9 @@ func (srv *APIServer) ListBannedNicknamesHandler(w http.ResponseWriter, r *http. } } +// 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 { @@ -252,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() @@ -260,6 +284,8 @@ 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 { @@ -270,6 +296,8 @@ func (srv *APIServer) RenderStats(w http.ResponseWriter, _ *http.Request) { _, _ = 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 { -- cgit From 023203ce5e1f85f004b9761e500e13073740ba0e Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Fri, 4 Jul 2025 20:01:23 -0700 Subject: Standardize IP extraction using net.SplitHostPort Replace custom extractIP function and inconsistent string.Split usage with Go's standard net.SplitHostPort to ensure proper IPv6 compatibility and consistent IP address handling across Redis operations. --- internal/mobius/transaction_handlers.go | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) (limited to 'internal') diff --git a/internal/mobius/transaction_handlers.go b/internal/mobius/transaction_handlers.go index 5181e15..c5b1bbb 100644 --- a/internal/mobius/transaction_handlers.go +++ b/internal/mobius/transaction_handlers.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "math/big" + "net" "os" "path" "strings" @@ -89,13 +90,6 @@ const ( ErrMsgCreateAlias = "Error creating alias" ) -// This function is used to extract the IP address from a given address string, exluding the port. -func extractIP(addr string) string { - if idx := strings.LastIndex(addr, ":"); idx != -1 { - return addr[:idx] - } - return addr -} // Converts bytes from Mac Roman encoding to UTF-8 var txtDecoder = charmap.Macintosh.NewDecoder() @@ -1048,7 +1042,7 @@ func HandleTranAgreed(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot if cc.Server.Redis != nil { login := cc.Account.Login - ip := extractIP(cc.RemoteAddr) + 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 @@ -1182,7 +1176,7 @@ func HandleDisconnectUser(cc *hotline.ClientConn, t *hotline.Transaction) (res [ )) 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 { @@ -1200,7 +1194,7 @@ func HandleDisconnectUser(cc *hotline.ClientConn, t *hotline.Transaction) (res [ 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 { @@ -1790,7 +1784,7 @@ func HandleSetClientUserInfo(cc *hotline.ClientConn, t *hotline.Transaction) (re cc.UserName = t.GetField(hotline.FieldUserName).Data if cc.Server.Redis != nil { login := cc.Account.Login - ip := extractIP(cc.RemoteAddr) + 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 != "" { -- cgit From 1f3e5ec139022c20be8ca61b467b96ae7e78a36a Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Fri, 4 Jul 2025 22:07:15 -0700 Subject: Add comprehensive test coverage for News and improve error handling --- internal/mobius/news.go | 6 +- internal/mobius/news_test.go | 500 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 internal/mobius/news_test.go (limited to 'internal') 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 -- cgit From c0ec79dcfd267f7495274a9186b36a1b0669f000 Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Sat, 5 Jul 2025 17:29:03 -0700 Subject: Fix TestHandleSetClientUserInfo test expectations Fix two failing test cases in TestHandleSetClientUserInfo: - Corrected auto-reply test to use UserOptAutoResponse (bit 2) instead of UserOptRefusePM (bit 0) - Updated refuse private messages test to expect correct flag value when UserOptRefuseChat sets UserFlagRefusePChat The HandleSetClientUserInfo function was working correctly - the test expectations were wrong about how user options map to user flags. --- internal/mobius/transaction_handlers_test.go | 693 +++++++++++++++++++++++++++ 1 file changed, 693 insertions(+) (limited to 'internal') diff --git a/internal/mobius/transaction_handlers_test.go b/internal/mobius/transaction_handlers_test.go index 43c033f..0d975a7 100644 --- a/internal/mobius/transaction_handlers_test.go +++ b/internal/mobius/transaction_handlers_test.go @@ -2935,6 +2935,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 +4082,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) + } + }) + } +} -- cgit From 16f0a8527a0b9b736afbd4e71e6aac55e31b60e8 Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Tue, 18 Nov 2025 19:51:02 -0800 Subject: Add support for GIF banners with validation and improved error messages - Add custom validator for banner file extensions (.jpg, .jpeg, .gif) - Update HandleTranAgreed to dynamically detect banner type from file extension - Export FileTypeFromFilename function for use across packages - Add comprehensive test coverage for banner validation and type detection - Improve error messages to clearly communicate allowed file extensions --- hotline/config.go | 2 +- hotline/files.go | 8 +-- internal/mobius/config.go | 19 ++++++ internal/mobius/config_test.go | 95 ++++++++++++++++++++++++++++ internal/mobius/transaction_handlers.go | 3 +- internal/mobius/transaction_handlers_test.go | 50 +++++++++++++++ 6 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 internal/mobius/config_test.go (limited to 'internal') diff --git a/hotline/config.go b/hotline/config.go index a7bd81e..75d5c4d 100644 --- a/hotline/config.go +++ b/hotline/config.go @@ -3,7 +3,7 @@ package hotline type Config struct { Name string `yaml:"Name" validate:"required,max=50"` // Name used for Tracker registration Description string `yaml:"Description" validate:"required,max=200"` // Description used for Tracker registration - BannerFile string `yaml:"BannerFile"` // Path to Banner jpg + BannerFile string `yaml:"BannerFile" validate:"omitempty,bannerext"` // Path to Banner jpg or gif FileRoot string `yaml:"FileRoot" validate:"required"` // Path to Files EnableTrackerRegistration bool `yaml:"EnableTrackerRegistration"` // Toggle Tracker Registration Trackers []string `yaml:"Trackers" validate:"dive,hostname_port"` // List of trackers that the server should register with diff --git a/hotline/files.go b/hotline/files.go index a1db329..e9f7b4f 100644 --- a/hotline/files.go +++ b/hotline/files.go @@ -12,7 +12,7 @@ import ( "strings" ) -func fileTypeFromFilename(filename string) fileType { +func FileTypeFromFilename(filename string) fileType { fileExt := strings.ToLower(filepath.Ext(filename)) ft, ok := fileTypes[fileExt] if ok { @@ -26,7 +26,7 @@ func fileTypeFromInfo(info fs.FileInfo) (ft fileType, err error) { ft.CreatorCode = "n/a " ft.TypeCode = "fldr" } else { - ft = fileTypeFromFilename(info.Name()) + ft = FileTypeFromFilename(info.Name()) } return ft, nil @@ -87,8 +87,8 @@ func GetFileNameList(path string, ignoreList []string) (fields []Field, err erro copy(fnwi.Creator[:], fileCreator) } else { binary.BigEndian.PutUint32(fnwi.FileSize[:], uint32(rFile.Size())) - copy(fnwi.Type[:], fileTypeFromFilename(rFile.Name()).TypeCode) - copy(fnwi.Creator[:], fileTypeFromFilename(rFile.Name()).CreatorCode) + copy(fnwi.Type[:], FileTypeFromFilename(rFile.Name()).TypeCode) + copy(fnwi.Creator[:], FileTypeFromFilename(rFile.Name()).CreatorCode) } } else if file.IsDir() { dir, err := os.ReadDir(filepath.Join(path, file.Name())) 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/transaction_handlers.go b/internal/mobius/transaction_handlers.go index c5b1bbb..52ea29a 100644 --- a/internal/mobius/transaction_handlers.go +++ b/internal/mobius/transaction_handlers.go @@ -1095,7 +1095,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)) diff --git a/internal/mobius/transaction_handlers_test.go b/internal/mobius/transaction_handlers_test.go index 0d975a7..1c3fac0 100644 --- a/internal/mobius/transaction_handlers_test.go +++ b/internal/mobius/transaction_handlers_test.go @@ -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) { -- cgit From 489e26d1b3c224beffba2649406efd83580989fe Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Tue, 18 Nov 2025 20:56:12 -0800 Subject: Replace hardcoded magic values with named constants and improve type safety Adds new constants for file format identifiers (FormatFILP), fork types (ForkTypeINFO), and platform identifiers (PlatformAMAC, PlatformMWIN) to improve code maintainability and readability. Changes FlatFileForkHeader.ForkType field from [4]byte to ForkType type alias for better compile-time type checking, matching the pattern used in ForkInfoList.Fork. --- hotline/file_resume_data.go | 5 +++++ hotline/file_wrapper.go | 8 ++++---- hotline/flattened_file_object.go | 4 ++-- hotline/transfer_test.go | 4 ++-- internal/mobius/transaction_handlers_test.go | 6 +++--- 5 files changed, 16 insertions(+), 11 deletions(-) (limited to 'internal') diff --git a/hotline/file_resume_data.go b/hotline/file_resume_data.go index 80f5fe5..c9f27f6 100644 --- a/hotline/file_resume_data.go +++ b/hotline/file_resume_data.go @@ -32,6 +32,7 @@ type ForkInfoList struct { var ( ForkTypeDATA = ForkType{0x44, 0x41, 0x54, 0x41} // DATA: Data fork + ForkTypeINFO = ForkType{0x49, 0x4E, 0x46, 0x4F} // INFO: Information fork ForkTypeMACR = ForkType{0x4d, 0x41, 0x43, 0x52} // MACR: Mac resource fork ) @@ -45,7 +46,11 @@ func NewForkInfoList(b []byte) *ForkInfoList { } var ( + FormatFILP = [4]byte{0x46, 0x49, 0x4c, 0x50} // Flattened file format: "FILP" FormatRFLT = [4]byte{0x52, 0x46, 0x4C, 0x54} // File resume format: "RFLT" (?) + + PlatformAMAC = [4]byte{0x41, 0x4D, 0x41, 0x43} // Mac platform: "AMAC" + PlatformMWIN = [4]byte{0x4D, 0x57, 0x49, 0x4E} // Windows platform: "MWIN" ) func NewFileResumeData(list []ForkInfoList) *FileResumeData { diff --git a/hotline/file_wrapper.go b/hotline/file_wrapper.go index b6aff94..5ec99c3 100644 --- a/hotline/file_wrapper.go +++ b/hotline/file_wrapper.go @@ -227,7 +227,7 @@ func (f *fileWrapper) flattenedFileObject() (*flattenedFileObject, error) { } f.Ffo.FlatFileHeader = FlatFileHeader{ - Format: [4]byte{0x46, 0x49, 0x4c, 0x50}, // "FILP" + Format: FormatFILP, Version: [2]byte{0, 1}, ForkCount: [2]byte{0, 2}, } @@ -247,7 +247,7 @@ func (f *fileWrapper) flattenedFileObject() (*flattenedFileObject, error) { } } else { f.Ffo.FlatFileInformationFork = FlatFileInformationFork{ - Platform: [4]byte{0x41, 0x4D, 0x41, 0x43}, // "AMAC" TODO: Remove hardcode to support "AWIN" Platform (maybe?) + Platform: PlatformAMAC, // TODO: Remove hardcode to support "MWIN" Platform (maybe?) TypeSignature: [4]byte([]byte(ft.TypeCode)), CreatorSignature: [4]byte([]byte(ft.CreatorCode)), PlatformFlags: [4]byte{0, 0, 1, 0}, // TODO: What is this? @@ -263,12 +263,12 @@ func (f *fileWrapper) flattenedFileObject() (*flattenedFileObject, error) { } f.Ffo.FlatFileInformationForkHeader = FlatFileForkHeader{ - ForkType: [4]byte{0x49, 0x4E, 0x46, 0x4F}, // "INFO" + ForkType: ForkTypeINFO, DataSize: f.Ffo.FlatFileInformationFork.Size(), } f.Ffo.FlatFileDataForkHeader = FlatFileForkHeader{ - ForkType: [4]byte{0x44, 0x41, 0x54, 0x41}, // "DATA" + ForkType: ForkTypeDATA, DataSize: [4]byte{dataSize[0], dataSize[1], dataSize[2], dataSize[3]}, } f.Ffo.FlatFileResForkHeader = f.rsrcForkHeader() diff --git a/hotline/flattened_file_object.go b/hotline/flattened_file_object.go index 99ce204..7c0cd7d 100644 --- a/hotline/flattened_file_object.go +++ b/hotline/flattened_file_object.go @@ -45,7 +45,7 @@ type FlatFileInformationFork struct { func NewFlatFileInformationFork(fileName string, modifyTime [8]byte, typeSignature string, creatorSignature string) FlatFileInformationFork { return FlatFileInformationFork{ - Platform: [4]byte{0x41, 0x4D, 0x41, 0x43}, // "AMAC" TODO: Remove hardcode to support "AWIN" Platform (maybe?) + Platform: PlatformAMAC, // TODO: Remove hardcode to support "MWIN" Platform (maybe?) TypeSignature: [4]byte([]byte(typeSignature)), // TODO: Don't infer types from filename CreatorSignature: [4]byte([]byte(creatorSignature)), // TODO: Don't infer types from filename PlatformFlags: [4]byte{0, 0, 1, 0}, // TODO: What is this? @@ -128,7 +128,7 @@ func (ffif *FlatFileInformationFork) ReadNameSize() []byte { } type FlatFileForkHeader struct { - ForkType [4]byte // Either INFO, DATA or MACR + ForkType ForkType // Either INFO, DATA or MACR CompressionType [4]byte RSVD [4]byte DataSize [4]byte diff --git a/hotline/transfer_test.go b/hotline/transfer_test.go index 4aa142b..c915498 100644 --- a/hotline/transfer_test.go +++ b/hotline/transfer_test.go @@ -120,14 +120,14 @@ func Test_receiveFile(t *testing.T) { conn: func() io.Reader { testFile := flattenedFileObject{ FlatFileHeader: FlatFileHeader{ - Format: [4]byte{0x46, 0x49, 0x4c, 0x50}, // "FILP" + Format: FormatFILP, Version: [2]byte{0, 1}, ForkCount: [2]byte{0, 2}, }, FlatFileInformationForkHeader: FlatFileForkHeader{}, FlatFileInformationFork: NewFlatFileInformationFork("testfile.txt", [8]byte{}, "TEXT", "TEXT"), FlatFileDataForkHeader: FlatFileForkHeader{ - ForkType: [4]byte{0x4d, 0x41, 0x43, 0x52}, // DATA + ForkType: ForkTypeMACR, DataSize: [4]byte{0x00, 0x00, 0x00, 0x03}, }, } diff --git a/internal/mobius/transaction_handlers_test.go b/internal/mobius/transaction_handlers_test.go index 1c3fac0..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}, }, }, -- cgit From 1b2df8a630bfc83304ef90610bdf7ebe91d4e555 Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Wed, 19 Nov 2025 16:36:06 -0800 Subject: Fix error when downloading incomplete files --- hotline/file_transfer.go | 16 ++++++++-------- hotline/file_wrapper.go | 8 ++++++-- hotline/files.go | 4 ++-- internal/mobius/transaction_handlers.go | 15 +++++++-------- 4 files changed, 23 insertions(+), 20 deletions(-) (limited to 'internal') diff --git a/hotline/file_transfer.go b/hotline/file_transfer.go index 521b963..e8acbb3 100644 --- a/hotline/file_transfer.go +++ b/hotline/file_transfer.go @@ -257,7 +257,7 @@ func DownloadHandler(w io.Writer, fullPath string, fileTransfer *FileTransfer, f dataOffset = int64(binary.BigEndian.Uint32(fileTransfer.FileResumeData.ForkInfoList[0].DataSize[:])) } - fw, err := NewFileWrapper(fs, fullPath, 0) + hlFile, err := NewFile(fs, fullPath, 0) if err != nil { return fmt.Errorf("reading file header: %v", err) } @@ -267,12 +267,12 @@ func DownloadHandler(w io.Writer, fullPath string, fileTransfer *FileTransfer, f // If file transfer options are included, that means this is a "quick preview" request. In this case skip sending // the flat file info and proceed directly to sending the file data. if fileTransfer.Options == nil { - if _, err = io.Copy(w, fw.Ffo); err != nil { + if _, err = io.Copy(w, hlFile.Ffo); err != nil { return fmt.Errorf("send flat file object: %v", err) } } - file, err := fw.dataForkReader() + file, err := hlFile.dataForkReader() if err != nil { return fmt.Errorf("open data fork reader: %v", err) } @@ -288,13 +288,13 @@ func DownloadHandler(w io.Writer, fullPath string, fileTransfer *FileTransfer, f // If the client requested to resume transfer, do not send the resource fork header. if fileTransfer.FileResumeData == nil { - err = binary.Write(w, binary.BigEndian, fw.rsrcForkHeader()) + err = binary.Write(w, binary.BigEndian, hlFile.rsrcForkHeader()) if err != nil { return fmt.Errorf("send resource fork header: %v", err) } } - rFile, _ := fw.rsrcForkFile() + rFile, _ := hlFile.rsrcForkFile() //if err != nil { // // return fmt.Errorf("open resource fork file: %v", err) //} @@ -332,7 +332,7 @@ func UploadHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileTransfe return fmt.Errorf("open temp file for uploade: %w", err) } - f, err := NewFileWrapper(fileStore, fullPath, 0) + f, err := NewFile(fileStore, fullPath, 0) if err != nil { return err } @@ -424,7 +424,7 @@ func DownloadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *Fil return nil } - hlFile, err := NewFileWrapper(fileStore, path, 0) + hlFile, err := NewFile(fileStore, path, 0) if err != nil { return err } @@ -645,7 +645,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT filePath := path.Join(fullPath, fu.FormattedPath()) - hlFile, err := NewFileWrapper(fileStore, filePath, 0) + hlFile, err := NewFile(fileStore, filePath, 0) if err != nil { return err } diff --git a/hotline/file_wrapper.go b/hotline/file_wrapper.go index 41b3338..b80bc4b 100644 --- a/hotline/file_wrapper.go +++ b/hotline/file_wrapper.go @@ -30,7 +30,7 @@ type File struct { Ffo *flattenedFileObject } -func NewFileWrapper(fs FileStore, path string, dataOffset int64) (*File, error) { +func NewFile(fs FileStore, path string, dataOffset int64) (*File, error) { dir := filepath.Dir(path) fName := filepath.Base(path) f := File{ @@ -130,7 +130,11 @@ func (f *File) incFileWriter() (io.WriteCloser, error) { } func (f *File) dataForkReader() (io.Reader, error) { - return f.fs.Open(f.dataPath) + if _, err := f.fs.Stat(f.dataPath); err == nil { + return f.fs.Open(f.dataPath) + } + + return f.fs.Open(f.incompletePath) } func (f *File) rsrcForkFile() (*os.File, error) { diff --git a/hotline/files.go b/hotline/files.go index e9f7b4f..581b11c 100644 --- a/hotline/files.go +++ b/hotline/files.go @@ -112,9 +112,9 @@ func GetFileNameList(path string, ignoreList []string) (fields []Field, err erro continue } - hlFile, err := NewFileWrapper(&OSFileStore{}, path+"/"+file.Name(), 0) + hlFile, err := NewFile(&OSFileStore{}, path+"/"+file.Name(), 0) if err != nil { - return nil, fmt.Errorf("NewFileWrapper: %w", err) + return nil, fmt.Errorf("NewFile: %w", err) } copy(fnwi.FileSize[:], hlFile.TotalSize()) diff --git a/internal/mobius/transaction_handlers.go b/internal/mobius/transaction_handlers.go index 52ea29a..20a315d 100644 --- a/internal/mobius/transaction_handlers.go +++ b/internal/mobius/transaction_handlers.go @@ -86,11 +86,10 @@ const ( // General error messages ErrMsgAccountNotFound = "Account not found." - ErrMsgUserNotFound = "User not found." - ErrMsgCreateAlias = "Error creating alias" + ErrMsgUserNotFound = "User not found." + ErrMsgCreateAlias = "Error creating alias" ) - // Converts bytes from Mac Roman encoding to UTF-8 var txtDecoder = charmap.Macintosh.NewDecoder() @@ -307,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 } @@ -361,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 } @@ -449,7 +448,7 @@ 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 } @@ -494,7 +493,7 @@ 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 } @@ -1553,7 +1552,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 } -- cgit