]> git.r.bdr.sh - rbdr/mobius/commitdiff
Extensive refactor, quality of life enhancements
authorJeff Halter <redacted>
Wed, 17 Jul 2024 22:41:20 +0000 (15:41 -0700)
committerJeff Halter <redacted>
Wed, 17 Jul 2024 22:42:37 +0000 (15:42 -0700)
* Added ability to reload config, agreement, news, and user accounts without restarting the server by sending SIGHUP to the running process
* Added ability to use modern unix or windows line breaks in Agreement.txt and MessageBoard.txt instead of classic MacOS `\r` breaks.
* Extensive refactor towards swappable backends for the active server state
* Extensive refactored towards making the hotline package generic and re-usable for alternate server implemenations
* Fix bug where users whose accounts have been deleted would not be disconnected

69 files changed:
cmd/mobius-hotline-server/main.go
cmd/mobius-hotline-server/mobius/config/MessageBoard.txt
go.mod
go.sum
hotline/access.go
hotline/access_test.go
hotline/account.go
hotline/account_manager.go
hotline/ban.go
hotline/chat.go
hotline/client.go
hotline/client_conn.go
hotline/config.go
hotline/doc.go [new file with mode: 0644]
hotline/field.go
hotline/field_test.go
hotline/file_header.go [deleted file]
hotline/file_header_test.go [deleted file]
hotline/file_name_with_info.go
hotline/file_name_with_info_test.go
hotline/file_path.go
hotline/file_path_test.go
hotline/file_transfer.go
hotline/file_transfer_test.go
hotline/file_wrapper.go
hotline/files.go
hotline/flattened_file_object.go
hotline/handshake.go
hotline/handshake_test.go
hotline/message_board.go
hotline/news.go
hotline/news_test.go
hotline/server.go
hotline/server_blackbox_test.go
hotline/server_test.go
hotline/test/config/config.yaml
hotline/time.go
hotline/transaction.go
hotline/transaction_handlers.go
hotline/transaction_handlers_test.go [deleted file]
hotline/transaction_test.go
hotline/transfer_test.go
hotline/user.go
hotline/user_test.go
internal/mobius/account_manager.go [new file with mode: 0644]
internal/mobius/agreement.go [new file with mode: 0644]
internal/mobius/ban.go
internal/mobius/ban_test.go [new file with mode: 0644]
internal/mobius/config.go
internal/mobius/news.go
internal/mobius/test/config/Agreement.txt [new file with mode: 0644]
internal/mobius/test/config/Banlist.yaml [new file with mode: 0644]
internal/mobius/test/config/Files/getFileNameListTestDir/testfile-1k [new file with mode: 0644]
internal/mobius/test/config/Files/test/testfile-1k [new file with mode: 0644]
internal/mobius/test/config/Files/test/testfile-5k [new file with mode: 0644]
internal/mobius/test/config/Files/testdir/some-nested-file.txt [new file with mode: 0644]
internal/mobius/test/config/Files/testfile-1k [new file with mode: 0644]
internal/mobius/test/config/Files/testfile-8b [new file with mode: 0644]
internal/mobius/test/config/Files/testfile.sit [new file with mode: 0644]
internal/mobius/test/config/Files/testfile.txt [new file with mode: 0644]
internal/mobius/test/config/MessageBoard.txt [new file with mode: 0644]
internal/mobius/test/config/ThreadedNews.yaml [new file with mode: 0644]
internal/mobius/test/config/Users/admin.yaml [new file with mode: 0644]
internal/mobius/test/config/Users/guest.yaml [new file with mode: 0644]
internal/mobius/test/config/config.yaml [new file with mode: 0644]
internal/mobius/threaded_news.go
internal/mobius/threaded_news_test.go [new file with mode: 0644]
internal/mobius/transaction_handlers.go [new file with mode: 0644]
internal/mobius/transaction_handlers_test.go [new file with mode: 0644]

index 878b425952a700355178dcc6b4d9dd15ebe6aa3a..04b3c57a3597920b5a15a06df7b4d2c4748d6908 100644 (file)
@@ -16,15 +16,13 @@ import (
        "os"
        "os/signal"
        "path"
        "os"
        "os/signal"
        "path"
-       "runtime"
+       "path/filepath"
        "syscall"
 )
 
 //go:embed mobius/config
 var cfgTemplate embed.FS
 
        "syscall"
 )
 
 //go:embed mobius/config
 var cfgTemplate embed.FS
 
-const defaultPort = 5500
-
 var logLevels = map[string]slog.Level{
        "debug": slog.LevelDebug,
        "info":  slog.LevelInfo,
 var logLevels = map[string]slog.Level{
        "debug": slog.LevelDebug,
        "info":  slog.LevelInfo,
@@ -45,13 +43,12 @@ func main() {
        signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGINT, os.Interrupt)
 
        netInterface := flag.String("interface", "", "IP addr of interface to listen on.  Defaults to all interfaces.")
        signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGINT, os.Interrupt)
 
        netInterface := flag.String("interface", "", "IP addr of interface to listen on.  Defaults to all interfaces.")
-       basePort := flag.Int("bind", defaultPort, "Base Hotline server port.  File transfer port is base port + 1.")
+       basePort := flag.Int("bind", 5500, "Base Hotline server port.  File transfer port is base port + 1.")
        statsPort := flag.String("stats-port", "", "Enable stats HTTP endpoint on address and port")
        statsPort := flag.String("stats-port", "", "Enable stats HTTP endpoint on address and port")
-       configDir := flag.String("config", defaultConfigPath(), "Path to config root")
+       configDir := flag.String("config", configSearchPaths(), "Path to config root")
        printVersion := flag.Bool("version", false, "Print version and exit")
        logLevel := flag.String("log-level", "info", "Log level")
        logFile := flag.String("log-file", "", "Path to log file")
        printVersion := flag.Bool("version", false, "Print version and exit")
        logLevel := flag.String("log-level", "info", "Log level")
        logFile := flag.String("log-file", "", "Path to log file")
-
        init := flag.Bool("init", false, "Populate the config dir with default configuration")
 
        flag.Parse()
        init := flag.Bool("init", false, "Populate the config dir with default configuration")
 
        flag.Parse()
@@ -91,18 +88,18 @@ func main() {
                }
        }
 
                }
        }
 
-       if _, err := os.Stat(*configDir); os.IsNotExist(err) {
-               slogger.Error("Configuration directory not found.  Correct the path or re-run with -init to generate initial config.")
-               os.Exit(1)
-       }
-
        config, err := mobius.LoadConfig(path.Join(*configDir, "config.yaml"))
        if err != nil {
                slogger.Error(fmt.Sprintf("Error loading config: %v", err))
                os.Exit(1)
        }
 
        config, err := mobius.LoadConfig(path.Join(*configDir, "config.yaml"))
        if err != nil {
                slogger.Error(fmt.Sprintf("Error loading config: %v", err))
                os.Exit(1)
        }
 
-       srv, err := hotline.NewServer(*config, *configDir, *netInterface, *basePort, slogger, &hotline.OSFileStore{})
+       srv, err := hotline.NewServer(
+               hotline.WithInterface(*netInterface),
+               hotline.WithLogger(slogger),
+               hotline.WithPort(*basePort),
+               hotline.WithConfig(*config),
+       )
        if err != nil {
                slogger.Error(fmt.Sprintf("Error starting server: %s", err))
                os.Exit(1)
        if err != nil {
                slogger.Error(fmt.Sprintf("Error starting server: %s", err))
                os.Exit(1)
@@ -126,12 +123,59 @@ func main() {
                os.Exit(1)
        }
 
                os.Exit(1)
        }
 
-       sh := statHandler{hlServer: srv}
+       srv.AccountManager, err = mobius.NewYAMLAccountManager(filepath.Join(*configDir, "Users/"))
+       if err != nil {
+               slogger.Error(fmt.Sprintf("Error loading accounts: %v", err))
+               os.Exit(1)
+       }
+
+       srv.Agreement, err = mobius.NewAgreement(*configDir, "\r")
+       if err != nil {
+               slogger.Error(fmt.Sprintf("Error loading agreement: %v", err))
+               os.Exit(1)
+       }
+
+       bannerPath := filepath.Join(*configDir, config.BannerFile)
+       srv.Banner, err = os.ReadFile(bannerPath)
+       if err != nil {
+               slogger.Error(fmt.Sprintf("Error loading accounts: %v", err))
+               os.Exit(1)
+       }
+
+       reloadFunc := func() {
+               if err := srv.MessageBoard.(*mobius.FlatNews).Reload(); err != nil {
+                       slogger.Error("Error reloading news", "err", err)
+               }
+
+               if err := srv.BanList.(*mobius.BanFile).Load(); err != nil {
+                       slogger.Error("Error reloading ban list", "err", err)
+               }
+
+               if err := srv.ThreadedNewsMgr.(*mobius.ThreadedNewsYAML).Load(); err != nil {
+                       slogger.Error("Error reloading threaded news list", "err", err)
+               }
+
+               if err := srv.Agreement.(*mobius.Agreement).Reload(); err != nil {
+                       slogger.Error(fmt.Sprintf("Error reloading agreement: %v", err))
+                       os.Exit(1)
+               }
+       }
+
+       reloadHandler := func(reloadFunc func()) func(w http.ResponseWriter, _ *http.Request) {
+               return func(w http.ResponseWriter, _ *http.Request) {
+                       reloadFunc()
+
+                       _, _ = io.WriteString(w, `{ "msg": "config reloaded" }`)
+               }
+       }
+
+       sh := APIHandler{hlServer: srv}
        if *statsPort != "" {
                http.HandleFunc("/", sh.RenderStats)
        if *statsPort != "" {
                http.HandleFunc("/", sh.RenderStats)
+               http.HandleFunc("/api/v1/stats", sh.RenderStats)
+               http.HandleFunc("/api/v1/reload", reloadHandler(reloadFunc))
 
                go func(srv *hotline.Server) {
 
                go func(srv *hotline.Server) {
-                       // Use the default DefaultServeMux.
                        err = http.ListenAndServe(":"+*statsPort, nil)
                        if err != nil {
                                log.Fatal(err)
                        err = http.ListenAndServe(":"+*statsPort, nil)
                        if err != nil {
                                log.Fatal(err)
@@ -146,17 +190,7 @@ func main() {
                        case syscall.SIGHUP:
                                slogger.Info("SIGHUP received.  Reloading configuration.")
 
                        case syscall.SIGHUP:
                                slogger.Info("SIGHUP received.  Reloading configuration.")
 
-                               if err := srv.MessageBoard.(*mobius.FlatNews).Reload(); err != nil {
-                                       slogger.Error("Error reloading news", "err", err)
-                               }
-
-                               if err := srv.BanList.(*mobius.BanFile).Load(); err != nil {
-                                       slogger.Error("Error reloading ban list", "err", err)
-                               }
-
-                               if err := srv.ThreadedNewsMgr.(*mobius.ThreadedNewsYAML).Load(); err != nil {
-                                       slogger.Error("Error reloading threaded news list", "err", err)
-                               }
+                               reloadFunc()
                        default:
                                signal.Stop(sigChan)
                                cancel()
                        default:
                                signal.Stop(sigChan)
                                cancel()
@@ -168,19 +202,23 @@ func main() {
 
        slogger.Info("Hotline server started",
                "version", version,
 
        slogger.Info("Hotline server started",
                "version", version,
+               "config", *configDir,
                "API port", fmt.Sprintf("%s:%v", *netInterface, *basePort),
                "Transfer port", fmt.Sprintf("%s:%v", *netInterface, *basePort+1),
        )
 
                "API port", fmt.Sprintf("%s:%v", *netInterface, *basePort),
                "Transfer port", fmt.Sprintf("%s:%v", *netInterface, *basePort+1),
        )
 
+       // Assign functions to handle specific Hotline transaction types
+       mobius.RegisterHandlers(srv)
+
        // Serve Hotline requests until program exit
        log.Fatal(srv.ListenAndServe(ctx))
 }
 
        // Serve Hotline requests until program exit
        log.Fatal(srv.ListenAndServe(ctx))
 }
 
-type statHandler struct {
+type APIHandler struct {
        hlServer *hotline.Server
 }
 
        hlServer *hotline.Server
 }
 
-func (sh *statHandler) RenderStats(w http.ResponseWriter, _ *http.Request) {
+func (sh *APIHandler) RenderStats(w http.ResponseWriter, _ *http.Request) {
        u, err := json.Marshal(sh.hlServer.CurrentStats())
        if err != nil {
                panic(err)
        u, err := json.Marshal(sh.hlServer.CurrentStats())
        if err != nil {
                panic(err)
@@ -189,25 +227,14 @@ func (sh *statHandler) RenderStats(w http.ResponseWriter, _ *http.Request) {
        _, _ = io.WriteString(w, string(u))
 }
 
        _, _ = io.WriteString(w, string(u))
 }
 
-func defaultConfigPath() string {
-       var cfgPath string
-
-       switch runtime.GOOS {
-       case "windows":
-               cfgPath = "config/"
-       case "darwin":
-               if _, err := os.Stat("/usr/local/var/mobius/config/"); err == nil {
-                       cfgPath = "/usr/local/var/mobius/config/"
-               } else if _, err := os.Stat("/opt/homebrew/var/mobius/config"); err == nil {
-                       cfgPath = "/opt/homebrew/var/mobius/config/"
+func configSearchPaths() string {
+       for _, cfgPath := range mobius.ConfigSearchOrder {
+               if _, err := os.Stat(cfgPath); err == nil {
+                       return cfgPath
                }
                }
-       case "linux":
-               cfgPath = "/usr/local/var/mobius/config/"
-       default:
-               cfgPath = "./config/"
        }
 
        }
 
-       return cfgPath
+       return "config"
 }
 
 // copyDir recursively copies a directory tree, attempting to preserve permissions.
 }
 
 // copyDir recursively copies a directory tree, attempting to preserve permissions.
@@ -236,7 +263,7 @@ func copyDir(src, dst string) error {
                                if err != nil {
                                        return err
                                }
                                if err != nil {
                                        return err
                                }
-                               f.Close()
+                               _ = f.Close()
                        }
                } else {
                        f, err := os.Create(path.Join(dst, dirEntry.Name()))
                        }
                } else {
                        f, err := os.Create(path.Join(dst, dirEntry.Name()))
@@ -252,7 +279,7 @@ func copyDir(src, dst string) error {
                        if err != nil {
                                return err
                        }
                        if err != nil {
                                return err
                        }
-                       f.Close()
+                       _ = f.Close()
                }
        }
 
                }
        }
 
index cb36354e923b912f7c6e596c42c48ce234ce4a87..6df363bee72351e9626710bbe92a125378f4eee0 100644 (file)
@@ -1 +1,13 @@
-Welcome to Hotline
+From Adam Hinkley (Nov04 12:05):
+
+Welcome to...
+ _   _       _   _ _
+| | | | ___ | |_| (_)_ __   ___
+| |_| |/ _ \| __| | | '_ \ / _ \
+|  _  | (_) | |_| | | | | |  __/
+|_| |_|\___/ \__|_|_|_| |_|\___|
+
+This is the NEWS area of Hotline.  Users can leave messages in this area by clicking on the “Post” button in the toolbar.
+
+The messages stay here until the administrator cleans the News up.  To edit the news, simply open the text file named “News” in the same folder as the Hotline Server program.  Once you've finished editing it, click the “Reload News” button in the server.
+__________________________________________________________
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 24c803e3adc2b69313c4c29b4664b9e5f2928763..378e22979eae5e79cfb2428d6961263de13c12e9 100644 (file)
--- a/go.mod
+++ b/go.mod
@@ -3,22 +3,22 @@ module github.com/jhalter/mobius
 go 1.22
 
 require (
 go 1.22
 
 require (
-       github.com/go-playground/validator/v10 v10.19.0
+       github.com/go-playground/validator/v10 v10.22.0
        github.com/stretchr/testify v1.9.0
        github.com/stretchr/testify v1.9.0
-       golang.org/x/crypto v0.22.0
-       golang.org/x/text v0.14.0
+       golang.org/x/crypto v0.25.0
+       golang.org/x/text v0.16.0
        gopkg.in/natefinch/lumberjack.v2 v2.2.1
        gopkg.in/yaml.v3 v3.0.1
 )
 
 require (
        github.com/davecgh/go-spew v1.1.1 // indirect
        gopkg.in/natefinch/lumberjack.v2 v2.2.1
        gopkg.in/yaml.v3 v3.0.1
 )
 
 require (
        github.com/davecgh/go-spew v1.1.1 // indirect
-       github.com/gabriel-vasile/mimetype v1.4.3 // indirect
+       github.com/gabriel-vasile/mimetype v1.4.4 // indirect
        github.com/go-playground/locales v0.14.1 // indirect
        github.com/go-playground/universal-translator v0.18.1 // indirect
        github.com/leodido/go-urn v1.4.0 // indirect
        github.com/pmezard/go-difflib v1.0.0 // indirect
        github.com/stretchr/objx v0.5.2 // indirect
        github.com/go-playground/locales v0.14.1 // indirect
        github.com/go-playground/universal-translator v0.18.1 // indirect
        github.com/leodido/go-urn v1.4.0 // indirect
        github.com/pmezard/go-difflib v1.0.0 // indirect
        github.com/stretchr/objx v0.5.2 // indirect
-       golang.org/x/net v0.24.0 // indirect
-       golang.org/x/sys v0.19.0 // indirect
+       golang.org/x/net v0.27.0 // indirect
+       golang.org/x/sys v0.22.0 // indirect
 )
 )
diff --git a/go.sum b/go.sum
index a7c6080381faff979f96db4a2f06d63433c5d954..73ed644f96d0a4d7099413570cb1e77a6598c1b8 100644 (file)
--- a/go.sum
+++ b/go.sum
@@ -1,15 +1,15 @@
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
-github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
+github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
+github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
 github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
 github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
 github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
 github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
 github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
 github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
 github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
 github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
 github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
 github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
 github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
 github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4=
-github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
+github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
+github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
 github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
 github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
 github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -18,14 +18,14 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
 github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
-golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
-golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
-golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
-golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
-golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
-golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
+golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
+golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
+golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
+golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
+golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
+golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
index 179941f29be631875f82e3212c3140e44d607bf8..2d78464177e22e29c76889a873e04aedd8c51d97 100644 (file)
@@ -41,12 +41,12 @@ const (
        AccessSendPrivMsg      = 40 // Messaging: Can Send Messages (Note: 1.9 protocol doc incorrectly says this is bit 19)
 )
 
        AccessSendPrivMsg      = 40 // Messaging: Can Send Messages (Note: 1.9 protocol doc incorrectly says this is bit 19)
 )
 
-type accessBitmap [8]byte
+type AccessBitmap [8]byte
 
 
-func (bits *accessBitmap) Set(i int) {
+func (bits *AccessBitmap) Set(i int) {
        bits[i/8] |= 1 << uint(7-i%8)
 }
 
        bits[i/8] |= 1 << uint(7-i%8)
 }
 
-func (bits *accessBitmap) IsSet(i int) bool {
+func (bits *AccessBitmap) IsSet(i int) bool {
        return bits[i/8]&(1<<uint(7-i%8)) != 0
 }
        return bits[i/8]&(1<<uint(7-i%8)) != 0
 }
index b262407d00adba719a1fcdb7f0ba9fd06c28d54b..f2755f30c85fa3cda6d419c6c00d2c029e083967 100644 (file)
@@ -11,13 +11,13 @@ func Test_accessBitmap_IsSet(t *testing.T) {
        }
        tests := []struct {
                name string
        }
        tests := []struct {
                name string
-               bits accessBitmap
+               bits AccessBitmap
                args args
                want bool
        }{
                {
                        name: "returns true when bit is set",
                args args
                want bool
        }{
                {
                        name: "returns true when bit is set",
-                       bits: func() (access accessBitmap) {
+                       bits: func() (access AccessBitmap) {
                                access.Set(22)
                                return access
                        }(),
                                access.Set(22)
                                return access
                        }(),
@@ -26,7 +26,7 @@ func Test_accessBitmap_IsSet(t *testing.T) {
                },
                {
                        name: "returns false when bit is unset",
                },
                {
                        name: "returns false when bit is unset",
-                       bits: accessBitmap{},
+                       bits: AccessBitmap{},
                        args: args{i: 22},
                        want: false,
                },
                        args: args{i: 22},
                        want: false,
                },
index 7b2aafed09889a16a6b454036e844f4cd59083b6..7f770b197db7013d48b0e69dee7e308a1ebbe455 100644 (file)
@@ -14,16 +14,16 @@ type Account struct {
        Login    string       `yaml:"Login"`
        Name     string       `yaml:"Name"`
        Password string       `yaml:"Password"`
        Login    string       `yaml:"Login"`
        Name     string       `yaml:"Name"`
        Password string       `yaml:"Password"`
-       Access   accessBitmap `yaml:"Access,flow"`
+       Access   AccessBitmap `yaml:"Access,flow"`
 
        readOffset int // Internal offset to track read progress
 }
 
 
        readOffset int // Internal offset to track read progress
 }
 
-func NewAccount(login, name, password string, access accessBitmap) *Account {
+func NewAccount(login, name, password string, access AccessBitmap) *Account {
        return &Account{
                Login:    login,
                Name:     name,
        return &Account{
                Login:    login,
                Name:     name,
-               Password: hashAndSalt([]byte(password)),
+               Password: HashAndSalt([]byte(password)),
                Access:   access,
        }
 }
                Access:   access,
        }
 }
@@ -32,7 +32,7 @@ func NewAccount(login, name, password string, access accessBitmap) *Account {
 func (a *Account) Read(p []byte) (int, error) {
        fields := []Field{
                NewField(FieldUserName, []byte(a.Name)),
 func (a *Account) Read(p []byte) (int, error) {
        fields := []Field{
                NewField(FieldUserName, []byte(a.Name)),
-               NewField(FieldUserLogin, encodeString([]byte(a.Login))),
+               NewField(FieldUserLogin, EncodeString([]byte(a.Login))),
                NewField(FieldUserAccess, a.Access[:]),
        }
 
                NewField(FieldUserAccess, a.Access[:]),
        }
 
@@ -63,8 +63,8 @@ func (a *Account) Read(p []byte) (int, error) {
        return n, nil
 }
 
        return n, nil
 }
 
-// hashAndSalt generates a password hash from a users obfuscated plaintext password
-func hashAndSalt(pwd []byte) string {
+// HashAndSalt generates a password hash from a users obfuscated plaintext password
+func HashAndSalt(pwd []byte) string {
        hash, _ := bcrypt.GenerateFromPassword(pwd, bcrypt.MinCost)
 
        return string(hash)
        hash, _ := bcrypt.GenerateFromPassword(pwd, bcrypt.MinCost)
 
        return string(hash)
index 769d12cd683002675a66775dfcd6fa21af540663..03621df2691d19c370ae776f1a79f6d7d935925d 100644 (file)
@@ -1,15 +1,5 @@
 package hotline
 
 package hotline
 
-import (
-       "fmt"
-       "github.com/stretchr/testify/mock"
-       "gopkg.in/yaml.v3"
-       "os"
-       "path"
-       "path/filepath"
-       "sync"
-)
-
 type AccountManager interface {
        Create(account Account) error
        Update(account Account, newLogin string) error
 type AccountManager interface {
        Create(account Account) error
        Update(account Account, newLogin string) error
@@ -17,172 +7,3 @@ type AccountManager interface {
        List() []Account
        Delete(login string) error
 }
        List() []Account
        Delete(login string) error
 }
-
-type YAMLAccountManager struct {
-       accounts   map[string]Account
-       accountDir string
-
-       mu sync.Mutex
-}
-
-func NewYAMLAccountManager(accountDir string) (*YAMLAccountManager, error) {
-       accountMgr := YAMLAccountManager{
-               accountDir: accountDir,
-               accounts:   make(map[string]Account),
-       }
-
-       matches, err := filepath.Glob(filepath.Join(accountDir, "*.yaml"))
-       if err != nil {
-               return nil, err
-       }
-
-       if len(matches) == 0 {
-               return nil, fmt.Errorf("no accounts found in directory: %s", accountDir)
-       }
-
-       for _, file := range matches {
-               var account Account
-               if err = loadFromYAMLFile(file, &account); err != nil {
-                       return nil, fmt.Errorf("error loading account %s: %w", file, err)
-               }
-
-               accountMgr.accounts[account.Login] = account
-       }
-
-       return &accountMgr, nil
-}
-
-func (am *YAMLAccountManager) Create(account Account) error {
-       am.mu.Lock()
-       defer am.mu.Unlock()
-
-       // Create account file, returning an error if one already exists.
-       file, err := os.OpenFile(
-               filepath.Join(am.accountDir, path.Join("/", account.Login+".yaml")),
-               os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644,
-       )
-       if err != nil {
-               return fmt.Errorf("error creating account file: %w", err)
-       }
-       defer file.Close()
-
-       b, err := yaml.Marshal(account)
-       if err != nil {
-               return fmt.Errorf("marshal account to YAML: %v", err)
-       }
-
-       _, err = file.Write(b)
-       if err != nil {
-               return fmt.Errorf("write account file: %w", err)
-       }
-
-       am.accounts[account.Login] = account
-
-       return nil
-}
-
-func (am *YAMLAccountManager) Update(account Account, newLogin string) error {
-       am.mu.Lock()
-       defer am.mu.Unlock()
-
-       // If the login has changed, rename the account file.
-       if account.Login != newLogin {
-               err := os.Rename(
-                       filepath.Join(am.accountDir, path.Join("/", account.Login)+".yaml"),
-                       filepath.Join(am.accountDir, path.Join("/", newLogin)+".yaml"),
-               )
-               if err != nil {
-                       return fmt.Errorf("error renaming account file: %w", err)
-               }
-
-               account.Login = newLogin
-               am.accounts[newLogin] = account
-
-               delete(am.accounts, account.Login)
-       }
-
-       out, err := yaml.Marshal(&account)
-       if err != nil {
-               return err
-       }
-
-       if err := os.WriteFile(filepath.Join(am.accountDir, newLogin+".yaml"), out, 0644); err != nil {
-               return fmt.Errorf("error writing account file: %w", err)
-       }
-
-       am.accounts[account.Login] = account
-
-       return nil
-}
-
-func (am *YAMLAccountManager) Get(login string) *Account {
-       am.mu.Lock()
-       defer am.mu.Unlock()
-
-       account, ok := am.accounts[login]
-       if !ok {
-               return nil
-       }
-
-       return &account
-}
-
-func (am *YAMLAccountManager) List() []Account {
-       am.mu.Lock()
-       defer am.mu.Unlock()
-
-       var accounts []Account
-       for _, account := range am.accounts {
-               accounts = append(accounts, account)
-       }
-
-       return accounts
-}
-
-func (am *YAMLAccountManager) Delete(login string) error {
-       am.mu.Lock()
-       defer am.mu.Unlock()
-
-       err := os.Remove(filepath.Join(am.accountDir, path.Join("/", login+".yaml")))
-       if err != nil {
-               return fmt.Errorf("delete account file: %v", err)
-       }
-
-       delete(am.accounts, login)
-
-       return nil
-}
-
-type MockAccountManager struct {
-       mock.Mock
-}
-
-func (m *MockAccountManager) Create(account Account) error {
-       args := m.Called(account)
-
-       return args.Error(0)
-}
-
-func (m *MockAccountManager) Update(account Account, newLogin string) error {
-       args := m.Called(account, newLogin)
-
-       return args.Error(0)
-}
-
-func (m *MockAccountManager) Get(login string) *Account {
-       args := m.Called(login)
-
-       return args.Get(0).(*Account)
-}
-
-func (m *MockAccountManager) List() []Account {
-       args := m.Called()
-
-       return args.Get(0).([]Account)
-}
-
-func (m *MockAccountManager) Delete(login string) error {
-       args := m.Called(login)
-
-       return args.Error(0)
-}
index 14f41b9728253353cdbcf40fe6eb5c56d87ecde6..7a9fd31a41dcae251c30708fe113521ee422efa9 100644 (file)
@@ -2,7 +2,8 @@ package hotline
 
 import "time"
 
 
 import "time"
 
-const tempBanDuration = 30 * time.Minute
+// BanDuration is the length of time for temporary bans.
+const BanDuration = 30 * time.Minute
 
 type BanMgr interface {
        Add(ip string, until *time.Time) error
 
 type BanMgr interface {
        Add(ip string, until *time.Time) error
index 6f2a4dd7098016c8a137843b42f496ea3ac21faa..dcde1d3a2466e6baaee4505812598f0cdaca0b46 100644 (file)
@@ -23,41 +23,6 @@ type ChatManager interface {
        Members(id ChatID) []*ClientConn
 }
 
        Members(id ChatID) []*ClientConn
 }
 
-type MockChatManager struct {
-       mock.Mock
-}
-
-func (m *MockChatManager) New(cc *ClientConn) ChatID {
-       args := m.Called(cc)
-
-       return args.Get(0).(ChatID)
-}
-
-func (m *MockChatManager) GetSubject(id ChatID) string {
-       args := m.Called(id)
-
-       return args.String(0)
-}
-
-func (m *MockChatManager) Join(id ChatID, cc *ClientConn) {
-       m.Called(id, cc)
-}
-
-func (m *MockChatManager) Leave(id ChatID, clientID [2]byte) {
-       m.Called(id, clientID)
-}
-
-func (m *MockChatManager) SetSubject(id ChatID, subject string) {
-       m.Called(id, subject)
-
-}
-
-func (m *MockChatManager) Members(id ChatID) []*ClientConn {
-       args := m.Called(id)
-
-       return args.Get(0).([]*ClientConn)
-}
-
 type MemChatManager struct {
        chats map[ChatID]*PrivateChat
 
 type MemChatManager struct {
        chats map[ChatID]*PrivateChat
 
@@ -135,3 +100,38 @@ func (cm *MemChatManager) SetSubject(id ChatID, subject string) {
 
        chat.Subject = subject
 }
 
        chat.Subject = subject
 }
+
+type MockChatManager struct {
+       mock.Mock
+}
+
+func (m *MockChatManager) New(cc *ClientConn) ChatID {
+       args := m.Called(cc)
+
+       return args.Get(0).(ChatID)
+}
+
+func (m *MockChatManager) GetSubject(id ChatID) string {
+       args := m.Called(id)
+
+       return args.String(0)
+}
+
+func (m *MockChatManager) Join(id ChatID, cc *ClientConn) {
+       m.Called(id, cc)
+}
+
+func (m *MockChatManager) Leave(id ChatID, clientID [2]byte) {
+       m.Called(id, clientID)
+}
+
+func (m *MockChatManager) SetSubject(id ChatID, subject string) {
+       m.Called(id, subject)
+
+}
+
+func (m *MockChatManager) Members(id ChatID) []*ClientConn {
+       args := m.Called(id)
+
+       return args.Get(0).([]*ClientConn)
+}
index d7af92096de96cbb73cacebb63596c2f6dafc5ac..e300bb8bb61485ea5d510353cb7a6a36b21e4734 100644 (file)
@@ -84,8 +84,8 @@ func (c *Client) Connect(address, login, passwd string) (err error) {
                        TranLogin, [2]byte{0, 0},
                        NewField(FieldUserName, []byte(c.Pref.Username)),
                        NewField(FieldUserIconID, c.Pref.IconBytes()),
                        TranLogin, [2]byte{0, 0},
                        NewField(FieldUserName, []byte(c.Pref.Username)),
                        NewField(FieldUserIconID, c.Pref.IconBytes()),
-                       NewField(FieldUserLogin, encodeString([]byte(login))),
-                       NewField(FieldUserPassword, encodeString([]byte(passwd))),
+                       NewField(FieldUserLogin, EncodeString([]byte(login))),
+                       NewField(FieldUserPassword, EncodeString([]byte(passwd))),
                ),
        )
        if err != nil {
                ),
        )
        if err != nil {
@@ -185,12 +185,6 @@ func (c *Client) HandleTransaction(ctx context.Context, t *Transaction) error {
                                return err
                        }
                }
                                return err
                        }
                }
-       } else {
-               c.Logger.Debug(
-                       "Unimplemented transaction type",
-                       "IsReply", t.IsReply,
-                       "type", t.Type[:],
-               )
        }
 
        return nil
        }
 
        return nil
index dc589967c7f46ca810790a8cea949119f10dc869..060a2b9e1a133b67f0e61dcd9fc8f590cb70cc58 100644 (file)
@@ -26,7 +26,7 @@ type ClientConn struct {
        Icon       []byte // TODO: make fixed size of 2
        Version    []byte // TODO: make fixed size of 2
 
        Icon       []byte // TODO: make fixed size of 2
        Version    []byte // TODO: make fixed size of 2
 
-       flagsMU sync.Mutex // TODO: move into UserFlags struct
+       FlagsMU sync.Mutex // TODO: move into UserFlags struct
        Flags   UserFlags
 
        UserName  []byte
        Flags   UserFlags
 
        UserName  []byte
@@ -37,7 +37,7 @@ type ClientConn struct {
 
        ClientFileTransferMgr ClientFileTransferMgr
 
 
        ClientFileTransferMgr ClientFileTransferMgr
 
-       logger *slog.Logger
+       Logger *slog.Logger
 
        mu sync.RWMutex
 }
 
        mu sync.RWMutex
 }
@@ -64,7 +64,7 @@ func (cftm *ClientFileTransferMgr) Add(ftType FileTransferType, ft *FileTransfer
        cftm.mu.Lock()
        defer cftm.mu.Unlock()
 
        cftm.mu.Lock()
        defer cftm.mu.Unlock()
 
-       cftm.transfers[ftType][ft.refNum] = ft
+       cftm.transfers[ftType][ft.RefNum] = ft
 }
 
 func (cftm *ClientFileTransferMgr) Get(ftType FileTransferType) []FileTransfer {
 }
 
 func (cftm *ClientFileTransferMgr) Get(ftType FileTransferType) []FileTransfer {
@@ -95,9 +95,9 @@ func (cc *ClientConn) SendAll(t [2]byte, fields ...Field) {
 }
 
 func (cc *ClientConn) handleTransaction(transaction Transaction) {
 }
 
 func (cc *ClientConn) handleTransaction(transaction Transaction) {
-       if handler, ok := TransactionHandlers[transaction.Type]; ok {
+       if handler, ok := cc.Server.handlers[transaction.Type]; ok {
                if transaction.Type != TranKeepAlive {
                if transaction.Type != TranKeepAlive {
-                       cc.logger.Info(tranTypeNames[transaction.Type])
+                       cc.Logger.Info(tranTypeNames[transaction.Type])
                }
 
                for _, t := range handler(cc, &transaction) {
                }
 
                for _, t := range handler(cc, &transaction) {
@@ -144,7 +144,7 @@ func (cc *ClientConn) Authorize(access int) bool {
        return cc.Account.Access.IsSet(access)
 }
 
        return cc.Account.Access.IsSet(access)
 }
 
-// Disconnect notifies other clients that a client has disconnected
+// Disconnect notifies other clients that a client has disconnected and closes the connection.
 func (cc *ClientConn) Disconnect() {
        cc.Server.ClientMgr.Delete(cc.ID)
 
 func (cc *ClientConn) Disconnect() {
        cc.Server.ClientMgr.Delete(cc.ID)
 
@@ -153,7 +153,7 @@ func (cc *ClientConn) Disconnect() {
        }
 
        if err := cc.Connection.Close(); err != nil {
        }
 
        if err := cc.Connection.Close(); err != nil {
-               cc.Server.Logger.Error("error closing client connection", "RemoteAddr", cc.RemoteAddr)
+               cc.Server.Logger.Debug("error closing client connection", "RemoteAddr", cc.RemoteAddr)
        }
 }
 
        }
 }
 
@@ -161,7 +161,7 @@ func (cc *ClientConn) Disconnect() {
 func (cc *ClientConn) NotifyOthers(t Transaction) (trans []Transaction) {
        for _, c := range cc.Server.ClientMgr.List() {
                if c.ID != cc.ID {
 func (cc *ClientConn) NotifyOthers(t Transaction) (trans []Transaction) {
        for _, c := range cc.Server.ClientMgr.List() {
                if c.ID != cc.ID {
-                       t.clientID = c.ID
+                       t.ClientID = c.ID
                        trans = append(trans, t)
                }
        }
                        trans = append(trans, t)
                }
        }
@@ -173,7 +173,7 @@ func (cc *ClientConn) NewReply(t *Transaction, fields ...Field) Transaction {
        return Transaction{
                IsReply:  1,
                ID:       t.ID,
        return Transaction{
                IsReply:  1,
                ID:       t.ID,
-               clientID: cc.ID,
+               ClientID: cc.ID,
                Fields:   fields,
        }
 }
                Fields:   fields,
        }
 }
@@ -182,7 +182,7 @@ func (cc *ClientConn) NewReply(t *Transaction, fields ...Field) Transaction {
 func (cc *ClientConn) NewErrReply(t *Transaction, errMsg string) []Transaction {
        return []Transaction{
                {
 func (cc *ClientConn) NewErrReply(t *Transaction, errMsg string) []Transaction {
        return []Transaction{
                {
-                       clientID:  cc.ID,
+                       ClientID:  cc.ID,
                        IsReply:   1,
                        ID:        t.ID,
                        ErrorCode: [4]byte{0, 0, 0, 1},
                        IsReply:   1,
                        ID:        t.ID,
                        ErrorCode: [4]byte{0, 0, 0, 1},
index a30bbf77465c1a21dd6a9e04fb2ef5bfc0a6b45b..1ad0ec2ccb45a58cb1ac8eb52f9113a4abf2cbbc 100644 (file)
@@ -1,15 +1,9 @@
 package hotline
 
 package hotline
 
-const (
-       userIdleSeconds        = 300 // time in seconds before an inactive user is marked idle
-       idleCheckInterval      = 10  // time in seconds to check for idle users
-       trackerUpdateFrequency = 300 // time in seconds between tracker re-registration
-)
-
 type Config struct {
        Name                      string   `yaml:"Name" validate:"required,max=50"`         // Name used for Tracker registration
        Description               string   `yaml:"Description" validate:"required,max=200"` // Description used for Tracker registration
 type Config struct {
        Name                      string   `yaml:"Name" validate:"required,max=50"`         // Name used for Tracker registration
        Description               string   `yaml:"Description" validate:"required,max=200"` // Description used for Tracker registration
-       BannerFile                string   `yaml:"BannerFile"`                              // Path to banner jpg
+       BannerFile                string   `yaml:"BannerFile"`                              // Path to Banner jpg
        FileRoot                  string   `yaml:"FileRoot" validate:"required"`            // Path to Files
        EnableTrackerRegistration bool     `yaml:"EnableTrackerRegistration"`               // Toggle Tracker Registration
        Trackers                  []string `yaml:"Trackers" validate:"dive,hostname_port"`  // List of trackers that the server should register with
        FileRoot                  string   `yaml:"FileRoot" validate:"required"`            // Path to Files
        EnableTrackerRegistration bool     `yaml:"EnableTrackerRegistration"`               // Toggle Tracker Registration
        Trackers                  []string `yaml:"Trackers" validate:"dive,hostname_port"`  // List of trackers that the server should register with
diff --git a/hotline/doc.go b/hotline/doc.go
new file mode 100644 (file)
index 0000000..5a6df33
--- /dev/null
@@ -0,0 +1,2 @@
+// Package hotline provides Hotline client and server implementations.
+package hotline
index 4c760d5f248691867eb09e58f09a7672df74f8e0..65ff39497ad57cf788f5858736785892d298f675 100644 (file)
@@ -92,8 +92,8 @@ func NewField(fieldType [2]byte, data []byte) Field {
        return f
 }
 
        return f
 }
 
-// fieldScanner implements bufio.SplitFunc for parsing byte slices into complete tokens
-func fieldScanner(data []byte, _ bool) (advance int, token []byte, err error) {
+// FieldScanner implements bufio.SplitFunc for parsing byte slices into complete tokens
+func FieldScanner(data []byte, _ bool) (advance int, token []byte, err error) {
        if len(data) < minFieldLen {
                return 0, nil, nil
        }
        if len(data) < minFieldLen {
                return 0, nil, nil
        }
@@ -122,7 +122,7 @@ func (f *Field) DecodeInt() (int, error) {
 }
 
 func (f *Field) DecodeObfuscatedString() string {
 }
 
 func (f *Field) DecodeObfuscatedString() string {
-       return string(encodeString(f.Data))
+       return string(EncodeString(f.Data))
 }
 
 // DecodeNewsPath decodes the field data to a news path.
 }
 
 // DecodeNewsPath decodes the field data to a news path.
@@ -185,7 +185,7 @@ func (f *Field) Write(p []byte) (int, error) {
        return minFieldLen + dataSize, nil
 }
 
        return minFieldLen + dataSize, nil
 }
 
-func getField(id [2]byte, fields *[]Field) *Field {
+func GetField(id [2]byte, fields *[]Field) *Field {
        for _, field := range *fields {
                if id == field.Type {
                        return &field
        for _, field := range *fields {
                if id == field.Type {
                        return &field
index abb4d44b1a832c5e67265d0e575ac5f8fa11ccaf..fc11c74472790de5497195e0eab47fbf0cafcfc5 100644 (file)
@@ -90,12 +90,12 @@ func Test_fieldScanner(t *testing.T) {
        }
        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
        }
        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
-                       gotAdvance, gotToken, err := fieldScanner(tt.args.data, tt.args.in1)
-                       if !tt.wantErr(t, err, fmt.Sprintf("fieldScanner(%v, %v)", tt.args.data, tt.args.in1)) {
+                       gotAdvance, gotToken, err := FieldScanner(tt.args.data, tt.args.in1)
+                       if !tt.wantErr(t, err, fmt.Sprintf("FieldScanner(%v, %v)", tt.args.data, tt.args.in1)) {
                                return
                        }
                                return
                        }
-                       assert.Equalf(t, tt.wantAdvance, gotAdvance, "fieldScanner(%v, %v)", tt.args.data, tt.args.in1)
-                       assert.Equalf(t, tt.wantToken, gotToken, "fieldScanner(%v, %v)", tt.args.data, tt.args.in1)
+                       assert.Equalf(t, tt.wantAdvance, gotAdvance, "FieldScanner(%v, %v)", tt.args.data, tt.args.in1)
+                       assert.Equalf(t, tt.wantToken, gotToken, "FieldScanner(%v, %v)", tt.args.data, tt.args.in1)
                })
        }
 }
                })
        }
 }
diff --git a/hotline/file_header.go b/hotline/file_header.go
deleted file mode 100644 (file)
index b18229a..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-package hotline
-
-import (
-       "encoding/binary"
-       "io"
-       "slices"
-)
-
-type FileHeader struct {
-       Size     [2]byte // Total size of FileHeader payload
-       Type     [2]byte // 0 for file, 1 for dir
-       FilePath []byte  // encoded file path
-
-       readOffset int // Internal offset to track read progress
-}
-
-func NewFileHeader(fileName string, isDir bool) FileHeader {
-       fh := FileHeader{
-               FilePath: EncodeFilePath(fileName),
-       }
-       if isDir {
-               fh.Type = [2]byte{0x00, 0x01}
-       }
-
-       encodedPathLen := uint16(len(fh.FilePath) + len(fh.Type))
-       binary.BigEndian.PutUint16(fh.Size[:], encodedPathLen)
-
-       return fh
-}
-
-func (fh *FileHeader) Read(p []byte) (int, error) {
-       buf := slices.Concat(
-               fh.Size[:],
-               fh.Type[:],
-               fh.FilePath,
-       )
-
-       if fh.readOffset >= len(buf) {
-               return 0, io.EOF // All bytes have been read
-       }
-
-       n := copy(p, buf[fh.readOffset:])
-       fh.readOffset += n
-
-       return n, nil
-}
diff --git a/hotline/file_header_test.go b/hotline/file_header_test.go
deleted file mode 100644 (file)
index 308d6b9..0000000
+++ /dev/null
@@ -1,94 +0,0 @@
-package hotline
-
-import (
-       "io"
-       "reflect"
-       "testing"
-)
-
-func TestNewFileHeader(t *testing.T) {
-       type args struct {
-               fileName string
-               isDir    bool
-       }
-       tests := []struct {
-               name string
-               args args
-               want FileHeader
-       }{
-               {
-                       name: "when path is file",
-                       args: args{
-                               fileName: "foo",
-                               isDir:    false,
-                       },
-                       want: FileHeader{
-                               Size:     [2]byte{0x00, 0x0a},
-                               Type:     [2]byte{0x00, 0x00},
-                               FilePath: EncodeFilePath("foo"),
-                       },
-               },
-               {
-                       name: "when path is dir",
-                       args: args{
-                               fileName: "foo",
-                               isDir:    true,
-                       },
-                       want: FileHeader{
-                               Size:     [2]byte{0x00, 0x0a},
-                               Type:     [2]byte{0x00, 0x01},
-                               FilePath: EncodeFilePath("foo"),
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       if got := NewFileHeader(tt.args.fileName, tt.args.isDir); !reflect.DeepEqual(got, tt.want) {
-                               t.Errorf("NewFileHeader() = %v, want %v", got, tt.want)
-                       }
-               })
-       }
-}
-
-func TestFileHeader_Payload(t *testing.T) {
-       type fields struct {
-               Size     [2]byte
-               Type     [2]byte
-               FilePath []byte
-       }
-       tests := []struct {
-               name   string
-               fields fields
-               want   []byte
-       }{
-               {
-                       name: "has expected payload bytes",
-                       fields: fields{
-                               Size:     [2]byte{0x00, 0x0a},
-                               Type:     [2]byte{0x00, 0x00},
-                               FilePath: EncodeFilePath("foo"),
-                       },
-                       want: []byte{
-                               0x00, 0x0a, // total size
-                               0x00, 0x00, // type
-                               0x00, 0x01, // path item count
-                               0x00, 0x00, // path separator
-                               0x03,             // pathName len
-                               0x66, 0x6f, 0x6f, // "foo"
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       fh := &FileHeader{
-                               Size:     tt.fields.Size,
-                               Type:     tt.fields.Type,
-                               FilePath: tt.fields.FilePath,
-                       }
-                       got, _ := io.ReadAll(fh)
-                       if !reflect.DeepEqual(got, tt.want) {
-                               t.Errorf("Read() = %v, want %v", got, tt.want)
-                       }
-               })
-       }
-}
index 3324a6252e837be1b2619bfe24c3c036efd36462..3a4a79563bdbde613e399173b3cec4edc6cfba82 100644 (file)
@@ -8,14 +8,14 @@ import (
 )
 
 type FileNameWithInfo struct {
 )
 
 type FileNameWithInfo struct {
-       fileNameWithInfoHeader
+       FileNameWithInfoHeader
        Name []byte // File Name
 
        readOffset int // Internal offset to track read progress
 }
 
        Name []byte // File Name
 
        readOffset int // Internal offset to track read progress
 }
 
-// fileNameWithInfoHeader contains the fixed length fields of FileNameWithInfo
-type fileNameWithInfoHeader struct {
+// FileNameWithInfoHeader contains the fixed length fields of FileNameWithInfo
+type FileNameWithInfoHeader struct {
        Type       [4]byte // File type code
        Creator    [4]byte // File creator code
        FileSize   [4]byte // File Size in bytes
        Type       [4]byte // File type code
        Creator    [4]byte // File creator code
        FileSize   [4]byte // File Size in bytes
@@ -24,7 +24,7 @@ type fileNameWithInfoHeader struct {
        NameSize   [2]byte // Length of Name field
 }
 
        NameSize   [2]byte // Length of Name field
 }
 
-func (f *fileNameWithInfoHeader) nameLen() int {
+func (f *FileNameWithInfoHeader) nameLen() int {
        return int(binary.BigEndian.Uint16(f.NameSize[:]))
 }
 
        return int(binary.BigEndian.Uint16(f.NameSize[:]))
 }
 
@@ -51,11 +51,11 @@ func (f *FileNameWithInfo) Read(p []byte) (int, error) {
 }
 
 func (f *FileNameWithInfo) Write(p []byte) (int, error) {
 }
 
 func (f *FileNameWithInfo) Write(p []byte) (int, error) {
-       err := binary.Read(bytes.NewReader(p), binary.BigEndian, &f.fileNameWithInfoHeader)
+       err := binary.Read(bytes.NewReader(p), binary.BigEndian, &f.FileNameWithInfoHeader)
        if err != nil {
                return 0, err
        }
        if err != nil {
                return 0, err
        }
-       headerLen := binary.Size(f.fileNameWithInfoHeader)
+       headerLen := binary.Size(f.FileNameWithInfoHeader)
        f.Name = p[headerLen : headerLen+f.nameLen()]
 
        return len(p), nil
        f.Name = p[headerLen : headerLen+f.nameLen()]
 
        return len(p), nil
index 321e2473e2e3892ed331a0f0fc78ea937af334f3..d1b03c20f842884a812a394b9da595d64538cb16 100644 (file)
@@ -9,7 +9,7 @@ import (
 
 func TestFileNameWithInfo_MarshalBinary(t *testing.T) {
        type fields struct {
 
 func TestFileNameWithInfo_MarshalBinary(t *testing.T) {
        type fields struct {
-               fileNameWithInfoHeader fileNameWithInfoHeader
+               fileNameWithInfoHeader FileNameWithInfoHeader
                name                   []byte
        }
        tests := []struct {
                name                   []byte
        }
        tests := []struct {
@@ -21,7 +21,7 @@ func TestFileNameWithInfo_MarshalBinary(t *testing.T) {
                {
                        name: "returns expected bytes",
                        fields: fields{
                {
                        name: "returns expected bytes",
                        fields: fields{
-                               fileNameWithInfoHeader: fileNameWithInfoHeader{
+                               fileNameWithInfoHeader: FileNameWithInfoHeader{
                                        Type:       [4]byte{0x54, 0x45, 0x58, 0x54}, // TEXT
                                        Creator:    [4]byte{0x54, 0x54, 0x58, 0x54}, // TTXT
                                        FileSize:   [4]byte{0x00, 0x43, 0x16, 0xd3}, // File Size
                                        Type:       [4]byte{0x54, 0x45, 0x58, 0x54}, // TEXT
                                        Creator:    [4]byte{0x54, 0x54, 0x58, 0x54}, // TTXT
                                        FileSize:   [4]byte{0x00, 0x43, 0x16, 0xd3}, // File Size
@@ -46,7 +46,7 @@ func TestFileNameWithInfo_MarshalBinary(t *testing.T) {
        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
                        f := &FileNameWithInfo{
        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
                        f := &FileNameWithInfo{
-                               fileNameWithInfoHeader: tt.fields.fileNameWithInfoHeader,
+                               FileNameWithInfoHeader: tt.fields.fileNameWithInfoHeader,
                                Name:                   tt.fields.name,
                        }
                        gotData, err := io.ReadAll(f)
                                Name:                   tt.fields.name,
                        }
                        gotData, err := io.ReadAll(f)
@@ -63,7 +63,7 @@ func TestFileNameWithInfo_MarshalBinary(t *testing.T) {
 
 func TestFileNameWithInfo_UnmarshalBinary(t *testing.T) {
        type fields struct {
 
 func TestFileNameWithInfo_UnmarshalBinary(t *testing.T) {
        type fields struct {
-               fileNameWithInfoHeader fileNameWithInfoHeader
+               fileNameWithInfoHeader FileNameWithInfoHeader
                name                   []byte
        }
        type args struct {
                name                   []byte
        }
        type args struct {
@@ -90,7 +90,7 @@ func TestFileNameWithInfo_UnmarshalBinary(t *testing.T) {
                                },
                        },
                        want: &FileNameWithInfo{
                                },
                        },
                        want: &FileNameWithInfo{
-                               fileNameWithInfoHeader: fileNameWithInfoHeader{
+                               FileNameWithInfoHeader: FileNameWithInfoHeader{
                                        Type:       [4]byte{0x54, 0x45, 0x58, 0x54}, // TEXT
                                        Creator:    [4]byte{0x54, 0x54, 0x58, 0x54}, // TTXT
                                        FileSize:   [4]byte{0x00, 0x43, 0x16, 0xd3}, // File Size
                                        Type:       [4]byte{0x54, 0x45, 0x58, 0x54}, // TEXT
                                        Creator:    [4]byte{0x54, 0x54, 0x58, 0x54}, // TTXT
                                        FileSize:   [4]byte{0x00, 0x43, 0x16, 0xd3}, // File Size
@@ -106,7 +106,7 @@ func TestFileNameWithInfo_UnmarshalBinary(t *testing.T) {
        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
                        f := &FileNameWithInfo{
        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
                        f := &FileNameWithInfo{
-                               fileNameWithInfoHeader: tt.fields.fileNameWithInfoHeader,
+                               FileNameWithInfoHeader: tt.fields.fileNameWithInfoHeader,
                                Name:                   tt.fields.name,
                        }
                        if _, err := f.Write(tt.args.data); (err != nil) != tt.wantErr {
                                Name:                   tt.fields.name,
                        }
                        if _, err := f.Write(tt.args.data); (err != nil) != tt.wantErr {
index 620f54d5cd0a5b969ade3237ad57617d65fd370e..f4a27cc587b9c89f876ba7593adac1323573c350 100644 (file)
@@ -102,7 +102,7 @@ func (fp *FilePath) Len() uint16 {
        return binary.BigEndian.Uint16(fp.ItemCount[:])
 }
 
        return binary.BigEndian.Uint16(fp.ItemCount[:])
 }
 
-func readPath(fileRoot string, filePath, fileName []byte) (fullPath string, err error) {
+func ReadPath(fileRoot string, filePath, fileName []byte) (fullPath string, err error) {
        var fp FilePath
        if filePath != nil {
                if _, err = fp.Write(filePath); err != nil {
        var fp FilePath
        if filePath != nil {
                if _, err = fp.Write(filePath); err != nil {
index effd46297935d3b2da791f82291c37defbe13807..23c9a9654b3d5d3d8092e1eedebb70b51a889586 100644 (file)
@@ -154,13 +154,13 @@ func Test_readPath(t *testing.T) {
        }
        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
        }
        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
-                       got, err := readPath(tt.args.fileRoot, tt.args.filePath, tt.args.fileName)
+                       got, err := ReadPath(tt.args.fileRoot, tt.args.filePath, tt.args.fileName)
                        if (err != nil) != tt.wantErr {
                        if (err != nil) != tt.wantErr {
-                               t.Errorf("readPath() error = %v, wantErr %v", err, tt.wantErr)
+                               t.Errorf("ReadPath() error = %v, wantErr %v", err, tt.wantErr)
                                return
                        }
                        if got != tt.want {
                                return
                        }
                        if got != tt.want {
-                               t.Errorf("readPath() got = %v, want %v", got, tt.want)
+                               t.Errorf("ReadPath() got = %v, want %v", got, tt.want)
                        }
                })
        }
                        }
                })
        }
index b567b182f5767bd646b65328a5018dcaa51fece0..e37295abd3d581d3140fe090b0a6015841551ab4 100644 (file)
@@ -12,6 +12,7 @@ import (
        "math"
        "os"
        "path/filepath"
        "math"
        "os"
        "path/filepath"
+       "slices"
        "strings"
        "sync"
 )
        "strings"
        "sync"
 )
@@ -59,15 +60,11 @@ func (ftm *MemFileTransferMgr) Add(ft *FileTransfer) {
        ftm.mu.Lock()
        defer ftm.mu.Unlock()
 
        ftm.mu.Lock()
        defer ftm.mu.Unlock()
 
-       _, _ = rand.Read(ft.refNum[:])
+       _, _ = rand.Read(ft.RefNum[:])
 
 
-       ftm.fileTransfers[ft.refNum] = ft
+       ftm.fileTransfers[ft.RefNum] = ft
 
        ft.ClientConn.ClientFileTransferMgr.Add(ft.Type, ft)
 
        ft.ClientConn.ClientFileTransferMgr.Add(ft.Type, ft)
-
-       //ft.ClientConn.transfersMU.Lock()
-       //ft.ClientConn.transfers[ft.Type] = ft
-       //ft.ClientConn.transfersMU.Unlock()
 }
 
 func (ftm *MemFileTransferMgr) Get(id FileTransferID) *FileTransfer {
 }
 
 func (ftm *MemFileTransferMgr) Get(id FileTransferID) *FileTransfer {
@@ -83,9 +80,6 @@ func (ftm *MemFileTransferMgr) Delete(id FileTransferID) {
 
        ft := ftm.fileTransfers[id]
 
 
        ft := ftm.fileTransfers[id]
 
-       //ft.ClientConn.transfersMU.Lock()
-       //delete(ft.ClientConn.transfers[ft.Type], ft.refNum)
-       //ft.ClientConn.transfersMU.Unlock()
        ft.ClientConn.ClientFileTransferMgr.Delete(ft.Type, id)
 
        delete(ftm.fileTransfers, id)
        ft.ClientConn.ClientFileTransferMgr.Delete(ft.Type, id)
 
        delete(ftm.fileTransfers, id)
@@ -95,12 +89,12 @@ func (ftm *MemFileTransferMgr) Delete(id FileTransferID) {
 type FileTransfer struct {
        FileName         []byte
        FilePath         []byte
 type FileTransfer struct {
        FileName         []byte
        FilePath         []byte
-       refNum           [4]byte
+       RefNum           [4]byte
        Type             FileTransferType
        TransferSize     []byte
        FolderItemCount  []byte
        Type             FileTransferType
        TransferSize     []byte
        FolderItemCount  []byte
-       fileResumeData   *FileResumeData
-       options          []byte
+       FileResumeData   *FileResumeData
+       Options          []byte
        bytesSentCounter *WriteCounter
        ClientConn       *ClientConn
 }
        bytesSentCounter *WriteCounter
        ClientConn       *ClientConn
 }
@@ -122,7 +116,7 @@ func (wc *WriteCounter) Write(p []byte) (int, error) {
        return n, nil
 }
 
        return n, nil
 }
 
-func (cc *ClientConn) newFileTransfer(transferType FileTransferType, fileName, filePath, size []byte) *FileTransfer {
+func (cc *ClientConn) NewFileTransfer(transferType FileTransferType, fileName, filePath, size []byte) *FileTransfer {
        ft := &FileTransfer{
                FileName:         fileName,
                FilePath:         filePath,
        ft := &FileTransfer{
                FileName:         fileName,
                FilePath:         filePath,
@@ -131,19 +125,9 @@ func (cc *ClientConn) newFileTransfer(transferType FileTransferType, fileName, f
                ClientConn:       cc,
                bytesSentCounter: &WriteCounter{},
        }
                ClientConn:       cc,
                bytesSentCounter: &WriteCounter{},
        }
-       //
-       //_, _ = rand.Read(ft.refNum[:])
-       //
-       //cc.transfersMU.Lock()
-       //defer cc.transfersMU.Unlock()
-       //cc.transfers[transferType][ft.refNum] = ft
 
        cc.Server.FileTransferMgr.Add(ft)
 
 
        cc.Server.FileTransferMgr.Add(ft)
 
-       //cc.Server.mux.Lock()
-       //defer cc.Server.mux.Unlock()
-       //cc.Server.fileTransfers[ft.refNum] = ft
-
        return ft
 }
 
        return ft
 }
 
@@ -217,6 +201,45 @@ func (fu *folderUpload) FormattedPath() string {
        return filepath.Join(pathSegments...)
 }
 
        return filepath.Join(pathSegments...)
 }
 
+type FileHeader struct {
+       Size     [2]byte // Total size of FileHeader payload
+       Type     [2]byte // 0 for file, 1 for dir
+       FilePath []byte  // encoded file path
+
+       readOffset int // Internal offset to track read progress
+}
+
+func NewFileHeader(fileName string, isDir bool) FileHeader {
+       fh := FileHeader{
+               FilePath: EncodeFilePath(fileName),
+       }
+       if isDir {
+               fh.Type = [2]byte{0x00, 0x01}
+       }
+
+       encodedPathLen := uint16(len(fh.FilePath) + len(fh.Type))
+       binary.BigEndian.PutUint16(fh.Size[:], encodedPathLen)
+
+       return fh
+}
+
+func (fh *FileHeader) Read(p []byte) (int, error) {
+       buf := slices.Concat(
+               fh.Size[:],
+               fh.Type[:],
+               fh.FilePath,
+       )
+
+       if fh.readOffset >= len(buf) {
+               return 0, io.EOF // All bytes have been read
+       }
+
+       n := copy(p, buf[fh.readOffset:])
+       fh.readOffset += n
+
+       return n, nil
+}
+
 func DownloadHandler(w io.Writer, fullPath string, fileTransfer *FileTransfer, fs FileStore, rLogger *slog.Logger, preserveForks bool) error {
        //s.Stats.DownloadCounter += 1
        //s.Stats.DownloadsInProgress += 1
 func DownloadHandler(w io.Writer, fullPath string, fileTransfer *FileTransfer, fs FileStore, rLogger *slog.Logger, preserveForks bool) error {
        //s.Stats.DownloadCounter += 1
        //s.Stats.DownloadsInProgress += 1
@@ -225,11 +248,11 @@ func DownloadHandler(w io.Writer, fullPath string, fileTransfer *FileTransfer, f
        //}()
 
        var dataOffset int64
        //}()
 
        var dataOffset int64
-       if fileTransfer.fileResumeData != nil {
-               dataOffset = int64(binary.BigEndian.Uint32(fileTransfer.fileResumeData.ForkInfoList[0].DataSize[:]))
+       if fileTransfer.FileResumeData != nil {
+               dataOffset = int64(binary.BigEndian.Uint32(fileTransfer.FileResumeData.ForkInfoList[0].DataSize[:]))
        }
 
        }
 
-       fw, err := newFileWrapper(fs, fullPath, 0)
+       fw, err := NewFileWrapper(fs, fullPath, 0)
        if err != nil {
                return fmt.Errorf("reading file header: %v", err)
        }
        if err != nil {
                return fmt.Errorf("reading file header: %v", err)
        }
@@ -238,8 +261,8 @@ func DownloadHandler(w io.Writer, fullPath string, fileTransfer *FileTransfer, f
 
        // If file transfer options are included, that means this is a "quick preview" request.  In this case skip sending
        // the flat file info and proceed directly to sending the file data.
 
        // If file transfer options are included, that means this is a "quick preview" request.  In this case skip sending
        // the flat file info and proceed directly to sending the file data.
-       if fileTransfer.options == nil {
-               if _, err = io.Copy(w, fw.ffo); err != nil {
+       if fileTransfer.Options == nil {
+               if _, err = io.Copy(w, fw.Ffo); err != nil {
                        return fmt.Errorf("send flat file object: %v", err)
                }
        }
                        return fmt.Errorf("send flat file object: %v", err)
                }
        }
@@ -259,7 +282,7 @@ func DownloadHandler(w io.Writer, fullPath string, fileTransfer *FileTransfer, f
        }
 
        // If the client requested to resume transfer, do not send the resource fork header.
        }
 
        // If the client requested to resume transfer, do not send the resource fork header.
-       if fileTransfer.fileResumeData == nil {
+       if fileTransfer.FileResumeData == nil {
                err = binary.Write(w, binary.BigEndian, fw.rsrcForkHeader())
                if err != nil {
                        return fmt.Errorf("send resource fork header: %v", err)
                err = binary.Write(w, binary.BigEndian, fw.rsrcForkHeader())
                if err != nil {
                        return fmt.Errorf("send resource fork header: %v", err)
@@ -294,13 +317,13 @@ func UploadHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileTransfe
        }
        if errors.Is(err, fs.ErrNotExist) {
                // If not found, open or create a new .incomplete file
        }
        if errors.Is(err, fs.ErrNotExist) {
                // If not found, open or create a new .incomplete file
-               file, err = os.OpenFile(fullPath+incompleteFileSuffix, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
+               file, err = os.OpenFile(fullPath+IncompleteFileSuffix, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
                if err != nil {
                        return err
                }
        }
 
                if err != nil {
                        return err
                }
        }
 
-       f, err := newFileWrapper(fileStore, fullPath, 0)
+       f, err := NewFileWrapper(fileStore, fullPath, 0)
        if err != nil {
                return err
        }
        if err != nil {
                return err
        }
@@ -315,7 +338,7 @@ func UploadHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileTransfe
                        return err
                }
 
                        return err
                }
 
-               iForkWriter, err = f.infoForkWriter()
+               iForkWriter, err = f.InfoForkWriter()
                if err != nil {
                        return err
                }
                if err != nil {
                        return err
                }
@@ -389,7 +412,7 @@ func DownloadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *Fil
                        return nil
                }
 
                        return nil
                }
 
-               hlFile, err := newFileWrapper(fileStore, path, 0)
+               hlFile, err := NewFileWrapper(fileStore, path, 0)
                if err != nil {
                        return err
                }
                if err != nil {
                        return err
                }
@@ -445,17 +468,17 @@ func DownloadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *Fil
 
                rLogger.Info("File download started",
                        "fileName", info.Name(),
 
                rLogger.Info("File download started",
                        "fileName", info.Name(),
-                       "TransferSize", fmt.Sprintf("%x", hlFile.ffo.TransferSize(dataOffset)),
+                       "TransferSize", fmt.Sprintf("%x", hlFile.Ffo.TransferSize(dataOffset)),
                )
 
                // Send file size to client
                )
 
                // Send file size to client
-               if _, err := rwc.Write(hlFile.ffo.TransferSize(dataOffset)); err != nil {
+               if _, err := rwc.Write(hlFile.Ffo.TransferSize(dataOffset)); err != nil {
                        rLogger.Error(err.Error())
                        return fmt.Errorf("error sending file size: %w", err)
                }
 
                // Send ffo bytes to client
                        rLogger.Error(err.Error())
                        return fmt.Errorf("error sending file size: %w", err)
                }
 
                // Send ffo bytes to client
-               _, err = io.Copy(rwc, hlFile.ffo)
+               _, err = io.Copy(rwc, hlFile.Ffo)
                if err != nil {
                        return fmt.Errorf("error sending flat file object: %w", err)
                }
                if err != nil {
                        return fmt.Errorf("error sending flat file object: %w", err)
                }
@@ -470,7 +493,7 @@ func DownloadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *Fil
                        return fmt.Errorf("error sending file: %w", err)
                }
 
                        return fmt.Errorf("error sending file: %w", err)
                }
 
-               if nextAction[1] != 2 && hlFile.ffo.FlatFileHeader.ForkCount[1] == 3 {
+               if nextAction[1] != 2 && hlFile.Ffo.FlatFileHeader.ForkCount[1] == 3 {
                        err = binary.Write(rwc, binary.BigEndian, hlFile.rsrcForkHeader())
                        if err != nil {
                                return fmt.Errorf("error sending resource fork header: %w", err)
                        err = binary.Write(rwc, binary.BigEndian, hlFile.rsrcForkHeader())
                        if err != nil {
                                return fmt.Errorf("error sending resource fork header: %w", err)
@@ -560,7 +583,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT
                        }
 
                        //  Check if we have a partial file already.  If so, send dlFldrAction_ResumeFile to client to resume upload.
                        }
 
                        //  Check if we have a partial file already.  If so, send dlFldrAction_ResumeFile to client to resume upload.
-                       incompleteFile, err := os.Stat(filepath.Join(fullPath, fu.FormattedPath()+incompleteFileSuffix))
+                       incompleteFile, err := os.Stat(filepath.Join(fullPath, fu.FormattedPath()+IncompleteFileSuffix))
                        if err != nil && !errors.Is(err, fs.ErrNotExist) {
                                return err
                        }
                        if err != nil && !errors.Is(err, fs.ErrNotExist) {
                                return err
                        }
@@ -579,7 +602,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT
                                offset := make([]byte, 4)
                                binary.BigEndian.PutUint32(offset, uint32(incompleteFile.Size()))
 
                                offset := make([]byte, 4)
                                binary.BigEndian.PutUint32(offset, uint32(incompleteFile.Size()))
 
-                               file, err := os.OpenFile(fullPath+"/"+fu.FormattedPath()+incompleteFileSuffix, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+                               file, err := os.OpenFile(fullPath+"/"+fu.FormattedPath()+IncompleteFileSuffix, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
                                if err != nil {
                                        return err
                                }
                                if err != nil {
                                        return err
                                }
@@ -615,7 +638,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT
 
                                filePath := filepath.Join(fullPath, fu.FormattedPath())
 
 
                                filePath := filepath.Join(fullPath, fu.FormattedPath())
 
-                               hlFile, err := newFileWrapper(fileStore, filePath, 0)
+                               hlFile, err := NewFileWrapper(fileStore, filePath, 0)
                                if err != nil {
                                        return err
                                }
                                if err != nil {
                                        return err
                                }
@@ -630,7 +653,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT
                                rForkWriter := io.Discard
                                iForkWriter := io.Discard
                                if preserveForks {
                                rForkWriter := io.Discard
                                iForkWriter := io.Discard
                                if preserveForks {
-                                       iForkWriter, err = hlFile.infoForkWriter()
+                                       iForkWriter, err = hlFile.InfoForkWriter()
                                        if err != nil {
                                                return err
                                        }
                                        if err != nil {
                                                return err
                                        }
index d12f29acde3e6b9d7dd39aa49e1bd1d7a910740c..b1236b650c7fb3513af13137f98163ede5598639 100644 (file)
@@ -3,6 +3,8 @@ package hotline
 import (
        "encoding/binary"
        "github.com/stretchr/testify/assert"
 import (
        "encoding/binary"
        "github.com/stretchr/testify/assert"
+       "io"
+       "reflect"
        "testing"
 )
 
        "testing"
 )
 
@@ -74,12 +76,12 @@ func TestFileTransfer_String(t *testing.T) {
                        ft := &FileTransfer{
                                FileName:         tt.fields.FileName,
                                FilePath:         tt.fields.FilePath,
                        ft := &FileTransfer{
                                FileName:         tt.fields.FileName,
                                FilePath:         tt.fields.FilePath,
-                               refNum:           tt.fields.refNum,
+                               RefNum:           tt.fields.refNum,
                                Type:             tt.fields.Type,
                                TransferSize:     tt.fields.TransferSize,
                                FolderItemCount:  tt.fields.FolderItemCount,
                                Type:             tt.fields.Type,
                                TransferSize:     tt.fields.TransferSize,
                                FolderItemCount:  tt.fields.FolderItemCount,
-                               fileResumeData:   tt.fields.fileResumeData,
-                               options:          tt.fields.options,
+                               FileResumeData:   tt.fields.fileResumeData,
+                               Options:          tt.fields.options,
                                bytesSentCounter: tt.fields.bytesSentCounter,
                                ClientConn:       tt.fields.ClientConn,
                        }
                                bytesSentCounter: tt.fields.bytesSentCounter,
                                ClientConn:       tt.fields.ClientConn,
                        }
@@ -87,3 +89,90 @@ func TestFileTransfer_String(t *testing.T) {
                })
        }
 }
                })
        }
 }
+
+func TestNewFileHeader(t *testing.T) {
+       type args struct {
+               fileName string
+               isDir    bool
+       }
+       tests := []struct {
+               name string
+               args args
+               want FileHeader
+       }{
+               {
+                       name: "when path is file",
+                       args: args{
+                               fileName: "foo",
+                               isDir:    false,
+                       },
+                       want: FileHeader{
+                               Size:     [2]byte{0x00, 0x0a},
+                               Type:     [2]byte{0x00, 0x00},
+                               FilePath: EncodeFilePath("foo"),
+                       },
+               },
+               {
+                       name: "when path is dir",
+                       args: args{
+                               fileName: "foo",
+                               isDir:    true,
+                       },
+                       want: FileHeader{
+                               Size:     [2]byte{0x00, 0x0a},
+                               Type:     [2]byte{0x00, 0x01},
+                               FilePath: EncodeFilePath("foo"),
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       if got := NewFileHeader(tt.args.fileName, tt.args.isDir); !reflect.DeepEqual(got, tt.want) {
+                               t.Errorf("NewFileHeader() = %v, want %v", got, tt.want)
+                       }
+               })
+       }
+}
+
+func TestFileHeader_Payload(t *testing.T) {
+       type fields struct {
+               Size     [2]byte
+               Type     [2]byte
+               FilePath []byte
+       }
+       tests := []struct {
+               name   string
+               fields fields
+               want   []byte
+       }{
+               {
+                       name: "has expected payload bytes",
+                       fields: fields{
+                               Size:     [2]byte{0x00, 0x0a},
+                               Type:     [2]byte{0x00, 0x00},
+                               FilePath: EncodeFilePath("foo"),
+                       },
+                       want: []byte{
+                               0x00, 0x0a, // total size
+                               0x00, 0x00, // type
+                               0x00, 0x01, // path item count
+                               0x00, 0x00, // path separator
+                               0x03,             // pathName len
+                               0x66, 0x6f, 0x6f, // "foo"
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       fh := &FileHeader{
+                               Size:     tt.fields.Size,
+                               Type:     tt.fields.Type,
+                               FilePath: tt.fields.FilePath,
+                       }
+                       got, _ := io.ReadAll(fh)
+                       if !reflect.DeepEqual(got, tt.want) {
+                               t.Errorf("Read() = %v, want %v", got, tt.want)
+                       }
+               })
+       }
+}
index bc6319b4f7db2db50ba1a102b91a4366c5421b33..94b3f03d3062fe7a42c7bd8878753aa8a00c93c2 100644 (file)
@@ -12,41 +12,41 @@ import (
 )
 
 const (
 )
 
 const (
-       incompleteFileSuffix = ".incomplete"
-       infoForkNameTemplate = ".info_%s" // template string for info fork filenames
-       rsrcForkNameTemplate = ".rsrc_%s" // template string for resource fork filenames
+       IncompleteFileSuffix = ".incomplete"
+       InfoForkNameTemplate = ".info_%s" // template string for info fork filenames
+       RsrcForkNameTemplate = ".rsrc_%s" // template string for resource fork filenames
 )
 
 // fileWrapper encapsulates the data, info, and resource forks of a Hotline file and provides methods to manage the files.
 type fileWrapper struct {
        fs             FileStore
 )
 
 // fileWrapper encapsulates the data, info, and resource forks of a Hotline file and provides methods to manage the files.
 type fileWrapper struct {
        fs             FileStore
-       name           string // name of the file
+       Name           string // Name of the file
        path           string // path to file directory
        dataPath       string // path to the file data fork
        dataOffset     int64
        rsrcPath       string // path to the file resource fork
        infoPath       string // path to the file information fork
        incompletePath string // path to partially transferred temp file
        path           string // path to file directory
        dataPath       string // path to the file data fork
        dataOffset     int64
        rsrcPath       string // path to the file resource fork
        infoPath       string // path to the file information fork
        incompletePath string // path to partially transferred temp file
-       ffo            *flattenedFileObject
+       Ffo            *flattenedFileObject
 }
 
 }
 
-func newFileWrapper(fs FileStore, path string, dataOffset int64) (*fileWrapper, error) {
+func NewFileWrapper(fs FileStore, path string, dataOffset int64) (*fileWrapper, error) {
        dir := filepath.Dir(path)
        fName := filepath.Base(path)
        f := fileWrapper{
                fs:             fs,
        dir := filepath.Dir(path)
        fName := filepath.Base(path)
        f := fileWrapper{
                fs:             fs,
-               name:           fName,
+               Name:           fName,
                path:           dir,
                dataPath:       path,
                dataOffset:     dataOffset,
                path:           dir,
                dataPath:       path,
                dataOffset:     dataOffset,
-               rsrcPath:       filepath.Join(dir, fmt.Sprintf(rsrcForkNameTemplate, fName)),
-               infoPath:       filepath.Join(dir, fmt.Sprintf(infoForkNameTemplate, fName)),
-               incompletePath: filepath.Join(dir, fName+incompleteFileSuffix),
-               ffo:            &flattenedFileObject{},
+               rsrcPath:       filepath.Join(dir, fmt.Sprintf(RsrcForkNameTemplate, fName)),
+               infoPath:       filepath.Join(dir, fmt.Sprintf(InfoForkNameTemplate, fName)),
+               incompletePath: filepath.Join(dir, fName+IncompleteFileSuffix),
+               Ffo:            &flattenedFileObject{},
        }
 
        var err error
        }
 
        var err error
-       f.ffo, err = f.flattenedFileObject()
+       f.Ffo, err = f.flattenedFileObject()
        if err != nil {
                return nil, err
        }
        if err != nil {
                return nil, err
        }
@@ -54,7 +54,7 @@ func newFileWrapper(fs FileStore, path string, dataOffset int64) (*fileWrapper,
        return &f, nil
 }
 
        return &f, nil
 }
 
-func (f *fileWrapper) totalSize() []byte {
+func (f *fileWrapper) TotalSize() []byte {
        var s int64
        size := make([]byte, 4)
 
        var s int64
        size := make([]byte, 4)
 
@@ -85,23 +85,21 @@ func (f *fileWrapper) rsrcForkSize() (s [4]byte) {
 
 func (f *fileWrapper) rsrcForkHeader() FlatFileForkHeader {
        return FlatFileForkHeader{
 
 func (f *fileWrapper) rsrcForkHeader() FlatFileForkHeader {
        return FlatFileForkHeader{
-               ForkType:        [4]byte{0x4D, 0x41, 0x43, 0x52}, // "MACR"
-               CompressionType: [4]byte{},
-               RSVD:            [4]byte{},
-               DataSize:        f.rsrcForkSize(),
+               ForkType: [4]byte{0x4D, 0x41, 0x43, 0x52}, // "MACR"
+               DataSize: f.rsrcForkSize(),
        }
 }
 
 func (f *fileWrapper) incompleteDataName() string {
        }
 }
 
 func (f *fileWrapper) incompleteDataName() string {
-       return f.name + incompleteFileSuffix
+       return f.Name + IncompleteFileSuffix
 }
 
 func (f *fileWrapper) rsrcForkName() string {
 }
 
 func (f *fileWrapper) rsrcForkName() string {
-       return fmt.Sprintf(rsrcForkNameTemplate, f.name)
+       return fmt.Sprintf(RsrcForkNameTemplate, f.Name)
 }
 
 func (f *fileWrapper) infoForkName() string {
 }
 
 func (f *fileWrapper) infoForkName() string {
-       return fmt.Sprintf(infoForkNameTemplate, f.name)
+       return fmt.Sprintf(InfoForkNameTemplate, f.Name)
 }
 
 func (f *fileWrapper) rsrcForkWriter() (io.WriteCloser, error) {
 }
 
 func (f *fileWrapper) rsrcForkWriter() (io.WriteCloser, error) {
@@ -113,7 +111,7 @@ func (f *fileWrapper) rsrcForkWriter() (io.WriteCloser, error) {
        return file, nil
 }
 
        return file, nil
 }
 
-func (f *fileWrapper) infoForkWriter() (io.WriteCloser, error) {
+func (f *fileWrapper) InfoForkWriter() (io.WriteCloser, error) {
        file, err := os.OpenFile(f.infoPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
        if err != nil {
                return nil, err
        file, err := os.OpenFile(f.infoPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
        if err != nil {
                return nil, err
@@ -139,7 +137,7 @@ func (f *fileWrapper) rsrcForkFile() (*os.File, error) {
        return f.fs.Open(f.rsrcPath)
 }
 
        return f.fs.Open(f.rsrcPath)
 }
 
-func (f *fileWrapper) dataFile() (os.FileInfo, error) {
+func (f *fileWrapper) DataFile() (os.FileInfo, error) {
        if fi, err := f.fs.Stat(f.dataPath); err == nil {
                return fi, nil
        }
        if fi, err := f.fs.Stat(f.dataPath); err == nil {
                return fi, nil
        }
@@ -150,14 +148,14 @@ func (f *fileWrapper) dataFile() (os.FileInfo, error) {
        return nil, errors.New("file or directory not found")
 }
 
        return nil, errors.New("file or directory not found")
 }
 
-// move a fileWrapper and its associated meta files to newPath.
+// Move a file and its associated meta files to newPath.
 // Meta files include:
 // * Partially uploaded file ending with .incomplete
 // * Resource fork starting with .rsrc_
 // * Info fork starting with .info
 // Meta files include:
 // * Partially uploaded file ending with .incomplete
 // * Resource fork starting with .rsrc_
 // * Info fork starting with .info
-// During move of the meta files, os.ErrNotExist is ignored as these files may legitimately not exist.
-func (f *fileWrapper) move(newPath string) error {
-       err := f.fs.Rename(f.dataPath, filepath.Join(newPath, f.name))
+// During Move of the meta files, os.ErrNotExist is ignored as these files may legitimately not exist.
+func (f *fileWrapper) Move(newPath string) error {
+       err := f.fs.Rename(f.dataPath, filepath.Join(newPath, f.Name))
        if err != nil {
                return err
        }
        if err != nil {
                return err
        }
@@ -180,8 +178,8 @@ func (f *fileWrapper) move(newPath string) error {
        return nil
 }
 
        return nil
 }
 
-// delete a fileWrapper and its associated metadata files if they exist
-func (f *fileWrapper) delete() error {
+// Delete a fileWrapper and its associated metadata files if they exist
+func (f *fileWrapper) Delete() error {
        err := f.fs.RemoveAll(f.dataPath)
        if err != nil {
                return err
        err := f.fs.RemoveAll(f.dataPath)
        if err != nil {
                return err
@@ -218,20 +216,19 @@ func (f *fileWrapper) flattenedFileObject() (*flattenedFileObject, error) {
        if errors.Is(err, fs.ErrNotExist) {
                fileInfo, err = f.fs.Stat(f.incompletePath)
                if err == nil {
        if errors.Is(err, fs.ErrNotExist) {
                fileInfo, err = f.fs.Stat(f.incompletePath)
                if err == nil {
-                       mTime = toHotlineTime(fileInfo.ModTime())
+                       mTime = NewTime(fileInfo.ModTime())
                        binary.BigEndian.PutUint32(dataSize, uint32(fileInfo.Size()-f.dataOffset))
                        ft, _ = fileTypeFromInfo(fileInfo)
                }
        } else {
                        binary.BigEndian.PutUint32(dataSize, uint32(fileInfo.Size()-f.dataOffset))
                        ft, _ = fileTypeFromInfo(fileInfo)
                }
        } else {
-               mTime = toHotlineTime(fileInfo.ModTime())
+               mTime = NewTime(fileInfo.ModTime())
                binary.BigEndian.PutUint32(dataSize, uint32(fileInfo.Size()-f.dataOffset))
                ft, _ = fileTypeFromInfo(fileInfo)
        }
 
                binary.BigEndian.PutUint32(dataSize, uint32(fileInfo.Size()-f.dataOffset))
                ft, _ = fileTypeFromInfo(fileInfo)
        }
 
-       f.ffo.FlatFileHeader = FlatFileHeader{
+       f.Ffo.FlatFileHeader = FlatFileHeader{
                Format:    [4]byte{0x46, 0x49, 0x4c, 0x50}, // "FILP"
                Version:   [2]byte{0, 1},
                Format:    [4]byte{0x46, 0x49, 0x4c, 0x50}, // "FILP"
                Version:   [2]byte{0, 1},
-               RSVD:      [16]byte{},
                ForkCount: [2]byte{0, 2},
        }
 
                ForkCount: [2]byte{0, 2},
        }
 
@@ -242,43 +239,39 @@ func (f *fileWrapper) flattenedFileObject() (*flattenedFileObject, error) {
                        return nil, err
                }
 
                        return nil, err
                }
 
-               f.ffo.FlatFileHeader.ForkCount[1] = 3
+               f.Ffo.FlatFileHeader.ForkCount[1] = 3
 
 
-               _, err = io.Copy(&f.ffo.FlatFileInformationFork, bytes.NewReader(b))
+               _, err = io.Copy(&f.Ffo.FlatFileInformationFork, bytes.NewReader(b))
                if err != nil {
                        return nil, fmt.Errorf("error copying FlatFileInformationFork: %w", err)
                }
        } else {
                if err != nil {
                        return nil, fmt.Errorf("error copying FlatFileInformationFork: %w", err)
                }
        } else {
-               f.ffo.FlatFileInformationFork = FlatFileInformationFork{
+               f.Ffo.FlatFileInformationFork = FlatFileInformationFork{
                        Platform:         [4]byte{0x41, 0x4D, 0x41, 0x43}, // "AMAC" TODO: Remove hardcode to support "AWIN" Platform (maybe?)
                        TypeSignature:    [4]byte([]byte(ft.TypeCode)),
                        CreatorSignature: [4]byte([]byte(ft.CreatorCode)),
                        PlatformFlags:    [4]byte{0, 0, 1, 0}, // TODO: What is this?
                        CreateDate:       mTime,               // some filesystems don't support createTime
                        ModifyDate:       mTime,
                        Platform:         [4]byte{0x41, 0x4D, 0x41, 0x43}, // "AMAC" TODO: Remove hardcode to support "AWIN" Platform (maybe?)
                        TypeSignature:    [4]byte([]byte(ft.TypeCode)),
                        CreatorSignature: [4]byte([]byte(ft.CreatorCode)),
                        PlatformFlags:    [4]byte{0, 0, 1, 0}, // TODO: What is this?
                        CreateDate:       mTime,               // some filesystems don't support createTime
                        ModifyDate:       mTime,
-                       Name:             []byte(f.name),
+                       Name:             []byte(f.Name),
                        Comment:          []byte{},
                }
 
                ns := make([]byte, 2)
                        Comment:          []byte{},
                }
 
                ns := make([]byte, 2)
-               binary.BigEndian.PutUint16(ns, uint16(len(f.name)))
-               f.ffo.FlatFileInformationFork.NameSize = [2]byte(ns[:])
+               binary.BigEndian.PutUint16(ns, uint16(len(f.Name)))
+               f.Ffo.FlatFileInformationFork.NameSize = [2]byte(ns[:])
        }
 
        }
 
-       f.ffo.FlatFileInformationForkHeader = FlatFileForkHeader{
-               ForkType:        [4]byte{0x49, 0x4E, 0x46, 0x4F}, // "INFO"
-               CompressionType: [4]byte{},
-               RSVD:            [4]byte{},
-               DataSize:        f.ffo.FlatFileInformationFork.Size(),
+       f.Ffo.FlatFileInformationForkHeader = FlatFileForkHeader{
+               ForkType: [4]byte{0x49, 0x4E, 0x46, 0x4F}, // "INFO"
+               DataSize: f.Ffo.FlatFileInformationFork.Size(),
        }
 
        }
 
-       f.ffo.FlatFileDataForkHeader = FlatFileForkHeader{
-               ForkType:        [4]byte{0x44, 0x41, 0x54, 0x41}, // "DATA"
-               CompressionType: [4]byte{},
-               RSVD:            [4]byte{},
-               DataSize:        [4]byte{dataSize[0], dataSize[1], dataSize[2], dataSize[3]},
+       f.Ffo.FlatFileDataForkHeader = FlatFileForkHeader{
+               ForkType: [4]byte{0x44, 0x41, 0x54, 0x41}, // "DATA"
+               DataSize: [4]byte{dataSize[0], dataSize[1], dataSize[2], dataSize[3]},
        }
        }
-       f.ffo.FlatFileResForkHeader = f.rsrcForkHeader()
+       f.Ffo.FlatFileResForkHeader = f.rsrcForkHeader()
 
 
-       return f.ffo, nil
+       return f.Ffo, nil
 }
 }
index bc2f8d523a0be5fb0e161d0a524b81cb3241019c..a1db329b6ebfad16c7a88df914ea006298263835 100644 (file)
@@ -34,7 +34,7 @@ func fileTypeFromInfo(info fs.FileInfo) (ft fileType, err error) {
 
 const maxFileSize = 4294967296
 
 
 const maxFileSize = 4294967296
 
-func getFileNameList(path string, ignoreList []string) (fields []Field, err error) {
+func GetFileNameList(path string, ignoreList []string) (fields []Field, err error) {
        files, err := os.ReadDir(path)
        if err != nil {
                return fields, fmt.Errorf("error reading path: %s: %w", path, err)
        files, err := os.ReadDir(path)
        if err != nil {
                return fields, fmt.Errorf("error reading path: %s: %w", path, err)
@@ -112,14 +112,14 @@ func getFileNameList(path string, ignoreList []string) (fields []Field, err erro
                                continue
                        }
 
                                continue
                        }
 
-                       hlFile, err := newFileWrapper(&OSFileStore{}, path+"/"+file.Name(), 0)
+                       hlFile, err := NewFileWrapper(&OSFileStore{}, path+"/"+file.Name(), 0)
                        if err != nil {
                        if err != nil {
-                               return nil, fmt.Errorf("newFileWrapper: %w", err)
+                               return nil, fmt.Errorf("NewFileWrapper: %w", err)
                        }
 
                        }
 
-                       copy(fnwi.FileSize[:], hlFile.totalSize())
-                       copy(fnwi.Type[:], hlFile.ffo.FlatFileInformationFork.TypeSignature[:])
-                       copy(fnwi.Creator[:], hlFile.ffo.FlatFileInformationFork.CreatorSignature[:])
+                       copy(fnwi.FileSize[:], hlFile.TotalSize())
+                       copy(fnwi.Type[:], hlFile.Ffo.FlatFileInformationFork.TypeSignature[:])
+                       copy(fnwi.Creator[:], hlFile.Ffo.FlatFileInformationFork.CreatorSignature[:])
                }
 
                strippedName := strings.ReplaceAll(file.Name(), ".incomplete", "")
                }
 
                strippedName := strings.ReplaceAll(file.Name(), ".incomplete", "")
index fbc319bcd9a0cdcc76c0711f249179abcf5005a2..99ce204569551931a296db469da803799cf75be3 100644 (file)
@@ -56,21 +56,21 @@ func NewFlatFileInformationFork(fileName string, modifyTime [8]byte, typeSignatu
        }
 }
 
        }
 }
 
-func (ffif *FlatFileInformationFork) friendlyType() []byte {
+func (ffif *FlatFileInformationFork) FriendlyType() []byte {
        if name, ok := friendlyCreatorNames[string(ffif.TypeSignature[:])]; ok {
                return []byte(name)
        }
        return ffif.TypeSignature[:]
 }
 
        if name, ok := friendlyCreatorNames[string(ffif.TypeSignature[:])]; ok {
                return []byte(name)
        }
        return ffif.TypeSignature[:]
 }
 
-func (ffif *FlatFileInformationFork) friendlyCreator() []byte {
+func (ffif *FlatFileInformationFork) FriendlyCreator() []byte {
        if name, ok := friendlyCreatorNames[string(ffif.CreatorSignature[:])]; ok {
                return []byte(name)
        }
        return ffif.CreatorSignature[:]
 }
 
        if name, ok := friendlyCreatorNames[string(ffif.CreatorSignature[:])]; ok {
                return []byte(name)
        }
        return ffif.CreatorSignature[:]
 }
 
-func (ffif *FlatFileInformationFork) setComment(comment []byte) error {
+func (ffif *FlatFileInformationFork) SetComment(comment []byte) error {
        commentSize := make([]byte, 2)
        ffif.Comment = comment
        binary.BigEndian.PutUint16(commentSize, uint16(len(comment)))
        commentSize := make([]byte, 2)
        ffif.Comment = comment
        binary.BigEndian.PutUint16(commentSize, uint16(len(comment)))
index 55b074d8ba5a61faca6ecd2ceb1f9c10099c4270..b8e16c04fb61f58166252a11290dce76ecbf13e1 100644 (file)
@@ -72,15 +72,14 @@ func performHandshake(rw io.ReadWriter) error {
 
        // Copy exactly handshakeSize bytes from rw to handshake
        if _, err := io.CopyN(&h, rw, handshakeSize); err != nil {
 
        // Copy exactly handshakeSize bytes from rw to handshake
        if _, err := io.CopyN(&h, rw, handshakeSize); err != nil {
-               return fmt.Errorf("failed to read handshake data: %w", err)
+               return fmt.Errorf("read handshake: %w", err)
        }
        }
-
        if !h.Valid() {
                return errors.New("invalid protocol or sub-protocol in handshake")
        }
 
        if _, err := rw.Write(handshakeResponse[:]); err != nil {
        if !h.Valid() {
                return errors.New("invalid protocol or sub-protocol in handshake")
        }
 
        if _, err := rw.Write(handshakeResponse[:]); err != nil {
-               return fmt.Errorf("error sending handshake response: %w", err)
+               return fmt.Errorf("send handshake response: %w", err)
        }
 
        return nil
        }
 
        return nil
index 73ebfe411d16313e717d07dc4dc709679e7f1953..508fb0f88eeddcb77c56badb0ac0f5b36cdd0ac1 100644 (file)
@@ -166,7 +166,7 @@ func TestPerformHandshake(t *testing.T) {
                                0x54, 0x52, 0x54, 0x50, // TRTP
                        },
                        expectedOutput: nil,
                                0x54, 0x52, 0x54, 0x50, // TRTP
                        },
                        expectedOutput: nil,
-                       expectedError:  "failed to read handshake data: invalid handshake size",
+                       expectedError:  "read handshake: invalid handshake size",
                },
                {
                        name: "Invalid Protocol",
                },
                {
                        name: "Invalid Protocol",
index e5022a4860adb2ddc13ed4e07405e1a245d10cf3..1125a10706b967ceca23d16c22d9f87b537e9889 100644 (file)
@@ -1,8 +1,8 @@
 package hotline
 
 package hotline
 
-const defaultNewsDateFormat = "Jan02 15:04" // Jun23 20:49
+const NewsDateFormat = "Jan02 15:04" // Jun23 20:49
 
 
-const defaultNewsTemplate = `From %s (%s):
+const NewsTemplate = `From %s (%s):
 
 %s
 
 
 %s
 
index 0d0400c6e7bc7b15ff2946e0429c18874435d81d..a490a89c39529c2e82e0bacc1da34463f52d1faa 100644 (file)
@@ -3,6 +3,7 @@ package hotline
 import (
        "cmp"
        "encoding/binary"
 import (
        "cmp"
        "encoding/binary"
+       "github.com/stretchr/testify/mock"
        "io"
        "slices"
 )
        "io"
        "slices"
 )
@@ -236,3 +237,53 @@ func newsPathScanner(data []byte, _ bool) (advance int, token []byte, err error)
        advance = 3 + int(data[2])
        return advance, data[3:advance], nil
 }
        advance = 3 + int(data[2])
        return advance, data[3:advance], nil
 }
+
+type MockThreadNewsMgr struct {
+       mock.Mock
+}
+
+func (m *MockThreadNewsMgr) ListArticles(newsPath []string) NewsArtListData {
+       args := m.Called(newsPath)
+
+       return args.Get(0).(NewsArtListData)
+}
+
+func (m *MockThreadNewsMgr) GetArticle(newsPath []string, articleID uint32) *NewsArtData {
+       args := m.Called(newsPath, articleID)
+
+       return args.Get(0).(*NewsArtData)
+}
+func (m *MockThreadNewsMgr) DeleteArticle(newsPath []string, articleID uint32, recursive bool) error {
+       args := m.Called(newsPath, articleID, recursive)
+
+       return args.Error(0)
+}
+
+func (m *MockThreadNewsMgr) PostArticle(newsPath []string, parentArticleID uint32, article NewsArtData) error {
+       args := m.Called(newsPath, parentArticleID, article)
+
+       return args.Error(0)
+}
+func (m *MockThreadNewsMgr) CreateGrouping(newsPath []string, name string, itemType [2]byte) error {
+       args := m.Called(newsPath, name, itemType)
+
+       return args.Error(0)
+}
+
+func (m *MockThreadNewsMgr) GetCategories(paths []string) []NewsCategoryListData15 {
+       args := m.Called(paths)
+
+       return args.Get(0).([]NewsCategoryListData15)
+}
+
+func (m *MockThreadNewsMgr) NewsItem(newsPath []string) NewsCategoryListData15 {
+       args := m.Called(newsPath)
+
+       return args.Get(0).(NewsCategoryListData15)
+}
+
+func (m *MockThreadNewsMgr) DeleteNewsItem(newsPath []string) error {
+       args := m.Called(newsPath)
+
+       return args.Error(0)
+}
index 588c9796ce1dac31496e2377cba16320a7349f11..d1b043edc333762066a694d62c61846757ac04e0 100644 (file)
@@ -2,61 +2,10 @@ package hotline
 
 import (
        "github.com/stretchr/testify/assert"
 
 import (
        "github.com/stretchr/testify/assert"
-       "github.com/stretchr/testify/mock"
        "io"
        "testing"
 )
 
        "io"
        "testing"
 )
 
-type mockThreadNewsMgr struct {
-       mock.Mock
-}
-
-func (m *mockThreadNewsMgr) ListArticles(newsPath []string) NewsArtListData {
-       args := m.Called(newsPath)
-
-       return args.Get(0).(NewsArtListData)
-}
-
-func (m *mockThreadNewsMgr) GetArticle(newsPath []string, articleID uint32) *NewsArtData {
-       args := m.Called(newsPath, articleID)
-
-       return args.Get(0).(*NewsArtData)
-}
-func (m *mockThreadNewsMgr) DeleteArticle(newsPath []string, articleID uint32, recursive bool) error {
-       args := m.Called(newsPath, articleID, recursive)
-
-       return args.Error(0)
-}
-
-func (m *mockThreadNewsMgr) PostArticle(newsPath []string, parentArticleID uint32, article NewsArtData) error {
-       args := m.Called(newsPath, parentArticleID, article)
-
-       return args.Error(0)
-}
-func (m *mockThreadNewsMgr) CreateGrouping(newsPath []string, name string, itemType [2]byte) error {
-       args := m.Called(newsPath, name, itemType)
-
-       return args.Error(0)
-}
-
-func (m *mockThreadNewsMgr) GetCategories(paths []string) []NewsCategoryListData15 {
-       args := m.Called(paths)
-
-       return args.Get(0).([]NewsCategoryListData15)
-}
-
-func (m *mockThreadNewsMgr) NewsItem(newsPath []string) NewsCategoryListData15 {
-       args := m.Called(newsPath)
-
-       return args.Get(0).(NewsCategoryListData15)
-}
-
-func (m *mockThreadNewsMgr) DeleteNewsItem(newsPath []string) error {
-       args := m.Called(newsPath)
-
-       return args.Error(0)
-}
-
 func TestNewsCategoryListData15_MarshalBinary(t *testing.T) {
        type fields struct {
                Type     [2]byte
 func TestNewsCategoryListData15_MarshalBinary(t *testing.T) {
        type fields struct {
                Type     [2]byte
index 62322539eed67e01f85f9d69b07f0defc8577756..58a9209f1574d90387cc4389f21ea569be14e9c9 100644 (file)
@@ -9,13 +9,10 @@ import (
        "errors"
        "fmt"
        "golang.org/x/text/encoding/charmap"
        "errors"
        "fmt"
        "golang.org/x/text/encoding/charmap"
-       "gopkg.in/yaml.v3"
        "io"
        "log"
        "log/slog"
        "net"
        "io"
        "log"
        "log/slog"
        "net"
-       "os"
-       "path/filepath"
        "strings"
        "sync"
        "time"
        "strings"
        "sync"
        "time"
@@ -39,9 +36,10 @@ type Server struct {
        NetInterface string
        Port         int
 
        NetInterface string
        Port         int
 
-       Config    Config
-       ConfigDir string
-       Logger    *slog.Logger
+       handlers map[TranType]HandlerFunc
+
+       Config Config
+       Logger *slog.Logger
 
        TrackerPassID [4]byte
 
 
        TrackerPassID [4]byte
 
@@ -51,10 +49,8 @@ type Server struct {
 
        outbox chan Transaction
 
 
        outbox chan Transaction
 
-       // TODO
-       Agreement []byte
-       banner    []byte
-       // END TODO
+       Agreement io.ReadSeeker
+       Banner    []byte
 
        FileTransferMgr FileTransferMgr
        ChatMgr         ChatManager
 
        FileTransferMgr FileTransferMgr
        ChatMgr         ChatManager
@@ -66,60 +62,57 @@ type Server struct {
        MessageBoard io.ReadWriteSeeker
 }
 
        MessageBoard io.ReadWriteSeeker
 }
 
-// NewServer constructs a new Server from a config dir
-func NewServer(config Config, configDir, netInterface string, netPort int, logger *slog.Logger, fs FileStore) (*Server, error) {
-       server := Server{
-               NetInterface:    netInterface,
-               Port:            netPort,
-               Config:          config,
-               ConfigDir:       configDir,
-               Logger:          logger,
-               outbox:          make(chan Transaction),
-               Stats:           NewStats(),
-               FS:              fs,
-               ChatMgr:         NewMemChatManager(),
-               ClientMgr:       NewMemClientMgr(),
-               FileTransferMgr: NewMemFileTransferMgr(),
-       }
+type Option = func(s *Server)
 
 
-       // generate a new random passID for tracker registration
-       _, err := rand.Read(server.TrackerPassID[:])
-       if err != nil {
-               return nil, err
+func WithConfig(config Config) func(s *Server) {
+       return func(s *Server) {
+               s.Config = config
        }
        }
+}
 
 
-       server.Agreement, err = os.ReadFile(filepath.Join(configDir, agreementFile))
-       if err != nil {
-               return nil, err
+func WithLogger(logger *slog.Logger) func(s *Server) {
+       return func(s *Server) {
+               s.Logger = logger
        }
        }
+}
 
 
-       server.AccountManager, err = NewYAMLAccountManager(filepath.Join(configDir, "Users/"))
-       if err != nil {
-               return nil, fmt.Errorf("error loading accounts: %w", err)
+// WithPort optionally overrides the default TCP port.
+func WithPort(port int) func(s *Server) {
+       return func(s *Server) {
+               s.Port = port
        }
        }
+}
 
 
-       // If the FileRoot is an absolute path, use it, otherwise treat as a relative path to the config dir.
-       if !filepath.IsAbs(server.Config.FileRoot) {
-               server.Config.FileRoot = filepath.Join(configDir, server.Config.FileRoot)
+// WithInterface optionally sets a specific interface to listen on.
+func WithInterface(netInterface string) func(s *Server) {
+       return func(s *Server) {
+               s.NetInterface = netInterface
        }
        }
+}
 
 
-       server.banner, err = os.ReadFile(filepath.Join(server.ConfigDir, server.Config.BannerFile))
-       if err != nil {
-               return nil, fmt.Errorf("error opening banner: %w", err)
-       }
+type ServerConfig struct {
+}
 
 
-       if server.Config.EnableTrackerRegistration {
-               server.Logger.Info(
-                       "Tracker registration enabled",
-                       "frequency", fmt.Sprintf("%vs", trackerUpdateFrequency),
-                       "trackers", server.Config.Trackers,
-               )
+func NewServer(options ...Option) (*Server, error) {
+       server := Server{
+               handlers:        make(map[TranType]HandlerFunc),
+               outbox:          make(chan Transaction),
+               FS:              &OSFileStore{},
+               ChatMgr:         NewMemChatManager(),
+               ClientMgr:       NewMemClientMgr(),
+               FileTransferMgr: NewMemFileTransferMgr(),
+               Stats:           NewStats(),
+       }
 
 
-               go server.registerWithTrackers()
+       for _, opt := range options {
+               opt(&server)
        }
 
        }
 
-       // Start Client Keepalive go routine
-       go server.keepaliveHandler()
+       // generate a new random passID for tracker registration
+       _, err := rand.Read(server.TrackerPassID[:])
+       if err != nil {
+               return nil, err
+       }
 
        return &server, nil
 }
 
        return &server, nil
 }
@@ -129,6 +122,10 @@ func (s *Server) CurrentStats() map[string]interface{} {
 }
 
 func (s *Server) ListenAndServe(ctx context.Context) error {
 }
 
 func (s *Server) ListenAndServe(ctx context.Context) error {
+       go s.registerWithTrackers(ctx)
+       go s.keepaliveHandler(ctx)
+       go s.processOutbox()
+
        var wg sync.WaitGroup
 
        wg.Add(1)
        var wg sync.WaitGroup
 
        wg.Add(1)
@@ -179,7 +176,7 @@ func (s *Server) ServeFileTransfers(ctx context.Context, ln net.Listener) error
 }
 
 func (s *Server) sendTransaction(t Transaction) error {
 }
 
 func (s *Server) sendTransaction(t Transaction) error {
-       client := s.ClientMgr.Get(t.clientID)
+       client := s.ClientMgr.Get(t.ClientID)
 
        if client == nil {
                return nil
 
        if client == nil {
                return nil
@@ -187,7 +184,7 @@ func (s *Server) sendTransaction(t Transaction) error {
 
        _, err := io.Copy(client.Connection, &t)
        if err != nil {
 
        _, err := io.Copy(client.Connection, &t)
        if err != nil {
-               return fmt.Errorf("failed to send transaction to client %v: %v", t.clientID, err)
+               return fmt.Errorf("failed to send transaction to client %v: %v", t.ClientID, err)
        }
 
        return nil
        }
 
        return nil
@@ -205,78 +202,107 @@ func (s *Server) processOutbox() {
 }
 
 func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
 }
 
 func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
-       go s.processOutbox()
-
        for {
        for {
-               conn, err := ln.Accept()
-               if err != nil {
-                       s.Logger.Error("error accepting connection", "err", err)
-               }
-               connCtx := context.WithValue(ctx, contextKeyReq, requestCtx{
-                       remoteAddr: conn.RemoteAddr().String(),
-               })
+               select {
+               case <-ctx.Done():
+                       s.Logger.Info("Server shutting down")
+                       return ctx.Err()
+               default:
+                       conn, err := ln.Accept()
+                       if err != nil {
+                               s.Logger.Error("Error accepting connection", "err", err)
+                               continue
+                       }
 
 
-               go func() {
-                       s.Logger.Info("Connection established", "RemoteAddr", conn.RemoteAddr())
-
-                       defer conn.Close()
-                       if err := s.handleNewConnection(connCtx, conn, conn.RemoteAddr().String()); err != nil {
-                               if err == io.EOF {
-                                       s.Logger.Info("Client disconnected", "RemoteAddr", conn.RemoteAddr())
-                               } else {
-                                       s.Logger.Error("error serving request", "RemoteAddr", conn.RemoteAddr(), "err", err)
+                       go func() {
+                               connCtx := context.WithValue(ctx, contextKeyReq, requestCtx{
+                                       remoteAddr: conn.RemoteAddr().String(),
+                               })
+
+                               s.Logger.Info("Connection established", "addr", conn.RemoteAddr())
+                               defer conn.Close()
+
+                               if err := s.handleNewConnection(connCtx, conn, conn.RemoteAddr().String()); err != nil {
+                                       if err == io.EOF {
+                                               s.Logger.Info("Client disconnected", "RemoteAddr", conn.RemoteAddr())
+                                       } else {
+                                               s.Logger.Error("Error serving request", "RemoteAddr", conn.RemoteAddr(), "err", err)
+                                       }
                                }
                                }
-                       }
-               }()
+                       }()
+               }
        }
 }
 
        }
 }
 
-const (
-       agreementFile = "Agreement.txt"
-)
+// time in seconds between tracker re-registration
+const trackerUpdateFrequency = 300
+
+// registerWithTrackers runs every trackerUpdateFrequency seconds to update the server's tracker entry on all configured
+// trackers.
+func (s *Server) registerWithTrackers(ctx context.Context) {
+       ticker := time.NewTicker(trackerUpdateFrequency * time.Second)
+       defer ticker.Stop()
 
 
-func (s *Server) registerWithTrackers() {
        for {
        for {
-               tr := &TrackerRegistration{
-                       UserCount:   len(s.ClientMgr.List()),
-                       PassID:      s.TrackerPassID,
-                       Name:        s.Config.Name,
-                       Description: s.Config.Description,
-               }
-               binary.BigEndian.PutUint16(tr.Port[:], uint16(s.Port))
-               for _, t := range s.Config.Trackers {
-                       if err := register(&RealDialer{}, t, tr); err != nil {
-                               s.Logger.Error(fmt.Sprintf("unable to register with tracker %v", t), "error", err)
+               select {
+               case <-ctx.Done():
+                       return
+               case <-ticker.C:
+                       if s.Config.EnableTrackerRegistration {
+                               tr := &TrackerRegistration{
+                                       UserCount:   len(s.ClientMgr.List()),
+                                       PassID:      s.TrackerPassID,
+                                       Name:        s.Config.Name,
+                                       Description: s.Config.Description,
+                               }
+                               binary.BigEndian.PutUint16(tr.Port[:], uint16(s.Port))
+
+                               for _, t := range s.Config.Trackers {
+                                       if err := register(&RealDialer{}, t, tr); err != nil {
+                                               s.Logger.Error(fmt.Sprintf("Unable to register with tracker %v", t), "error", err)
+                                       }
+                               }
                        }
                }
                        }
                }
-
-               time.Sleep(trackerUpdateFrequency * time.Second)
        }
        }
-}
 
 
-// keepaliveHandler
-func (s *Server) keepaliveHandler() {
-       for {
-               time.Sleep(idleCheckInterval * time.Second)
-
-               for _, c := range s.ClientMgr.List() {
-                       c.mu.Lock()
+}
 
 
-                       c.IdleTime += idleCheckInterval
+const (
+       userIdleSeconds   = 300 // time in seconds before an inactive user is marked idle
+       idleCheckInterval = 10  // time in seconds to check for idle users
+)
 
 
-                       // Check if the user
-                       if c.IdleTime > userIdleSeconds && !c.Flags.IsSet(UserFlagAway) {
-                               c.Flags.Set(UserFlagAway, 1)
+// keepaliveHandler runs every idleCheckInterval seconds and increments a user's idle time by idleCheckInterval seconds.
+// If the updated idle time exceeds userIdleSeconds and the user was not previously idle, we notify all connected clients
+// that the user has gone idle.  For most clients, this turns the user grey in the user list.
+func (s *Server) keepaliveHandler(ctx context.Context) {
+       ticker := time.NewTicker(idleCheckInterval * time.Second)
+       defer ticker.Stop()
 
 
-                               c.SendAll(
-                                       TranNotifyChangeUser,
-                                       NewField(FieldUserID, c.ID[:]),
-                                       NewField(FieldUserFlags, c.Flags[:]),
-                                       NewField(FieldUserName, c.UserName),
-                                       NewField(FieldUserIconID, c.Icon),
-                               )
+       for {
+               select {
+               case <-ctx.Done():
+                       return
+               case <-ticker.C:
+                       for _, c := range s.ClientMgr.List() {
+                               c.mu.Lock()
+                               c.IdleTime += idleCheckInterval
+
+                               // Check if the user
+                               if c.IdleTime > userIdleSeconds && !c.Flags.IsSet(UserFlagAway) {
+                                       c.Flags.Set(UserFlagAway, 1)
+
+                                       c.SendAll(
+                                               TranNotifyChangeUser,
+                                               NewField(FieldUserID, c.ID[:]),
+                                               NewField(FieldUserFlags, c.Flags[:]),
+                                               NewField(FieldUserName, c.UserName),
+                                               NewField(FieldUserIconID, c.Icon),
+                                       )
+                               }
+                               c.mu.Unlock()
                        }
                        }
-                       c.mu.Unlock()
                }
        }
 }
                }
        }
 }
@@ -296,18 +322,6 @@ func (s *Server) NewClientConn(conn io.ReadWriteCloser, remoteAddr string) *Clie
        return clientConn
 }
 
        return clientConn
 }
 
-// loadFromYAMLFile loads data from a YAML file into the provided data structure.
-func loadFromYAMLFile(path string, data interface{}) error {
-       fh, err := os.Open(path)
-       if err != nil {
-               return err
-       }
-       defer fh.Close()
-
-       decoder := yaml.NewDecoder(fh)
-       return decoder.Decode(data)
-}
-
 func sendBanMessage(rwc io.Writer, message string) {
        t := NewTransaction(
                TranServerMsg,
 func sendBanMessage(rwc io.Writer, message string) {
        t := NewTransaction(
                TranServerMsg,
@@ -323,6 +337,10 @@ func sendBanMessage(rwc io.Writer, message string) {
 func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser, remoteAddr string) error {
        defer dontPanic(s.Logger)
 
 func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser, remoteAddr string) error {
        defer dontPanic(s.Logger)
 
+       if err := performHandshake(rwc); err != nil {
+               return fmt.Errorf("perform handshake: %w", err)
+       }
+
        // Check if remoteAddr is present in the ban list
        ipAddr := strings.Split(remoteAddr, ":")[0]
        if isBanned, banUntil := s.BanList.IsBanned(ipAddr); isBanned {
        // Check if remoteAddr is present in the ban list
        ipAddr := strings.Split(remoteAddr, ":")[0]
        if isBanned, banUntil := s.BanList.IsBanned(ipAddr); isBanned {
@@ -341,10 +359,6 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser
                }
        }
 
                }
        }
 
-       if err := performHandshake(rwc); err != nil {
-               return fmt.Errorf("error performing handshake: %w", err)
-       }
-
        // Create a new scanner for parsing incoming bytes into transaction tokens
        scanner := bufio.NewScanner(rwc)
        scanner.Split(transactionScanner)
        // Create a new scanner for parsing incoming bytes into transaction tokens
        scanner := bufio.NewScanner(rwc)
        scanner.Split(transactionScanner)
@@ -372,7 +386,7 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser
                login = GuestAccount
        }
 
                login = GuestAccount
        }
 
-       c.logger = s.Logger.With("ip", ipAddr, "login", login)
+       c.Logger = s.Logger.With("ip", ipAddr, "login", login)
 
        // If authentication fails, send error reply and close connection
        if !c.Authenticate(login, encodedPassword) {
 
        // If authentication fails, send error reply and close connection
        if !c.Authenticate(login, encodedPassword) {
@@ -383,7 +397,7 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser
                        return err
                }
 
                        return err
                }
 
-               c.logger.Info("Login failed", "clientVersion", fmt.Sprintf("%x", c.Version))
+               c.Logger.Info("Login failed", "clientVersion", fmt.Sprintf("%x", c.Version))
 
                return nil
        }
 
                return nil
        }
@@ -427,7 +441,10 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser
                        c.Server.outbox <- NewTransaction(TranShowAgreement, c.ID, NewField(FieldNoServerAgreement, []byte{1}))
                }
        } else {
                        c.Server.outbox <- NewTransaction(TranShowAgreement, c.ID, NewField(FieldNoServerAgreement, []byte{1}))
                }
        } else {
-               c.Server.outbox <- NewTransaction(TranShowAgreement, c.ID, NewField(FieldData, s.Agreement))
+               _, _ = c.Server.Agreement.Seek(0, 0)
+               data, _ := io.ReadAll(c.Server.Agreement)
+
+               c.Server.outbox <- NewTransaction(TranShowAgreement, c.ID, NewField(FieldData, data))
        }
 
        // If the client has provided a username as part of the login, we can infer that it is using the 1.2.3 login
        }
 
        // If the client has provided a username as part of the login, we can infer that it is using the 1.2.3 login
@@ -435,8 +452,8 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser
        if len(c.UserName) != 0 {
                // Add the client username to the logger.  For 1.5+ clients, we don't have this information yet as it comes as
                // part of TranAgreed
        if len(c.UserName) != 0 {
                // Add the client username to the logger.  For 1.5+ clients, we don't have this information yet as it comes as
                // part of TranAgreed
-               c.logger = c.logger.With("Name", string(c.UserName))
-               c.logger.Info("Login successful", "clientVersion", "Not sent (probably 1.2.3)")
+               c.Logger = c.Logger.With("name", string(c.UserName))
+               c.Logger.Info("Login successful")
 
                // Notify other clients on the server that the new user has logged in.  For 1.5+ clients we don't have this
                // information yet, so we do it in TranAgreed instead
 
                // Notify other clients on the server that the new user has logged in.  For 1.5+ clients we don't have this
                // information yet, so we do it in TranAgreed instead
@@ -463,11 +480,11 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser
        // Scan for new transactions and handle them as they come in.
        for scanner.Scan() {
                // Copy the scanner bytes to a new slice to it to avoid a data race when the scanner re-uses the buffer.
        // Scan for new transactions and handle them as they come in.
        for scanner.Scan() {
                // Copy the scanner bytes to a new slice to it to avoid a data race when the scanner re-uses the buffer.
-               buf := make([]byte, len(scanner.Bytes()))
-               copy(buf, scanner.Bytes())
+               tmpBuf := make([]byte, len(scanner.Bytes()))
+               copy(tmpBuf, scanner.Bytes())
 
                var t Transaction
 
                var t Transaction
-               if _, err := t.Write(buf); err != nil {
+               if _, err := t.Write(tmpBuf); err != nil {
                        return err
                }
 
                        return err
                }
 
@@ -506,15 +523,15 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro
                "Name", string(fileTransfer.ClientConn.UserName),
        )
 
                "Name", string(fileTransfer.ClientConn.UserName),
        )
 
-       fullPath, err := readPath(s.Config.FileRoot, fileTransfer.FilePath, fileTransfer.FileName)
+       fullPath, err := ReadPath(s.Config.FileRoot, fileTransfer.FilePath, fileTransfer.FileName)
        if err != nil {
                return err
        }
 
        switch fileTransfer.Type {
        case BannerDownload:
        if err != nil {
                return err
        }
 
        switch fileTransfer.Type {
        case BannerDownload:
-               if _, err := io.Copy(rwc, bytes.NewBuffer(s.banner)); err != nil {
-                       return fmt.Errorf("error sending banner: %w", err)
+               if _, err := io.Copy(rwc, bytes.NewBuffer(s.Banner)); err != nil {
+                       return fmt.Errorf("error sending Banner: %w", err)
                }
        case FileDownload:
                s.Stats.Increment(StatDownloadCounter, StatDownloadsInProgress)
                }
        case FileDownload:
                s.Stats.Increment(StatDownloadCounter, StatDownloadsInProgress)
index 45d32d4dc2285f121e233f157080321814fee02d..888ca3f0ac86aa6bda2f8f72e348c1e58dc38f82 100644 (file)
@@ -35,13 +35,13 @@ func assertTransferBytesEqual(t *testing.T, wantHexDump string, got []byte) bool
 
 var tranSortFunc = func(a, b Transaction) int {
        return cmp.Compare(
 
 var tranSortFunc = func(a, b Transaction) int {
        return cmp.Compare(
-               binary.BigEndian.Uint16(a.clientID[:]),
-               binary.BigEndian.Uint16(b.clientID[:]),
+               binary.BigEndian.Uint16(a.ClientID[:]),
+               binary.BigEndian.Uint16(b.ClientID[:]),
        )
 }
 
        )
 }
 
-// tranAssertEqual compares equality of transactions slices after stripping out the random transaction Type
-func tranAssertEqual(t *testing.T, tran1, tran2 []Transaction) bool {
+// TranAssertEqual compares equality of transactions slices after stripping out the random transaction Type
+func TranAssertEqual(t *testing.T, tran1, tran2 []Transaction) bool {
        var newT1 []Transaction
        var newT2 []Transaction
 
        var newT1 []Transaction
        var newT2 []Transaction
 
index 0487d2e586a650b7e268138f99f86491586767a4..c3d38da57db7a6b35bda33b4308ffc0a88902740 100644 (file)
@@ -110,7 +110,7 @@ func TestServer_handleFileTransfer(t *testing.T) {
                                FileTransferMgr: &MemFileTransferMgr{
                                        fileTransfers: map[FileTransferID]*FileTransfer{
                                                {0, 0, 0, 5}: {
                                FileTransferMgr: &MemFileTransferMgr{
                                        fileTransfers: map[FileTransferID]*FileTransfer{
                                                {0, 0, 0, 5}: {
-                                                       refNum:   [4]byte{0, 0, 0, 5},
+                                                       RefNum:   [4]byte{0, 0, 0, 5},
                                                        Type:     FileDownload,
                                                        FileName: []byte("testfile-8b"),
                                                        FilePath: []byte{},
                                                        Type:     FileDownload,
                                                        FileName: []byte("testfile-8b"),
                                                        FilePath: []byte{},
@@ -171,7 +171,6 @@ func TestServer_handleFileTransfer(t *testing.T) {
                        s := &Server{
                                FileTransferMgr: tt.fields.FileTransferMgr,
                                Config:          tt.fields.Config,
                        s := &Server{
                                FileTransferMgr: tt.fields.FileTransferMgr,
                                Config:          tt.fields.Config,
-                               ConfigDir:       tt.fields.ConfigDir,
                                Logger:          tt.fields.Logger,
                                Stats:           tt.fields.Stats,
                                FS:              tt.fields.FS,
                                Logger:          tt.fields.Logger,
                                Stats:           tt.fields.Stats,
                                FS:              tt.fields.FS,
@@ -183,61 +182,3 @@ func TestServer_handleFileTransfer(t *testing.T) {
                })
        }
 }
                })
        }
 }
-
-type TestData struct {
-       Name  string `yaml:"name"`
-       Value int    `yaml:"value"`
-}
-
-func TestLoadFromYAMLFile(t *testing.T) {
-       tests := []struct {
-               name     string
-               fileName string
-               content  string
-               wantData TestData
-               wantErr  bool
-       }{
-               {
-                       name:     "Valid YAML file",
-                       fileName: "valid.yaml",
-                       content:  "name: Test\nvalue: 123\n",
-                       wantData: TestData{Name: "Test", Value: 123},
-                       wantErr:  false,
-               },
-               {
-                       name:     "File not found",
-                       fileName: "nonexistent.yaml",
-                       content:  "",
-                       wantData: TestData{},
-                       wantErr:  true,
-               },
-               {
-                       name:     "Invalid YAML content",
-                       fileName: "invalid.yaml",
-                       content:  "name: Test\nvalue: invalid_int\n",
-                       wantData: TestData{},
-                       wantErr:  true,
-               },
-       }
-
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       // Setup: Create a temporary file with the provided content if content is not empty
-                       if tt.content != "" {
-                               err := os.WriteFile(tt.fileName, []byte(tt.content), 0644)
-                               assert.NoError(t, err)
-                               defer os.Remove(tt.fileName) // Cleanup the file after the test
-                       }
-
-                       var data TestData
-                       err := loadFromYAMLFile(tt.fileName, &data)
-
-                       if tt.wantErr {
-                               assert.Error(t, err)
-                       } else {
-                               assert.NoError(t, err)
-                               assert.Equal(t, tt.wantData, data)
-                       }
-               })
-       }
-}
index cb7f15a4dc9c81dcf2f484fc7d75889876d45dfb..5b2fefd2bc22c73614b182d16477edd531f28faa 100644 (file)
@@ -1,6 +1,5 @@
 Name: Halcyon's Test Server
 Description: Experimental Hotline server
 Name: Halcyon's Test Server
 Description: Experimental Hotline server
-BannerID: 1
 FileRoot: conFiles/
 EnableTrackerRegistration: false
 Trackers: 
 FileRoot: conFiles/
 EnableTrackerRegistration: false
 Trackers: 
index edc700dd29f8b47a2cf3a3954b21a1839336cf45..35fa3992e9f5933bf778c4e6a45fe6c6f0b5f07f 100644 (file)
@@ -6,9 +6,11 @@ import (
        "time"
 )
 
        "time"
 )
 
-// toHotlineTime converts a time.Time to the 8 byte Hotline time format:
+type Time [8]byte
+
+// NewTime converts a time.Time to the 8 byte Hotline time format:
 // Year (2 bytes), milliseconds (2 bytes) and seconds (4 bytes)
 // Year (2 bytes), milliseconds (2 bytes) and seconds (4 bytes)
-func toHotlineTime(t time.Time) (b [8]byte) {
+func NewTime(t time.Time) (b Time) {
        yearBytes := make([]byte, 2)
        secondBytes := make([]byte, 4)
 
        yearBytes := make([]byte, 2)
        secondBytes := make([]byte, 4)
 
index 7a456a53237ad7f552a026fb2407c5891c0aa470..fa1e96dba5d47f6b2e256caa38d7a72e99afc82b 100644 (file)
@@ -85,18 +85,18 @@ type Transaction struct {
        ParamCount [2]byte  // Number of the parameters for this transaction
        Fields     []Field
 
        ParamCount [2]byte  // Number of the parameters for this transaction
        Fields     []Field
 
-       clientID   [2]byte // Internal identifier for target client
-       readOffset int     // Internal offset to track read progress
+       ClientID   ClientID // Internal identifier for target client
+       readOffset int      // Internal offset to track read progress
 }
 
 var tranTypeNames = map[TranType]string{
        TranChatMsg:            "Receive chat",
        TranNotifyChangeUser:   "User change",
        TranError:              "Error",
 }
 
 var tranTypeNames = map[TranType]string{
        TranChatMsg:            "Receive chat",
        TranNotifyChangeUser:   "User change",
        TranError:              "Error",
-       TranShowAgreement:      "Show Agreement",
+       TranShowAgreement:      "Show agreement",
        TranUserAccess:         "User access",
        TranNotifyDeleteUser:   "User left",
        TranUserAccess:         "User access",
        TranNotifyDeleteUser:   "User left",
-       TranAgreed:             "TranAgreed",
+       TranAgreed:             "Accept agreement",
        TranChatSend:           "Send chat",
        TranDelNewsArt:         "Delete news article",
        TranDelNewsItem:        "Delete news item",
        TranChatSend:           "Send chat",
        TranDelNewsArt:         "Delete news article",
        TranDelNewsItem:        "Delete news item",
@@ -141,18 +141,15 @@ var tranTypeNames = map[TranType]string{
        TranDownloadBanner:     "Download banner",
 }
 
        TranDownloadBanner:     "Download banner",
 }
 
-//func (t TranType) LogValue() slog.Value {
-//     return slog.StringValue(tranTypeNames[t])
-//}
-
-// NewTransaction creates a new Transaction with the specified type, client Type, and optional fields.
-func NewTransaction(t TranType, clientID [2]byte, fields ...Field) Transaction {
+// NewTransaction creates a new Transaction with the specified type, client, and optional fields.
+func NewTransaction(t TranType, clientID ClientID, fields ...Field) Transaction {
        transaction := Transaction{
                Type:     t,
        transaction := Transaction{
                Type:     t,
-               clientID: clientID,
+               ClientID: clientID,
                Fields:   fields,
        }
 
                Fields:   fields,
        }
 
+       // Give the transaction a random ID.
        binary.BigEndian.PutUint32(transaction.ID[:], rand.Uint32())
 
        return transaction
        binary.BigEndian.PutUint32(transaction.ID[:], rand.Uint32())
 
        return transaction
@@ -183,7 +180,7 @@ func (t *Transaction) Write(p []byte) (n int, err error) {
        copy(t.ParamCount[:], p[20:22])
 
        scanner := bufio.NewScanner(bytes.NewReader(p[22:tranLen]))
        copy(t.ParamCount[:], p[20:22])
 
        scanner := bufio.NewScanner(bytes.NewReader(p[22:tranLen]))
-       scanner.Split(fieldScanner)
+       scanner.Split(FieldScanner)
 
        for i := 0; i < int(paramCount); i++ {
                if !scanner.Scan() {
 
        for i := 0; i < int(paramCount); i++ {
                if !scanner.Scan() {
index ead394a50775b198491e9f60de7cafc04e955e52..e454d08e5801c68f03acafdd2785a7690fe002bf 100644 (file)
 package hotline
 
 package hotline
 
-import (
-       "bufio"
-       "bytes"
-       "encoding/binary"
-       "fmt"
-       "io"
-       "math/big"
-       "os"
-       "path"
-       "path/filepath"
-       "strings"
-       "time"
-)
-
 // HandlerFunc is the signature of a func to handle a Hotline transaction.
 type HandlerFunc func(*ClientConn, *Transaction) []Transaction
 
 // HandlerFunc is the signature of a func to handle a Hotline transaction.
 type HandlerFunc func(*ClientConn, *Transaction) []Transaction
 
-// TransactionHandlers maps a transaction type to a handler function.
-var TransactionHandlers = map[TranType]HandlerFunc{
-       TranAgreed:             HandleTranAgreed,
-       TranChatSend:           HandleChatSend,
-       TranDelNewsArt:         HandleDelNewsArt,
-       TranDelNewsItem:        HandleDelNewsItem,
-       TranDeleteFile:         HandleDeleteFile,
-       TranDeleteUser:         HandleDeleteUser,
-       TranDisconnectUser:     HandleDisconnectUser,
-       TranDownloadFile:       HandleDownloadFile,
-       TranDownloadFldr:       HandleDownloadFolder,
-       TranGetClientInfoText:  HandleGetClientInfoText,
-       TranGetFileInfo:        HandleGetFileInfo,
-       TranGetFileNameList:    HandleGetFileNameList,
-       TranGetMsgs:            HandleGetMsgs,
-       TranGetNewsArtData:     HandleGetNewsArtData,
-       TranGetNewsArtNameList: HandleGetNewsArtNameList,
-       TranGetNewsCatNameList: HandleGetNewsCatNameList,
-       TranGetUser:            HandleGetUser,
-       TranGetUserNameList:    HandleGetUserNameList,
-       TranInviteNewChat:      HandleInviteNewChat,
-       TranInviteToChat:       HandleInviteToChat,
-       TranJoinChat:           HandleJoinChat,
-       TranKeepAlive:          HandleKeepAlive,
-       TranLeaveChat:          HandleLeaveChat,
-       TranListUsers:          HandleListUsers,
-       TranMoveFile:           HandleMoveFile,
-       TranNewFolder:          HandleNewFolder,
-       TranNewNewsCat:         HandleNewNewsCat,
-       TranNewNewsFldr:        HandleNewNewsFldr,
-       TranNewUser:            HandleNewUser,
-       TranUpdateUser:         HandleUpdateUser,
-       TranOldPostNews:        HandleTranOldPostNews,
-       TranPostNewsArt:        HandlePostNewsArt,
-       TranRejectChatInvite:   HandleRejectChatInvite,
-       TranSendInstantMsg:     HandleSendInstantMsg,
-       TranSetChatSubject:     HandleSetChatSubject,
-       TranMakeFileAlias:      HandleMakeAlias,
-       TranSetClientUserInfo:  HandleSetClientUserInfo,
-       TranSetFileInfo:        HandleSetFileInfo,
-       TranSetUser:            HandleSetUser,
-       TranUploadFile:         HandleUploadFile,
-       TranUploadFldr:         HandleUploadFolder,
-       TranUserBroadcast:      HandleUserBroadcast,
-       TranDownloadBanner:     HandleDownloadBanner,
+func (s *Server) HandleFunc(tranType [2]byte, handler HandlerFunc) {
+       s.handlers[tranType] = handler
 }
 
 // The total size of a chat message data field is 8192 bytes.
 }
 
 // The total size of a chat message data field is 8192 bytes.
-const chatMsgLimit = 8192
-
-func HandleChatSend(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessSendChat) {
-               return cc.NewErrReply(t, "You are not allowed to participate in chat.")
-       }
-
-       // Truncate long usernames
-       // %13.13s: This means a string that is right-aligned in a field of 13 characters.
-       // If the string is longer than 13 characters, it will be truncated to 13 characters.
-       formattedMsg := fmt.Sprintf("\r%13.13s:  %s", cc.UserName, t.GetField(FieldData).Data)
-
-       // By holding the option key, Hotline chat allows users to send /me formatted messages like:
-       // *** Halcyon does stuff
-       // This is indicated by the presence of the optional field FieldChatOptions set to a value of 1.
-       // Most clients do not send this option for normal chat messages.
-       if t.GetField(FieldChatOptions).Data != nil && bytes.Equal(t.GetField(FieldChatOptions).Data, []byte{0, 1}) {
-               formattedMsg = fmt.Sprintf("\r*** %s %s", cc.UserName, t.GetField(FieldData).Data)
-       }
-
-       // Truncate the message to the limit.  This does not handle the edge case of a string ending on multibyte character.
-       formattedMsg = formattedMsg[:min(len(formattedMsg), chatMsgLimit)]
-
-       // The ChatID field is used to identify messages as belonging to a private chat.
-       // All clients *except* Frogblast omit this field for public chat, but Frogblast sends a value of 00 00 00 00.
-       chatID := t.GetField(FieldChatID).Data
-       if chatID != nil && !bytes.Equal([]byte{0, 0, 0, 0}, chatID) {
-
-               // send the message to all connected clients of the private chat
-               for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) {
-                       res = append(res, NewTransaction(
-                               TranChatMsg,
-                               c.ID,
-                               NewField(FieldChatID, chatID),
-                               NewField(FieldData, []byte(formattedMsg)),
-                       ))
-               }
-               return res
-       }
-
-       //cc.Server.mux.Lock()
-       for _, c := range cc.Server.ClientMgr.List() {
-               if c == nil || cc.Account == nil {
-                       continue
-               }
-               // Skip clients that do not have the read chat permission.
-               if c.Authorize(AccessReadChat) {
-                       res = append(res, NewTransaction(TranChatMsg, c.ID, NewField(FieldData, []byte(formattedMsg))))
-               }
-       }
-       //cc.Server.mux.Unlock()
-
-       return res
-}
-
-// HandleSendInstantMsg sends instant message to the user on the current server.
-// Fields used in the request:
-//
-//     103     User Type
-//     113     Options
-//             One of the following values:
-//             - User message (myOpt_UserMessage = 1)
-//             - Refuse message (myOpt_RefuseMessage = 2)
-//             - Refuse chat (myOpt_RefuseChat  = 3)
-//             - Automatic response (myOpt_AutomaticResponse = 4)"
-//     101     Data    Optional
-//     214     Quoting message Optional
-//
-// Fields used in the reply:
-// None
-func HandleSendInstantMsg(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessSendPrivMsg) {
-               return cc.NewErrReply(t, "You are not allowed to send private messages.")
-       }
-
-       msg := t.GetField(FieldData)
-       userID := t.GetField(FieldUserID)
-
-       reply := NewTransaction(
-               TranServerMsg,
-               [2]byte(userID.Data),
-               NewField(FieldData, msg.Data),
-               NewField(FieldUserName, cc.UserName),
-               NewField(FieldUserID, cc.ID[:]),
-               NewField(FieldOptions, []byte{0, 1}),
-       )
-
-       // Later versions of Hotline include the original message in the FieldQuotingMsg field so
-       //  the receiving client can display both the received message and what it is in reply to
-       if t.GetField(FieldQuotingMsg).Data != nil {
-               reply.Fields = append(reply.Fields, NewField(FieldQuotingMsg, t.GetField(FieldQuotingMsg).Data))
-       }
-
-       otherClient := cc.Server.ClientMgr.Get([2]byte(userID.Data))
-       if otherClient == nil {
-               return res
-       }
-
-       // Check if target user has "Refuse private messages" flag
-       if otherClient.Flags.IsSet(UserFlagRefusePM) {
-               res = append(res,
-                       NewTransaction(
-                               TranServerMsg,
-                               cc.ID,
-                               NewField(FieldData, []byte(string(otherClient.UserName)+" does not accept private messages.")),
-                               NewField(FieldUserName, otherClient.UserName),
-                               NewField(FieldUserID, otherClient.ID[:]),
-                               NewField(FieldOptions, []byte{0, 2}),
-                       ),
-               )
-       } else {
-               res = append(res, reply)
-       }
-
-       // Respond with auto reply if other client has it enabled
-       if len(otherClient.AutoReply) > 0 {
-               res = append(res,
-                       NewTransaction(
-                               TranServerMsg,
-                               cc.ID,
-                               NewField(FieldData, otherClient.AutoReply),
-                               NewField(FieldUserName, otherClient.UserName),
-                               NewField(FieldUserID, otherClient.ID[:]),
-                               NewField(FieldOptions, []byte{0, 1}),
-                       ),
-               )
-       }
-
-       return append(res, cc.NewReply(t))
-}
-
-var fileTypeFLDR = [4]byte{0x66, 0x6c, 0x64, 0x72}
-
-func HandleGetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction) {
-       fileName := t.GetField(FieldFileName).Data
-       filePath := t.GetField(FieldFilePath).Data
-
-       fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
-       if err != nil {
-               return res
-       }
-
-       fw, err := newFileWrapper(cc.Server.FS, fullFilePath, 0)
-       if err != nil {
-               return res
-       }
-
-       encodedName, err := txtEncoder.String(fw.name)
-       if err != nil {
-               return res
-       }
-
-       fields := []Field{
-               NewField(FieldFileName, []byte(encodedName)),
-               NewField(FieldFileTypeString, fw.ffo.FlatFileInformationFork.friendlyType()),
-               NewField(FieldFileCreatorString, fw.ffo.FlatFileInformationFork.friendlyCreator()),
-               NewField(FieldFileType, fw.ffo.FlatFileInformationFork.TypeSignature[:]),
-               NewField(FieldFileCreateDate, fw.ffo.FlatFileInformationFork.CreateDate[:]),
-               NewField(FieldFileModifyDate, fw.ffo.FlatFileInformationFork.ModifyDate[:]),
-       }
-
-       // Include the optional FileComment field if there is a comment.
-       if len(fw.ffo.FlatFileInformationFork.Comment) != 0 {
-               fields = append(fields, NewField(FieldFileComment, fw.ffo.FlatFileInformationFork.Comment))
-       }
-
-       // Include the FileSize field for files.
-       if fw.ffo.FlatFileInformationFork.TypeSignature != fileTypeFLDR {
-               fields = append(fields, NewField(FieldFileSize, fw.totalSize()))
-       }
-
-       res = append(res, cc.NewReply(t, fields...))
-       return res
-}
-
-// HandleSetFileInfo updates a file or folder Name and/or comment from the Get Info window
-// Fields used in the request:
-// * 201       File Name
-// * 202       File path       Optional
-// * 211       File new Name   Optional
-// * 210       File comment    Optional
-// Fields used in the reply:   None
-func HandleSetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction) {
-       fileName := t.GetField(FieldFileName).Data
-       filePath := t.GetField(FieldFilePath).Data
-
-       fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
-       if err != nil {
-               return res
-       }
-
-       fi, err := cc.Server.FS.Stat(fullFilePath)
-       if err != nil {
-               return res
-       }
-
-       hlFile, err := newFileWrapper(cc.Server.FS, fullFilePath, 0)
-       if err != nil {
-               return res
-       }
-       if t.GetField(FieldFileComment).Data != nil {
-               switch mode := fi.Mode(); {
-               case mode.IsDir():
-                       if !cc.Authorize(AccessSetFolderComment) {
-                               return cc.NewErrReply(t, "You are not allowed to set comments for folders.")
-                       }
-               case mode.IsRegular():
-                       if !cc.Authorize(AccessSetFileComment) {
-                               return cc.NewErrReply(t, "You are not allowed to set comments for files.")
-                       }
-               }
-
-               if err := hlFile.ffo.FlatFileInformationFork.setComment(t.GetField(FieldFileComment).Data); err != nil {
-                       return res
-               }
-               w, err := hlFile.infoForkWriter()
-               if err != nil {
-                       return res
-               }
-               _, err = io.Copy(w, &hlFile.ffo.FlatFileInformationFork)
-               if err != nil {
-                       return res
-               }
-       }
-
-       fullNewFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, t.GetField(FieldFileNewName).Data)
-       if err != nil {
-               return nil
-       }
-
-       fileNewName := t.GetField(FieldFileNewName).Data
-
-       if fileNewName != nil {
-               switch mode := fi.Mode(); {
-               case mode.IsDir():
-                       if !cc.Authorize(AccessRenameFolder) {
-                               return cc.NewErrReply(t, "You are not allowed to rename folders.")
-                       }
-                       err = os.Rename(fullFilePath, fullNewFilePath)
-                       if os.IsNotExist(err) {
-                               return cc.NewErrReply(t, "Cannot rename folder "+string(fileName)+" because it does not exist or cannot be found.")
-
-                       }
-               case mode.IsRegular():
-                       if !cc.Authorize(AccessRenameFile) {
-                               return cc.NewErrReply(t, "You are not allowed to rename files.")
-                       }
-                       fileDir, err := readPath(cc.Server.Config.FileRoot, filePath, []byte{})
-                       if err != nil {
-                               return nil
-                       }
-                       hlFile.name, err = txtDecoder.String(string(fileNewName))
-                       if err != nil {
-                               return res
-                       }
-
-                       err = hlFile.move(fileDir)
-                       if os.IsNotExist(err) {
-                               return cc.NewErrReply(t, "Cannot rename file "+string(fileName)+" because it does not exist or cannot be found.")
-                       }
-                       if err != nil {
-                               return res
-                       }
-               }
-       }
-
-       res = append(res, cc.NewReply(t))
-       return res
-}
-
-// HandleDeleteFile deletes a file or folder
-// Fields used in the request:
-// * 201       File Name
-// * 202       File path
-// Fields used in the reply: none
-func HandleDeleteFile(cc *ClientConn, t *Transaction) (res []Transaction) {
-       fileName := t.GetField(FieldFileName).Data
-       filePath := t.GetField(FieldFilePath).Data
-
-       fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
-       if err != nil {
-               return res
-       }
-
-       hlFile, err := newFileWrapper(cc.Server.FS, fullFilePath, 0)
-       if err != nil {
-               return res
-       }
-
-       fi, err := hlFile.dataFile()
-       if err != nil {
-               return cc.NewErrReply(t, "Cannot delete file "+string(fileName)+" because it does not exist or cannot be found.")
-       }
-
-       switch mode := fi.Mode(); {
-       case mode.IsDir():
-               if !cc.Authorize(AccessDeleteFolder) {
-                       return cc.NewErrReply(t, "You are not allowed to delete folders.")
-               }
-       case mode.IsRegular():
-               if !cc.Authorize(AccessDeleteFile) {
-                       return cc.NewErrReply(t, "You are not allowed to delete files.")
-               }
-       }
-
-       if err := hlFile.delete(); err != nil {
-               return res
-       }
-
-       res = append(res, cc.NewReply(t))
-       return res
-}
-
-// HandleMoveFile moves files or folders. Note: seemingly not documented
-func HandleMoveFile(cc *ClientConn, t *Transaction) (res []Transaction) {
-       fileName := string(t.GetField(FieldFileName).Data)
-
-       filePath, err := readPath(cc.Server.Config.FileRoot, t.GetField(FieldFilePath).Data, t.GetField(FieldFileName).Data)
-       if err != nil {
-               return res
-       }
-
-       fileNewPath, err := readPath(cc.Server.Config.FileRoot, t.GetField(FieldFileNewPath).Data, nil)
-       if err != nil {
-               return res
-       }
-
-       cc.logger.Info("Move file", "src", filePath+"/"+fileName, "dst", fileNewPath+"/"+fileName)
-
-       hlFile, err := newFileWrapper(cc.Server.FS, filePath, 0)
-       if err != nil {
-               return res
-       }
-
-       fi, err := hlFile.dataFile()
-       if err != nil {
-               return cc.NewErrReply(t, "Cannot delete file "+fileName+" because it does not exist or cannot be found.")
-       }
-       switch mode := fi.Mode(); {
-       case mode.IsDir():
-               if !cc.Authorize(AccessMoveFolder) {
-                       return cc.NewErrReply(t, "You are not allowed to move folders.")
-               }
-       case mode.IsRegular():
-               if !cc.Authorize(AccessMoveFile) {
-                       return cc.NewErrReply(t, "You are not allowed to move files.")
-               }
-       }
-       if err := hlFile.move(fileNewPath); err != nil {
-               return res
-       }
-       // TODO: handle other possible errors; e.g. fileWrapper delete fails due to fileWrapper permission issue
-
-       res = append(res, cc.NewReply(t))
-       return res
-}
-
-func HandleNewFolder(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessCreateFolder) {
-               return cc.NewErrReply(t, "You are not allowed to create folders.")
-       }
-       folderName := string(t.GetField(FieldFileName).Data)
-
-       folderName = path.Join("/", folderName)
-
-       var subPath string
-
-       // FieldFilePath is only present for nested paths
-       if t.GetField(FieldFilePath).Data != nil {
-               var newFp FilePath
-               _, err := newFp.Write(t.GetField(FieldFilePath).Data)
-               if err != nil {
-                       return res
-               }
-
-               for _, pathItem := range newFp.Items {
-                       subPath = filepath.Join("/", subPath, string(pathItem.Name))
-               }
-       }
-       newFolderPath := path.Join(cc.Server.Config.FileRoot, subPath, folderName)
-       newFolderPath, err := txtDecoder.String(newFolderPath)
-       if err != nil {
-               return res
-       }
-
-       // TODO: check path and folder Name lengths
-
-       if _, err := cc.Server.FS.Stat(newFolderPath); !os.IsNotExist(err) {
-               msg := fmt.Sprintf("Cannot create folder \"%s\" because there is already a file or folder with that Name.", folderName)
-               return cc.NewErrReply(t, msg)
-       }
-
-       if err := cc.Server.FS.Mkdir(newFolderPath, 0777); err != nil {
-               msg := fmt.Sprintf("Cannot create folder \"%s\" because an error occurred.", folderName)
-               return cc.NewErrReply(t, msg)
-       }
-
-       return append(res, cc.NewReply(t))
-}
-
-func HandleSetUser(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessModifyUser) {
-               return cc.NewErrReply(t, "You are not allowed to modify accounts.")
-       }
-
-       login := t.GetField(FieldUserLogin).DecodeObfuscatedString()
-       userName := string(t.GetField(FieldUserName).Data)
-
-       newAccessLvl := t.GetField(FieldUserAccess).Data
-
-       account := cc.Server.AccountManager.Get(login)
-       if account == nil {
-               return cc.NewErrReply(t, "Account not found.")
-       }
-       account.Name = userName
-       copy(account.Access[:], newAccessLvl)
-
-       // If the password field is cleared in the Hotline edit user UI, the SetUser transaction does
-       // not include FieldUserPassword
-       if t.GetField(FieldUserPassword).Data == nil {
-               account.Password = hashAndSalt([]byte(""))
-       }
-
-       if !bytes.Equal([]byte{0}, t.GetField(FieldUserPassword).Data) {
-               account.Password = hashAndSalt(t.GetField(FieldUserPassword).Data)
-       }
-
-       err := cc.Server.AccountManager.Update(*account, account.Login)
-       if err != nil {
-               cc.logger.Error("Error updating account", "Err", err)
-       }
-
-       // Notify connected clients logged in as the user of the new access level
-       for _, c := range cc.Server.ClientMgr.List() {
-               if c.Account.Login == login {
-                       newT := NewTransaction(TranUserAccess, c.ID, NewField(FieldUserAccess, newAccessLvl))
-                       res = append(res, newT)
-
-                       if c.Authorize(AccessDisconUser) {
-                               c.Flags.Set(UserFlagAdmin, 1)
-                       } else {
-                               c.Flags.Set(UserFlagAdmin, 0)
-                       }
-
-                       c.Account.Access = account.Access
-
-                       cc.SendAll(
-                               TranNotifyChangeUser,
-                               NewField(FieldUserID, c.ID[:]),
-                               NewField(FieldUserFlags, c.Flags[:]),
-                               NewField(FieldUserName, c.UserName),
-                               NewField(FieldUserIconID, c.Icon),
-                       )
-               }
-       }
-
-       return append(res, cc.NewReply(t))
-}
-
-func HandleGetUser(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessOpenUser) {
-               return cc.NewErrReply(t, "You are not allowed to view accounts.")
-       }
-
-       account := cc.Server.AccountManager.Get(string(t.GetField(FieldUserLogin).Data))
-       if account == nil {
-               return cc.NewErrReply(t, "Account does not exist.")
-       }
-
-       return append(res, cc.NewReply(t,
-               NewField(FieldUserName, []byte(account.Name)),
-               NewField(FieldUserLogin, encodeString(t.GetField(FieldUserLogin).Data)),
-               NewField(FieldUserPassword, []byte(account.Password)),
-               NewField(FieldUserAccess, account.Access[:]),
-       ))
-}
-
-func HandleListUsers(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessOpenUser) {
-               return cc.NewErrReply(t, "You are not allowed to view accounts.")
-       }
-
-       var userFields []Field
-       for _, acc := range cc.Server.AccountManager.List() {
-               b, err := io.ReadAll(&acc)
-               if err != nil {
-                       cc.logger.Error("Error reading account", "Account", acc.Login, "Err", err)
-                       continue
-               }
-
-               userFields = append(userFields, NewField(FieldData, b))
-       }
-
-       return append(res, cc.NewReply(t, userFields...))
-}
-
-// HandleUpdateUser is used by the v1.5+ multi-user editor to perform account editing for multiple users at a time.
-// An update can be a mix of these actions:
-// * Create user
-// * Delete user
-// * Modify user (including renaming the account login)
-//
-// The Transaction sent by the client includes one data field per user that was modified.  This data field in turn
-// contains another data field encoded in its payload with a varying number of sub fields depending on which action is
-// performed.  This seems to be the only place in the Hotline protocol where a data field contains another data field.
-func HandleUpdateUser(cc *ClientConn, t *Transaction) (res []Transaction) {
-       for _, field := range t.Fields {
-               var subFields []Field
-
-               // Create a new scanner for parsing incoming bytes into transaction tokens
-               scanner := bufio.NewScanner(bytes.NewReader(field.Data[2:]))
-               scanner.Split(fieldScanner)
-
-               for i := 0; i < int(binary.BigEndian.Uint16(field.Data[0:2])); i++ {
-                       scanner.Scan()
-
-                       var field Field
-                       if _, err := field.Write(scanner.Bytes()); err != nil {
-                               return res
-                       }
-                       subFields = append(subFields, field)
-               }
-
-               // If there's only one subfield, that indicates this is a delete operation for the login in FieldData
-               if len(subFields) == 1 {
-                       if !cc.Authorize(AccessDeleteUser) {
-                               return cc.NewErrReply(t, "You are not allowed to delete accounts.")
-                       }
-
-                       login := string(encodeString(getField(FieldData, &subFields).Data))
-
-                       cc.logger.Info("DeleteUser", "login", login)
-
-                       if err := cc.Server.AccountManager.Delete(login); err != nil {
-                               cc.logger.Error("Error deleting account", "Err", err)
-                               return res
-                       }
-
-                       for _, client := range cc.Server.ClientMgr.List() {
-                               if client.Account.Login == login {
-                                       //                                      "You are logged in with an account which was deleted."
-
-                                       res = append(res,
-                                               NewTransaction(TranServerMsg, [2]byte{},
-                                                       NewField(FieldData, []byte("You are logged in with an account which was deleted.")),
-                                                       NewField(FieldChatOptions, []byte{0}),
-                                               ),
-                                       )
-
-                                       go func(c *ClientConn) {
-                                               time.Sleep(3 * time.Second)
-                                               c.Disconnect()
-                                       }(client)
-                               }
-                       }
-
-                       continue
-               }
-
-               // login of the account to update
-               var accountToUpdate, loginToRename string
-
-               // If FieldData is included, this is a rename operation where FieldData contains the login of the existing
-               // account and FieldUserLogin contains the new login.
-               if getField(FieldData, &subFields) != nil {
-                       loginToRename = string(encodeString(getField(FieldData, &subFields).Data))
-               }
-               userLogin := string(encodeString(getField(FieldUserLogin, &subFields).Data))
-               if loginToRename != "" {
-                       accountToUpdate = loginToRename
-               } else {
-                       accountToUpdate = userLogin
-               }
-
-               // Check if accountToUpdate has an existing account.  If so, we know we are updating an existing user.
-               if acc := cc.Server.AccountManager.Get(accountToUpdate); acc != nil {
-                       if loginToRename != "" {
-                               cc.logger.Info("RenameUser", "prevLogin", accountToUpdate, "newLogin", userLogin)
-                       } else {
-                               cc.logger.Info("UpdateUser", "login", accountToUpdate)
-                       }
-
-                       // Account exists, so this is an update action.
-                       if !cc.Authorize(AccessModifyUser) {
-                               return cc.NewErrReply(t, "You are not allowed to modify accounts.")
-                       }
-
-                       // This part is a bit tricky. There are three possibilities:
-                       // 1) The transaction is intended to update the password.
-                       //        In this case, FieldUserPassword is sent with the new password.
-                       // 2) The transaction is intended to remove the password.
-                       //    In this case, FieldUserPassword is not sent.
-                       // 3) The transaction updates the users access bits, but not the password.
-                       //    In this case, FieldUserPassword is sent with zero as the only byte.
-                       if getField(FieldUserPassword, &subFields) != nil {
-                               newPass := getField(FieldUserPassword, &subFields).Data
-                               if !bytes.Equal([]byte{0}, newPass) {
-                                       acc.Password = hashAndSalt(newPass)
-                               }
-                       } else {
-                               acc.Password = hashAndSalt([]byte(""))
-                       }
-
-                       if getField(FieldUserAccess, &subFields) != nil {
-                               copy(acc.Access[:], getField(FieldUserAccess, &subFields).Data)
-                       }
-
-                       acc.Name = string(getField(FieldUserName, &subFields).Data)
-
-                       err := cc.Server.AccountManager.Update(*acc, string(encodeString(getField(FieldUserLogin, &subFields).Data)))
-
-                       if err != nil {
-                               return res
-                       }
-               } else {
-                       if !cc.Authorize(AccessCreateUser) {
-                               return cc.NewErrReply(t, "You are not allowed to create new accounts.")
-                       }
-
-                       cc.logger.Info("CreateUser", "login", userLogin)
-
-                       newAccess := accessBitmap{}
-                       copy(newAccess[:], getField(FieldUserAccess, &subFields).Data)
-
-                       // Prevent account from creating new account with greater permission
-                       for i := 0; i < 64; i++ {
-                               if newAccess.IsSet(i) {
-                                       if !cc.Authorize(i) {
-                                               return cc.NewErrReply(t, "Cannot create account with more access than yourself.")
-                                       }
-                               }
-                       }
-
-                       account := NewAccount(userLogin, string(getField(FieldUserName, &subFields).Data), string(getField(FieldUserPassword, &subFields).Data), newAccess)
-
-                       err := cc.Server.AccountManager.Create(*account)
-                       if err != nil {
-                               return cc.NewErrReply(t, "Cannot create account because there is already an account with that login.")
-                       }
-               }
-       }
-
-       return append(res, cc.NewReply(t))
-}
-
-// HandleNewUser creates a new user account
-func HandleNewUser(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessCreateUser) {
-               return cc.NewErrReply(t, "You are not allowed to create new accounts.")
-       }
-
-       login := t.GetField(FieldUserLogin).DecodeObfuscatedString()
-
-       // If the account already exists, reply with an error.
-       if account := cc.Server.AccountManager.Get(login); account != nil {
-               return cc.NewErrReply(t, "Cannot create account "+login+" because there is already an account with that login.")
-       }
-
-       var newAccess accessBitmap
-       copy(newAccess[:], t.GetField(FieldUserAccess).Data)
-
-       // Prevent account from creating new account with greater permission
-       for i := 0; i < 64; i++ {
-               if newAccess.IsSet(i) {
-                       if !cc.Authorize(i) {
-                               return cc.NewErrReply(t, "Cannot create account with more access than yourself.")
-                       }
-               }
-       }
-
-       account := NewAccount(login, string(t.GetField(FieldUserName).Data), string(t.GetField(FieldUserPassword).Data), newAccess)
-
-       err := cc.Server.AccountManager.Create(*account)
-       if err != nil {
-               return cc.NewErrReply(t, "Cannot create account because there is already an account with that login.")
-       }
-
-       return append(res, cc.NewReply(t))
-}
-
-func HandleDeleteUser(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessDeleteUser) {
-               return cc.NewErrReply(t, "You are not allowed to delete accounts.")
-       }
-
-       login := t.GetField(FieldUserLogin).DecodeObfuscatedString()
-
-       if err := cc.Server.AccountManager.Delete(login); err != nil {
-               cc.logger.Error("Error deleting account", "Err", err)
-               return res
-       }
-
-       for _, client := range cc.Server.ClientMgr.List() {
-               if client.Account.Login == login {
-                       res = append(res,
-                               NewTransaction(TranServerMsg, client.ID,
-                                       NewField(FieldData, []byte("You are logged in with an account which was deleted.")),
-                                       NewField(FieldChatOptions, []byte{2}),
-                               ),
-                       )
-
-                       go func(c *ClientConn) {
-                               time.Sleep(2 * time.Second)
-                               c.Disconnect()
-                       }(client)
-               }
-       }
-
-       return append(res, cc.NewReply(t))
-}
-
-// HandleUserBroadcast sends an Administrator Message to all connected clients of the server
-func HandleUserBroadcast(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessBroadcast) {
-               return cc.NewErrReply(t, "You are not allowed to send broadcast messages.")
-       }
-
-       cc.SendAll(
-               TranServerMsg,
-               NewField(FieldData, t.GetField(FieldData).Data),
-               NewField(FieldChatOptions, []byte{0}),
-       )
-
-       return append(res, cc.NewReply(t))
-}
-
-// HandleGetClientInfoText returns user information for the specific user.
-//
-// Fields used in the request:
-// 103 User Type
-//
-// Fields used in the reply:
-// 102 User Name
-// 101 Data            User info text string
-func HandleGetClientInfoText(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessGetClientInfo) {
-               return cc.NewErrReply(t, "You are not allowed to get client info.")
-       }
-
-       clientID := t.GetField(FieldUserID).Data
-
-       clientConn := cc.Server.ClientMgr.Get(ClientID(clientID))
-       if clientConn == nil {
-               return cc.NewErrReply(t, "User not found.")
-       }
-
-       return append(res, cc.NewReply(t,
-               NewField(FieldData, []byte(clientConn.String())),
-               NewField(FieldUserName, clientConn.UserName),
-       ))
-}
-
-func HandleGetUserNameList(cc *ClientConn, t *Transaction) (res []Transaction) {
-       var fields []Field
-       for _, c := range cc.Server.ClientMgr.List() {
-               b, err := io.ReadAll(&User{
-                       ID:    c.ID,
-                       Icon:  c.Icon,
-                       Flags: c.Flags[:],
-                       Name:  string(c.UserName),
-               })
-               if err != nil {
-                       return nil
-               }
-
-               fields = append(fields, NewField(FieldUsernameWithInfo, b))
-       }
-
-       return []Transaction{cc.NewReply(t, fields...)}
-}
-
-func HandleTranAgreed(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if t.GetField(FieldUserName).Data != nil {
-               if cc.Authorize(AccessAnyName) {
-                       cc.UserName = t.GetField(FieldUserName).Data
-               } else {
-                       cc.UserName = []byte(cc.Account.Name)
-               }
-       }
-
-       cc.Icon = t.GetField(FieldUserIconID).Data
-
-       cc.logger = cc.logger.With("Name", string(cc.UserName))
-       cc.logger.Info("Login successful")
-
-       options := t.GetField(FieldOptions).Data
-       optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options)))
-
-       // Check refuse private PM option
-
-       cc.flagsMU.Lock()
-       defer cc.flagsMU.Unlock()
-       cc.Flags.Set(UserFlagRefusePM, optBitmap.Bit(UserOptRefusePM))
-
-       // Check refuse private chat option
-       cc.Flags.Set(UserFlagRefusePChat, optBitmap.Bit(UserOptRefuseChat))
-
-       // Check auto response
-       if optBitmap.Bit(UserOptAutoResponse) == 1 {
-               cc.AutoReply = t.GetField(FieldAutomaticResponse).Data
-       }
-
-       trans := cc.NotifyOthers(
-               NewTransaction(
-                       TranNotifyChangeUser, [2]byte{0, 0},
-                       NewField(FieldUserName, cc.UserName),
-                       NewField(FieldUserID, cc.ID[:]),
-                       NewField(FieldUserIconID, cc.Icon),
-                       NewField(FieldUserFlags, cc.Flags[:]),
-               ),
-       )
-       res = append(res, trans...)
-
-       if cc.Server.Config.BannerFile != "" {
-               res = append(res, NewTransaction(TranServerBanner, cc.ID, NewField(FieldBannerType, []byte("JPEG"))))
-       }
-
-       res = append(res, cc.NewReply(t))
-
-       return res
-}
-
-// HandleTranOldPostNews updates the flat news
-// Fields used in this request:
-// 101 Data
-func HandleTranOldPostNews(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessNewsPostArt) {
-               return cc.NewErrReply(t, "You are not allowed to post news.")
-       }
-
-       newsDateTemplate := defaultNewsDateFormat
-       if cc.Server.Config.NewsDateFormat != "" {
-               newsDateTemplate = cc.Server.Config.NewsDateFormat
-       }
-
-       newsTemplate := defaultNewsTemplate
-       if cc.Server.Config.NewsDelimiter != "" {
-               newsTemplate = cc.Server.Config.NewsDelimiter
-       }
-
-       newsPost := fmt.Sprintf(newsTemplate+"\r", cc.UserName, time.Now().Format(newsDateTemplate), t.GetField(FieldData).Data)
-       newsPost = strings.ReplaceAll(newsPost, "\n", "\r")
-
-       _, err := cc.Server.MessageBoard.Write([]byte(newsPost))
-       if err != nil {
-               cc.logger.Error("error writing news post", "err", err)
-               return nil
-       }
-
-       // Notify all clients of updated news
-       cc.SendAll(
-               TranNewMsg,
-               NewField(FieldData, []byte(newsPost)),
-       )
-
-       return append(res, cc.NewReply(t))
-}
-
-func HandleDisconnectUser(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessDisconUser) {
-               return cc.NewErrReply(t, "You are not allowed to disconnect users.")
-       }
-
-       clientID := [2]byte(t.GetField(FieldUserID).Data)
-       clientConn := cc.Server.ClientMgr.Get(clientID)
-
-       if clientConn.Authorize(AccessCannotBeDiscon) {
-               return cc.NewErrReply(t, clientConn.Account.Login+" is not allowed to be disconnected.")
-       }
-
-       // If FieldOptions is set, then the client IP is banned in addition to disconnected.
-       // 00 01 = temporary ban
-       // 00 02 = permanent ban
-       if t.GetField(FieldOptions).Data != nil {
-               switch t.GetField(FieldOptions).Data[1] {
-               case 1:
-                       // send message: "You are temporarily banned on this server"
-                       cc.logger.Info("Disconnect & temporarily ban " + string(clientConn.UserName))
-
-                       res = append(res, NewTransaction(
-                               TranServerMsg,
-                               clientConn.ID,
-                               NewField(FieldData, []byte("You are temporarily banned on this server")),
-                               NewField(FieldChatOptions, []byte{0, 0}),
-                       ))
-
-                       banUntil := time.Now().Add(tempBanDuration)
-                       ip := strings.Split(clientConn.RemoteAddr, ":")[0]
-
-                       err := cc.Server.BanList.Add(ip, &banUntil)
-                       if err != nil {
-                               cc.logger.Error("Error saving ban", "err", err)
-                               // TODO
-                       }
-               case 2:
-                       // send message: "You are permanently banned on this server"
-                       cc.logger.Info("Disconnect & ban " + string(clientConn.UserName))
-
-                       res = append(res, NewTransaction(
-                               TranServerMsg,
-                               clientConn.ID,
-                               NewField(FieldData, []byte("You are permanently banned on this server")),
-                               NewField(FieldChatOptions, []byte{0, 0}),
-                       ))
-
-                       ip := strings.Split(clientConn.RemoteAddr, ":")[0]
-
-                       err := cc.Server.BanList.Add(ip, nil)
-                       if err != nil {
-                               // TODO
-                       }
-               }
-       }
-
-       // TODO: remove this awful hack
-       go func() {
-               time.Sleep(1 * time.Second)
-               clientConn.Disconnect()
-       }()
-
-       return append(res, cc.NewReply(t))
-}
-
-// HandleGetNewsCatNameList returns a list of news categories for a path
-// Fields used in the request:
-// 325 News path       (Optional)
-func HandleGetNewsCatNameList(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessNewsReadArt) {
-               return cc.NewErrReply(t, "You are not allowed to read news.")
-       }
-
-       pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath()
-       if err != nil {
-
-       }
-
-       var fields []Field
-       for _, cat := range cc.Server.ThreadedNewsMgr.GetCategories(pathStrs) {
-               b, err := io.ReadAll(&cat)
-               if err != nil {
-                       // TODO
-               }
-
-               fields = append(fields, NewField(FieldNewsCatListData15, b))
-       }
-
-       return append(res, cc.NewReply(t, fields...))
-}
-
-func HandleNewNewsCat(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessNewsCreateCat) {
-               return cc.NewErrReply(t, "You are not allowed to create news categories.")
-       }
-
-       name := string(t.GetField(FieldNewsCatName).Data)
-       pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath()
-       if err != nil {
-               return res
-       }
-
-       err = cc.Server.ThreadedNewsMgr.CreateGrouping(pathStrs, name, NewsCategory)
-       if err != nil {
-               cc.logger.Error("error creating news category", "err", err)
-       }
-
-       return []Transaction{cc.NewReply(t)}
-}
-
-// Fields used in the request:
-// 322 News category Name
-// 325 News path
-func HandleNewNewsFldr(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessNewsCreateFldr) {
-               return cc.NewErrReply(t, "You are not allowed to create news folders.")
-       }
-
-       name := string(t.GetField(FieldFileName).Data)
-       pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath()
-       if err != nil {
-               return res
-       }
-
-       err = cc.Server.ThreadedNewsMgr.CreateGrouping(pathStrs, name, NewsBundle)
-       if err != nil {
-               cc.logger.Error("error creating news bundle", "err", err)
-       }
-
-       return append(res, cc.NewReply(t))
-}
-
-// HandleGetNewsArtData gets the list of article names at the specified news path.
-
-// Fields used in the request:
-// 325 News path       Optional
-
-// Fields used in the reply:
-// 321 News article list data  Optional
-func HandleGetNewsArtNameList(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessNewsReadArt) {
-               return cc.NewErrReply(t, "You are not allowed to read news.")
-       }
-
-       pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath()
-       if err != nil {
-               return res
-       }
-
-       nald := cc.Server.ThreadedNewsMgr.ListArticles(pathStrs)
-
-       b, err := io.ReadAll(&nald)
-       if err != nil {
-               return res
-       }
-
-       return append(res, cc.NewReply(t, NewField(FieldNewsArtListData, b)))
-}
-
-// HandleGetNewsArtData requests information about the specific news article.
-// Fields used in the request:
-//
-// Request fields
-// 325 News path
-// 326 News article Type
-// 327 News article data flavor
-//
-// Fields used in the reply:
-// 328 News article title
-// 329 News article poster
-// 330 News article date
-// 331 Previous article Type
-// 332 Next article Type
-// 335 Parent article Type
-// 336 First child article Type
-// 327 News article data flavor        "Should be “text/plain”
-// 333 News article data       Optional (if data flavor is “text/plain”)
-func HandleGetNewsArtData(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessNewsReadArt) {
-               return cc.NewErrReply(t, "You are not allowed to read news.")
-       }
-
-       newsPath, err := t.GetField(FieldNewsPath).DecodeNewsPath()
-       if err != nil {
-               return res
-       }
-
-       convertedID, err := t.GetField(FieldNewsArtID).DecodeInt()
-       if err != nil {
-               return res
-       }
-
-       art := cc.Server.ThreadedNewsMgr.GetArticle(newsPath, uint32(convertedID))
-       if art == nil {
-               return append(res, cc.NewReply(t))
-       }
-
-       res = append(res, cc.NewReply(t,
-               NewField(FieldNewsArtTitle, []byte(art.Title)),
-               NewField(FieldNewsArtPoster, []byte(art.Poster)),
-               NewField(FieldNewsArtDate, art.Date[:]),
-               NewField(FieldNewsArtPrevArt, art.PrevArt[:]),
-               NewField(FieldNewsArtNextArt, art.NextArt[:]),
-               NewField(FieldNewsArtParentArt, art.ParentArt[:]),
-               NewField(FieldNewsArt1stChildArt, art.FirstChildArt[:]),
-               NewField(FieldNewsArtDataFlav, []byte("text/plain")),
-               NewField(FieldNewsArtData, []byte(art.Data)),
-       ))
-       return res
-}
-
-// HandleDelNewsItem deletes a threaded news folder or category.
-// Fields used in the request:
-// 325 News path
-// Fields used in the reply:
-// None
-func HandleDelNewsItem(cc *ClientConn, t *Transaction) (res []Transaction) {
-       pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath()
-       if err != nil {
-               return res
-       }
-
-       item := cc.Server.ThreadedNewsMgr.NewsItem(pathStrs)
-
-       if item.Type == [2]byte{0, 3} {
-               if !cc.Authorize(AccessNewsDeleteCat) {
-                       return cc.NewErrReply(t, "You are not allowed to delete news categories.")
-               }
-       } else {
-               if !cc.Authorize(AccessNewsDeleteFldr) {
-                       return cc.NewErrReply(t, "You are not allowed to delete news folders.")
-               }
-       }
-
-       err = cc.Server.ThreadedNewsMgr.DeleteNewsItem(pathStrs)
-       if err != nil {
-               return res
-       }
-
-       return append(res, cc.NewReply(t))
-}
-
-// HandleDelNewsArt deletes a threaded news article.
-// Request Fields
-// 325 News path
-// 326 News article Type
-// 337 News article recursive delete   - Delete child articles (1) or not (0)
-func HandleDelNewsArt(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessNewsDeleteArt) {
-               return cc.NewErrReply(t, "You are not allowed to delete news articles.")
-
-       }
-
-       pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath()
-       if err != nil {
-               return res
-       }
-
-       articleID, err := t.GetField(FieldNewsArtID).DecodeInt()
-       if err != nil {
-               cc.logger.Error("error reading article Type", "err", err)
-               return
-       }
-
-       deleteRecursive := bytes.Equal([]byte{0, 1}, t.GetField(FieldNewsArtRecurseDel).Data)
-
-       err = cc.Server.ThreadedNewsMgr.DeleteArticle(pathStrs, uint32(articleID), deleteRecursive)
-       if err != nil {
-               cc.logger.Error("error deleting news article", "err", err)
-       }
-
-       return []Transaction{cc.NewReply(t)}
-}
-
-// Request fields
-// 325 News path
-// 326 News article Type                                                       Type of the parent article?
-// 328 News article title
-// 334 News article flags
-// 327 News article data flavor                Currently “text/plain”
-// 333 News article data
-func HandlePostNewsArt(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessNewsPostArt) {
-               return cc.NewErrReply(t, "You are not allowed to post news articles.")
-       }
-
-       pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath()
-       if err != nil {
-               return res
-       }
-
-       parentArticleID, err := t.GetField(FieldNewsArtID).DecodeInt()
-       if err != nil {
-               return res
-       }
-
-       err = cc.Server.ThreadedNewsMgr.PostArticle(
-               pathStrs,
-               uint32(parentArticleID),
-               NewsArtData{
-                       Title:    string(t.GetField(FieldNewsArtTitle).Data),
-                       Poster:   string(cc.UserName),
-                       Date:     toHotlineTime(time.Now()),
-                       DataFlav: NewsFlavor,
-                       Data:     string(t.GetField(FieldNewsArtData).Data),
-               },
-       )
-       if err != nil {
-               cc.logger.Error("error posting news article", "err", err)
-       }
-
-       return append(res, cc.NewReply(t))
-}
-
-// HandleGetMsgs returns the flat news data
-func HandleGetMsgs(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessNewsReadArt) {
-               return cc.NewErrReply(t, "You are not allowed to read news.")
-       }
-
-       _, _ = cc.Server.MessageBoard.Seek(0, 0)
-
-       newsData, err := io.ReadAll(cc.Server.MessageBoard)
-       if err != nil {
-               // TODO
-       }
-
-       return append(res, cc.NewReply(t, NewField(FieldData, newsData)))
-}
-
-func HandleDownloadFile(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessDownloadFile) {
-               return cc.NewErrReply(t, "You are not allowed to download files.")
-       }
-
-       fileName := t.GetField(FieldFileName).Data
-       filePath := t.GetField(FieldFilePath).Data
-       resumeData := t.GetField(FieldFileResumeData).Data
-
-       var dataOffset int64
-       var frd FileResumeData
-       if resumeData != nil {
-               if err := frd.UnmarshalBinary(t.GetField(FieldFileResumeData).Data); err != nil {
-                       return res
-               }
-               // TODO: handle rsrc fork offset
-               dataOffset = int64(binary.BigEndian.Uint32(frd.ForkInfoList[0].DataSize[:]))
-       }
-
-       fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
-       if err != nil {
-               return res
-       }
-
-       hlFile, err := newFileWrapper(cc.Server.FS, fullFilePath, dataOffset)
-       if err != nil {
-               return res
-       }
-
-       xferSize := hlFile.ffo.TransferSize(0)
-
-       ft := cc.newFileTransfer(FileDownload, fileName, filePath, xferSize)
-
-       // TODO: refactor to remove this
-       if resumeData != nil {
-               var frd FileResumeData
-               if err := frd.UnmarshalBinary(t.GetField(FieldFileResumeData).Data); err != nil {
-                       return res
-               }
-               ft.fileResumeData = &frd
-       }
-
-       // Optional field for when a client requests file preview
-       // Used only for TEXT, JPEG, GIFF, BMP or PICT files
-       // The value will always be 2
-       if t.GetField(FieldFileTransferOptions).Data != nil {
-               ft.options = t.GetField(FieldFileTransferOptions).Data
-               xferSize = hlFile.ffo.FlatFileDataForkHeader.DataSize[:]
-       }
-
-       res = append(res, cc.NewReply(t,
-               NewField(FieldRefNum, ft.refNum[:]),
-               NewField(FieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count
-               NewField(FieldTransferSize, xferSize),
-               NewField(FieldFileSize, hlFile.ffo.FlatFileDataForkHeader.DataSize[:]),
-       ))
-
-       return res
-}
-
-// Download all files from the specified folder and sub-folders
-func HandleDownloadFolder(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessDownloadFile) {
-               return cc.NewErrReply(t, "You are not allowed to download folders.")
-       }
-
-       fullFilePath, err := readPath(cc.Server.Config.FileRoot, t.GetField(FieldFilePath).Data, t.GetField(FieldFileName).Data)
-       if err != nil {
-               return res
-       }
-
-       transferSize, err := CalcTotalSize(fullFilePath)
-       if err != nil {
-               return res
-       }
-       itemCount, err := CalcItemCount(fullFilePath)
-       if err != nil {
-               return res
-       }
-
-       fileTransfer := cc.newFileTransfer(FolderDownload, t.GetField(FieldFileName).Data, t.GetField(FieldFilePath).Data, transferSize)
-
-       var fp FilePath
-       _, err = fp.Write(t.GetField(FieldFilePath).Data)
-       if err != nil {
-               return res
-       }
-
-       res = append(res, cc.NewReply(t,
-               NewField(FieldRefNum, fileTransfer.refNum[:]),
-               NewField(FieldTransferSize, transferSize),
-               NewField(FieldFolderItemCount, itemCount),
-               NewField(FieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count
-       ))
-       return res
-}
-
-// Upload all files from the local folder and its subfolders to the specified path on the server
-// Fields used in the request
-// 201 File Name
-// 202 File path
-// 108 transfer size   Total size of all items in the folder
-// 220 Folder item count
-// 204 File transfer options   "Optional Currently set to 1" (TODO: ??)
-func HandleUploadFolder(cc *ClientConn, t *Transaction) (res []Transaction) {
-       var fp FilePath
-       if t.GetField(FieldFilePath).Data != nil {
-               if _, err := fp.Write(t.GetField(FieldFilePath).Data); err != nil {
-                       return res
-               }
-       }
-
-       // Handle special cases for Upload and Drop Box folders
-       if !cc.Authorize(AccessUploadAnywhere) {
-               if !fp.IsUploadDir() && !fp.IsDropbox() {
-                       return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload of the folder \"%v\" because you are only allowed to upload to the \"Uploads\" folder.", string(t.GetField(FieldFileName).Data)))
-               }
-       }
-
-       fileTransfer := cc.newFileTransfer(FolderUpload,
-               t.GetField(FieldFileName).Data,
-               t.GetField(FieldFilePath).Data,
-               t.GetField(FieldTransferSize).Data,
-       )
-
-       fileTransfer.FolderItemCount = t.GetField(FieldFolderItemCount).Data
-
-       return append(res, cc.NewReply(t, NewField(FieldRefNum, fileTransfer.refNum[:])))
-}
-
-// HandleUploadFile
-// Fields used in the request:
-// 201 File Name
-// 202 File path
-// 204 File transfer options   "Optional
-// Used only to resume download, currently has value 2"
-// 108 File transfer size      "Optional used if download is not resumed"
-func HandleUploadFile(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessUploadFile) {
-               return cc.NewErrReply(t, "You are not allowed to upload files.")
-       }
-
-       fileName := t.GetField(FieldFileName).Data
-       filePath := t.GetField(FieldFilePath).Data
-       transferOptions := t.GetField(FieldFileTransferOptions).Data
-       transferSize := t.GetField(FieldTransferSize).Data // not sent for resume
-
-       var fp FilePath
-       if filePath != nil {
-               if _, err := fp.Write(filePath); err != nil {
-                       return res
-               }
-       }
-
-       // Handle special cases for Upload and Drop Box folders
-       if !cc.Authorize(AccessUploadAnywhere) {
-               if !fp.IsUploadDir() && !fp.IsDropbox() {
-                       return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload of the file \"%v\" because you are only allowed to upload to the \"Uploads\" folder.", string(fileName)))
-               }
-       }
-       fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
-       if err != nil {
-               return res
-       }
-
-       if _, err := cc.Server.FS.Stat(fullFilePath); err == nil {
-               return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload because there is already a file named \"%v\".  Try choosing a different Name.", string(fileName)))
-       }
-
-       ft := cc.newFileTransfer(FileUpload, fileName, filePath, transferSize)
-
-       replyT := cc.NewReply(t, NewField(FieldRefNum, ft.refNum[:]))
-
-       // client has requested to resume a partially transferred file
-       if transferOptions != nil {
-               fileInfo, err := cc.Server.FS.Stat(fullFilePath + incompleteFileSuffix)
-               if err != nil {
-                       return res
-               }
-
-               offset := make([]byte, 4)
-               binary.BigEndian.PutUint32(offset, uint32(fileInfo.Size()))
-
-               fileResumeData := NewFileResumeData([]ForkInfoList{
-                       *NewForkInfoList(offset),
-               })
-
-               b, _ := fileResumeData.BinaryMarshal()
-
-               ft.TransferSize = offset
-
-               replyT.Fields = append(replyT.Fields, NewField(FieldFileResumeData, b))
-       }
-
-       res = append(res, replyT)
-       return res
-}
-
-func HandleSetClientUserInfo(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if len(t.GetField(FieldUserIconID).Data) == 4 {
-               cc.Icon = t.GetField(FieldUserIconID).Data[2:]
-       } else {
-               cc.Icon = t.GetField(FieldUserIconID).Data
-       }
-       if cc.Authorize(AccessAnyName) {
-               cc.UserName = t.GetField(FieldUserName).Data
-       }
-
-       // the options field is only passed by the client versions > 1.2.3.
-       options := t.GetField(FieldOptions).Data
-       if options != nil {
-               optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options)))
-
-               //flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(cc.Flags[:])))
-               //flagBitmap.SetBit(flagBitmap, UserFlagRefusePM, optBitmap.Bit(UserOptRefusePM))
-               //binary.BigEndian.PutUint16(cc.Flags[:], uint16(flagBitmap.Int64()))
-
-               cc.Flags.Set(UserFlagRefusePM, optBitmap.Bit(UserOptRefusePM))
-               cc.Flags.Set(UserFlagRefusePChat, optBitmap.Bit(UserOptRefuseChat))
-               //
-               //flagBitmap.SetBit(flagBitmap, UserFlagRefusePChat, optBitmap.Bit(UserOptRefuseChat))
-               //binary.BigEndian.PutUint16(cc.Flags[:], uint16(flagBitmap.Int64()))
-
-               // Check auto response
-               if optBitmap.Bit(UserOptAutoResponse) == 1 {
-                       cc.AutoReply = t.GetField(FieldAutomaticResponse).Data
-               } else {
-                       cc.AutoReply = []byte{}
-               }
-       }
-
-       for _, c := range cc.Server.ClientMgr.List() {
-               res = append(res, NewTransaction(
-                       TranNotifyChangeUser,
-                       c.ID,
-                       NewField(FieldUserID, cc.ID[:]),
-                       NewField(FieldUserIconID, cc.Icon),
-                       NewField(FieldUserFlags, cc.Flags[:]),
-                       NewField(FieldUserName, cc.UserName),
-               ))
-       }
-
-       return res
-}
-
-// HandleKeepAlive responds to keepalive transactions with an empty reply
-// * HL 1.9.2 Client sends keepalive msg every 3 minutes
-// * HL 1.2.3 Client doesn't send keepalives
-func HandleKeepAlive(cc *ClientConn, t *Transaction) (res []Transaction) {
-       res = append(res, cc.NewReply(t))
-
-       return res
-}
-
-func HandleGetFileNameList(cc *ClientConn, t *Transaction) (res []Transaction) {
-       fullPath, err := readPath(
-               cc.Server.Config.FileRoot,
-               t.GetField(FieldFilePath).Data,
-               nil,
-       )
-       if err != nil {
-               return res
-       }
-
-       var fp FilePath
-       if t.GetField(FieldFilePath).Data != nil {
-               if _, err = fp.Write(t.GetField(FieldFilePath).Data); err != nil {
-                       return res
-               }
-       }
-
-       // Handle special case for drop box folders
-       if fp.IsDropbox() && !cc.Authorize(AccessViewDropBoxes) {
-               return cc.NewErrReply(t, "You are not allowed to view drop boxes.")
-       }
-
-       fileNames, err := getFileNameList(fullPath, cc.Server.Config.IgnoreFiles)
-       if err != nil {
-               return res
-       }
-
-       res = append(res, cc.NewReply(t, fileNames...))
-
-       return res
-}
-
-// =================================
-//     Hotline private chat flow
-// =================================
-// 1. ClientA sends TranInviteNewChat to server with user Type to invite
-// 2. Server creates new ChatID
-// 3. Server sends TranInviteToChat to invitee
-// 4. Server replies to ClientA with new Chat Type
-//
-// A dialog box pops up in the invitee client with options to accept or decline the invitation.
-// If Accepted is clicked:
-// 1. ClientB sends TranJoinChat with FieldChatID
-
-// HandleInviteNewChat invites users to new private chat
-func HandleInviteNewChat(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessOpenChat) {
-               return cc.NewErrReply(t, "You are not allowed to request private chat.")
-       }
-
-       // Client to Invite
-       targetID := t.GetField(FieldUserID).Data
-
-       // Create a new chat with self as initial member.
-       newChatID := cc.Server.ChatMgr.New(cc)
-
-       // Check if target user has "Refuse private chat" flag
-       targetClient := cc.Server.ClientMgr.Get([2]byte(targetID))
-       flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(targetClient.Flags[:])))
-       if flagBitmap.Bit(UserFlagRefusePChat) == 1 {
-               res = append(res,
-                       NewTransaction(
-                               TranServerMsg,
-                               cc.ID,
-                               NewField(FieldData, []byte(string(targetClient.UserName)+" does not accept private chats.")),
-                               NewField(FieldUserName, targetClient.UserName),
-                               NewField(FieldUserID, targetClient.ID[:]),
-                               NewField(FieldOptions, []byte{0, 2}),
-                       ),
-               )
-       } else {
-               res = append(res,
-                       NewTransaction(
-                               TranInviteToChat,
-                               [2]byte(targetID),
-                               NewField(FieldChatID, newChatID[:]),
-                               NewField(FieldUserName, cc.UserName),
-                               NewField(FieldUserID, cc.ID[:]),
-                       ),
-               )
-       }
-
-       return append(
-               res,
-               cc.NewReply(t,
-                       NewField(FieldChatID, newChatID[:]),
-                       NewField(FieldUserName, cc.UserName),
-                       NewField(FieldUserID, cc.ID[:]),
-                       NewField(FieldUserIconID, cc.Icon),
-                       NewField(FieldUserFlags, cc.Flags[:]),
-               ),
-       )
-}
-
-func HandleInviteToChat(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessOpenChat) {
-               return cc.NewErrReply(t, "You are not allowed to request private chat.")
-       }
-
-       // Client to Invite
-       targetID := t.GetField(FieldUserID).Data
-       chatID := t.GetField(FieldChatID).Data
-
-       return []Transaction{
-               NewTransaction(
-                       TranInviteToChat,
-                       [2]byte(targetID),
-                       NewField(FieldChatID, chatID),
-                       NewField(FieldUserName, cc.UserName),
-                       NewField(FieldUserID, cc.ID[:]),
-               ),
-               cc.NewReply(
-                       t,
-                       NewField(FieldChatID, chatID),
-                       NewField(FieldUserName, cc.UserName),
-                       NewField(FieldUserID, cc.ID[:]),
-                       NewField(FieldUserIconID, cc.Icon),
-                       NewField(FieldUserFlags, cc.Flags[:]),
-               ),
-       }
-}
-
-func HandleRejectChatInvite(cc *ClientConn, t *Transaction) (res []Transaction) {
-       chatID := [4]byte(t.GetField(FieldChatID).Data)
-
-       for _, c := range cc.Server.ChatMgr.Members(chatID) {
-               res = append(res,
-                       NewTransaction(
-                               TranChatMsg,
-                               c.ID,
-                               NewField(FieldChatID, chatID[:]),
-                               NewField(FieldData, append(cc.UserName, []byte(" declined invitation to chat")...)),
-                       ),
-               )
-       }
-
-       return res
-}
-
-// HandleJoinChat is sent from a v1.8+ Hotline client when the joins a private chat
-// Fields used in the reply:
-// * 115       Chat subject
-// * 300       User Name with info (Optional)
-// * 300       (more user names with info)
-func HandleJoinChat(cc *ClientConn, t *Transaction) (res []Transaction) {
-       chatID := t.GetField(FieldChatID).Data
-
-       // Send TranNotifyChatChangeUser to current members of the chat to inform of new user
-       for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) {
-               res = append(res,
-                       NewTransaction(
-                               TranNotifyChatChangeUser,
-                               c.ID,
-                               NewField(FieldChatID, chatID),
-                               NewField(FieldUserName, cc.UserName),
-                               NewField(FieldUserID, cc.ID[:]),
-                               NewField(FieldUserIconID, cc.Icon),
-                               NewField(FieldUserFlags, cc.Flags[:]),
-                       ),
-               )
-       }
-
-       cc.Server.ChatMgr.Join(ChatID(chatID), cc)
-
-       subject := cc.Server.ChatMgr.GetSubject(ChatID(chatID))
-
-       replyFields := []Field{NewField(FieldChatSubject, []byte(subject))}
-       for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) {
-               b, err := io.ReadAll(&User{
-                       ID:    c.ID,
-                       Icon:  c.Icon,
-                       Flags: c.Flags[:],
-                       Name:  string(c.UserName),
-               })
-               if err != nil {
-                       return res
-               }
-               replyFields = append(replyFields, NewField(FieldUsernameWithInfo, b))
-       }
-
-       return append(res, cc.NewReply(t, replyFields...))
-}
-
-// HandleLeaveChat is sent from a v1.8+ Hotline client when the user exits a private chat
-// Fields used in the request:
-//   - 114     FieldChatID
-//
-// Reply is not expected.
-func HandleLeaveChat(cc *ClientConn, t *Transaction) (res []Transaction) {
-       chatID := t.GetField(FieldChatID).Data
-
-       cc.Server.ChatMgr.Leave([4]byte(chatID), cc.ID)
-
-       // Notify members of the private chat that the user has left
-       for _, c := range cc.Server.ChatMgr.Members(ChatID(chatID)) {
-               res = append(res,
-                       NewTransaction(
-                               TranNotifyChatDeleteUser,
-                               c.ID,
-                               NewField(FieldChatID, chatID),
-                               NewField(FieldUserID, cc.ID[:]),
-                       ),
-               )
-       }
-
-       return res
-}
-
-// HandleSetChatSubject is sent from a v1.8+ Hotline client when the user sets a private chat subject
-// Fields used in the request:
-// * 114       Chat Type
-// * 115       Chat subject
-// Reply is not expected.
-func HandleSetChatSubject(cc *ClientConn, t *Transaction) (res []Transaction) {
-       chatID := t.GetField(FieldChatID).Data
-
-       cc.Server.ChatMgr.SetSubject([4]byte(chatID), string(t.GetField(FieldChatSubject).Data))
-
-       // Notify chat members of new subject.
-       for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) {
-               res = append(res,
-                       NewTransaction(
-                               TranNotifyChatSubject,
-                               c.ID,
-                               NewField(FieldChatID, chatID),
-                               NewField(FieldChatSubject, t.GetField(FieldChatSubject).Data),
-                       ),
-               )
-       }
-
-       return res
-}
-
-// HandleMakeAlias makes a file alias using the specified path.
-// Fields used in the request:
-// 201 File Name
-// 202 File path
-// 212 File new path   Destination path
-//
-// Fields used in the reply:
-// None
-func HandleMakeAlias(cc *ClientConn, t *Transaction) (res []Transaction) {
-       if !cc.Authorize(AccessMakeAlias) {
-               return cc.NewErrReply(t, "You are not allowed to make aliases.")
-       }
-       fileName := t.GetField(FieldFileName).Data
-       filePath := t.GetField(FieldFilePath).Data
-       fileNewPath := t.GetField(FieldFileNewPath).Data
-
-       fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
-       if err != nil {
-               return res
-       }
-
-       fullNewFilePath, err := readPath(cc.Server.Config.FileRoot, fileNewPath, fileName)
-       if err != nil {
-               return res
-       }
-
-       cc.logger.Debug("Make alias", "src", fullFilePath, "dst", fullNewFilePath)
-
-       if err := cc.Server.FS.Symlink(fullFilePath, fullNewFilePath); err != nil {
-               return cc.NewErrReply(t, "Error creating alias")
-       }
-
-       res = append(res, cc.NewReply(t))
-       return res
-}
-
-// HandleDownloadBanner handles requests for a new banner from the server
-// Fields used in the request:
-// None
-// Fields used in the reply:
-// 107 FieldRefNum                     Used later for transfer
-// 108 FieldTransferSize       Size of data to be downloaded
-func HandleDownloadBanner(cc *ClientConn, t *Transaction) (res []Transaction) {
-       ft := cc.newFileTransfer(BannerDownload, []byte{}, []byte{}, make([]byte, 4))
-       binary.BigEndian.PutUint32(ft.TransferSize, uint32(len(cc.Server.banner)))
-
-       return append(res, cc.NewReply(t,
-               NewField(FieldRefNum, ft.refNum[:]),
-               NewField(FieldTransferSize, ft.TransferSize),
-       ))
-}
+const LimitChatMsg = 8192
diff --git a/hotline/transaction_handlers_test.go b/hotline/transaction_handlers_test.go
deleted file mode 100644 (file)
index 610e113..0000000
+++ /dev/null
@@ -1,3693 +0,0 @@
-package hotline
-
-import (
-       "errors"
-       "github.com/stretchr/testify/assert"
-       "github.com/stretchr/testify/mock"
-       "io"
-       "io/fs"
-       "os"
-       "path/filepath"
-       "strings"
-       "testing"
-       "time"
-)
-
-func TestHandleSetChatSubject(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name string
-               args args
-               want []Transaction
-       }{
-               {
-                       name: "sends chat subject to private chat members",
-                       args: args{
-                               cc: &ClientConn{
-                                       UserName: []byte{0x00, 0x01},
-                                       Server: &Server{
-                                               ChatMgr: func() *MockChatManager {
-                                                       m := MockChatManager{}
-                                                       m.On("Members", ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*ClientConn{
-                                                               {
-                                                                       Account: &Account{
-                                                                               Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
-                                                                       },
-                                                                       ID: [2]byte{0, 1},
-                                                               },
-                                                               {
-                                                                       Account: &Account{
-                                                                               Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
-                                                                       },
-                                                                       ID: [2]byte{0, 2},
-                                                               },
-                                                       })
-                                                       m.On("SetSubject", ChatID{0x0, 0x0, 0x0, 0x1}, "Test Subject")
-                                                       return &m
-                                               }(),
-                                               //PrivateChats: map[[4]byte]*PrivateChat{
-                                               //      [4]byte{0, 0, 0, 1}: {
-                                               //              Subject: "unset",
-                                               //              ClientConn: map[[2]byte]*ClientConn{
-                                               //                      [2]byte{0, 1}: {
-                                               //                              Account: &Account{
-                                               //                                      Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
-                                               //                              },
-                                               //                              ID: [2]byte{0, 1},
-                                               //                      },
-                                               //                      [2]byte{0, 2}: {
-                                               //                              Account: &Account{
-                                               //                                      Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
-                                               //                              },
-                                               //                              ID: [2]byte{0, 2},
-                                               //                      },
-                                               //              },
-                                               //      },
-                                               //},
-                                               ClientMgr: func() *MockClientMgr {
-                                                       m := MockClientMgr{}
-                                                       m.On("List").Return([]*ClientConn{
-                                                               {
-                                                                       Account: &Account{
-                                                                               Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
-                                                                       },
-                                                                       ID: [2]byte{0, 1},
-                                                               },
-                                                               {
-                                                                       Account: &Account{
-                                                                               Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
-                                                                       },
-                                                                       ID: [2]byte{0, 2},
-                                                               },
-                                                       },
-                                                       )
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: Transaction{
-                                       Type: [2]byte{0, 0x6a},
-                                       ID:   [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldChatID, []byte{0, 0, 0, 1}),
-                                               NewField(FieldChatSubject, []byte("Test Subject")),
-                                       },
-                               },
-                       },
-                       want: []Transaction{
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       Type:     [2]byte{0, 0x77},
-                                       Fields: []Field{
-                                               NewField(FieldChatID, []byte{0, 0, 0, 1}),
-                                               NewField(FieldChatSubject, []byte("Test Subject")),
-                                       },
-                               },
-                               {
-                                       clientID: [2]byte{0, 2},
-                                       Type:     [2]byte{0, 0x77},
-                                       Fields: []Field{
-                                               NewField(FieldChatID, []byte{0, 0, 0, 1}),
-                                               NewField(FieldChatSubject, []byte("Test Subject")),
-                                       },
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       got := HandleSetChatSubject(tt.args.cc, &tt.args.t)
-                       if !tranAssertEqual(t, tt.want, got) {
-                               t.Errorf("HandleSetChatSubject() got = %v, want %v", got, tt.want)
-                       }
-               })
-       }
-}
-
-func TestHandleLeaveChat(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name string
-               args args
-               want []Transaction
-       }{
-               {
-                       name: "when client 2 leaves chat",
-                       args: args{
-                               cc: &ClientConn{
-                                       ID: [2]byte{0, 2},
-                                       Server: &Server{
-                                               ChatMgr: func() *MockChatManager {
-                                                       m := MockChatManager{}
-                                                       m.On("Members", ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*ClientConn{
-                                                               {
-                                                                       Account: &Account{
-                                                                               Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
-                                                                       },
-                                                                       ID: [2]byte{0, 1},
-                                                               },
-                                                       })
-                                                       m.On("Leave", ChatID{0x0, 0x0, 0x0, 0x1}, [2]uint8{0x0, 0x2})
-                                                       m.On("GetSubject").Return("unset")
-                                                       return &m
-                                               }(),
-                                               ClientMgr: func() *MockClientMgr {
-                                                       m := MockClientMgr{}
-                                                       m.On("Get").Return([]*ClientConn{
-                                                               {
-                                                                       Account: &Account{
-                                                                               Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
-                                                                       },
-                                                                       ID: [2]byte{0, 1},
-                                                               },
-                                                               {
-                                                                       Account: &Account{
-                                                                               Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
-                                                                       },
-                                                                       ID: [2]byte{0, 2},
-                                                               },
-                                                       },
-                                                       )
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(TranDeleteUser, [2]byte{}, NewField(FieldChatID, []byte{0, 0, 0, 1})),
-                       },
-                       want: []Transaction{
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       Type:     [2]byte{0, 0x76},
-                                       Fields: []Field{
-                                               NewField(FieldChatID, []byte{0, 0, 0, 1}),
-                                               NewField(FieldUserID, []byte{0, 2}),
-                                       },
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       got := HandleLeaveChat(tt.args.cc, &tt.args.t)
-                       if !tranAssertEqual(t, tt.want, got) {
-                               t.Errorf("HandleLeaveChat() got = %v, want %v", got, tt.want)
-                       }
-               })
-       }
-}
-
-func TestHandleGetUserNameList(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name string
-               args args
-               want []Transaction
-       }{
-               {
-                       name: "replies with userlist transaction",
-                       args: args{
-                               cc: &ClientConn{
-                                       ID: [2]byte{0, 1},
-                                       Server: &Server{
-                                               ClientMgr: func() *MockClientMgr {
-                                                       m := MockClientMgr{}
-                                                       m.On("List").Return([]*ClientConn{
-                                                               {
-                                                                       ID:       [2]byte{0, 1},
-                                                                       Icon:     []byte{0, 2},
-                                                                       Flags:    [2]byte{0, 3},
-                                                                       UserName: []byte{0, 4},
-                                                               },
-                                                               {
-                                                                       ID:       [2]byte{0, 2},
-                                                                       Icon:     []byte{0, 2},
-                                                                       Flags:    [2]byte{0, 3},
-                                                                       UserName: []byte{0, 4},
-                                                               },
-                                                       },
-                                                       )
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: Transaction{},
-                       },
-                       want: []Transaction{
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       IsReply:  0x01,
-                                       Fields: []Field{
-                                               NewField(
-                                                       FieldUsernameWithInfo,
-                                                       []byte{00, 01, 00, 02, 00, 03, 00, 02, 00, 04},
-                                               ),
-                                               NewField(
-                                                       FieldUsernameWithInfo,
-                                                       []byte{00, 02, 00, 02, 00, 03, 00, 02, 00, 04},
-                                               ),
-                                       },
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       got := HandleGetUserNameList(tt.args.cc, &tt.args.t)
-                       assert.Equal(t, tt.want, got)
-               })
-       }
-}
-
-func TestHandleChatSend(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name string
-               args args
-               want []Transaction
-       }{
-               {
-                       name: "sends chat msg transaction to all clients",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessSendChat)
-                                                       return bits
-                                               }(),
-                                       },
-                                       UserName: []byte{0x00, 0x01},
-                                       Server: &Server{
-                                               ClientMgr: func() *MockClientMgr {
-                                                       m := MockClientMgr{}
-                                                       m.On("List").Return([]*ClientConn{
-                                                               {
-                                                                       Account: &Account{
-                                                                               Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
-                                                                       },
-                                                                       ID: [2]byte{0, 1},
-                                                               },
-                                                               {
-                                                                       Account: &Account{
-                                                                               Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
-                                                                       },
-                                                                       ID: [2]byte{0, 2},
-                                                               },
-                                                       },
-                                                       )
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: Transaction{
-                                       Fields: []Field{
-                                               NewField(FieldData, []byte("hai")),
-                                       },
-                               },
-                       },
-                       want: []Transaction{
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       Flags:    0x00,
-                                       IsReply:  0x00,
-                                       Type:     [2]byte{0, 0x6a},
-                                       Fields: []Field{
-                                               NewField(FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
-                                       },
-                               },
-                               {
-                                       clientID: [2]byte{0, 2},
-                                       Flags:    0x00,
-                                       IsReply:  0x00,
-                                       Type:     [2]byte{0, 0x6a},
-                                       Fields: []Field{
-                                               NewField(FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "treats Chat Type 00 00 00 00 as a public chat message",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessSendChat)
-                                                       return bits
-                                               }(),
-                                       },
-                                       UserName: []byte{0x00, 0x01},
-                                       Server: &Server{
-                                               ClientMgr: func() *MockClientMgr {
-                                                       m := MockClientMgr{}
-                                                       m.On("List").Return([]*ClientConn{
-                                                               {
-                                                                       Account: &Account{
-                                                                               Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
-                                                                       },
-                                                                       ID: [2]byte{0, 1},
-                                                               },
-                                                               {
-                                                                       Account: &Account{
-                                                                               Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
-                                                                       },
-                                                                       ID: [2]byte{0, 2},
-                                                               },
-                                                       },
-                                                       )
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: Transaction{
-                                       Fields: []Field{
-                                               NewField(FieldData, []byte("hai")),
-                                               NewField(FieldChatID, []byte{0, 0, 0, 0}),
-                                       },
-                               },
-                       },
-                       want: []Transaction{
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       Type:     [2]byte{0, 0x6a},
-                                       Fields: []Field{
-                                               NewField(FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
-                                       },
-                               },
-                               {
-                                       clientID: [2]byte{0, 2},
-                                       Type:     [2]byte{0, 0x6a},
-                                       Fields: []Field{
-                                               NewField(FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "when user does not have required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-                                               //Accounts: map[string]*Account{},
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranChatSend, [2]byte{0, 1},
-                                       NewField(FieldData, []byte("hai")),
-                               ),
-                       },
-                       want: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to participate in chat.")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "sends chat msg as emote if FieldChatOptions is set to 1",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessSendChat)
-                                                       return bits
-                                               }(),
-                                       },
-                                       UserName: []byte("Testy McTest"),
-                                       Server: &Server{
-                                               ClientMgr: func() *MockClientMgr {
-                                                       m := MockClientMgr{}
-                                                       m.On("List").Return([]*ClientConn{
-                                                               {
-                                                                       Account: &Account{
-                                                                               Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
-                                                                       },
-                                                                       ID: [2]byte{0, 1},
-                                                               },
-                                                               {
-                                                                       Account: &Account{
-                                                                               Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
-                                                                       },
-                                                                       ID: [2]byte{0, 2},
-                                                               },
-                                                       },
-                                                       )
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: Transaction{
-                                       Fields: []Field{
-                                               NewField(FieldData, []byte("performed action")),
-                                               NewField(FieldChatOptions, []byte{0x00, 0x01}),
-                                       },
-                               },
-                       },
-                       want: []Transaction{
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       Flags:    0x00,
-                                       IsReply:  0x00,
-                                       Type:     [2]byte{0, 0x6a},
-                                       Fields: []Field{
-                                               NewField(FieldData, []byte("\r*** Testy McTest performed action")),
-                                       },
-                               },
-                               {
-                                       clientID: [2]byte{0, 2},
-                                       Flags:    0x00,
-                                       IsReply:  0x00,
-                                       Type:     [2]byte{0, 0x6a},
-                                       Fields: []Field{
-                                               NewField(FieldData, []byte("\r*** Testy McTest performed action")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "does not send chat msg as emote if FieldChatOptions is set to 0",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessSendChat)
-                                                       return bits
-                                               }(),
-                                       },
-                                       UserName: []byte("Testy McTest"),
-                                       Server: &Server{
-                                               ClientMgr: func() *MockClientMgr {
-                                                       m := MockClientMgr{}
-                                                       m.On("List").Return([]*ClientConn{
-                                                               {
-                                                                       Account: &Account{
-                                                                               Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
-                                                                       },
-                                                                       ID: [2]byte{0, 1},
-                                                               },
-                                                               {
-                                                                       Account: &Account{
-                                                                               Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
-                                                                       },
-                                                                       ID: [2]byte{0, 2},
-                                                               },
-                                                       },
-                                                       )
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: Transaction{
-                                       Fields: []Field{
-                                               NewField(FieldData, []byte("hello")),
-                                               NewField(FieldChatOptions, []byte{0x00, 0x00}),
-                                       },
-                               },
-                       },
-                       want: []Transaction{
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       Type:     [2]byte{0, 0x6a},
-                                       Fields: []Field{
-                                               NewField(FieldData, []byte("\r Testy McTest:  hello")),
-                                       },
-                               },
-                               {
-                                       clientID: [2]byte{0, 2},
-                                       Type:     [2]byte{0, 0x6a},
-                                       Fields: []Field{
-                                               NewField(FieldData, []byte("\r Testy McTest:  hello")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "only sends chat msg to clients with AccessReadChat permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessSendChat)
-                                                       return bits
-                                               }(),
-                                       },
-                                       UserName: []byte{0x00, 0x01},
-                                       Server: &Server{
-                                               ClientMgr: func() *MockClientMgr {
-                                                       m := MockClientMgr{}
-                                                       m.On("List").Return([]*ClientConn{
-                                                               {
-                                                                       Account: &Account{
-                                                                               Access: func() accessBitmap {
-                                                                                       var bits accessBitmap
-                                                                                       bits.Set(AccessReadChat)
-                                                                                       return bits
-                                                                               }(),
-                                                                       },
-                                                                       ID: [2]byte{0, 1},
-                                                               },
-                                                               {
-                                                                       Account: &Account{},
-                                                                       ID:      [2]byte{0, 2},
-                                                               },
-                                                       },
-                                                       )
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: Transaction{
-                                       Fields: []Field{
-                                               NewField(FieldData, []byte("hai")),
-                                       },
-                               },
-                       },
-                       want: []Transaction{
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       Type:     [2]byte{0, 0x6a},
-                                       Fields: []Field{
-                                               NewField(FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "only sends private chat msg to members of private chat",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessSendChat)
-                                                       return bits
-                                               }(),
-                                       },
-                                       UserName: []byte{0x00, 0x01},
-                                       Server: &Server{
-                                               ChatMgr: func() *MockChatManager {
-                                                       m := MockChatManager{}
-                                                       m.On("Members", ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*ClientConn{
-                                                               {
-                                                                       ID: [2]byte{0, 1},
-                                                               },
-                                                               {
-                                                                       ID: [2]byte{0, 2},
-                                                               },
-                                                       })
-                                                       m.On("GetSubject").Return("unset")
-                                                       return &m
-                                               }(),
-                                               ClientMgr: func() *MockClientMgr {
-                                                       m := MockClientMgr{}
-                                                       m.On("List").Return([]*ClientConn{
-                                                               {
-                                                                       Account: &Account{
-                                                                               Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
-                                                                       },
-                                                                       ID: [2]byte{0, 1},
-                                                               },
-                                                               {
-                                                                       Account: &Account{
-                                                                               Access: accessBitmap{0, 0, 0, 0, 0, 0, 0, 0},
-                                                                       },
-                                                                       ID: [2]byte{0, 2},
-                                                               },
-                                                               {
-                                                                       Account: &Account{
-                                                                               Access: accessBitmap{0, 0, 0, 0, 0, 0, 0, 0},
-                                                                       },
-                                                                       ID: [2]byte{0, 3},
-                                                               },
-                                                       },
-                                                       )
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: Transaction{
-                                       Fields: []Field{
-                                               NewField(FieldData, []byte("hai")),
-                                               NewField(FieldChatID, []byte{0, 0, 0, 1}),
-                                       },
-                               },
-                       },
-                       want: []Transaction{
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       Type:     [2]byte{0, 0x6a},
-                                       Fields: []Field{
-                                               NewField(FieldChatID, []byte{0, 0, 0, 1}),
-                                               NewField(FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
-                                       },
-                               },
-                               {
-                                       clientID: [2]byte{0, 2},
-                                       Type:     [2]byte{0, 0x6a},
-                                       Fields: []Field{
-                                               NewField(FieldChatID, []byte{0, 0, 0, 1}),
-                                               NewField(FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
-                                       },
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       got := HandleChatSend(tt.args.cc, &tt.args.t)
-                       tranAssertEqual(t, tt.want, got)
-               })
-       }
-}
-
-func TestHandleGetFileInfo(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "returns expected fields when a valid file is requested",
-                       args: args{
-                               cc: &ClientConn{
-                                       ID: [2]byte{0x00, 0x01},
-                                       Server: &Server{
-                                               FS: &OSFileStore{},
-                                               Config: Config{
-                                                       FileRoot: func() string {
-                                                               path, _ := os.Getwd()
-                                                               return filepath.Join(path, "/test/config/Files")
-                                                       }(),
-                                               },
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranGetFileInfo, [2]byte{},
-                                       NewField(FieldFileName, []byte("testfile.txt")),
-                                       NewField(FieldFilePath, []byte{0x00, 0x00}),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       IsReply:  0x01,
-                                       Type:     [2]byte{0, 0},
-                                       Fields: []Field{
-                                               NewField(FieldFileName, []byte("testfile.txt")),
-                                               NewField(FieldFileTypeString, []byte("Text File")),
-                                               NewField(FieldFileCreatorString, []byte("ttxt")),
-                                               NewField(FieldFileType, []byte("TEXT")),
-                                               NewField(FieldFileCreateDate, make([]byte, 8)),
-                                               NewField(FieldFileModifyDate, make([]byte, 8)),
-                                               NewField(FieldFileSize, []byte{0x0, 0x0, 0x0, 0x17}),
-                                       },
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleGetFileInfo(tt.args.cc, &tt.args.t)
-
-                       // Clear the file timestamp fields to work around problems running the tests in multiple timezones
-                       // TODO: revisit how to test this by mocking the stat calls
-                       gotRes[0].Fields[4].Data = make([]byte, 8)
-                       gotRes[0].Fields[5].Data = make([]byte, 8)
-
-                       if !tranAssertEqual(t, tt.wantRes, gotRes) {
-                               t.Errorf("HandleGetFileInfo() gotRes = %v, want %v", gotRes, tt.wantRes)
-                       }
-               })
-       }
-}
-
-func TestHandleNewFolder(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "without required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       return bits
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranNewFolder,
-                                       [2]byte{0, 0},
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to create folders.")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "when path is nested",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessCreateFolder)
-                                                       return bits
-                                               }(),
-                                       },
-                                       ID: [2]byte{0, 1},
-                                       Server: &Server{
-                                               Config: Config{
-                                                       FileRoot: "/Files/",
-                                               },
-                                               FS: func() *MockFileStore {
-                                                       mfs := &MockFileStore{}
-                                                       mfs.On("Mkdir", "/Files/aaa/testFolder", fs.FileMode(0777)).Return(nil)
-                                                       mfs.On("Stat", "/Files/aaa/testFolder").Return(nil, os.ErrNotExist)
-                                                       return mfs
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranNewFolder, [2]byte{0, 1},
-                                       NewField(FieldFileName, []byte("testFolder")),
-                                       NewField(FieldFilePath, []byte{
-                                               0x00, 0x01,
-                                               0x00, 0x00,
-                                               0x03,
-                                               0x61, 0x61, 0x61,
-                                       }),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       IsReply:  0x01,
-                               },
-                       },
-               },
-               {
-                       name: "when path is not nested",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessCreateFolder)
-                                                       return bits
-                                               }(),
-                                       },
-                                       ID: [2]byte{0, 1},
-                                       Server: &Server{
-                                               Config: Config{
-                                                       FileRoot: "/Files",
-                                               },
-                                               FS: func() *MockFileStore {
-                                                       mfs := &MockFileStore{}
-                                                       mfs.On("Mkdir", "/Files/testFolder", fs.FileMode(0777)).Return(nil)
-                                                       mfs.On("Stat", "/Files/testFolder").Return(nil, os.ErrNotExist)
-                                                       return mfs
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranNewFolder, [2]byte{0, 1},
-                                       NewField(FieldFileName, []byte("testFolder")),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       IsReply:  0x01,
-                               },
-                       },
-               },
-               {
-                       name: "when Write returns an err",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessCreateFolder)
-                                                       return bits
-                                               }(),
-                                       },
-                                       ID: [2]byte{0, 1},
-                                       Server: &Server{
-                                               Config: Config{
-                                                       FileRoot: "/Files/",
-                                               },
-                                               FS: func() *MockFileStore {
-                                                       mfs := &MockFileStore{}
-                                                       mfs.On("Mkdir", "/Files/aaa/testFolder", fs.FileMode(0777)).Return(nil)
-                                                       mfs.On("Stat", "/Files/aaa/testFolder").Return(nil, os.ErrNotExist)
-                                                       return mfs
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranNewFolder, [2]byte{0, 1},
-                                       NewField(FieldFileName, []byte("testFolder")),
-                                       NewField(FieldFilePath, []byte{
-                                               0x00,
-                                       }),
-                               ),
-                       },
-                       wantRes: []Transaction{},
-               },
-               {
-                       name: "FieldFileName does not allow directory traversal",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessCreateFolder)
-                                                       return bits
-                                               }(),
-                                       },
-                                       ID: [2]byte{0, 1},
-                                       Server: &Server{
-                                               Config: Config{
-                                                       FileRoot: "/Files/",
-                                               },
-                                               FS: func() *MockFileStore {
-                                                       mfs := &MockFileStore{}
-                                                       mfs.On("Mkdir", "/Files/testFolder", fs.FileMode(0777)).Return(nil)
-                                                       mfs.On("Stat", "/Files/testFolder").Return(nil, os.ErrNotExist)
-                                                       return mfs
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranNewFolder, [2]byte{0, 1},
-                                       NewField(FieldFileName, []byte("../../testFolder")),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       IsReply:  0x01,
-                               },
-                       },
-               },
-               {
-                       name: "FieldFilePath does not allow directory traversal",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessCreateFolder)
-                                                       return bits
-                                               }(),
-                                       },
-                                       ID: [2]byte{0, 1},
-                                       Server: &Server{
-                                               Config: Config{
-                                                       FileRoot: "/Files/",
-                                               },
-                                               FS: func() *MockFileStore {
-                                                       mfs := &MockFileStore{}
-                                                       mfs.On("Mkdir", "/Files/foo/testFolder", fs.FileMode(0777)).Return(nil)
-                                                       mfs.On("Stat", "/Files/foo/testFolder").Return(nil, os.ErrNotExist)
-                                                       return mfs
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranNewFolder, [2]byte{0, 1},
-                                       NewField(FieldFileName, []byte("testFolder")),
-                                       NewField(FieldFilePath, []byte{
-                                               0x00, 0x02,
-                                               0x00, 0x00,
-                                               0x03,
-                                               0x2e, 0x2e, 0x2f,
-                                               0x00, 0x00,
-                                               0x03,
-                                               0x66, 0x6f, 0x6f,
-                                       }),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       IsReply:  0x01,
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleNewFolder(tt.args.cc, &tt.args.t)
-
-                       if !tranAssertEqual(t, tt.wantRes, gotRes) {
-                               t.Errorf("HandleNewFolder() gotRes = %v, want %v", gotRes, tt.wantRes)
-                       }
-               })
-       }
-}
-
-func TestHandleUploadFile(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "when request is valid and user has Upload Anywhere permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Server: &Server{
-                                               FS:              &OSFileStore{},
-                                               FileTransferMgr: NewMemFileTransferMgr(),
-                                               Config: Config{
-                                                       FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(),
-                                               }},
-                                       ClientFileTransferMgr: NewClientFileTransferMgr(),
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessUploadFile)
-                                                       bits.Set(AccessUploadAnywhere)
-                                                       return bits
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranUploadFile, [2]byte{0, 1},
-                                       NewField(FieldFileName, []byte("testFile")),
-                                       NewField(FieldFilePath, []byte{
-                                               0x00, 0x01,
-                                               0x00, 0x00,
-                                               0x03,
-                                               0x2e, 0x2e, 0x2f,
-                                       }),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply: 0x01,
-                                       Fields: []Field{
-                                               NewField(FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}), // rand.Seed(1)
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "when user does not have required access",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       return bits
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranUploadFile, [2]byte{0, 1},
-                                       NewField(FieldFileName, []byte("testFile")),
-                                       NewField(FieldFilePath, []byte{
-                                               0x00, 0x01,
-                                               0x00, 0x00,
-                                               0x03,
-                                               0x2e, 0x2e, 0x2f,
-                                       }),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to upload files.")), // rand.Seed(1)
-                                       },
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleUploadFile(tt.args.cc, &tt.args.t)
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleMakeAlias(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "with valid input and required permissions",
-                       args: args{
-                               cc: &ClientConn{
-                                       logger: NewTestLogger(),
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessMakeAlias)
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-                                               Config: Config{
-                                                       FileRoot: func() string {
-                                                               path, _ := os.Getwd()
-                                                               return path + "/test/config/Files"
-                                                       }(),
-                                               },
-                                               Logger: NewTestLogger(),
-                                               FS: func() *MockFileStore {
-                                                       mfs := &MockFileStore{}
-                                                       path, _ := os.Getwd()
-                                                       mfs.On(
-                                                               "Symlink",
-                                                               path+"/test/config/Files/foo/testFile",
-                                                               path+"/test/config/Files/bar/testFile",
-                                                       ).Return(nil)
-                                                       return mfs
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranMakeFileAlias, [2]byte{0, 1},
-                                       NewField(FieldFileName, []byte("testFile")),
-                                       NewField(FieldFilePath, EncodeFilePath(strings.Join([]string{"foo"}, "/"))),
-                                       NewField(FieldFileNewPath, EncodeFilePath(strings.Join([]string{"bar"}, "/"))),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply: 0x01,
-                                       Fields:  []Field(nil),
-                               },
-                       },
-               },
-               {
-                       name: "when symlink returns an error",
-                       args: args{
-                               cc: &ClientConn{
-                                       logger: NewTestLogger(),
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessMakeAlias)
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-                                               Config: Config{
-                                                       FileRoot: func() string {
-                                                               path, _ := os.Getwd()
-                                                               return path + "/test/config/Files"
-                                                       }(),
-                                               },
-                                               Logger: NewTestLogger(),
-                                               FS: func() *MockFileStore {
-                                                       mfs := &MockFileStore{}
-                                                       path, _ := os.Getwd()
-                                                       mfs.On(
-                                                               "Symlink",
-                                                               path+"/test/config/Files/foo/testFile",
-                                                               path+"/test/config/Files/bar/testFile",
-                                                       ).Return(errors.New("ohno"))
-                                                       return mfs
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranMakeFileAlias, [2]byte{0, 1},
-                                       NewField(FieldFileName, []byte("testFile")),
-                                       NewField(FieldFilePath, EncodeFilePath(strings.Join([]string{"foo"}, "/"))),
-                                       NewField(FieldFileNewPath, EncodeFilePath(strings.Join([]string{"bar"}, "/"))),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("Error creating alias")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "when user does not have required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       logger: NewTestLogger(),
-                                       Account: &Account{
-                                               Access: accessBitmap{},
-                                       },
-                                       Server: &Server{
-                                               Config: Config{
-                                                       FileRoot: func() string {
-                                                               path, _ := os.Getwd()
-                                                               return path + "/test/config/Files"
-                                                       }(),
-                                               },
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranMakeFileAlias, [2]byte{0, 1},
-                                       NewField(FieldFileName, []byte("testFile")),
-                                       NewField(FieldFilePath, []byte{
-                                               0x00, 0x01,
-                                               0x00, 0x00,
-                                               0x03,
-                                               0x2e, 0x2e, 0x2e,
-                                       }),
-                                       NewField(FieldFileNewPath, []byte{
-                                               0x00, 0x01,
-                                               0x00, 0x00,
-                                               0x03,
-                                               0x2e, 0x2e, 0x2e,
-                                       }),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to make aliases.")),
-                                       },
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleMakeAlias(tt.args.cc, &tt.args.t)
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleGetUser(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "when account is valid",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessOpenUser)
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-                                               AccountManager: func() *MockAccountManager {
-                                                       m := MockAccountManager{}
-                                                       m.On("Get", "guest").Return(&Account{
-                                                               Login:    "guest",
-                                                               Name:     "Guest",
-                                                               Password: "password",
-                                                               Access:   accessBitmap{},
-                                                       })
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranGetUser, [2]byte{0, 1},
-                                       NewField(FieldUserLogin, []byte("guest")),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply: 0x01,
-                                       Fields: []Field{
-                                               NewField(FieldUserName, []byte("Guest")),
-                                               NewField(FieldUserLogin, encodeString([]byte("guest"))),
-                                               NewField(FieldUserPassword, []byte("password")),
-                                               NewField(FieldUserAccess, []byte{0, 0, 0, 0, 0, 0, 0, 0}),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "when user does not have required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-                                               //Accounts: map[string]*Account{},
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranGetUser, [2]byte{0, 1},
-                                       NewField(FieldUserLogin, []byte("nonExistentUser")),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to view accounts.")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "when account does not exist",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessOpenUser)
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-                                               AccountManager: func() *MockAccountManager {
-                                                       m := MockAccountManager{}
-                                                       m.On("Get", "nonExistentUser").Return((*Account)(nil))
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranGetUser, [2]byte{0, 1},
-                                       NewField(FieldUserLogin, []byte("nonExistentUser")),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       Flags:     0x00,
-                                       IsReply:   0x01,
-                                       Type:      [2]byte{0, 0},
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("Account does not exist.")),
-                                       },
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleGetUser(tt.args.cc, &tt.args.t)
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleDeleteUser(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "when user exists",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessDeleteUser)
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-                                               AccountManager: func() *MockAccountManager {
-                                                       m := MockAccountManager{}
-                                                       m.On("Delete", "testuser").Return(nil)
-                                                       return &m
-                                               }(),
-                                               ClientMgr: func() *MockClientMgr {
-                                                       m := MockClientMgr{}
-                                                       m.On("List").Return([]*ClientConn{}) // TODO
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranDeleteUser, [2]byte{0, 1},
-                                       NewField(FieldUserLogin, encodeString([]byte("testuser"))),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       Flags:   0x00,
-                                       IsReply: 0x01,
-                                       Type:    [2]byte{0, 0},
-                                       Fields:  []Field(nil),
-                               },
-                       },
-               },
-               {
-                       name: "when user does not have required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: accessBitmap{},
-                                       },
-                                       Server: &Server{
-                                               //Accounts: map[string]*Account{},
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranDeleteUser, [2]byte{0, 1},
-                                       NewField(FieldUserLogin, encodeString([]byte("testuser"))),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to delete accounts.")),
-                                       },
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleDeleteUser(tt.args.cc, &tt.args.t)
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleGetMsgs(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "returns news data",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessNewsReadArt)
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-                                               MessageBoard: func() *mockReadWriteSeeker {
-                                                       m := mockReadWriteSeeker{}
-                                                       m.On("Seek", int64(0), 0).Return(int64(0), nil)
-                                                       m.On("Read", mock.AnythingOfType("[]uint8")).Run(func(args mock.Arguments) {
-                                                               arg := args.Get(0).([]uint8)
-                                                               copy(arg, "TEST")
-                                                       }).Return(4, io.EOF)
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranGetMsgs, [2]byte{0, 1},
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply: 0x01,
-                                       Fields: []Field{
-                                               NewField(FieldData, []byte("TEST")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "when user does not have required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: accessBitmap{},
-                                       },
-                                       Server: &Server{
-                                               //Accounts: map[string]*Account{},
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranGetMsgs, [2]byte{0, 1},
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to read news.")),
-                                       },
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleGetMsgs(tt.args.cc, &tt.args.t)
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleNewUser(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "when user does not have required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-                                               //Accounts: map[string]*Account{},
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranNewUser, [2]byte{0, 1},
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to create new accounts.")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "when user attempts to create account with greater access",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessCreateUser)
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-                                               AccountManager: func() *MockAccountManager {
-                                                       m := MockAccountManager{}
-                                                       m.On("Get", "userB").Return((*Account)(nil))
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranNewUser, [2]byte{0, 1},
-                                       NewField(FieldUserLogin, encodeString([]byte("userB"))),
-                                       NewField(
-                                               FieldUserAccess,
-                                               func() []byte {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessDisconUser)
-                                                       return bits[:]
-                                               }(),
-                                       ),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("Cannot create account with more access than yourself.")),
-                                       },
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleNewUser(tt.args.cc, &tt.args.t)
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleListUsers(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "when user does not have required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-                                               //Accounts: map[string]*Account{},
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranNewUser, [2]byte{0, 1},
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to view accounts.")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "when user has required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessOpenUser)
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-                                               AccountManager: func() *MockAccountManager {
-                                                       m := MockAccountManager{}
-                                                       m.On("List").Return([]Account{
-                                                               {
-                                                                       Name:     "guest",
-                                                                       Login:    "guest",
-                                                                       Password: "zz",
-                                                                       Access:   accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
-                                                               },
-                                                       })
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranGetClientInfoText, [2]byte{0, 1},
-                                       NewField(FieldUserID, []byte{0, 1}),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply: 0x01,
-                                       Fields: []Field{
-                                               NewField(FieldData, []byte{
-                                                       0x00, 0x04, 0x00, 0x66, 0x00, 0x05, 0x67, 0x75, 0x65, 0x73, 0x74, 0x00, 0x69, 0x00, 0x05, 0x98,
-                                                       0x8a, 0x9a, 0x8c, 0x8b, 0x00, 0x6e, 0x00, 0x08, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
-                                                       0x00, 0x6a, 0x00, 0x01, 0x78,
-                                               }),
-                                       },
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleListUsers(tt.args.cc, &tt.args.t)
-
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleDownloadFile(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "when user does not have required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{},
-                               },
-                               t: NewTransaction(TranDownloadFile, [2]byte{0, 1}),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to download files.")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "with a valid file",
-                       args: args{
-                               cc: &ClientConn{
-                                       ClientFileTransferMgr: NewClientFileTransferMgr(),
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessDownloadFile)
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-                                               FS:              &OSFileStore{},
-                                               FileTransferMgr: NewMemFileTransferMgr(),
-                                               Config: Config{
-                                                       FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(),
-                                               },
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranDownloadFile,
-                                       [2]byte{0, 1},
-                                       NewField(FieldFileName, []byte("testfile.txt")),
-                                       NewField(FieldFilePath, []byte{0x0, 0x00}),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply: 0x01,
-                                       Fields: []Field{
-                                               NewField(FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}),
-                                               NewField(FieldWaitingCount, []byte{0x00, 0x00}),
-                                               NewField(FieldTransferSize, []byte{0x00, 0x00, 0x00, 0xa5}),
-                                               NewField(FieldFileSize, []byte{0x00, 0x00, 0x00, 0x17}),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "when client requests to resume 1k test file at offset 256",
-                       args: args{
-                               cc: &ClientConn{
-                                       ClientFileTransferMgr: NewClientFileTransferMgr(),
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessDownloadFile)
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-                                               FS: &OSFileStore{},
-
-                                               // FS: func() *MockFileStore {
-                                               //      path, _ := os.Getwd()
-                                               //      testFile, err := os.Open(path + "/test/config/Files/testfile-1k")
-                                               //      if err != nil {
-                                               //              panic(err)
-                                               //      }
-                                               //
-                                               //      mfi := &MockFileInfo{}
-                                               //      mfi.On("Mode").Return(fs.FileMode(0))
-                                               //      mfs := &MockFileStore{}
-                                               //      mfs.On("Stat", "/fakeRoot/Files/testfile.txt").Return(mfi, nil)
-                                               //      mfs.On("Open", "/fakeRoot/Files/testfile.txt").Return(testFile, nil)
-                                               //      mfs.On("Stat", "/fakeRoot/Files/.info_testfile.txt").Return(nil, errors.New("no"))
-                                               //      mfs.On("Stat", "/fakeRoot/Files/.rsrc_testfile.txt").Return(nil, errors.New("no"))
-                                               //
-                                               //      return mfs
-                                               // }(),
-                                               FileTransferMgr: NewMemFileTransferMgr(),
-                                               Config: Config{
-                                                       FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(),
-                                               },
-                                               //Accounts: map[string]*Account{},
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranDownloadFile,
-                                       [2]byte{0, 1},
-                                       NewField(FieldFileName, []byte("testfile-1k")),
-                                       NewField(FieldFilePath, []byte{0x00, 0x00}),
-                                       NewField(
-                                               FieldFileResumeData,
-                                               func() []byte {
-                                                       frd := FileResumeData{
-                                                               ForkCount: [2]byte{0, 2},
-                                                               ForkInfoList: []ForkInfoList{
-                                                                       {
-                                                                               Fork:     [4]byte{0x44, 0x41, 0x54, 0x41}, // "DATA"
-                                                                               DataSize: [4]byte{0, 0, 0x01, 0x00},       // request offset 256
-                                                                       },
-                                                                       {
-                                                                               Fork:     [4]byte{0x4d, 0x41, 0x43, 0x52}, // "MACR"
-                                                                               DataSize: [4]byte{0, 0, 0, 0},
-                                                                       },
-                                                               },
-                                                       }
-                                                       b, _ := frd.BinaryMarshal()
-                                                       return b
-                                               }(),
-                                       ),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply: 0x01,
-                                       Fields: []Field{
-                                               NewField(FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}),
-                                               NewField(FieldWaitingCount, []byte{0x00, 0x00}),
-                                               NewField(FieldTransferSize, []byte{0x00, 0x00, 0x03, 0x8d}),
-                                               NewField(FieldFileSize, []byte{0x00, 0x00, 0x03, 0x00}),
-                                       },
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleDownloadFile(tt.args.cc, &tt.args.t)
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleUpdateUser(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "when action is create user without required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       logger: NewTestLogger(),
-                                       Server: &Server{
-                                               AccountManager: func() *MockAccountManager {
-                                                       m := MockAccountManager{}
-                                                       m.On("Get", "bbb").Return((*Account)(nil))
-                                                       return &m
-                                               }(),
-                                               Logger: NewTestLogger(),
-                                       },
-                                       Account: &Account{
-                                               Access: accessBitmap{},
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranUpdateUser,
-                                       [2]byte{0, 0},
-                                       NewField(FieldData, []byte{
-                                               0x00, 0x04, // field count
-
-                                               0x00, 0x69, // FieldUserLogin = 105
-                                               0x00, 0x03,
-                                               0x9d, 0x9d, 0x9d,
-
-                                               0x00, 0x6a, // FieldUserPassword = 106
-                                               0x00, 0x03,
-                                               0x9c, 0x9c, 0x9c,
-
-                                               0x00, 0x66, // FieldUserName = 102
-                                               0x00, 0x03,
-                                               0x61, 0x61, 0x61,
-
-                                               0x00, 0x6e, // FieldUserAccess = 110
-                                               0x00, 0x08,
-                                               0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00,
-                                       }),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to create new accounts.")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "when action is modify user without required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       logger: NewTestLogger(),
-                                       Server: &Server{
-                                               Logger: NewTestLogger(),
-                                               AccountManager: func() *MockAccountManager {
-                                                       m := MockAccountManager{}
-                                                       m.On("Get", "bbb").Return(&Account{})
-                                                       return &m
-                                               }(),
-                                       },
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       return bits
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranUpdateUser,
-                                       [2]byte{0, 0},
-                                       NewField(FieldData, []byte{
-                                               0x00, 0x04, // field count
-
-                                               0x00, 0x69, // FieldUserLogin = 105
-                                               0x00, 0x03,
-                                               0x9d, 0x9d, 0x9d,
-
-                                               0x00, 0x6a, // FieldUserPassword = 106
-                                               0x00, 0x03,
-                                               0x9c, 0x9c, 0x9c,
-
-                                               0x00, 0x66, // FieldUserName = 102
-                                               0x00, 0x03,
-                                               0x61, 0x61, 0x61,
-
-                                               0x00, 0x6e, // FieldUserAccess = 110
-                                               0x00, 0x08,
-                                               0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00,
-                                       }),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to modify accounts.")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "when action is delete user without required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       logger: NewTestLogger(),
-                                       Server: &Server{},
-                                       Account: &Account{
-                                               Access: accessBitmap{},
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranUpdateUser,
-                                       [2]byte{0, 0},
-                                       NewField(FieldData, []byte{
-                                               0x00, 0x01,
-                                               0x00, 0x65,
-                                               0x00, 0x03,
-                                               0x88, 0x9e, 0x8b,
-                                       }),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to delete accounts.")),
-                                       },
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleUpdateUser(tt.args.cc, &tt.args.t)
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleDelNewsArt(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "without required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       return bits
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranDelNewsArt,
-                                       [2]byte{0, 0},
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to delete news articles.")),
-                                       },
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleDelNewsArt(tt.args.cc, &tt.args.t)
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleDisconnectUser(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "without required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       return bits
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranDelNewsArt,
-                                       [2]byte{0, 0},
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to disconnect users.")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "when target user has 'cannot be disconnected' priv",
-                       args: args{
-                               cc: &ClientConn{
-                                       Server: &Server{
-                                               ClientMgr: func() *MockClientMgr {
-                                                       m := MockClientMgr{}
-                                                       m.On("Get", ClientID{0x0, 0x1}).Return(&ClientConn{
-                                                               Account: &Account{
-                                                                       Login: "unnamed",
-                                                                       Access: func() accessBitmap {
-                                                                               var bits accessBitmap
-                                                                               bits.Set(AccessCannotBeDiscon)
-                                                                               return bits
-                                                                       }(),
-                                                               },
-                                                       },
-                                                       )
-                                                       return &m
-                                               }(),
-                                       },
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessDisconUser)
-                                                       return bits
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranDelNewsArt,
-                                       [2]byte{0, 0},
-                                       NewField(FieldUserID, []byte{0, 1}),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("unnamed is not allowed to be disconnected.")),
-                                       },
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleDisconnectUser(tt.args.cc, &tt.args.t)
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleSendInstantMsg(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "without required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       return bits
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranDelNewsArt,
-                                       [2]byte{0, 0},
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to send private messages.")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "when client 1 sends a message to client 2",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessSendPrivMsg)
-                                                       return bits
-                                               }(),
-                                       },
-                                       ID:       [2]byte{0, 1},
-                                       UserName: []byte("User1"),
-                                       Server: &Server{
-                                               ClientMgr: func() *MockClientMgr {
-                                                       m := MockClientMgr{}
-                                                       m.On("Get", ClientID{0x0, 0x2}).Return(&ClientConn{
-                                                               AutoReply: []byte(nil),
-                                                               Flags:     [2]byte{0, 0},
-                                                       },
-                                                       )
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranSendInstantMsg,
-                                       [2]byte{0, 1},
-                                       NewField(FieldData, []byte("hai")),
-                                       NewField(FieldUserID, []byte{0, 2}),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               NewTransaction(
-                                       TranServerMsg,
-                                       [2]byte{0, 2},
-                                       NewField(FieldData, []byte("hai")),
-                                       NewField(FieldUserName, []byte("User1")),
-                                       NewField(FieldUserID, []byte{0, 1}),
-                                       NewField(FieldOptions, []byte{0, 1}),
-                               ),
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       IsReply:  0x01,
-                                       Fields:   []Field(nil),
-                               },
-                       },
-               },
-               {
-                       name: "when client 2 has autoreply enabled",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessSendPrivMsg)
-                                                       return bits
-                                               }(),
-                                       },
-                                       ID:       [2]byte{0, 1},
-                                       UserName: []byte("User1"),
-                                       Server: &Server{
-                                               ClientMgr: func() *MockClientMgr {
-                                                       m := MockClientMgr{}
-                                                       m.On("Get", ClientID{0x0, 0x2}).Return(&ClientConn{
-                                                               Flags:     [2]byte{0, 0},
-                                                               ID:        [2]byte{0, 2},
-                                                               UserName:  []byte("User2"),
-                                                               AutoReply: []byte("autohai"),
-                                                       })
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranSendInstantMsg,
-                                       [2]byte{0, 1},
-                                       NewField(FieldData, []byte("hai")),
-                                       NewField(FieldUserID, []byte{0, 2}),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               NewTransaction(
-                                       TranServerMsg,
-                                       [2]byte{0, 2},
-                                       NewField(FieldData, []byte("hai")),
-                                       NewField(FieldUserName, []byte("User1")),
-                                       NewField(FieldUserID, []byte{0, 1}),
-                                       NewField(FieldOptions, []byte{0, 1}),
-                               ),
-                               NewTransaction(
-                                       TranServerMsg,
-                                       [2]byte{0, 1},
-                                       NewField(FieldData, []byte("autohai")),
-                                       NewField(FieldUserName, []byte("User2")),
-                                       NewField(FieldUserID, []byte{0, 2}),
-                                       NewField(FieldOptions, []byte{0, 1}),
-                               ),
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       IsReply:  0x01,
-                                       Fields:   []Field(nil),
-                               },
-                       },
-               },
-               {
-                       name: "when client 2 has refuse private messages enabled",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessSendPrivMsg)
-                                                       return bits
-                                               }(),
-                                       },
-                                       ID:       [2]byte{0, 1},
-                                       UserName: []byte("User1"),
-                                       Server: &Server{
-                                               ClientMgr: func() *MockClientMgr {
-                                                       m := MockClientMgr{}
-                                                       m.On("Get", ClientID{0x0, 0x2}).Return(&ClientConn{
-                                                               Flags:    [2]byte{255, 255},
-                                                               ID:       [2]byte{0, 2},
-                                                               UserName: []byte("User2"),
-                                                       },
-                                                       )
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranSendInstantMsg,
-                                       [2]byte{0, 1},
-                                       NewField(FieldData, []byte("hai")),
-                                       NewField(FieldUserID, []byte{0, 2}),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               NewTransaction(
-                                       TranServerMsg,
-                                       [2]byte{0, 1},
-                                       NewField(FieldData, []byte("User2 does not accept private messages.")),
-                                       NewField(FieldUserName, []byte("User2")),
-                                       NewField(FieldUserID, []byte{0, 2}),
-                                       NewField(FieldOptions, []byte{0, 2}),
-                               ),
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       IsReply:  0x01,
-                                       Fields:   []Field(nil),
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleSendInstantMsg(tt.args.cc, &tt.args.t)
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleDeleteFile(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "when user does not have required permission to delete a folder",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-                                               Config: Config{
-                                                       FileRoot: func() string {
-                                                               return "/fakeRoot/Files"
-                                                       }(),
-                                               },
-                                               FS: func() *MockFileStore {
-                                                       mfi := &MockFileInfo{}
-                                                       mfi.On("Mode").Return(fs.FileMode(0))
-                                                       mfi.On("Size").Return(int64(100))
-                                                       mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout))
-                                                       mfi.On("IsDir").Return(false)
-                                                       mfi.On("Name").Return("testfile")
-
-                                                       mfs := &MockFileStore{}
-                                                       mfs.On("Stat", "/fakeRoot/Files/aaa/testfile").Return(mfi, nil)
-                                                       mfs.On("Stat", "/fakeRoot/Files/aaa/.info_testfile").Return(nil, errors.New("err"))
-                                                       mfs.On("Stat", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil, errors.New("err"))
-
-                                                       return mfs
-                                               }(),
-                                               //Accounts: map[string]*Account{},
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranDeleteFile, [2]byte{0, 1},
-                                       NewField(FieldFileName, []byte("testfile")),
-                                       NewField(FieldFilePath, []byte{
-                                               0x00, 0x01,
-                                               0x00, 0x00,
-                                               0x03,
-                                               0x61, 0x61, 0x61,
-                                       }),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to delete files.")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "deletes all associated metadata files",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessDeleteFile)
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-                                               Config: Config{
-                                                       FileRoot: func() string {
-                                                               return "/fakeRoot/Files"
-                                                       }(),
-                                               },
-                                               FS: func() *MockFileStore {
-                                                       mfi := &MockFileInfo{}
-                                                       mfi.On("Mode").Return(fs.FileMode(0))
-                                                       mfi.On("Size").Return(int64(100))
-                                                       mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout))
-                                                       mfi.On("IsDir").Return(false)
-                                                       mfi.On("Name").Return("testfile")
-
-                                                       mfs := &MockFileStore{}
-                                                       mfs.On("Stat", "/fakeRoot/Files/aaa/testfile").Return(mfi, nil)
-                                                       mfs.On("Stat", "/fakeRoot/Files/aaa/.info_testfile").Return(nil, errors.New("err"))
-                                                       mfs.On("Stat", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil, errors.New("err"))
-
-                                                       mfs.On("RemoveAll", "/fakeRoot/Files/aaa/testfile").Return(nil)
-                                                       mfs.On("Remove", "/fakeRoot/Files/aaa/testfile.incomplete").Return(nil)
-                                                       mfs.On("Remove", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil)
-                                                       mfs.On("Remove", "/fakeRoot/Files/aaa/.info_testfile").Return(nil)
-
-                                                       return mfs
-                                               }(),
-                                               //Accounts: map[string]*Account{},
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranDeleteFile, [2]byte{0, 1},
-                                       NewField(FieldFileName, []byte("testfile")),
-                                       NewField(FieldFilePath, []byte{
-                                               0x00, 0x01,
-                                               0x00, 0x00,
-                                               0x03,
-                                               0x61, 0x61, 0x61,
-                                       }),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply: 0x01,
-                                       Fields:  []Field(nil),
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleDeleteFile(tt.args.cc, &tt.args.t)
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-
-                       tt.args.cc.Server.FS.(*MockFileStore).AssertExpectations(t)
-               })
-       }
-}
-
-func TestHandleGetFileNameList(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "when FieldFilePath is a drop box, but user does not have AccessViewDropBoxes ",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-
-                                               Config: Config{
-                                                       FileRoot: func() string {
-                                                               path, _ := os.Getwd()
-                                                               return filepath.Join(path, "/test/config/Files/getFileNameListTestDir")
-                                                       }(),
-                                               },
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranGetFileNameList, [2]byte{0, 1},
-                                       NewField(FieldFilePath, []byte{
-                                               0x00, 0x01,
-                                               0x00, 0x00,
-                                               0x08,
-                                               0x64, 0x72, 0x6f, 0x70, 0x20, 0x62, 0x6f, 0x78, // "drop box"
-                                       }),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to view drop boxes.")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "with file root",
-                       args: args{
-                               cc: &ClientConn{
-                                       Server: &Server{
-                                               Config: Config{
-                                                       FileRoot: func() string {
-                                                               path, _ := os.Getwd()
-                                                               return filepath.Join(path, "/test/config/Files/getFileNameListTestDir")
-                                                       }(),
-                                               },
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranGetFileNameList, [2]byte{0, 1},
-                                       NewField(FieldFilePath, []byte{
-                                               0x00, 0x00,
-                                               0x00, 0x00,
-                                       }),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply: 0x01,
-                                       Fields: []Field{
-                                               NewField(
-                                                       FieldFileNameWithInfo,
-                                                       func() []byte {
-                                                               fnwi := FileNameWithInfo{
-                                                                       fileNameWithInfoHeader: fileNameWithInfoHeader{
-                                                                               Type:       [4]byte{0x54, 0x45, 0x58, 0x54},
-                                                                               Creator:    [4]byte{0x54, 0x54, 0x58, 0x54},
-                                                                               FileSize:   [4]byte{0, 0, 0x04, 0},
-                                                                               RSVD:       [4]byte{},
-                                                                               NameScript: [2]byte{},
-                                                                               NameSize:   [2]byte{0, 0x0b},
-                                                                       },
-                                                                       Name: []byte("testfile-1k"),
-                                                               }
-                                                               b, _ := io.ReadAll(&fnwi)
-                                                               return b
-                                                       }(),
-                                               ),
-                                       },
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleGetFileNameList(tt.args.cc, &tt.args.t)
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleGetClientInfoText(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "when user does not have required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-                                               //Accounts: map[string]*Account{},
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranGetClientInfoText, [2]byte{0, 1},
-                                       NewField(FieldUserID, []byte{0, 1}),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to get client info.")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "with a valid user",
-                       args: args{
-                               cc: &ClientConn{
-                                       UserName:   []byte("Testy McTest"),
-                                       RemoteAddr: "1.2.3.4:12345",
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessGetClientInfo)
-                                                       return bits
-                                               }(),
-                                               Name:  "test",
-                                               Login: "test",
-                                       },
-                                       Server: &Server{
-                                               ClientMgr: func() *MockClientMgr {
-                                                       m := MockClientMgr{}
-                                                       m.On("Get", ClientID{0x0, 0x1}).Return(&ClientConn{
-                                                               UserName:   []byte("Testy McTest"),
-                                                               RemoteAddr: "1.2.3.4:12345",
-                                                               Account: &Account{
-                                                                       Access: func() accessBitmap {
-                                                                               var bits accessBitmap
-                                                                               bits.Set(AccessGetClientInfo)
-                                                                               return bits
-                                                                       }(),
-                                                                       Name:  "test",
-                                                                       Login: "test",
-                                                               },
-                                                       },
-                                                       )
-                                                       return &m
-                                               }(),
-                                       },
-                                       ClientFileTransferMgr: ClientFileTransferMgr{},
-                               },
-                               t: NewTransaction(
-                                       TranGetClientInfoText, [2]byte{0, 1},
-                                       NewField(FieldUserID, []byte{0, 1}),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply: 0x01,
-                                       Fields: []Field{
-                                               NewField(FieldData, []byte(
-                                                       strings.ReplaceAll(`Nickname:   Testy McTest
-Name:       test
-Account:    test
-Address:    1.2.3.4:12345
-
--------- File Downloads ---------
-
-None.
-
-------- Folder Downloads --------
-
-None.
-
---------- File Uploads ----------
-
-None.
-
--------- Folder Uploads ---------
-
-None.
-
-------- Waiting Downloads -------
-
-None.
-
-`, "\n", "\r")),
-                                               ),
-                                               NewField(FieldUserName, []byte("Testy McTest")),
-                                       },
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleGetClientInfoText(tt.args.cc, &tt.args.t)
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleTranAgreed(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "normal request flow",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessDisconUser)
-                                                       bits.Set(AccessAnyName)
-                                                       return bits
-                                               }()},
-                                       Icon:    []byte{0, 1},
-                                       Flags:   [2]byte{0, 1},
-                                       Version: []byte{0, 1},
-                                       ID:      [2]byte{0, 1},
-                                       logger:  NewTestLogger(),
-                                       Server: &Server{
-                                               Config: Config{
-                                                       BannerFile: "banner.jpg",
-                                               },
-                                               ClientMgr: func() *MockClientMgr {
-                                                       m := MockClientMgr{}
-                                                       m.On("List").Return([]*ClientConn{
-                                                               //{
-                                                               //      ID:       [2]byte{0, 2},
-                                                               //      UserName: []byte("UserB"),
-                                                               //},
-                                                       },
-                                                       )
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranAgreed, [2]byte{},
-                                       NewField(FieldUserName, []byte("username")),
-                                       NewField(FieldUserIconID, []byte{0, 1}),
-                                       NewField(FieldOptions, []byte{0, 0}),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       Type:     [2]byte{0, 0x7a},
-                                       Fields: []Field{
-                                               NewField(FieldBannerType, []byte("JPEG")),
-                                       },
-                               },
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       IsReply:  0x01,
-                                       Fields:   []Field{},
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleTranAgreed(tt.args.cc, &tt.args.t)
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleSetClientUserInfo(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "when client does not have AccessAnyName",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       return bits
-                                               }(),
-                                       },
-                                       ID:       [2]byte{0, 1},
-                                       UserName: []byte("Guest"),
-                                       Flags:    [2]byte{0, 1},
-                                       Server: &Server{
-                                               ClientMgr: func() *MockClientMgr {
-                                                       m := MockClientMgr{}
-                                                       m.On("List").Return([]*ClientConn{
-                                                               {
-                                                                       ID: [2]byte{0, 1},
-                                                               },
-                                                       })
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranSetClientUserInfo, [2]byte{},
-                                       NewField(FieldUserIconID, []byte{0, 1}),
-                                       NewField(FieldUserName, []byte("NOPE")),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       Type:     [2]byte{0x01, 0x2d},
-                                       Fields: []Field{
-                                               NewField(FieldUserID, []byte{0, 1}),
-                                               NewField(FieldUserIconID, []byte{0, 1}),
-                                               NewField(FieldUserFlags, []byte{0, 1}),
-                                               NewField(FieldUserName, []byte("Guest"))},
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleSetClientUserInfo(tt.args.cc, &tt.args.t)
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleDelNewsItem(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "when user does not have permission to delete a news category",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: accessBitmap{},
-                                       },
-                                       ID: [2]byte{0, 1},
-                                       Server: &Server{
-                                               ThreadedNewsMgr: func() *mockThreadNewsMgr {
-                                                       m := mockThreadNewsMgr{}
-                                                       m.On("NewsItem", []string{"test"}).Return(NewsCategoryListData15{
-                                                               Type: NewsCategory,
-                                                       })
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranDelNewsItem, [2]byte{},
-                                       NewField(FieldNewsPath,
-                                               []byte{
-                                                       0, 1,
-                                                       0, 0,
-                                                       4,
-                                                       0x74, 0x65, 0x73, 0x74,
-                                               },
-                                       ),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       clientID:  [2]byte{0, 1},
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to delete news categories.")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "when user does not have permission to delete a news folder",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: accessBitmap{},
-                                       },
-                                       ID: [2]byte{0, 1},
-                                       Server: &Server{
-                                               ThreadedNewsMgr: func() *mockThreadNewsMgr {
-                                                       m := mockThreadNewsMgr{}
-                                                       m.On("NewsItem", []string{"test"}).Return(NewsCategoryListData15{
-                                                               Type: NewsBundle,
-                                                       })
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranDelNewsItem, [2]byte{},
-                                       NewField(FieldNewsPath,
-                                               []byte{
-                                                       0, 1,
-                                                       0, 0,
-                                                       4,
-                                                       0x74, 0x65, 0x73, 0x74,
-                                               },
-                                       ),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       clientID:  [2]byte{0, 1},
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to delete news folders.")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "when user deletes a news folder",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessNewsDeleteFldr)
-                                                       return bits
-                                               }(),
-                                       },
-                                       ID: [2]byte{0, 1},
-                                       Server: &Server{
-                                               ThreadedNewsMgr: func() *mockThreadNewsMgr {
-                                                       m := mockThreadNewsMgr{}
-                                                       m.On("NewsItem", []string{"test"}).Return(NewsCategoryListData15{Type: NewsBundle})
-                                                       m.On("DeleteNewsItem", []string{"test"}).Return(nil)
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranDelNewsItem, [2]byte{},
-                                       NewField(FieldNewsPath,
-                                               []byte{
-                                                       0, 1,
-                                                       0, 0,
-                                                       4,
-                                                       0x74, 0x65, 0x73, 0x74,
-                                               },
-                                       ),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       IsReply:  0x01,
-                                       Fields:   []Field{},
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleDelNewsItem(tt.args.cc, &tt.args.t)
-
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleTranOldPostNews(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "when user does not have required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: accessBitmap{},
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranOldPostNews, [2]byte{0, 1},
-                                       NewField(FieldData, []byte("hai")),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to post news.")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "when user posts news update",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessNewsPostArt)
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-                                               Config: Config{
-                                                       NewsDateFormat: "",
-                                               },
-                                               ClientMgr: func() *MockClientMgr {
-                                                       m := MockClientMgr{}
-                                                       m.On("List").Return([]*ClientConn{})
-                                                       return &m
-                                               }(),
-                                               MessageBoard: func() *mockReadWriteSeeker {
-                                                       m := mockReadWriteSeeker{}
-                                                       m.On("Seek", int64(0), 0).Return(int64(0), nil)
-                                                       m.On("Read", mock.AnythingOfType("[]uint8")).Run(func(args mock.Arguments) {
-                                                               arg := args.Get(0).([]uint8)
-                                                               copy(arg, "TEST")
-                                                       }).Return(4, io.EOF)
-                                                       m.On("Write", mock.AnythingOfType("[]uint8")).Return(3, nil)
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranOldPostNews, [2]byte{0, 1},
-                                       NewField(FieldData, []byte("hai")),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply: 0x01,
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleTranOldPostNews(tt.args.cc, &tt.args.t)
-
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleInviteNewChat(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "when user does not have required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       return bits
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(TranInviteNewChat, [2]byte{0, 1}),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to request private chat.")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "when userA invites userB to new private chat",
-                       args: args{
-                               cc: &ClientConn{
-                                       ID: [2]byte{0, 1},
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessOpenChat)
-                                                       return bits
-                                               }(),
-                                       },
-                                       UserName: []byte("UserA"),
-                                       Icon:     []byte{0, 1},
-                                       Flags:    [2]byte{0, 0},
-                                       Server: &Server{
-                                               ClientMgr: func() *MockClientMgr {
-                                                       m := MockClientMgr{}
-                                                       m.On("Get", ClientID{0x0, 0x2}).Return(&ClientConn{
-                                                               ID:       [2]byte{0, 2},
-                                                               UserName: []byte("UserB"),
-                                                       })
-                                                       return &m
-                                               }(),
-                                               ChatMgr: func() *MockChatManager {
-                                                       m := MockChatManager{}
-                                                       m.On("New", mock.AnythingOfType("*hotline.ClientConn")).Return(ChatID{0x52, 0xfd, 0xfc, 0x07})
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranInviteNewChat, [2]byte{0, 1},
-                                       NewField(FieldUserID, []byte{0, 2}),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       clientID: [2]byte{0, 2},
-                                       Type:     [2]byte{0, 0x71},
-                                       Fields: []Field{
-                                               NewField(FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}),
-                                               NewField(FieldUserName, []byte("UserA")),
-                                               NewField(FieldUserID, []byte{0, 1}),
-                                       },
-                               },
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       IsReply:  0x01,
-                                       Fields: []Field{
-                                               NewField(FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}),
-                                               NewField(FieldUserName, []byte("UserA")),
-                                               NewField(FieldUserID, []byte{0, 1}),
-                                               NewField(FieldUserIconID, []byte{0, 1}),
-                                               NewField(FieldUserFlags, []byte{0, 0}),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "when userA invites userB to new private chat, but UserB has refuse private chat enabled",
-                       args: args{
-                               cc: &ClientConn{
-                                       ID: [2]byte{0, 1},
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessOpenChat)
-                                                       return bits
-                                               }(),
-                                       },
-                                       UserName: []byte("UserA"),
-                                       Icon:     []byte{0, 1},
-                                       Flags:    [2]byte{0, 0},
-                                       Server: &Server{
-                                               ClientMgr: func() *MockClientMgr {
-                                                       m := MockClientMgr{}
-                                                       m.On("Get", ClientID{0, 2}).Return(&ClientConn{
-                                                               ID:       [2]byte{0, 2},
-                                                               Icon:     []byte{0, 1},
-                                                               UserName: []byte("UserB"),
-                                                               Flags:    [2]byte{255, 255},
-                                                       })
-                                                       return &m
-                                               }(),
-                                               ChatMgr: func() *MockChatManager {
-                                                       m := MockChatManager{}
-                                                       m.On("New", mock.AnythingOfType("*hotline.ClientConn")).Return(ChatID{0x52, 0xfd, 0xfc, 0x07})
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranInviteNewChat, [2]byte{0, 1},
-                                       NewField(FieldUserID, []byte{0, 2}),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       Type:     [2]byte{0, 0x68},
-                                       Fields: []Field{
-                                               NewField(FieldData, []byte("UserB does not accept private chats.")),
-                                               NewField(FieldUserName, []byte("UserB")),
-                                               NewField(FieldUserID, []byte{0, 2}),
-                                               NewField(FieldOptions, []byte{0, 2}),
-                                       },
-                               },
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       IsReply:  0x01,
-                                       Fields: []Field{
-                                               NewField(FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}),
-                                               NewField(FieldUserName, []byte("UserA")),
-                                               NewField(FieldUserID, []byte{0, 1}),
-                                               NewField(FieldUserIconID, []byte{0, 1}),
-                                               NewField(FieldUserFlags, []byte{0, 0}),
-                                       },
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-
-                       gotRes := HandleInviteNewChat(tt.args.cc, &tt.args.t)
-
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleGetNewsArtData(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "when user does not have required permission",
-                       args: args{
-                               cc: &ClientConn{Account: &Account{}},
-                               t: NewTransaction(
-                                       TranGetNewsArtData, [2]byte{0, 1},
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to read news.")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "when user has required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessNewsReadArt)
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-                                               ThreadedNewsMgr: func() *mockThreadNewsMgr {
-                                                       m := mockThreadNewsMgr{}
-                                                       m.On("GetArticle", []string{"Example Category"}, uint32(1)).Return(&NewsArtData{
-                                                               Title:         "title",
-                                                               Poster:        "poster",
-                                                               Date:          [8]byte{},
-                                                               PrevArt:       [4]byte{0, 0, 0, 1},
-                                                               NextArt:       [4]byte{0, 0, 0, 2},
-                                                               ParentArt:     [4]byte{0, 0, 0, 3},
-                                                               FirstChildArt: [4]byte{0, 0, 0, 4},
-                                                               DataFlav:      []byte("text/plain"),
-                                                               Data:          "article data",
-                                                       })
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranGetNewsArtData, [2]byte{0, 1},
-                                       NewField(FieldNewsPath, []byte{
-                                               // Example Category
-                                               0x00, 0x01, 0x00, 0x00, 0x10, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x20, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79,
-                                       }),
-                                       NewField(FieldNewsArtID, []byte{0, 1}),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply: 1,
-                                       Fields: []Field{
-                                               NewField(FieldNewsArtTitle, []byte("title")),
-                                               NewField(FieldNewsArtPoster, []byte("poster")),
-                                               NewField(FieldNewsArtDate, []byte{0, 0, 0, 0, 0, 0, 0, 0}),
-                                               NewField(FieldNewsArtPrevArt, []byte{0, 0, 0, 1}),
-                                               NewField(FieldNewsArtNextArt, []byte{0, 0, 0, 2}),
-                                               NewField(FieldNewsArtParentArt, []byte{0, 0, 0, 3}),
-                                               NewField(FieldNewsArt1stChildArt, []byte{0, 0, 0, 4}),
-                                               NewField(FieldNewsArtDataFlav, []byte("text/plain")),
-                                               NewField(FieldNewsArtData, []byte("article data")),
-                                       },
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleGetNewsArtData(tt.args.cc, &tt.args.t)
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleGetNewsArtNameList(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "when user does not have required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-                                               //Accounts: map[string]*Account{},
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranGetNewsArtNameList, [2]byte{0, 1},
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       Flags:     0x00,
-                                       IsReply:   0x01,
-                                       Type:      [2]byte{0, 0},
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to read news.")),
-                                       },
-                               },
-                       },
-               },
-               //{
-               //      name: "when user has required access",
-               //      args: args{
-               //              cc: &ClientConn{
-               //                      Account: &Account{
-               //                              Access: func() accessBitmap {
-               //                                      var bits accessBitmap
-               //                                      bits.Set(AccessNewsReadArt)
-               //                                      return bits
-               //                              }(),
-               //                      },
-               //                      Server: &Server{
-               //                              ThreadedNewsMgr: func() *mockThreadNewsMgr {
-               //                                      m := mockThreadNewsMgr{}
-               //                                      m.On("ListArticles", []string{"Example Category"}).Return(NewsArtListData{
-               //                                              Name:        []byte("testTitle"),
-               //                                              NewsArtList: []byte{},
-               //                                      })
-               //                                      return &m
-               //                              }(),
-               //                      },
-               //              },
-               //              t: NewTransaction(
-               //                      TranGetNewsArtNameList,
-               //                      [2]byte{0, 1},
-               //                      //  00000000  00 01 00 00 10 45 78 61  6d 70 6c 65 20 43 61 74  |.....Example Cat|
-               //                      //  00000010  65 67 6f 72 79                                    |egory|
-               //                      NewField(FieldNewsPath, []byte{
-               //                              0x00, 0x01, 0x00, 0x00, 0x10, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x20, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79,
-               //                      }),
-               //              ),
-               //      },
-               //      wantRes: []Transaction{
-               //              {
-               //                      IsReply: 0x01,
-               //                      Fields: []Field{
-               //                              NewField(FieldNewsArtListData, []byte{
-               //                                      0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
-               //                                      0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
-               //                                      0x09, 0x74, 0x65, 0x73, 0x74, 0x54, 0x69, 0x74, 0x6c, 0x65, 0x0a, 0x74, 0x65, 0x73, 0x74, 0x50,
-               //                                      0x6f, 0x73, 0x74, 0x65, 0x72, 0x0a, 0x74, 0x65, 0x78, 0x74, 0x2f, 0x70, 0x6c, 0x61, 0x69, 0x6e,
-               //                                      0x00, 0x08,
-               //                              },
-               //                              ),
-               //                      },
-               //              },
-               //      },
-               //},
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleGetNewsArtNameList(tt.args.cc, &tt.args.t)
-
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleNewNewsFldr(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "when user does not have required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       return bits
-                                               }(),
-                                       },
-                                       Server: &Server{
-                                               //Accounts: map[string]*Account{},
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranGetNewsArtNameList, [2]byte{0, 1},
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       Flags:     0x00,
-                                       IsReply:   0x01,
-                                       Type:      [2]byte{0, 0},
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to create news folders.")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "with a valid request",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessNewsCreateFldr)
-                                                       return bits
-                                               }(),
-                                       },
-                                       logger: NewTestLogger(),
-                                       ID:     [2]byte{0, 1},
-                                       Server: &Server{
-                                               ThreadedNewsMgr: func() *mockThreadNewsMgr {
-                                                       m := mockThreadNewsMgr{}
-                                                       m.On("CreateGrouping", []string{"test"}, "testFolder", NewsBundle).Return(nil)
-                                                       return &m
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranGetNewsArtNameList, [2]byte{0, 1},
-                                       NewField(FieldFileName, []byte("testFolder")),
-                                       NewField(FieldNewsPath,
-                                               []byte{
-                                                       0, 1,
-                                                       0, 0,
-                                                       4,
-                                                       0x74, 0x65, 0x73, 0x74,
-                                               },
-                                       ),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       clientID: [2]byte{0, 1},
-                                       IsReply:  0x01,
-                                       Fields:   []Field{},
-                               },
-                       },
-               },
-               //{
-               //      Name: "when there is an error writing the threaded news file",
-               //      args: args{
-               //              cc: &ClientConn{
-               //                      Account: &Account{
-               //                              Access: func() accessBitmap {
-               //                                      var bits accessBitmap
-               //                                      bits.Set(AccessNewsCreateFldr)
-               //                                      return bits
-               //                              }(),
-               //                      },
-               //                      logger: NewTestLogger(),
-               //                      Type:     [2]byte{0, 1},
-               //                      Server: &Server{
-               //                              ConfigDir: "/fakeConfigRoot",
-               //                              FS: func() *MockFileStore {
-               //                                      mfs := &MockFileStore{}
-               //                                      mfs.On("WriteFile", "/fakeConfigRoot/ThreadedNews.yaml", mock.Anything, mock.Anything).Return(os.ErrNotExist)
-               //                                      return mfs
-               //                              }(),
-               //                              ThreadedNews: &ThreadedNews{Categories: map[string]NewsCategoryListData15{
-               //                                      "test": {
-               //                                              Type:     []byte{0, 2},
-               //                                              Count:    nil,
-               //                                              NameSize: 0,
-               //                                              Name:     "test",
-               //                                              SubCats:  make(map[string]NewsCategoryListData15),
-               //                                      },
-               //                              }},
-               //                      },
-               //              },
-               //              t: NewTransaction(
-               //                      TranGetNewsArtNameList, [2]byte{0, 1},
-               //                      NewField(FieldFileName, []byte("testFolder")),
-               //                      NewField(FieldNewsPath,
-               //                              []byte{
-               //                                      0, 1,
-               //                                      0, 0,
-               //                                      4,
-               //                                      0x74, 0x65, 0x73, 0x74,
-               //                              },
-               //                      ),
-               //              ),
-               //      },
-               //      wantRes: []Transaction{
-               //              {
-               //                      clientID:  [2]byte{0, 1},
-               //                      Flags:     0x00,
-               //                      IsReply:   0x01,
-               //                      Type:      [2]byte{0, 0},
-               //                      ErrorCode: [4]byte{0, 0, 0, 1},
-               //                      Fields: []Field{
-               //                              NewField(FieldError, []byte("Error creating news folder.")),
-               //                      },
-               //              },
-               //      },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleNewNewsFldr(tt.args.cc, &tt.args.t)
-
-                       tranAssertEqual(t, tt.wantRes, gotRes)
-               })
-       }
-}
-
-func TestHandleDownloadBanner(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               // TODO: Add test cases.
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       gotRes := HandleDownloadBanner(tt.args.cc, &tt.args.t)
-
-                       assert.Equalf(t, tt.wantRes, gotRes, "HandleDownloadBanner(%v, %v)", tt.args.cc, &tt.args.t)
-               })
-       }
-}
-
-func TestHandlePostNewsArt(t *testing.T) {
-       type args struct {
-               cc *ClientConn
-               t  Transaction
-       }
-       tests := []struct {
-               name    string
-               args    args
-               wantRes []Transaction
-       }{
-               {
-                       name: "without required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       return bits
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranPostNewsArt,
-                                       [2]byte{0, 0},
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 1},
-                                       Fields: []Field{
-                                               NewField(FieldError, []byte("You are not allowed to post news articles.")),
-                                       },
-                               },
-                       },
-               },
-               {
-                       name: "with required permission",
-                       args: args{
-                               cc: &ClientConn{
-                                       Server: &Server{
-                                               ThreadedNewsMgr: func() *mockThreadNewsMgr {
-                                                       m := mockThreadNewsMgr{}
-                                                       m.On("PostArticle", []string{"www"}, uint32(0), mock.AnythingOfType("hotline.NewsArtData")).Return(nil)
-                                                       return &m
-                                               }(),
-                                       },
-                                       Account: &Account{
-                                               Access: func() accessBitmap {
-                                                       var bits accessBitmap
-                                                       bits.Set(AccessNewsPostArt)
-                                                       return bits
-                                               }(),
-                                       },
-                               },
-                               t: NewTransaction(
-                                       TranPostNewsArt,
-                                       [2]byte{0, 0},
-                                       NewField(FieldNewsPath, []byte{0x00, 0x01, 0x00, 0x00, 0x03, 0x77, 0x77, 0x77}),
-                                       NewField(FieldNewsArtID, []byte{0x00, 0x00, 0x00, 0x00}),
-                               ),
-                       },
-                       wantRes: []Transaction{
-                               {
-                                       IsReply:   0x01,
-                                       ErrorCode: [4]byte{0, 0, 0, 0},
-                                       Fields:    []Field{},
-                               },
-                       },
-               },
-       }
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       tranAssertEqual(t, tt.wantRes, HandlePostNewsArt(tt.args.cc, &tt.args.t))
-               })
-       }
-}
index e7b535d0034d0d157184728d229189952f4464e3..97d865d26f3a9d1804f50db618715c2f84b7dbb5 100644 (file)
@@ -282,7 +282,7 @@ func TestTransaction_Read(t1 *testing.T) {
        for _, tt := range tests {
                t1.Run(tt.name, func(t1 *testing.T) {
                        t := &Transaction{
        for _, tt := range tests {
                t1.Run(tt.name, func(t1 *testing.T) {
                        t := &Transaction{
-                               clientID:   tt.fields.clientID,
+                               ClientID:   tt.fields.clientID,
                                Flags:      tt.fields.Flags,
                                IsReply:    tt.fields.IsReply,
                                Type:       tt.fields.Type,
                                Flags:      tt.fields.Flags,
                                IsReply:    tt.fields.IsReply,
                                Type:       tt.fields.Type,
@@ -362,7 +362,7 @@ func TestTransaction_Write(t1 *testing.T) {
                                                Data:      []byte("hai"),
                                        },
                                },
                                                Data:      []byte("hai"),
                                        },
                                },
-                               clientID:   [2]byte{},
+                               ClientID:   [2]byte{},
                                readOffset: 0,
                        },
                },
                                readOffset: 0,
                        },
                },
@@ -376,7 +376,7 @@ func TestTransaction_Write(t1 *testing.T) {
                        }
                        assert.Equalf(t1, tt.wantN, gotN, "Write(%v)", tt.args.p)
 
                        }
                        assert.Equalf(t1, tt.wantN, gotN, "Write(%v)", tt.args.p)
 
-                       tranAssertEqual(t1, []Transaction{tt.wantTransaction}, []Transaction{*t})
+                       TranAssertEqual(t1, []Transaction{tt.wantTransaction}, []Transaction{*t})
                })
        }
 }
                })
        }
 }
index 3b6c44604ef70d1d17adb521192570f7c1756a13..4aa142b08889bb32768b23501cf0fd49cb079f81 100644 (file)
@@ -122,16 +122,13 @@ func Test_receiveFile(t *testing.T) {
                                                FlatFileHeader: FlatFileHeader{
                                                        Format:    [4]byte{0x46, 0x49, 0x4c, 0x50}, // "FILP"
                                                        Version:   [2]byte{0, 1},
                                                FlatFileHeader: FlatFileHeader{
                                                        Format:    [4]byte{0x46, 0x49, 0x4c, 0x50}, // "FILP"
                                                        Version:   [2]byte{0, 1},
-                                                       RSVD:      [16]byte{},
                                                        ForkCount: [2]byte{0, 2},
                                                },
                                                FlatFileInformationForkHeader: FlatFileForkHeader{},
                                                FlatFileInformationFork:       NewFlatFileInformationFork("testfile.txt", [8]byte{}, "TEXT", "TEXT"),
                                                FlatFileDataForkHeader: FlatFileForkHeader{
                                                        ForkCount: [2]byte{0, 2},
                                                },
                                                FlatFileInformationForkHeader: FlatFileForkHeader{},
                                                FlatFileInformationFork:       NewFlatFileInformationFork("testfile.txt", [8]byte{}, "TEXT", "TEXT"),
                                                FlatFileDataForkHeader: FlatFileForkHeader{
-                                                       ForkType:        [4]byte{0x4d, 0x41, 0x43, 0x52}, // DATA
-                                                       CompressionType: [4]byte{0, 0, 0, 0},
-                                                       RSVD:            [4]byte{0, 0, 0, 0},
-                                                       DataSize:        [4]byte{0x00, 0x00, 0x00, 0x03},
+                                                       ForkType: [4]byte{0x4d, 0x41, 0x43, 0x52}, // DATA
+                                                       DataSize: [4]byte{0x00, 0x00, 0x00, 0x03},
                                                },
                                        }
                                        fakeFileData := []byte{1, 2, 3}
                                                },
                                        }
                                        fakeFileData := []byte{1, 2, 3}
index f3754d36328141114d5ef6dd8139b86ee91134ff..c4e0790afd08b8531e7f55f3ab70b54551d0c155 100644 (file)
@@ -84,10 +84,10 @@ func (u *User) Write(p []byte) (int, error) {
        return 8 + namelen, nil
 }
 
        return 8 + namelen, nil
 }
 
-// encodeString takes []byte s containing cleartext and rotates by 255 into obfuscated cleartext.
+// EncodeString takes []byte s containing cleartext and rotates by 255 into obfuscated cleartext.
 // The Hotline protocol uses this format for sending passwords over network.
 // Not secure, but hey, it was the 90s!
 // The Hotline protocol uses this format for sending passwords over network.
 // Not secure, but hey, it was the 90s!
-func encodeString(clearText []byte) []byte {
+func EncodeString(clearText []byte) []byte {
        obfuText := make([]byte, len(clearText))
        for i := 0; i < len(clearText); i++ {
                obfuText[i] = 255 - clearText[i]
        obfuText := make([]byte, len(clearText))
        for i := 0; i < len(clearText); i++ {
                obfuText[i] = 255 - clearText[i]
index 90c59f76a12b487011b8e2d489488030bfa7d6c0..24290096db87ae6f26f26b69aefc8f7ab7380584 100644 (file)
@@ -83,7 +83,7 @@ func TestNegatedUserString(t *testing.T) {
        }
        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
        }
        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
-                       if got := encodeString(tt.args.encodedString); !bytes.Equal(got, tt.want) {
+                       if got := EncodeString(tt.args.encodedString); !bytes.Equal(got, tt.want) {
                                t.Errorf("NegatedUserString() = %x, want %x", got, tt.want)
                        }
                })
                                t.Errorf("NegatedUserString() = %x, want %x", got, tt.want)
                        }
                })
diff --git a/internal/mobius/account_manager.go b/internal/mobius/account_manager.go
new file mode 100644 (file)
index 0000000..cba33b2
--- /dev/null
@@ -0,0 +1,193 @@
+package mobius
+
+import (
+       "fmt"
+       "github.com/jhalter/mobius/hotline"
+       "github.com/stretchr/testify/mock"
+       "gopkg.in/yaml.v3"
+       "os"
+       "path"
+       "path/filepath"
+       "sync"
+)
+
+// loadFromYAMLFile loads data from a YAML file into the provided data structure.
+func loadFromYAMLFile(path string, data interface{}) error {
+       fh, err := os.Open(path)
+       if err != nil {
+               return err
+       }
+       defer fh.Close()
+
+       decoder := yaml.NewDecoder(fh)
+       return decoder.Decode(data)
+}
+
+type YAMLAccountManager struct {
+       accounts   map[string]hotline.Account
+       accountDir string
+
+       mu sync.Mutex
+}
+
+func NewYAMLAccountManager(accountDir string) (*YAMLAccountManager, error) {
+       accountMgr := YAMLAccountManager{
+               accountDir: accountDir,
+               accounts:   make(map[string]hotline.Account),
+       }
+
+       matches, err := filepath.Glob(filepath.Join(accountDir, "*.yaml"))
+       if err != nil {
+               return nil, err
+       }
+
+       if len(matches) == 0 {
+               return nil, fmt.Errorf("no accounts found in directory: %s", accountDir)
+       }
+
+       for _, file := range matches {
+               var account hotline.Account
+               if err = loadFromYAMLFile(file, &account); err != nil {
+                       return nil, fmt.Errorf("error loading account %s: %w", file, err)
+               }
+
+               accountMgr.accounts[account.Login] = account
+       }
+
+       return &accountMgr, nil
+}
+
+func (am *YAMLAccountManager) Create(account hotline.Account) error {
+       am.mu.Lock()
+       defer am.mu.Unlock()
+
+       // Create account file, returning an error if one already exists.
+       file, err := os.OpenFile(
+               filepath.Join(am.accountDir, path.Join("/", account.Login+".yaml")),
+               os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644,
+       )
+       if err != nil {
+               return fmt.Errorf("create account file: %w", err)
+       }
+       defer file.Close()
+
+       b, err := yaml.Marshal(account)
+       if err != nil {
+               return fmt.Errorf("marshal account to YAML: %v", err)
+       }
+
+       _, err = file.Write(b)
+       if err != nil {
+               return fmt.Errorf("write account file: %w", err)
+       }
+
+       am.accounts[account.Login] = account
+
+       return nil
+}
+
+func (am *YAMLAccountManager) Update(account hotline.Account, newLogin string) error {
+       am.mu.Lock()
+       defer am.mu.Unlock()
+
+       // If the login has changed, rename the account file.
+       if account.Login != newLogin {
+               err := os.Rename(
+                       filepath.Join(am.accountDir, path.Join("/", account.Login)+".yaml"),
+                       filepath.Join(am.accountDir, path.Join("/", newLogin)+".yaml"),
+               )
+               if err != nil {
+                       return fmt.Errorf("error renaming account file: %w", err)
+               }
+
+               account.Login = newLogin
+               am.accounts[newLogin] = account
+
+               delete(am.accounts, account.Login)
+       }
+
+       out, err := yaml.Marshal(&account)
+       if err != nil {
+               return err
+       }
+
+       if err := os.WriteFile(filepath.Join(am.accountDir, newLogin+".yaml"), out, 0644); err != nil {
+               return fmt.Errorf("error writing account file: %w", err)
+       }
+
+       am.accounts[account.Login] = account
+
+       return nil
+}
+
+func (am *YAMLAccountManager) Get(login string) *hotline.Account {
+       am.mu.Lock()
+       defer am.mu.Unlock()
+
+       account, ok := am.accounts[login]
+       if !ok {
+               return nil
+       }
+
+       return &account
+}
+
+func (am *YAMLAccountManager) List() []hotline.Account {
+       am.mu.Lock()
+       defer am.mu.Unlock()
+
+       var accounts []hotline.Account
+       for _, account := range am.accounts {
+               accounts = append(accounts, account)
+       }
+
+       return accounts
+}
+
+func (am *YAMLAccountManager) Delete(login string) error {
+       am.mu.Lock()
+       defer am.mu.Unlock()
+
+       err := os.Remove(filepath.Join(am.accountDir, path.Join("/", login+".yaml")))
+       if err != nil {
+               return fmt.Errorf("delete account file: %v", err)
+       }
+
+       delete(am.accounts, login)
+
+       return nil
+}
+
+type MockAccountManager struct {
+       mock.Mock
+}
+
+func (m *MockAccountManager) Create(account hotline.Account) error {
+       args := m.Called(account)
+
+       return args.Error(0)
+}
+
+func (m *MockAccountManager) Update(account hotline.Account, newLogin string) error {
+       args := m.Called(account, newLogin)
+
+       return args.Error(0)
+}
+
+func (m *MockAccountManager) Get(login string) *hotline.Account {
+       args := m.Called(login)
+
+       return args.Get(0).(*hotline.Account)
+}
+
+func (m *MockAccountManager) List() []hotline.Account {
+       args := m.Called()
+
+       return args.Get(0).([]hotline.Account)
+}
+
+func (m *MockAccountManager) Delete(login string) error {
+       args := m.Called(login)
+
+       return args.Error(0)
+}
diff --git a/internal/mobius/agreement.go b/internal/mobius/agreement.go
new file mode 100644 (file)
index 0000000..c2a67c5
--- /dev/null
@@ -0,0 +1,78 @@
+package mobius
+
+import (
+       "fmt"
+       "io"
+       "os"
+       "path/filepath"
+       "strings"
+       "sync"
+)
+
+const agreementFile = "Agreement.txt"
+
+type Agreement struct {
+       data        []byte
+       filePath    string
+       lineEndings string
+
+       mu         sync.RWMutex
+       readOffset int // Internal offset to track read progress
+}
+
+func NewAgreement(path, lineEndings string) (*Agreement, error) {
+       data, err := os.ReadFile(filepath.Join(path, agreementFile))
+       if err != nil {
+               return &Agreement{}, fmt.Errorf("read file: %w", err)
+       }
+
+       // Swap line breaks
+       agreement := strings.ReplaceAll(string(data), "\n", lineEndings)
+       agreement = strings.ReplaceAll(agreement, "\r\n", lineEndings)
+
+       return &Agreement{
+               data:        []byte(agreement),
+               filePath:    filepath.Join(path, agreementFile),
+               lineEndings: lineEndings,
+       }, nil
+}
+
+func (a *Agreement) Reload() error {
+       a.mu.Lock()
+       defer a.mu.Unlock()
+
+       data, err := os.ReadFile(a.filePath)
+       if err != nil {
+               return fmt.Errorf("read file: %w", err)
+       }
+
+       // Swap line breaks
+       agreement := strings.ReplaceAll(string(data), "\n", a.lineEndings)
+       agreement = strings.ReplaceAll(agreement, "\r\n", a.lineEndings)
+
+       a.data = []byte(agreement)
+
+       return nil
+}
+
+// It returns the number of bytes read and any error encountered.
+func (a *Agreement) Read(p []byte) (int, error) {
+       a.mu.Lock()
+       defer a.mu.Unlock()
+
+       if a.readOffset >= len(a.data) {
+               return 0, io.EOF // All bytes have been read
+       }
+
+       n := copy(p, a.data[a.readOffset:])
+
+       a.readOffset += n
+
+       return n, nil
+}
+
+func (a *Agreement) Seek(offset int64, _ int) (int64, error) {
+       a.readOffset = int(offset)
+
+       return 0, nil
+}
index f78e3f39fc4b22fa6bcd6d847ba9db0f945245a2..e73b14ace05351cc38ac135733dbe292175600e6 100644 (file)
@@ -1,6 +1,7 @@
 package mobius
 
 import (
 package mobius
 
 import (
+       "fmt"
        "gopkg.in/yaml.v3"
        "os"
        "path/filepath"
        "gopkg.in/yaml.v3"
        "os"
        "path/filepath"
@@ -22,8 +23,11 @@ func NewBanFile(path string) (*BanFile, error) {
        }
 
        err := bf.Load()
        }
 
        err := bf.Load()
+       if err != nil {
+               return nil, fmt.Errorf("load ban file: %w", err)
+       }
 
 
-       return bf, err
+       return bf, nil
 }
 
 func (bf *BanFile) Load() error {
 }
 
 func (bf *BanFile) Load() error {
@@ -33,18 +37,17 @@ func (bf *BanFile) Load() error {
        bf.banList = make(map[string]*time.Time)
 
        fh, err := os.Open(bf.filePath)
        bf.banList = make(map[string]*time.Time)
 
        fh, err := os.Open(bf.filePath)
+       if os.IsNotExist(err) {
+               return nil
+       }
        if err != nil {
        if err != nil {
-               if os.IsNotExist(err) {
-                       return nil
-               }
-               return err
+               return fmt.Errorf("open file: %v", err)
        }
        defer fh.Close()
 
        }
        defer fh.Close()
 
-       decoder := yaml.NewDecoder(fh)
-       err = decoder.Decode(&bf.banList)
+       err = yaml.NewDecoder(fh).Decode(&bf.banList)
        if err != nil {
        if err != nil {
-               return err
+               return fmt.Errorf("decode yaml: %v", err)
        }
 
        return nil
        }
 
        return nil
@@ -58,10 +61,15 @@ func (bf *BanFile) Add(ip string, until *time.Time) error {
 
        out, err := yaml.Marshal(bf.banList)
        if err != nil {
 
        out, err := yaml.Marshal(bf.banList)
        if err != nil {
-               return err
+               return fmt.Errorf("marshal yaml: %v", err)
        }
 
        }
 
-       return os.WriteFile(filepath.Join(bf.filePath), out, 0644)
+       err = os.WriteFile(filepath.Join(bf.filePath), out, 0644)
+       if err != nil {
+               return fmt.Errorf("write file: %v", err)
+       }
+
+       return nil
 }
 
 func (bf *BanFile) IsBanned(ip string) (bool, *time.Time) {
 }
 
 func (bf *BanFile) IsBanned(ip string) (bool, *time.Time) {
diff --git a/internal/mobius/ban_test.go b/internal/mobius/ban_test.go
new file mode 100644 (file)
index 0000000..46860c6
--- /dev/null
@@ -0,0 +1,154 @@
+package mobius
+
+import (
+       "fmt"
+       "github.com/stretchr/testify/assert"
+       "os"
+       "path/filepath"
+       "sync"
+       "testing"
+       "time"
+)
+
+func TestNewBanFile(t *testing.T) {
+       cwd, _ := os.Getwd()
+       str := "2024-06-29T11:34:43.245899-07:00"
+       testTime, _ := time.Parse(time.RFC3339Nano, str)
+
+       type args struct {
+               path string
+       }
+       tests := []struct {
+               name    string
+               args    args
+               want    *BanFile
+               wantErr assert.ErrorAssertionFunc
+       }{
+               {
+                       name: "Valid path with valid content",
+                       args: args{path: filepath.Join(cwd, "test", "config", "Banlist.yaml")},
+                       want: &BanFile{
+                               filePath: filepath.Join(cwd, "test", "config", "Banlist.yaml"),
+                               banList:  map[string]*time.Time{"192.168.86.29": &testTime},
+                       },
+                       wantErr: assert.NoError,
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       got, err := NewBanFile(tt.args.path)
+                       if !tt.wantErr(t, err, fmt.Sprintf("NewBanFile(%v)", tt.args.path)) {
+                               return
+                       }
+                       assert.Equalf(t, tt.want, got, "NewBanFile(%v)", tt.args.path)
+               })
+       }
+}
+
+// TestAdd tests the Add function.
+func TestAdd(t *testing.T) {
+       // Create a temporary directory.
+       tmpDir, err := os.MkdirTemp("", "banfile_test")
+       if err != nil {
+               t.Fatalf("Failed to create temp directory: %v", err)
+       }
+       defer os.RemoveAll(tmpDir) // Clean up the temporary directory.
+
+       // Path to the temporary ban file.
+       tmpFilePath := filepath.Join(tmpDir, "banfile.yaml")
+
+       // Initialize BanFile.
+       bf := &BanFile{
+               filePath: tmpFilePath,
+               banList:  make(map[string]*time.Time),
+       }
+
+       // Define the test cases.
+       tests := []struct {
+               name   string
+               ip     string
+               until  *time.Time
+               expect map[string]*time.Time
+       }{
+               {
+                       name:  "Add IP with no expiration",
+                       ip:    "192.168.1.1",
+                       until: nil,
+                       expect: map[string]*time.Time{
+                               "192.168.1.1": nil,
+                       },
+               },
+               {
+                       name:  "Add IP with expiration",
+                       ip:    "192.168.1.2",
+                       until: func() *time.Time { t := time.Date(2024, 6, 29, 11, 34, 43, 245899000, time.UTC); return &t }(),
+                       expect: map[string]*time.Time{
+                               "192.168.1.1": nil,
+                               "192.168.1.2": func() *time.Time { t := time.Date(2024, 6, 29, 11, 34, 43, 245899000, time.UTC); return &t }(),
+                       },
+               },
+       }
+
+       // Run the test cases.
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       err := bf.Add(tt.ip, tt.until)
+                       assert.NoError(t, err, "Add() error")
+
+                       // Load the file to check its contents.
+                       loadedBanFile := &BanFile{filePath: tmpFilePath}
+                       err = loadedBanFile.Load()
+                       assert.NoError(t, err, "Load() error")
+                       assert.Equal(t, tt.expect, loadedBanFile.banList, "Ban list does not match")
+               })
+       }
+}
+
+func TestBanFile_IsBanned(t *testing.T) {
+       type fields struct {
+               banList map[string]*time.Time
+               Mutex   sync.Mutex
+       }
+       type args struct {
+               ip string
+       }
+       tests := []struct {
+               name   string
+               fields fields
+               args   args
+               want   bool
+               want1  *time.Time
+       }{
+               {
+                       name: "with permanent ban",
+                       fields: fields{
+                               banList: map[string]*time.Time{
+                                       "192.168.86.1": nil,
+                               },
+                       },
+                       args:  args{ip: "192.168.86.1"},
+                       want:  true,
+                       want1: nil,
+               },
+               {
+                       name: "with no ban",
+                       fields: fields{
+                               banList: map[string]*time.Time{},
+                       },
+                       args:  args{ip: "192.168.86.1"},
+                       want:  false,
+                       want1: nil,
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       bf := &BanFile{
+                               banList: tt.fields.banList,
+                               Mutex:   sync.Mutex{},
+                       }
+                       got, got1 := bf.IsBanned(tt.args.ip)
+                       assert.Equalf(t, tt.want, got, "IsBanned(%v)", tt.args.ip)
+                       assert.Equalf(t, tt.want1, got1, "IsBanned(%v)", tt.args.ip)
+               })
+       }
+}
index e985dd73b6f26beee2126cd8b19f0babe5d15c63..d04b14d35f7e0291c2566be5f954d92b740c45bf 100644 (file)
@@ -1,29 +1,40 @@
 package mobius
 
 import (
 package mobius
 
 import (
+       "fmt"
        "github.com/go-playground/validator/v10"
        "github.com/jhalter/mobius/hotline"
        "gopkg.in/yaml.v3"
        "github.com/go-playground/validator/v10"
        "github.com/jhalter/mobius/hotline"
        "gopkg.in/yaml.v3"
-       "log"
        "os"
        "os"
+       "path/filepath"
 )
 
 )
 
+var ConfigSearchOrder = []string{
+       "config",
+       "/usr/local/var/mobius/config",
+       "/opt/homebrew/var/mobius/config",
+}
+
 func LoadConfig(path string) (*hotline.Config, error) {
        var config hotline.Config
 
        yamlFile, err := os.ReadFile(path)
        if err != nil {
 func LoadConfig(path string) (*hotline.Config, error) {
        var config hotline.Config
 
        yamlFile, err := os.ReadFile(path)
        if err != nil {
-               return nil, err
+               return nil, fmt.Errorf("read file: %v", err)
        }
        }
-       err = yaml.Unmarshal(yamlFile, &config)
-       if err != nil {
-               log.Fatalf("Unmarshal: %v", err)
+
+       if err := yaml.Unmarshal(yamlFile, &config); err != nil {
+               return nil, fmt.Errorf("unmarshal YAML: %v", err)
        }
 
        validate := validator.New()
        }
 
        validate := validator.New()
-       err = validate.Struct(config)
-       if err != nil {
-               return nil, err
+       if err = validate.Struct(config); err != nil {
+               return nil, fmt.Errorf("validate config: %v", err)
+       }
+
+       // If the FileRoot is an absolute path, use it, otherwise treat as a relative path to the config dir.
+       if !filepath.IsAbs(config.FileRoot) {
+               config.FileRoot = filepath.Join(path, "../", config.FileRoot)
        }
 
        return &config, nil
        }
 
        return &config, nil
index 51e212d683a0106fbda6396f5c69a0b7ef4d4c75..13a728ad8215deaa0ef055518ec2a6aec4f58d3f 100644 (file)
@@ -5,28 +5,25 @@ import (
        "io"
        "os"
        "slices"
        "io"
        "os"
        "slices"
+       "strings"
        "sync"
 )
 
 type FlatNews struct {
        "sync"
 )
 
 type FlatNews struct {
-       mu sync.Mutex
-
        data     []byte
        filePath string
 
        data     []byte
        filePath string
 
+       mu         sync.Mutex
        readOffset int // Internal offset to track read progress
 }
 
 func NewFlatNews(path string) (*FlatNews, error) {
        readOffset int // Internal offset to track read progress
 }
 
 func NewFlatNews(path string) (*FlatNews, error) {
-       data, err := os.ReadFile(path)
-       if err != nil {
-               return &FlatNews{}, err
+       flatNews := &FlatNews{filePath: path}
+       if err := flatNews.Reload(); err != nil {
+               return nil, fmt.Errorf("reload: %w", err)
        }
 
        }
 
-       return &FlatNews{
-               data:     data,
-               filePath: path,
-       }, nil
+       return flatNews, nil
 }
 
 func (f *FlatNews) Reload() error {
 }
 
 func (f *FlatNews) Reload() error {
@@ -37,7 +34,12 @@ func (f *FlatNews) Reload() error {
        if err != nil {
                return err
        }
        if err != nil {
                return err
        }
-       f.data = data
+
+       // Swap line breaks
+       agreement := strings.ReplaceAll(string(data), "\n", "\r")
+       agreement = strings.ReplaceAll(agreement, "\r\n", "\r")
+
+       f.data = []byte(agreement)
 
        return nil
 }
 
        return nil
 }
diff --git a/internal/mobius/test/config/Agreement.txt b/internal/mobius/test/config/Agreement.txt
new file mode 100644 (file)
index 0000000..2a3bdb7
--- /dev/null
@@ -0,0 +1 @@
+This is a server agreement.  Say you agree.
\ No newline at end of file
diff --git a/internal/mobius/test/config/Banlist.yaml b/internal/mobius/test/config/Banlist.yaml
new file mode 100644 (file)
index 0000000..cf3fd5e
--- /dev/null
@@ -0,0 +1 @@
+192.168.86.29: 2024-06-29T11:34:43.245899-07:00
\ No newline at end of file
diff --git a/internal/mobius/test/config/Files/getFileNameListTestDir/testfile-1k b/internal/mobius/test/config/Files/getFileNameListTestDir/testfile-1k
new file mode 100644 (file)
index 0000000..31758a0
Binary files /dev/null and b/internal/mobius/test/config/Files/getFileNameListTestDir/testfile-1k differ
diff --git a/internal/mobius/test/config/Files/test/testfile-1k b/internal/mobius/test/config/Files/test/testfile-1k
new file mode 100644 (file)
index 0000000..31758a0
Binary files /dev/null and b/internal/mobius/test/config/Files/test/testfile-1k differ
diff --git a/internal/mobius/test/config/Files/test/testfile-5k b/internal/mobius/test/config/Files/test/testfile-5k
new file mode 100644 (file)
index 0000000..c889187
Binary files /dev/null and b/internal/mobius/test/config/Files/test/testfile-5k differ
diff --git a/internal/mobius/test/config/Files/testdir/some-nested-file.txt b/internal/mobius/test/config/Files/testdir/some-nested-file.txt
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/internal/mobius/test/config/Files/testfile-1k b/internal/mobius/test/config/Files/testfile-1k
new file mode 100644 (file)
index 0000000..31758a0
Binary files /dev/null and b/internal/mobius/test/config/Files/testfile-1k differ
diff --git a/internal/mobius/test/config/Files/testfile-8b b/internal/mobius/test/config/Files/testfile-8b
new file mode 100644 (file)
index 0000000..ecb617f
--- /dev/null
@@ -0,0 +1 @@
+|9à¼dâÍÞ
\ No newline at end of file
diff --git a/internal/mobius/test/config/Files/testfile.sit b/internal/mobius/test/config/Files/testfile.sit
new file mode 100644 (file)
index 0000000..8d1d542
--- /dev/null
@@ -0,0 +1 @@
+nothing to see here
\ No newline at end of file
diff --git a/internal/mobius/test/config/Files/testfile.txt b/internal/mobius/test/config/Files/testfile.txt
new file mode 100644 (file)
index 0000000..f0607d4
--- /dev/null
@@ -0,0 +1 @@
+Hello, I'm a test file!
\ No newline at end of file
diff --git a/internal/mobius/test/config/MessageBoard.txt b/internal/mobius/test/config/MessageBoard.txt
new file mode 100644 (file)
index 0000000..1a2f57a
--- /dev/null
@@ -0,0 +1 @@
+From test (Dec31 15:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:53):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:43):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:43):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:20):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:20):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:2):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 13:59):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 13:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 10:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec08 14:39):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec08 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec08 7:59):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec08 7:59):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 11:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 11:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 11:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 11:43):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 11:30):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 11:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 11:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 10:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 10:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 10:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 10:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 9:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec05 17:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec03 10:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 17:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:27):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:27):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:17):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:56):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:53):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:26):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:17):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 13:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 13:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 13:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:26):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:26):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:26):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:26):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:3):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:3):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:3):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:53):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:53):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:45):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:45):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:20):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:45):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:31):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:30):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:28):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:19):\r\rTest News Post\r\r__________________________________________________________\rFrom  (Nov30 11:42):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:17):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:8):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:5):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:2):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:2):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:0):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:45):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:31):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:25):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:25):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:20):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:59):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:35):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:32):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:8):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:8):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:7):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:5):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:53):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:28):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 10:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 10:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 16:6):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 16:6):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:45):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:43):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:7):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:7):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:6):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:6):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:5):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:4):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:4):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:4):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:3):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:2):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:2):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:0):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:0):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:56):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:56):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:56):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:45):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:32):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:32):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:20):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:4):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:42):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:42):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:31):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:31):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:31):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:31):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:30):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:30):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:30):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:30):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:30):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:28):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:28):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:28):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:27):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:27):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:27):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:26):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:25):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:8):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:8):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:7):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:7):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:57):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:57):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:53):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:40):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:20):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:20):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:41):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:28):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:27):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:27):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:26):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:25):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:0):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:59):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:53):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 11:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 11:35):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 11:31):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 11:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 11:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 11:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 11:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul11 13:25):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 17:25):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 17:25):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:45):\r\rTest News Post\r\r__________________________________________________________\r
\ No newline at end of file
diff --git a/internal/mobius/test/config/ThreadedNews.yaml b/internal/mobius/test/config/ThreadedNews.yaml
new file mode 100644 (file)
index 0000000..9f3fd65
--- /dev/null
@@ -0,0 +1,232 @@
+Categories:
+  TestBundle:
+    Type:
+    - 0
+    - 2
+    Name: TestBundle
+    Articles: {}
+    SubCats:
+      NestedBundle:
+        Type:
+        - 0
+        - 2
+        Name: NestedBundle
+        Articles: {}
+        SubCats:
+          NestedCat:
+            Type:
+            - 0
+            - 3
+            Name: NestedCat
+            Articles: {}
+            SubCats: {}
+            count: []
+            addsn: []
+            deletesn: []
+            guid: []
+        count: []
+        addsn: []
+        deletesn: []
+        guid: []
+      TestSubCat:
+        Type:
+        - 0
+        - 3
+        Name: TestSubCat
+        Articles:
+          1:
+            Title: SubCatArt
+            Poster: Halcyon 1.9.2
+            Date:
+            - 7
+            - 228
+            - 0
+            - 0
+            - 0
+            - 254
+            - 252
+            - 246
+            PrevArt:
+            - 0
+            - 0
+            - 0
+            - 0
+            NextArt:
+            - 0
+            - 0
+            - 0
+            - 0
+            ParentArt:
+            - 0
+            - 0
+            - 0
+            - 0
+            FirstChildArtArt:
+            - 0
+            - 0
+            - 0
+            - 0
+            DataFlav:
+            - 116
+            - 101
+            - 120
+            - 116
+            - 47
+            - 112
+            - 108
+            - 97
+            - 105
+            - 110
+            Data: I'm an article in a subcategory!
+        SubCats: {}
+        count: []
+        addsn: []
+        deletesn: []
+        guid: []
+    count: []
+    addsn: []
+    deletesn: []
+    guid: []
+  TestCat:
+    Type:
+    - 0
+    - 3
+    Name: TestCat
+    Articles:
+      1:
+        Title: TestArt
+        Poster: Halcyon 1.9.2
+        Date:
+        - 7
+        - 228
+        - 0
+        - 0
+        - 0
+        - 254
+        - 252
+        - 204
+        PrevArt:
+        - 0
+        - 0
+        - 0
+        - 0
+        NextArt:
+        - 0
+        - 0
+        - 0
+        - 2
+        ParentArt:
+        - 0
+        - 0
+        - 0
+        - 0
+        FirstChildArtArt:
+        - 0
+        - 0
+        - 0
+        - 2
+        DataFlav:
+        - 116
+        - 101
+        - 120
+        - 116
+        - 47
+        - 112
+        - 108
+        - 97
+        - 105
+        - 110
+        Data: TestArt Body
+      2:
+        Title: 'Re: TestArt'
+        Poster: Halcyon 1.9.2
+        Date:
+        - 7
+        - 228
+        - 0
+        - 0
+        - 0
+        - 254
+        - 252
+        - 216
+        PrevArt:
+        - 0
+        - 0
+        - 0
+        - 1
+        NextArt:
+        - 0
+        - 0
+        - 0
+        - 3
+        ParentArt:
+        - 0
+        - 0
+        - 0
+        - 1
+        FirstChildArtArt:
+        - 0
+        - 0
+        - 0
+        - 0
+        DataFlav:
+        - 116
+        - 101
+        - 120
+        - 116
+        - 47
+        - 112
+        - 108
+        - 97
+        - 105
+        - 110
+        Data: I'm a reply
+      3:
+        Title: TestArt 2
+        Poster: Halcyon 1.9.2
+        Date:
+        - 7
+        - 228
+        - 0
+        - 0
+        - 0
+        - 254
+        - 253
+        - 6
+        PrevArt:
+        - 0
+        - 0
+        - 0
+        - 2
+        NextArt:
+        - 0
+        - 0
+        - 0
+        - 0
+        ParentArt:
+        - 0
+        - 0
+        - 0
+        - 0
+        FirstChildArtArt:
+        - 0
+        - 0
+        - 0
+        - 0
+        DataFlav:
+        - 116
+        - 101
+        - 120
+        - 116
+        - 47
+        - 112
+        - 108
+        - 97
+        - 105
+        - 110
+        Data: Hello world
+    SubCats: {}
+    count: []
+    addsn: []
+    deletesn: []
+    guid: []
diff --git a/internal/mobius/test/config/Users/admin.yaml b/internal/mobius/test/config/Users/admin.yaml
new file mode 100644 (file)
index 0000000..1bf656b
--- /dev/null
@@ -0,0 +1,13 @@
+Login: admin
+Name: admin
+Password: $2a$04$2itGEYx8C1N5bsfRSoC9JuonS3I4YfnyVPZHLSwp7kEInRX0yoB.a
+Access: 
+- 255
+- 255
+- 255
+- 255
+- 255
+- 255
+- 0
+- 0
+
diff --git a/internal/mobius/test/config/Users/guest.yaml b/internal/mobius/test/config/Users/guest.yaml
new file mode 100644 (file)
index 0000000..57117bd
--- /dev/null
@@ -0,0 +1,12 @@
+Login: guest
+Name: guest
+Password: $2a$04$9P/jgLn1fR9TjSoWL.rKxuN6g.1TSpf2o6Hw.aaRuBwrWIJNwsKkS
+Access:
+- 125
+- 240
+- 12
+- 239
+- 171
+- 128
+- 0
+- 0
diff --git a/internal/mobius/test/config/config.yaml b/internal/mobius/test/config/config.yaml
new file mode 100644 (file)
index 0000000..5b2fefd
--- /dev/null
@@ -0,0 +1,6 @@
+Name: Halcyon's Test Server
+Description: Experimental Hotline server
+FileRoot: conFiles/
+EnableTrackerRegistration: false
+Trackers: 
+  - hltracker.com:5499
\ No newline at end of file
index bae6779effd9fbcbc8459d54bb2bd05d51ba9124..a49055c7ab0ec51b462edf6150d7945c6bb8e1dd 100644 (file)
@@ -95,16 +95,6 @@ func (n *ThreadedNewsYAML) GetArticle(newsPath []string, articleID uint32) *hotl
        return art
 }
 
        return art
 }
 
-//
-//func (n *ThreadedNewsYAML) GetNewsCatByPath(paths []string) map[string]hotline.NewsCategoryListData15 {
-//     n.mu.Lock()
-//     defer n.mu.Unlock()
-//
-//     cats := n.getCatByPath(paths)
-//
-//     return cats
-//}
-
 func (n *ThreadedNewsYAML) GetCategories(paths []string) []hotline.NewsCategoryListData15 {
        n.mu.Lock()
        defer n.mu.Unlock()
 func (n *ThreadedNewsYAML) GetCategories(paths []string) []hotline.NewsCategoryListData15 {
        n.mu.Lock()
        defer n.mu.Unlock()
diff --git a/internal/mobius/threaded_news_test.go b/internal/mobius/threaded_news_test.go
new file mode 100644 (file)
index 0000000..9269a85
--- /dev/null
@@ -0,0 +1,65 @@
+package mobius
+
+import (
+       "github.com/stretchr/testify/assert"
+       "os"
+       "testing"
+)
+
+type TestData struct {
+       Name  string `yaml:"name"`
+       Value int    `yaml:"value"`
+}
+
+func TestLoadFromYAMLFile(t *testing.T) {
+       tests := []struct {
+               name     string
+               fileName string
+               content  string
+               wantData TestData
+               wantErr  bool
+       }{
+               {
+                       name:     "Valid YAML file",
+                       fileName: "valid.yaml",
+                       content:  "name: Test\nvalue: 123\n",
+                       wantData: TestData{Name: "Test", Value: 123},
+                       wantErr:  false,
+               },
+               {
+                       name:     "File not found",
+                       fileName: "nonexistent.yaml",
+                       content:  "",
+                       wantData: TestData{},
+                       wantErr:  true,
+               },
+               {
+                       name:     "Invalid YAML content",
+                       fileName: "invalid.yaml",
+                       content:  "name: Test\nvalue: invalid_int\n",
+                       wantData: TestData{},
+                       wantErr:  true,
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       // Setup: Create a temporary file with the provided content if content is not empty
+                       if tt.content != "" {
+                               err := os.WriteFile(tt.fileName, []byte(tt.content), 0644)
+                               assert.NoError(t, err)
+                               defer os.Remove(tt.fileName) // Cleanup the file after the test
+                       }
+
+                       var data TestData
+                       err := loadFromYAMLFile(tt.fileName, &data)
+
+                       if tt.wantErr {
+                               assert.Error(t, err)
+                       } else {
+                               assert.NoError(t, err)
+                               assert.Equal(t, tt.wantData, data)
+                       }
+               })
+       }
+}
diff --git a/internal/mobius/transaction_handlers.go b/internal/mobius/transaction_handlers.go
new file mode 100644 (file)
index 0000000..3b07d0e
--- /dev/null
@@ -0,0 +1,1793 @@
+package mobius
+
+import (
+       "bufio"
+       "bytes"
+       "encoding/binary"
+       "fmt"
+       "github.com/jhalter/mobius/hotline"
+       "golang.org/x/text/encoding/charmap"
+       "io"
+       "math/big"
+       "os"
+       "path"
+       "path/filepath"
+       "strings"
+       "time"
+)
+
+// Converts bytes from Mac Roman encoding to UTF-8
+var txtDecoder = charmap.Macintosh.NewDecoder()
+
+// Converts bytes from UTF-8 to Mac Roman encoding
+var txtEncoder = charmap.Macintosh.NewEncoder()
+
+// Assign functions to handle specific Hotline transaction types
+func RegisterHandlers(srv *hotline.Server) {
+       srv.HandleFunc(hotline.TranAgreed, HandleTranAgreed)
+       srv.HandleFunc(hotline.TranChatSend, HandleChatSend)
+       srv.HandleFunc(hotline.TranDelNewsArt, HandleDelNewsArt)
+       srv.HandleFunc(hotline.TranDelNewsItem, HandleDelNewsItem)
+       srv.HandleFunc(hotline.TranDeleteFile, HandleDeleteFile)
+       srv.HandleFunc(hotline.TranDeleteUser, HandleDeleteUser)
+       srv.HandleFunc(hotline.TranDisconnectUser, HandleDisconnectUser)
+       srv.HandleFunc(hotline.TranDownloadFile, HandleDownloadFile)
+       srv.HandleFunc(hotline.TranDownloadFldr, HandleDownloadFolder)
+       srv.HandleFunc(hotline.TranGetClientInfoText, HandleGetClientInfoText)
+       srv.HandleFunc(hotline.TranGetFileInfo, HandleGetFileInfo)
+       srv.HandleFunc(hotline.TranGetFileNameList, HandleGetFileNameList)
+       srv.HandleFunc(hotline.TranGetMsgs, HandleGetMsgs)
+       srv.HandleFunc(hotline.TranGetNewsArtData, HandleGetNewsArtData)
+       srv.HandleFunc(hotline.TranGetNewsArtNameList, HandleGetNewsArtNameList)
+       srv.HandleFunc(hotline.TranGetNewsCatNameList, HandleGetNewsCatNameList)
+       srv.HandleFunc(hotline.TranGetUser, HandleGetUser)
+       srv.HandleFunc(hotline.TranGetUserNameList, HandleGetUserNameList)
+       srv.HandleFunc(hotline.TranInviteNewChat, HandleInviteNewChat)
+       srv.HandleFunc(hotline.TranInviteToChat, HandleInviteToChat)
+       srv.HandleFunc(hotline.TranJoinChat, HandleJoinChat)
+       srv.HandleFunc(hotline.TranKeepAlive, HandleKeepAlive)
+       srv.HandleFunc(hotline.TranLeaveChat, HandleLeaveChat)
+       srv.HandleFunc(hotline.TranListUsers, HandleListUsers)
+       srv.HandleFunc(hotline.TranMoveFile, HandleMoveFile)
+       srv.HandleFunc(hotline.TranNewFolder, HandleNewFolder)
+       srv.HandleFunc(hotline.TranNewNewsCat, HandleNewNewsCat)
+       srv.HandleFunc(hotline.TranNewNewsFldr, HandleNewNewsFldr)
+       srv.HandleFunc(hotline.TranNewUser, HandleNewUser)
+       srv.HandleFunc(hotline.TranUpdateUser, HandleUpdateUser)
+       srv.HandleFunc(hotline.TranOldPostNews, HandleTranOldPostNews)
+       srv.HandleFunc(hotline.TranPostNewsArt, HandlePostNewsArt)
+       srv.HandleFunc(hotline.TranRejectChatInvite, HandleRejectChatInvite)
+       srv.HandleFunc(hotline.TranSendInstantMsg, HandleSendInstantMsg)
+       srv.HandleFunc(hotline.TranSetChatSubject, HandleSetChatSubject)
+       srv.HandleFunc(hotline.TranMakeFileAlias, HandleMakeAlias)
+       srv.HandleFunc(hotline.TranSetClientUserInfo, HandleSetClientUserInfo)
+       srv.HandleFunc(hotline.TranSetFileInfo, HandleSetFileInfo)
+       srv.HandleFunc(hotline.TranSetUser, HandleSetUser)
+       srv.HandleFunc(hotline.TranUploadFile, HandleUploadFile)
+       srv.HandleFunc(hotline.TranUploadFldr, HandleUploadFolder)
+       srv.HandleFunc(hotline.TranUserBroadcast, HandleUserBroadcast)
+       srv.HandleFunc(hotline.TranDownloadBanner, HandleDownloadBanner)
+}
+
+func HandleChatSend(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessSendChat) {
+               return cc.NewErrReply(t, "You are not allowed to participate in chat.")
+       }
+
+       // Truncate long usernames
+       // %13.13s: This means a string that is right-aligned in a field of 13 characters.
+       // If the string is longer than 13 characters, it will be truncated to 13 characters.
+       formattedMsg := fmt.Sprintf("\r%13.13s:  %s", cc.UserName, t.GetField(hotline.FieldData).Data)
+
+       // By holding the option key, Hotline chat allows users to send /me formatted messages like:
+       // *** Halcyon does stuff
+       // This is indicated by the presence of the optional field FieldChatOptions set to a value of 1.
+       // Most clients do not send this option for normal chat messages.
+       if t.GetField(hotline.FieldChatOptions).Data != nil && bytes.Equal(t.GetField(hotline.FieldChatOptions).Data, []byte{0, 1}) {
+               formattedMsg = fmt.Sprintf("\r*** %s %s", cc.UserName, t.GetField(hotline.FieldData).Data)
+       }
+
+       // Truncate the message to the limit.  This does not handle the edge case of a string ending on multibyte character.
+       formattedMsg = formattedMsg[:min(len(formattedMsg), hotline.LimitChatMsg)]
+
+       // The ChatID field is used to identify messages as belonging to a private chat.
+       // All clients *except* Frogblast omit this field for public chat, but Frogblast sends a value of 00 00 00 00.
+       chatID := t.GetField(hotline.FieldChatID).Data
+       if chatID != nil && !bytes.Equal([]byte{0, 0, 0, 0}, chatID) {
+
+               // send the message to all connected clients of the private chat
+               for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) {
+                       res = append(res, hotline.NewTransaction(
+                               hotline.TranChatMsg,
+                               c.ID,
+                               hotline.NewField(hotline.FieldChatID, chatID),
+                               hotline.NewField(hotline.FieldData, []byte(formattedMsg)),
+                       ))
+               }
+               return res
+       }
+
+       //cc.Server.mux.Lock()
+       for _, c := range cc.Server.ClientMgr.List() {
+               if c == nil || cc.Account == nil {
+                       continue
+               }
+               // Skip clients that do not have the read chat permission.
+               if c.Authorize(hotline.AccessReadChat) {
+                       res = append(res, hotline.NewTransaction(hotline.TranChatMsg, c.ID, hotline.NewField(hotline.FieldData, []byte(formattedMsg))))
+               }
+       }
+       //cc.Server.mux.Unlock()
+
+       return res
+}
+
+// HandleSendInstantMsg sends instant message to the user on the current server.
+// Fields used in the request:
+//
+//     103     User Type
+//     113     Options
+//             One of the following values:
+//             - User message (myOpt_UserMessage = 1)
+//             - Refuse message (myOpt_RefuseMessage = 2)
+//             - Refuse chat (myOpt_RefuseChat  = 3)
+//             - Automatic response (myOpt_AutomaticResponse = 4)"
+//     101     Data    Optional
+//     214     Quoting message Optional
+//
+// Fields used in the reply:
+// None
+func HandleSendInstantMsg(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessSendPrivMsg) {
+               return cc.NewErrReply(t, "You are not allowed to send private messages.")
+       }
+
+       msg := t.GetField(hotline.FieldData)
+       userID := t.GetField(hotline.FieldUserID)
+
+       reply := hotline.NewTransaction(
+               hotline.TranServerMsg,
+               [2]byte(userID.Data),
+               hotline.NewField(hotline.FieldData, msg.Data),
+               hotline.NewField(hotline.FieldUserName, cc.UserName),
+               hotline.NewField(hotline.FieldUserID, cc.ID[:]),
+               hotline.NewField(hotline.FieldOptions, []byte{0, 1}),
+       )
+
+       // Later versions of Hotline include the original message in the FieldQuotingMsg field so
+       //  the receiving client can display both the received message and what it is in reply to
+       if t.GetField(hotline.FieldQuotingMsg).Data != nil {
+               reply.Fields = append(reply.Fields, hotline.NewField(hotline.FieldQuotingMsg, t.GetField(hotline.FieldQuotingMsg).Data))
+       }
+
+       otherClient := cc.Server.ClientMgr.Get([2]byte(userID.Data))
+       if otherClient == nil {
+               return res
+       }
+
+       // Check if target user has "Refuse private messages" flag
+       if otherClient.Flags.IsSet(hotline.UserFlagRefusePM) {
+               res = append(res,
+                       hotline.NewTransaction(
+                               hotline.TranServerMsg,
+                               cc.ID,
+                               hotline.NewField(hotline.FieldData, []byte(string(otherClient.UserName)+" does not accept private messages.")),
+                               hotline.NewField(hotline.FieldUserName, otherClient.UserName),
+                               hotline.NewField(hotline.FieldUserID, otherClient.ID[:]),
+                               hotline.NewField(hotline.FieldOptions, []byte{0, 2}),
+                       ),
+               )
+       } else {
+               res = append(res, reply)
+       }
+
+       // Respond with auto reply if other client has it enabled
+       if len(otherClient.AutoReply) > 0 {
+               res = append(res,
+                       hotline.NewTransaction(
+                               hotline.TranServerMsg,
+                               cc.ID,
+                               hotline.NewField(hotline.FieldData, otherClient.AutoReply),
+                               hotline.NewField(hotline.FieldUserName, otherClient.UserName),
+                               hotline.NewField(hotline.FieldUserID, otherClient.ID[:]),
+                               hotline.NewField(hotline.FieldOptions, []byte{0, 1}),
+                       ),
+               )
+       }
+
+       return append(res, cc.NewReply(t))
+}
+
+var fileTypeFLDR = [4]byte{0x66, 0x6c, 0x64, 0x72}
+
+func HandleGetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       fileName := t.GetField(hotline.FieldFileName).Data
+       filePath := t.GetField(hotline.FieldFilePath).Data
+
+       fullFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, filePath, fileName)
+       if err != nil {
+               return res
+       }
+
+       fw, err := hotline.NewFileWrapper(cc.Server.FS, fullFilePath, 0)
+       if err != nil {
+               return res
+       }
+
+       encodedName, err := txtEncoder.String(fw.Name)
+       if err != nil {
+               return res
+       }
+
+       fields := []hotline.Field{
+               hotline.NewField(hotline.FieldFileName, []byte(encodedName)),
+               hotline.NewField(hotline.FieldFileTypeString, fw.Ffo.FlatFileInformationFork.FriendlyType()),
+               hotline.NewField(hotline.FieldFileCreatorString, fw.Ffo.FlatFileInformationFork.FriendlyCreator()),
+               hotline.NewField(hotline.FieldFileType, fw.Ffo.FlatFileInformationFork.TypeSignature[:]),
+               hotline.NewField(hotline.FieldFileCreateDate, fw.Ffo.FlatFileInformationFork.CreateDate[:]),
+               hotline.NewField(hotline.FieldFileModifyDate, fw.Ffo.FlatFileInformationFork.ModifyDate[:]),
+       }
+
+       // Include the optional FileComment field if there is a comment.
+       if len(fw.Ffo.FlatFileInformationFork.Comment) != 0 {
+               fields = append(fields, hotline.NewField(hotline.FieldFileComment, fw.Ffo.FlatFileInformationFork.Comment))
+       }
+
+       // Include the FileSize field for files.
+       if fw.Ffo.FlatFileInformationFork.TypeSignature != fileTypeFLDR {
+               fields = append(fields, hotline.NewField(hotline.FieldFileSize, fw.TotalSize()))
+       }
+
+       res = append(res, cc.NewReply(t, fields...))
+       return res
+}
+
+// HandleSetFileInfo updates a file or folder Name and/or comment from the Get Info window
+// Fields used in the request:
+// * 201       File Name
+// * 202       File path       Optional
+// * 211       File new Name   Optional
+// * 210       File comment    Optional
+// Fields used in the reply:   None
+func HandleSetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       fileName := t.GetField(hotline.FieldFileName).Data
+       filePath := t.GetField(hotline.FieldFilePath).Data
+
+       fullFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, filePath, fileName)
+       if err != nil {
+               return res
+       }
+
+       fi, err := cc.Server.FS.Stat(fullFilePath)
+       if err != nil {
+               return res
+       }
+
+       hlFile, err := hotline.NewFileWrapper(cc.Server.FS, fullFilePath, 0)
+       if err != nil {
+               return res
+       }
+       if t.GetField(hotline.FieldFileComment).Data != nil {
+               switch mode := fi.Mode(); {
+               case mode.IsDir():
+                       if !cc.Authorize(hotline.AccessSetFolderComment) {
+                               return cc.NewErrReply(t, "You are not allowed to set comments for folders.")
+                       }
+               case mode.IsRegular():
+                       if !cc.Authorize(hotline.AccessSetFileComment) {
+                               return cc.NewErrReply(t, "You are not allowed to set comments for files.")
+                       }
+               }
+
+               if err := hlFile.Ffo.FlatFileInformationFork.SetComment(t.GetField(hotline.FieldFileComment).Data); err != nil {
+                       return res
+               }
+               w, err := hlFile.InfoForkWriter()
+               if err != nil {
+                       return res
+               }
+               _, err = io.Copy(w, &hlFile.Ffo.FlatFileInformationFork)
+               if err != nil {
+                       return res
+               }
+       }
+
+       fullNewFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, filePath, t.GetField(hotline.FieldFileNewName).Data)
+       if err != nil {
+               return nil
+       }
+
+       fileNewName := t.GetField(hotline.FieldFileNewName).Data
+
+       if fileNewName != nil {
+               switch mode := fi.Mode(); {
+               case mode.IsDir():
+                       if !cc.Authorize(hotline.AccessRenameFolder) {
+                               return cc.NewErrReply(t, "You are not allowed to rename folders.")
+                       }
+                       err = os.Rename(fullFilePath, fullNewFilePath)
+                       if os.IsNotExist(err) {
+                               return cc.NewErrReply(t, "Cannot rename folder "+string(fileName)+" because it does not exist or cannot be found.")
+
+                       }
+               case mode.IsRegular():
+                       if !cc.Authorize(hotline.AccessRenameFile) {
+                               return cc.NewErrReply(t, "You are not allowed to rename files.")
+                       }
+                       fileDir, err := hotline.ReadPath(cc.Server.Config.FileRoot, filePath, []byte{})
+                       if err != nil {
+                               return nil
+                       }
+                       hlFile.Name, err = txtDecoder.String(string(fileNewName))
+                       if err != nil {
+                               return res
+                       }
+
+                       err = hlFile.Move(fileDir)
+                       if os.IsNotExist(err) {
+                               return cc.NewErrReply(t, "Cannot rename file "+string(fileName)+" because it does not exist or cannot be found.")
+                       }
+                       if err != nil {
+                               return res
+                       }
+               }
+       }
+
+       res = append(res, cc.NewReply(t))
+       return res
+}
+
+// HandleDeleteFile deletes a file or folder
+// Fields used in the request:
+// * 201       File Name
+// * 202       File path
+// Fields used in the reply: none
+func HandleDeleteFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       fileName := t.GetField(hotline.FieldFileName).Data
+       filePath := t.GetField(hotline.FieldFilePath).Data
+
+       fullFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, filePath, fileName)
+       if err != nil {
+               return res
+       }
+
+       hlFile, err := hotline.NewFileWrapper(cc.Server.FS, fullFilePath, 0)
+       if err != nil {
+               return res
+       }
+
+       fi, err := hlFile.DataFile()
+       if err != nil {
+               return cc.NewErrReply(t, "Cannot delete file "+string(fileName)+" because it does not exist or cannot be found.")
+       }
+
+       switch mode := fi.Mode(); {
+       case mode.IsDir():
+               if !cc.Authorize(hotline.AccessDeleteFolder) {
+                       return cc.NewErrReply(t, "You are not allowed to delete folders.")
+               }
+       case mode.IsRegular():
+               if !cc.Authorize(hotline.AccessDeleteFile) {
+                       return cc.NewErrReply(t, "You are not allowed to delete files.")
+               }
+       }
+
+       if err := hlFile.Delete(); err != nil {
+               return res
+       }
+
+       res = append(res, cc.NewReply(t))
+       return res
+}
+
+// HandleMoveFile moves files or folders. Note: seemingly not documented
+func HandleMoveFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       fileName := string(t.GetField(hotline.FieldFileName).Data)
+
+       filePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, t.GetField(hotline.FieldFilePath).Data, t.GetField(hotline.FieldFileName).Data)
+       if err != nil {
+               return res
+       }
+
+       fileNewPath, err := hotline.ReadPath(cc.Server.Config.FileRoot, t.GetField(hotline.FieldFileNewPath).Data, nil)
+       if err != nil {
+               return res
+       }
+
+       cc.Logger.Info("Move file", "src", filePath+"/"+fileName, "dst", fileNewPath+"/"+fileName)
+
+       hlFile, err := hotline.NewFileWrapper(cc.Server.FS, filePath, 0)
+       if err != nil {
+               return res
+       }
+
+       fi, err := hlFile.DataFile()
+       if err != nil {
+               return cc.NewErrReply(t, "Cannot delete file "+fileName+" because it does not exist or cannot be found.")
+       }
+       switch mode := fi.Mode(); {
+       case mode.IsDir():
+               if !cc.Authorize(hotline.AccessMoveFolder) {
+                       return cc.NewErrReply(t, "You are not allowed to move folders.")
+               }
+       case mode.IsRegular():
+               if !cc.Authorize(hotline.AccessMoveFile) {
+                       return cc.NewErrReply(t, "You are not allowed to move files.")
+               }
+       }
+       if err := hlFile.Move(fileNewPath); err != nil {
+               return res
+       }
+       // TODO: handle other possible errors; e.g. fileWrapper delete fails due to fileWrapper permission issue
+
+       res = append(res, cc.NewReply(t))
+       return res
+}
+
+func HandleNewFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessCreateFolder) {
+               return cc.NewErrReply(t, "You are not allowed to create folders.")
+       }
+       folderName := string(t.GetField(hotline.FieldFileName).Data)
+
+       folderName = path.Join("/", folderName)
+
+       var subPath string
+
+       // FieldFilePath is only present for nested paths
+       if t.GetField(hotline.FieldFilePath).Data != nil {
+               var newFp hotline.FilePath
+               _, err := newFp.Write(t.GetField(hotline.FieldFilePath).Data)
+               if err != nil {
+                       return res
+               }
+
+               for _, pathItem := range newFp.Items {
+                       subPath = filepath.Join("/", subPath, string(pathItem.Name))
+               }
+       }
+       newFolderPath := path.Join(cc.Server.Config.FileRoot, subPath, folderName)
+       newFolderPath, err := txtDecoder.String(newFolderPath)
+       if err != nil {
+               return res
+       }
+
+       // TODO: check path and folder Name lengths
+
+       if _, err := cc.Server.FS.Stat(newFolderPath); !os.IsNotExist(err) {
+               msg := fmt.Sprintf("Cannot create folder \"%s\" because there is already a file or folder with that Name.", folderName)
+               return cc.NewErrReply(t, msg)
+       }
+
+       if err := cc.Server.FS.Mkdir(newFolderPath, 0777); err != nil {
+               msg := fmt.Sprintf("Cannot create folder \"%s\" because an error occurred.", folderName)
+               return cc.NewErrReply(t, msg)
+       }
+
+       return append(res, cc.NewReply(t))
+}
+
+func HandleSetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessModifyUser) {
+               return cc.NewErrReply(t, "You are not allowed to modify accounts.")
+       }
+
+       login := t.GetField(hotline.FieldUserLogin).DecodeObfuscatedString()
+       userName := string(t.GetField(hotline.FieldUserName).Data)
+
+       newAccessLvl := t.GetField(hotline.FieldUserAccess).Data
+
+       account := cc.Server.AccountManager.Get(login)
+       if account == nil {
+               return cc.NewErrReply(t, "Account not found.")
+       }
+       account.Name = userName
+       copy(account.Access[:], newAccessLvl)
+
+       // If the password field is cleared in the Hotline edit user UI, the SetUser transaction does
+       // not include FieldUserPassword
+       if t.GetField(hotline.FieldUserPassword).Data == nil {
+               account.Password = hotline.HashAndSalt([]byte(""))
+       }
+
+       if !bytes.Equal([]byte{0}, t.GetField(hotline.FieldUserPassword).Data) {
+               account.Password = hotline.HashAndSalt(t.GetField(hotline.FieldUserPassword).Data)
+       }
+
+       err := cc.Server.AccountManager.Update(*account, account.Login)
+       if err != nil {
+               cc.Logger.Error("Error updating account", "Err", err)
+       }
+
+       // Notify connected clients logged in as the user of the new access level
+       for _, c := range cc.Server.ClientMgr.List() {
+               if c.Account.Login == login {
+                       newT := hotline.NewTransaction(hotline.TranUserAccess, c.ID, hotline.NewField(hotline.FieldUserAccess, newAccessLvl))
+                       res = append(res, newT)
+
+                       if c.Authorize(hotline.AccessDisconUser) {
+                               c.Flags.Set(hotline.UserFlagAdmin, 1)
+                       } else {
+                               c.Flags.Set(hotline.UserFlagAdmin, 0)
+                       }
+
+                       c.Account.Access = account.Access
+
+                       cc.SendAll(
+                               hotline.TranNotifyChangeUser,
+                               hotline.NewField(hotline.FieldUserID, c.ID[:]),
+                               hotline.NewField(hotline.FieldUserFlags, c.Flags[:]),
+                               hotline.NewField(hotline.FieldUserName, c.UserName),
+                               hotline.NewField(hotline.FieldUserIconID, c.Icon),
+                       )
+               }
+       }
+
+       return append(res, cc.NewReply(t))
+}
+
+func HandleGetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessOpenUser) {
+               return cc.NewErrReply(t, "You are not allowed to view accounts.")
+       }
+
+       account := cc.Server.AccountManager.Get(string(t.GetField(hotline.FieldUserLogin).Data))
+       if account == nil {
+               return cc.NewErrReply(t, "Account does not exist.")
+       }
+
+       return append(res, cc.NewReply(t,
+               hotline.NewField(hotline.FieldUserName, []byte(account.Name)),
+               hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString(t.GetField(hotline.FieldUserLogin).Data)),
+               hotline.NewField(hotline.FieldUserPassword, []byte(account.Password)),
+               hotline.NewField(hotline.FieldUserAccess, account.Access[:]),
+       ))
+}
+
+func HandleListUsers(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessOpenUser) {
+               return cc.NewErrReply(t, "You are not allowed to view accounts.")
+       }
+
+       var userFields []hotline.Field
+       for _, acc := range cc.Server.AccountManager.List() {
+               b, err := io.ReadAll(&acc)
+               if err != nil {
+                       cc.Logger.Error("Error reading account", "Account", acc.Login, "Err", err)
+                       continue
+               }
+
+               userFields = append(userFields, hotline.NewField(hotline.FieldData, b))
+       }
+
+       return append(res, cc.NewReply(t, userFields...))
+}
+
+// HandleUpdateUser is used by the v1.5+ multi-user editor to perform account editing for multiple users at a time.
+// An update can be a mix of these actions:
+// * Create user
+// * Delete user
+// * Modify user (including renaming the account login)
+//
+// The Transaction sent by the client includes one data field per user that was modified.  This data field in turn
+// contains another data field encoded in its payload with a varying number of sub fields depending on which action is
+// performed.  This seems to be the only place in the Hotline protocol where a data field contains another data field.
+func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       for _, field := range t.Fields {
+               var subFields []hotline.Field
+
+               // Create a new scanner for parsing incoming bytes into transaction tokens
+               scanner := bufio.NewScanner(bytes.NewReader(field.Data[2:]))
+               scanner.Split(hotline.FieldScanner)
+
+               for i := 0; i < int(binary.BigEndian.Uint16(field.Data[0:2])); i++ {
+                       scanner.Scan()
+
+                       var field hotline.Field
+                       if _, err := field.Write(scanner.Bytes()); err != nil {
+                               return res
+                       }
+                       subFields = append(subFields, field)
+               }
+
+               // If there's only one subfield, that indicates this is a delete operation for the login in FieldData
+               if len(subFields) == 1 {
+                       if !cc.Authorize(hotline.AccessDeleteUser) {
+                               return cc.NewErrReply(t, "You are not allowed to delete accounts.")
+                       }
+
+                       login := string(hotline.EncodeString(hotline.GetField(hotline.FieldData, &subFields).Data))
+
+                       cc.Logger.Info("DeleteUser", "login", login)
+
+                       if err := cc.Server.AccountManager.Delete(login); err != nil {
+                               cc.Logger.Error("Error deleting account", "Err", err)
+                               return res
+                       }
+
+                       for _, client := range cc.Server.ClientMgr.List() {
+                               if client.Account.Login == login {
+                                       //                                      "You are logged in with an account which was deleted."
+
+                                       res = append(res,
+                                               hotline.NewTransaction(hotline.TranServerMsg, [2]byte{},
+                                                       hotline.NewField(hotline.FieldData, []byte("You are logged in with an account which was deleted.")),
+                                                       hotline.NewField(hotline.FieldChatOptions, []byte{0}),
+                                               ),
+                                       )
+
+                                       go func(c *hotline.ClientConn) {
+                                               time.Sleep(3 * time.Second)
+                                               c.Disconnect()
+                                       }(client)
+                               }
+                       }
+
+                       continue
+               }
+
+               // login of the account to update
+               var accountToUpdate, loginToRename string
+
+               // If FieldData is included, this is a rename operation where FieldData contains the login of the existing
+               // account and FieldUserLogin contains the new login.
+               if hotline.GetField(hotline.FieldData, &subFields) != nil {
+                       loginToRename = string(hotline.EncodeString(hotline.GetField(hotline.FieldData, &subFields).Data))
+               }
+               userLogin := string(hotline.EncodeString(hotline.GetField(hotline.FieldUserLogin, &subFields).Data))
+               if loginToRename != "" {
+                       accountToUpdate = loginToRename
+               } else {
+                       accountToUpdate = userLogin
+               }
+
+               // Check if accountToUpdate has an existing account.  If so, we know we are updating an existing user.
+               if acc := cc.Server.AccountManager.Get(accountToUpdate); acc != nil {
+                       if loginToRename != "" {
+                               cc.Logger.Info("RenameUser", "prevLogin", accountToUpdate, "newLogin", userLogin)
+                       } else {
+                               cc.Logger.Info("UpdateUser", "login", accountToUpdate)
+                       }
+
+                       // Account exists, so this is an update action.
+                       if !cc.Authorize(hotline.AccessModifyUser) {
+                               return cc.NewErrReply(t, "You are not allowed to modify accounts.")
+                       }
+
+                       // This part is a bit tricky. There are three possibilities:
+                       // 1) The transaction is intended to update the password.
+                       //        In this case, FieldUserPassword is sent with the new password.
+                       // 2) The transaction is intended to remove the password.
+                       //    In this case, FieldUserPassword is not sent.
+                       // 3) The transaction updates the users access bits, but not the password.
+                       //    In this case, FieldUserPassword is sent with zero as the only byte.
+                       if hotline.GetField(hotline.FieldUserPassword, &subFields) != nil {
+                               newPass := hotline.GetField(hotline.FieldUserPassword, &subFields).Data
+                               if !bytes.Equal([]byte{0}, newPass) {
+                                       acc.Password = hotline.HashAndSalt(newPass)
+                               }
+                       } else {
+                               acc.Password = hotline.HashAndSalt([]byte(""))
+                       }
+
+                       if hotline.GetField(hotline.FieldUserAccess, &subFields) != nil {
+                               copy(acc.Access[:], hotline.GetField(hotline.FieldUserAccess, &subFields).Data)
+                       }
+
+                       acc.Name = string(hotline.GetField(hotline.FieldUserName, &subFields).Data)
+
+                       err := cc.Server.AccountManager.Update(*acc, string(hotline.EncodeString(hotline.GetField(hotline.FieldUserLogin, &subFields).Data)))
+
+                       if err != nil {
+                               return res
+                       }
+               } else {
+                       if !cc.Authorize(hotline.AccessCreateUser) {
+                               return cc.NewErrReply(t, "You are not allowed to create new accounts.")
+                       }
+
+                       cc.Logger.Info("CreateUser", "login", userLogin)
+
+                       var newAccess hotline.AccessBitmap
+                       copy(newAccess[:], hotline.GetField(hotline.FieldUserAccess, &subFields).Data)
+
+                       // Prevent account from creating new account with greater permission
+                       for i := 0; i < 64; i++ {
+                               if newAccess.IsSet(i) {
+                                       if !cc.Authorize(i) {
+                                               return cc.NewErrReply(t, "Cannot create account with more access than yourself.")
+                                       }
+                               }
+                       }
+
+                       account := hotline.NewAccount(
+                               userLogin,
+                               string(hotline.GetField(hotline.FieldUserName, &subFields).Data),
+                               string(hotline.GetField(hotline.FieldUserPassword, &subFields).Data),
+                               newAccess,
+                       )
+
+                       err := cc.Server.AccountManager.Create(*account)
+                       if err != nil {
+                               return cc.NewErrReply(t, "Cannot create account because there is already an account with that login.")
+                       }
+               }
+       }
+
+       return append(res, cc.NewReply(t))
+}
+
+// HandleNewUser creates a new user account
+func HandleNewUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessCreateUser) {
+               return cc.NewErrReply(t, "You are not allowed to create new accounts.")
+       }
+
+       login := t.GetField(hotline.FieldUserLogin).DecodeObfuscatedString()
+
+       // If the account already exists, reply with an error.
+       if account := cc.Server.AccountManager.Get(login); account != nil {
+               return cc.NewErrReply(t, "Cannot create account "+login+" because there is already an account with that login.")
+       }
+
+       var newAccess hotline.AccessBitmap
+       copy(newAccess[:], t.GetField(hotline.FieldUserAccess).Data)
+
+       // Prevent account from creating new account with greater permission
+       for i := 0; i < 64; i++ {
+               if newAccess.IsSet(i) {
+                       if !cc.Authorize(i) {
+                               return cc.NewErrReply(t, "Cannot create account with more access than yourself.")
+                       }
+               }
+       }
+
+       account := hotline.NewAccount(login, string(t.GetField(hotline.FieldUserName).Data), string(t.GetField(hotline.FieldUserPassword).Data), newAccess)
+
+       err := cc.Server.AccountManager.Create(*account)
+       if err != nil {
+               return cc.NewErrReply(t, "Cannot create account because there is already an account with that login.")
+       }
+
+       return append(res, cc.NewReply(t))
+}
+
+func HandleDeleteUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessDeleteUser) {
+               return cc.NewErrReply(t, "You are not allowed to delete accounts.")
+       }
+
+       login := t.GetField(hotline.FieldUserLogin).DecodeObfuscatedString()
+
+       if err := cc.Server.AccountManager.Delete(login); err != nil {
+               cc.Logger.Error("Error deleting account", "Err", err)
+               return res
+       }
+
+       for _, client := range cc.Server.ClientMgr.List() {
+               if client.Account.Login == login {
+                       res = append(res,
+                               hotline.NewTransaction(hotline.TranServerMsg, client.ID,
+                                       hotline.NewField(hotline.FieldData, []byte("You are logged in with an account which was deleted.")),
+                                       hotline.NewField(hotline.FieldChatOptions, []byte{2}),
+                               ),
+                       )
+
+                       go func(c *hotline.ClientConn) {
+                               time.Sleep(2 * time.Second)
+                               c.Disconnect()
+                       }(client)
+               }
+       }
+
+       return append(res, cc.NewReply(t))
+}
+
+// HandleUserBroadcast sends an Administrator Message to all connected clients of the server
+func HandleUserBroadcast(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessBroadcast) {
+               return cc.NewErrReply(t, "You are not allowed to send broadcast messages.")
+       }
+
+       cc.SendAll(
+               hotline.TranServerMsg,
+               hotline.NewField(hotline.FieldData, t.GetField(hotline.FieldData).Data),
+               hotline.NewField(hotline.FieldChatOptions, []byte{0}),
+       )
+
+       return append(res, cc.NewReply(t))
+}
+
+// HandleGetClientInfoText returns user information for the specific user.
+//
+// Fields used in the request:
+// 103 User Type
+//
+// Fields used in the reply:
+// 102 User Name
+// 101 Data            User info text string
+func HandleGetClientInfoText(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessGetClientInfo) {
+               return cc.NewErrReply(t, "You are not allowed to get client info.")
+       }
+
+       clientID := t.GetField(hotline.FieldUserID).Data
+
+       clientConn := cc.Server.ClientMgr.Get(hotline.ClientID(clientID))
+       if clientConn == nil {
+               return cc.NewErrReply(t, "User not found.")
+       }
+
+       return append(res, cc.NewReply(t,
+               hotline.NewField(hotline.FieldData, []byte(clientConn.String())),
+               hotline.NewField(hotline.FieldUserName, clientConn.UserName),
+       ))
+}
+
+func HandleGetUserNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       var fields []hotline.Field
+       for _, c := range cc.Server.ClientMgr.List() {
+               b, err := io.ReadAll(&hotline.User{
+                       ID:    c.ID,
+                       Icon:  c.Icon,
+                       Flags: c.Flags[:],
+                       Name:  string(c.UserName),
+               })
+               if err != nil {
+                       return nil
+               }
+
+               fields = append(fields, hotline.NewField(hotline.FieldUsernameWithInfo, b))
+       }
+
+       return []hotline.Transaction{cc.NewReply(t, fields...)}
+}
+
+func HandleTranAgreed(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if t.GetField(hotline.FieldUserName).Data != nil {
+               if cc.Authorize(hotline.AccessAnyName) {
+                       cc.UserName = t.GetField(hotline.FieldUserName).Data
+               } else {
+                       cc.UserName = []byte(cc.Account.Name)
+               }
+       }
+
+       cc.Icon = t.GetField(hotline.FieldUserIconID).Data
+
+       cc.Logger = cc.Logger.With("Name", string(cc.UserName))
+       cc.Logger.Info("Login successful")
+
+       options := t.GetField(hotline.FieldOptions).Data
+       optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options)))
+
+       // Check refuse private PM option
+
+       cc.FlagsMU.Lock()
+       defer cc.FlagsMU.Unlock()
+       cc.Flags.Set(hotline.UserFlagRefusePM, optBitmap.Bit(hotline.UserOptRefusePM))
+
+       // Check refuse private chat option
+       cc.Flags.Set(hotline.UserFlagRefusePChat, optBitmap.Bit(hotline.UserOptRefuseChat))
+
+       // Check auto response
+       if optBitmap.Bit(hotline.UserOptAutoResponse) == 1 {
+               cc.AutoReply = t.GetField(hotline.FieldAutomaticResponse).Data
+       }
+
+       trans := cc.NotifyOthers(
+               hotline.NewTransaction(
+                       hotline.TranNotifyChangeUser, [2]byte{0, 0},
+                       hotline.NewField(hotline.FieldUserName, cc.UserName),
+                       hotline.NewField(hotline.FieldUserID, cc.ID[:]),
+                       hotline.NewField(hotline.FieldUserIconID, cc.Icon),
+                       hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]),
+               ),
+       )
+       res = append(res, trans...)
+
+       if cc.Server.Config.BannerFile != "" {
+               res = append(res, hotline.NewTransaction(hotline.TranServerBanner, cc.ID, hotline.NewField(hotline.FieldBannerType, []byte("JPEG"))))
+       }
+
+       res = append(res, cc.NewReply(t))
+
+       return res
+}
+
+// HandleTranOldPostNews updates the flat news
+// Fields used in this request:
+// 101 Data
+func HandleTranOldPostNews(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessNewsPostArt) {
+               return cc.NewErrReply(t, "You are not allowed to post news.")
+       }
+
+       newsDateTemplate := hotline.NewsDateFormat
+       if cc.Server.Config.NewsDateFormat != "" {
+               newsDateTemplate = cc.Server.Config.NewsDateFormat
+       }
+
+       newsTemplate := hotline.NewsTemplate
+       if cc.Server.Config.NewsDelimiter != "" {
+               newsTemplate = cc.Server.Config.NewsDelimiter
+       }
+
+       newsPost := fmt.Sprintf(newsTemplate+"\r", cc.UserName, time.Now().Format(newsDateTemplate), t.GetField(hotline.FieldData).Data)
+       newsPost = strings.ReplaceAll(newsPost, "\n", "\r")
+
+       _, err := cc.Server.MessageBoard.Write([]byte(newsPost))
+       if err != nil {
+               cc.Logger.Error("error writing news post", "err", err)
+               return nil
+       }
+
+       // Notify all clients of updated news
+       cc.SendAll(
+               hotline.TranNewMsg,
+               hotline.NewField(hotline.FieldData, []byte(newsPost)),
+       )
+
+       return append(res, cc.NewReply(t))
+}
+
+func HandleDisconnectUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessDisconUser) {
+               return cc.NewErrReply(t, "You are not allowed to disconnect users.")
+       }
+
+       clientID := [2]byte(t.GetField(hotline.FieldUserID).Data)
+       clientConn := cc.Server.ClientMgr.Get(clientID)
+
+       if clientConn.Authorize(hotline.AccessCannotBeDiscon) {
+               return cc.NewErrReply(t, clientConn.Account.Login+" is not allowed to be disconnected.")
+       }
+
+       // If FieldOptions is set, then the client IP is banned in addition to disconnected.
+       // 00 01 = temporary ban
+       // 00 02 = permanent ban
+       if t.GetField(hotline.FieldOptions).Data != nil {
+               switch t.GetField(hotline.FieldOptions).Data[1] {
+               case 1:
+                       // send message: "You are temporarily banned on this server"
+                       cc.Logger.Info("Disconnect & temporarily ban " + string(clientConn.UserName))
+
+                       res = append(res, hotline.NewTransaction(
+                               hotline.TranServerMsg,
+                               clientConn.ID,
+                               hotline.NewField(hotline.FieldData, []byte("You are temporarily banned on this server")),
+                               hotline.NewField(hotline.FieldChatOptions, []byte{0, 0}),
+                       ))
+
+                       banUntil := time.Now().Add(hotline.BanDuration)
+                       ip := strings.Split(clientConn.RemoteAddr, ":")[0]
+
+                       err := cc.Server.BanList.Add(ip, &banUntil)
+                       if err != nil {
+                               cc.Logger.Error("Error saving ban", "err", err)
+                               // TODO
+                       }
+               case 2:
+                       // send message: "You are permanently banned on this server"
+                       cc.Logger.Info("Disconnect & ban " + string(clientConn.UserName))
+
+                       res = append(res, hotline.NewTransaction(
+                               hotline.TranServerMsg,
+                               clientConn.ID,
+                               hotline.NewField(hotline.FieldData, []byte("You are permanently banned on this server")),
+                               hotline.NewField(hotline.FieldChatOptions, []byte{0, 0}),
+                       ))
+
+                       ip := strings.Split(clientConn.RemoteAddr, ":")[0]
+
+                       err := cc.Server.BanList.Add(ip, nil)
+                       if err != nil {
+                               // TODO
+                       }
+               }
+       }
+
+       // TODO: remove this awful hack
+       go func() {
+               time.Sleep(1 * time.Second)
+               clientConn.Disconnect()
+       }()
+
+       return append(res, cc.NewReply(t))
+}
+
+// HandleGetNewsCatNameList returns a list of news categories for a path
+// Fields used in the request:
+// 325 News path       (Optional)
+func HandleGetNewsCatNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessNewsReadArt) {
+               return cc.NewErrReply(t, "You are not allowed to read news.")
+       }
+
+       pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
+       if err != nil {
+
+       }
+
+       var fields []hotline.Field
+       for _, cat := range cc.Server.ThreadedNewsMgr.GetCategories(pathStrs) {
+               b, err := io.ReadAll(&cat)
+               if err != nil {
+                       // TODO
+               }
+
+               fields = append(fields, hotline.NewField(hotline.FieldNewsCatListData15, b))
+       }
+
+       return append(res, cc.NewReply(t, fields...))
+}
+
+func HandleNewNewsCat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessNewsCreateCat) {
+               return cc.NewErrReply(t, "You are not allowed to create news categories.")
+       }
+
+       name := string(t.GetField(hotline.FieldNewsCatName).Data)
+       pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
+       if err != nil {
+               return res
+       }
+
+       err = cc.Server.ThreadedNewsMgr.CreateGrouping(pathStrs, name, hotline.NewsCategory)
+       if err != nil {
+               cc.Logger.Error("error creating news category", "err", err)
+       }
+
+       return []hotline.Transaction{cc.NewReply(t)}
+}
+
+// Fields used in the request:
+// 322 News category Name
+// 325 News path
+func HandleNewNewsFldr(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessNewsCreateFldr) {
+               return cc.NewErrReply(t, "You are not allowed to create news folders.")
+       }
+
+       name := string(t.GetField(hotline.FieldFileName).Data)
+       pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
+       if err != nil {
+               return res
+       }
+
+       err = cc.Server.ThreadedNewsMgr.CreateGrouping(pathStrs, name, hotline.NewsBundle)
+       if err != nil {
+               cc.Logger.Error("error creating news bundle", "err", err)
+       }
+
+       return append(res, cc.NewReply(t))
+}
+
+// HandleGetNewsArtData gets the list of article names at the specified news path.
+
+// Fields used in the request:
+// 325 News path       Optional
+
+// Fields used in the reply:
+// 321 News article list data  Optional
+func HandleGetNewsArtNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessNewsReadArt) {
+               return cc.NewErrReply(t, "You are not allowed to read news.")
+       }
+
+       pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
+       if err != nil {
+               return res
+       }
+
+       nald := cc.Server.ThreadedNewsMgr.ListArticles(pathStrs)
+
+       b, err := io.ReadAll(&nald)
+       if err != nil {
+               return res
+       }
+
+       return append(res, cc.NewReply(t, hotline.NewField(hotline.FieldNewsArtListData, b)))
+}
+
+// HandleGetNewsArtData requests information about the specific news article.
+// Fields used in the request:
+//
+// Request fields
+// 325 News path
+// 326 News article Type
+// 327 News article data flavor
+//
+// Fields used in the reply:
+// 328 News article title
+// 329 News article poster
+// 330 News article date
+// 331 Previous article Type
+// 332 Next article Type
+// 335 Parent article Type
+// 336 First child article Type
+// 327 News article data flavor        "Should be “text/plain”
+// 333 News article data       Optional (if data flavor is “text/plain”)
+func HandleGetNewsArtData(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessNewsReadArt) {
+               return cc.NewErrReply(t, "You are not allowed to read news.")
+       }
+
+       newsPath, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
+       if err != nil {
+               return res
+       }
+
+       convertedID, err := t.GetField(hotline.FieldNewsArtID).DecodeInt()
+       if err != nil {
+               return res
+       }
+
+       art := cc.Server.ThreadedNewsMgr.GetArticle(newsPath, uint32(convertedID))
+       if art == nil {
+               return append(res, cc.NewReply(t))
+       }
+
+       res = append(res, cc.NewReply(t,
+               hotline.NewField(hotline.FieldNewsArtTitle, []byte(art.Title)),
+               hotline.NewField(hotline.FieldNewsArtPoster, []byte(art.Poster)),
+               hotline.NewField(hotline.FieldNewsArtDate, art.Date[:]),
+               hotline.NewField(hotline.FieldNewsArtPrevArt, art.PrevArt[:]),
+               hotline.NewField(hotline.FieldNewsArtNextArt, art.NextArt[:]),
+               hotline.NewField(hotline.FieldNewsArtParentArt, art.ParentArt[:]),
+               hotline.NewField(hotline.FieldNewsArt1stChildArt, art.FirstChildArt[:]),
+               hotline.NewField(hotline.FieldNewsArtDataFlav, []byte("text/plain")),
+               hotline.NewField(hotline.FieldNewsArtData, []byte(art.Data)),
+       ))
+       return res
+}
+
+// HandleDelNewsItem deletes a threaded news folder or category.
+// Fields used in the request:
+// 325 News path
+// Fields used in the reply:
+// None
+func HandleDelNewsItem(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
+       if err != nil {
+               return res
+       }
+
+       item := cc.Server.ThreadedNewsMgr.NewsItem(pathStrs)
+
+       if item.Type == [2]byte{0, 3} {
+               if !cc.Authorize(hotline.AccessNewsDeleteCat) {
+                       return cc.NewErrReply(t, "You are not allowed to delete news categories.")
+               }
+       } else {
+               if !cc.Authorize(hotline.AccessNewsDeleteFldr) {
+                       return cc.NewErrReply(t, "You are not allowed to delete news folders.")
+               }
+       }
+
+       err = cc.Server.ThreadedNewsMgr.DeleteNewsItem(pathStrs)
+       if err != nil {
+               return res
+       }
+
+       return append(res, cc.NewReply(t))
+}
+
+// HandleDelNewsArt deletes a threaded news article.
+// Request Fields
+// 325 News path
+// 326 News article Type
+// 337 News article recursive delete   - Delete child articles (1) or not (0)
+func HandleDelNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessNewsDeleteArt) {
+               return cc.NewErrReply(t, "You are not allowed to delete news articles.")
+
+       }
+
+       pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
+       if err != nil {
+               return res
+       }
+
+       articleID, err := t.GetField(hotline.FieldNewsArtID).DecodeInt()
+       if err != nil {
+               cc.Logger.Error("error reading article Type", "err", err)
+               return
+       }
+
+       deleteRecursive := bytes.Equal([]byte{0, 1}, t.GetField(hotline.FieldNewsArtRecurseDel).Data)
+
+       err = cc.Server.ThreadedNewsMgr.DeleteArticle(pathStrs, uint32(articleID), deleteRecursive)
+       if err != nil {
+               cc.Logger.Error("error deleting news article", "err", err)
+       }
+
+       return []hotline.Transaction{cc.NewReply(t)}
+}
+
+// Request fields
+// 325 News path
+// 326 News article Type                                                       Type of the parent article?
+// 328 News article title
+// 334 News article flags
+// 327 News article data flavor                Currently “text/plain”
+// 333 News article data
+func HandlePostNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessNewsPostArt) {
+               return cc.NewErrReply(t, "You are not allowed to post news articles.")
+       }
+
+       pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
+       if err != nil {
+               return res
+       }
+
+       parentArticleID, err := t.GetField(hotline.FieldNewsArtID).DecodeInt()
+       if err != nil {
+               return res
+       }
+
+       err = cc.Server.ThreadedNewsMgr.PostArticle(
+               pathStrs,
+               uint32(parentArticleID),
+               hotline.NewsArtData{
+                       Title:    string(t.GetField(hotline.FieldNewsArtTitle).Data),
+                       Poster:   string(cc.UserName),
+                       Date:     hotline.NewTime(time.Now()),
+                       DataFlav: hotline.NewsFlavor,
+                       Data:     string(t.GetField(hotline.FieldNewsArtData).Data),
+               },
+       )
+       if err != nil {
+               cc.Logger.Error("error posting news article", "err", err)
+       }
+
+       return append(res, cc.NewReply(t))
+}
+
+// HandleGetMsgs returns the flat news data
+func HandleGetMsgs(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessNewsReadArt) {
+               return cc.NewErrReply(t, "You are not allowed to read news.")
+       }
+
+       _, _ = cc.Server.MessageBoard.Seek(0, 0)
+
+       newsData, err := io.ReadAll(cc.Server.MessageBoard)
+       if err != nil {
+               // TODO
+       }
+
+       return append(res, cc.NewReply(t, hotline.NewField(hotline.FieldData, newsData)))
+}
+
+func HandleDownloadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessDownloadFile) {
+               return cc.NewErrReply(t, "You are not allowed to download files.")
+       }
+
+       fileName := t.GetField(hotline.FieldFileName).Data
+       filePath := t.GetField(hotline.FieldFilePath).Data
+       resumeData := t.GetField(hotline.FieldFileResumeData).Data
+
+       var dataOffset int64
+       var frd hotline.FileResumeData
+       if resumeData != nil {
+               if err := frd.UnmarshalBinary(t.GetField(hotline.FieldFileResumeData).Data); err != nil {
+                       return res
+               }
+               // TODO: handle rsrc fork offset
+               dataOffset = int64(binary.BigEndian.Uint32(frd.ForkInfoList[0].DataSize[:]))
+       }
+
+       fullFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, filePath, fileName)
+       if err != nil {
+               return res
+       }
+
+       hlFile, err := hotline.NewFileWrapper(cc.Server.FS, fullFilePath, dataOffset)
+       if err != nil {
+               return res
+       }
+
+       xferSize := hlFile.Ffo.TransferSize(0)
+
+       ft := cc.NewFileTransfer(hotline.FileDownload, fileName, filePath, xferSize)
+
+       // TODO: refactor to remove this
+       if resumeData != nil {
+               var frd hotline.FileResumeData
+               if err := frd.UnmarshalBinary(t.GetField(hotline.FieldFileResumeData).Data); err != nil {
+                       return res
+               }
+               ft.FileResumeData = &frd
+       }
+
+       // Optional field for when a client requests file preview
+       // Used only for TEXT, JPEG, GIFF, BMP or PICT files
+       // The value will always be 2
+       if t.GetField(hotline.FieldFileTransferOptions).Data != nil {
+               ft.Options = t.GetField(hotline.FieldFileTransferOptions).Data
+               xferSize = hlFile.Ffo.FlatFileDataForkHeader.DataSize[:]
+       }
+
+       res = append(res, cc.NewReply(t,
+               hotline.NewField(hotline.FieldRefNum, ft.RefNum[:]),
+               hotline.NewField(hotline.FieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count
+               hotline.NewField(hotline.FieldTransferSize, xferSize),
+               hotline.NewField(hotline.FieldFileSize, hlFile.Ffo.FlatFileDataForkHeader.DataSize[:]),
+       ))
+
+       return res
+}
+
+// Download all files from the specified folder and sub-folders
+func HandleDownloadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessDownloadFile) {
+               return cc.NewErrReply(t, "You are not allowed to download folders.")
+       }
+
+       fullFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, t.GetField(hotline.FieldFilePath).Data, t.GetField(hotline.FieldFileName).Data)
+       if err != nil {
+               return res
+       }
+
+       transferSize, err := hotline.CalcTotalSize(fullFilePath)
+       if err != nil {
+               return res
+       }
+       itemCount, err := hotline.CalcItemCount(fullFilePath)
+       if err != nil {
+               return res
+       }
+
+       fileTransfer := cc.NewFileTransfer(hotline.FolderDownload, t.GetField(hotline.FieldFileName).Data, t.GetField(hotline.FieldFilePath).Data, transferSize)
+
+       var fp hotline.FilePath
+       _, err = fp.Write(t.GetField(hotline.FieldFilePath).Data)
+       if err != nil {
+               return res
+       }
+
+       res = append(res, cc.NewReply(t,
+               hotline.NewField(hotline.FieldRefNum, fileTransfer.RefNum[:]),
+               hotline.NewField(hotline.FieldTransferSize, transferSize),
+               hotline.NewField(hotline.FieldFolderItemCount, itemCount),
+               hotline.NewField(hotline.FieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count
+       ))
+       return res
+}
+
+// Upload all files from the local folder and its subfolders to the specified path on the server
+// Fields used in the request
+// 201 File Name
+// 202 File path
+// 108 hotline.Transfer size   Total size of all items in the folder
+// 220 Folder item count
+// 204 File transfer options   "Optional Currently set to 1" (TODO: ??)
+func HandleUploadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       var fp hotline.FilePath
+       if t.GetField(hotline.FieldFilePath).Data != nil {
+               if _, err := fp.Write(t.GetField(hotline.FieldFilePath).Data); err != nil {
+                       return res
+               }
+       }
+
+       // Handle special cases for Upload and Drop Box folders
+       if !cc.Authorize(hotline.AccessUploadAnywhere) {
+               if !fp.IsUploadDir() && !fp.IsDropbox() {
+                       return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload of the folder \"%v\" because you are only allowed to upload to the \"Uploads\" folder.", string(t.GetField(hotline.FieldFileName).Data)))
+               }
+       }
+
+       fileTransfer := cc.NewFileTransfer(hotline.FolderUpload,
+               t.GetField(hotline.FieldFileName).Data,
+               t.GetField(hotline.FieldFilePath).Data,
+               t.GetField(hotline.FieldTransferSize).Data,
+       )
+
+       fileTransfer.FolderItemCount = t.GetField(hotline.FieldFolderItemCount).Data
+
+       return append(res, cc.NewReply(t, hotline.NewField(hotline.FieldRefNum, fileTransfer.RefNum[:])))
+}
+
+// HandleUploadFile
+// Fields used in the request:
+// 201 File Name
+// 202 File path
+// 204 File transfer options   "Optional
+// Used only to resume download, currently has value 2"
+// 108 File transfer size      "Optional used if download is not resumed"
+func HandleUploadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessUploadFile) {
+               return cc.NewErrReply(t, "You are not allowed to upload files.")
+       }
+
+       fileName := t.GetField(hotline.FieldFileName).Data
+       filePath := t.GetField(hotline.FieldFilePath).Data
+       transferOptions := t.GetField(hotline.FieldFileTransferOptions).Data
+       transferSize := t.GetField(hotline.FieldTransferSize).Data // not sent for resume
+
+       var fp hotline.FilePath
+       if filePath != nil {
+               if _, err := fp.Write(filePath); err != nil {
+                       return res
+               }
+       }
+
+       // Handle special cases for Upload and Drop Box folders
+       if !cc.Authorize(hotline.AccessUploadAnywhere) {
+               if !fp.IsUploadDir() && !fp.IsDropbox() {
+                       return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload of the file \"%v\" because you are only allowed to upload to the \"Uploads\" folder.", string(fileName)))
+               }
+       }
+       fullFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, filePath, fileName)
+       if err != nil {
+               return res
+       }
+
+       if _, err := cc.Server.FS.Stat(fullFilePath); err == nil {
+               return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload because there is already a file named \"%v\".  Try choosing a different Name.", string(fileName)))
+       }
+
+       ft := cc.NewFileTransfer(hotline.FileUpload, fileName, filePath, transferSize)
+
+       replyT := cc.NewReply(t, hotline.NewField(hotline.FieldRefNum, ft.RefNum[:]))
+
+       // client has requested to resume a partially transferred file
+       if transferOptions != nil {
+               fileInfo, err := cc.Server.FS.Stat(fullFilePath + hotline.IncompleteFileSuffix)
+               if err != nil {
+                       return res
+               }
+
+               offset := make([]byte, 4)
+               binary.BigEndian.PutUint32(offset, uint32(fileInfo.Size()))
+
+               fileResumeData := hotline.NewFileResumeData([]hotline.ForkInfoList{
+                       *hotline.NewForkInfoList(offset),
+               })
+
+               b, _ := fileResumeData.BinaryMarshal()
+
+               ft.TransferSize = offset
+
+               replyT.Fields = append(replyT.Fields, hotline.NewField(hotline.FieldFileResumeData, b))
+       }
+
+       res = append(res, replyT)
+       return res
+}
+
+func HandleSetClientUserInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if len(t.GetField(hotline.FieldUserIconID).Data) == 4 {
+               cc.Icon = t.GetField(hotline.FieldUserIconID).Data[2:]
+       } else {
+               cc.Icon = t.GetField(hotline.FieldUserIconID).Data
+       }
+       if cc.Authorize(hotline.AccessAnyName) {
+               cc.UserName = t.GetField(hotline.FieldUserName).Data
+       }
+
+       // the options field is only passed by the client versions > 1.2.3.
+       options := t.GetField(hotline.FieldOptions).Data
+       if options != nil {
+               optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options)))
+
+               cc.Flags.Set(hotline.UserFlagRefusePM, optBitmap.Bit(hotline.UserOptRefusePM))
+               cc.Flags.Set(hotline.UserFlagRefusePChat, optBitmap.Bit(hotline.UserOptRefuseChat))
+
+               // Check auto response
+               if optBitmap.Bit(hotline.UserOptAutoResponse) == 1 {
+                       cc.AutoReply = t.GetField(hotline.FieldAutomaticResponse).Data
+               } else {
+                       cc.AutoReply = []byte{}
+               }
+       }
+
+       for _, c := range cc.Server.ClientMgr.List() {
+               res = append(res, hotline.NewTransaction(
+                       hotline.TranNotifyChangeUser,
+                       c.ID,
+                       hotline.NewField(hotline.FieldUserID, cc.ID[:]),
+                       hotline.NewField(hotline.FieldUserIconID, cc.Icon),
+                       hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]),
+                       hotline.NewField(hotline.FieldUserName, cc.UserName),
+               ))
+       }
+
+       return res
+}
+
+// HandleKeepAlive responds to keepalive transactions with an empty reply
+// * HL 1.9.2 Client sends keepalive msg every 3 minutes
+// * HL 1.2.3 Client doesn't send keepalives
+func HandleKeepAlive(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       res = append(res, cc.NewReply(t))
+
+       return res
+}
+
+func HandleGetFileNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       fullPath, err := hotline.ReadPath(
+               cc.Server.Config.FileRoot,
+               t.GetField(hotline.FieldFilePath).Data,
+               nil,
+       )
+       if err != nil {
+               return res
+       }
+
+       var fp hotline.FilePath
+       if t.GetField(hotline.FieldFilePath).Data != nil {
+               if _, err = fp.Write(t.GetField(hotline.FieldFilePath).Data); err != nil {
+                       return res
+               }
+       }
+
+       // Handle special case for drop box folders
+       if fp.IsDropbox() && !cc.Authorize(hotline.AccessViewDropBoxes) {
+               return cc.NewErrReply(t, "You are not allowed to view drop boxes.")
+       }
+
+       fileNames, err := hotline.GetFileNameList(fullPath, cc.Server.Config.IgnoreFiles)
+       if err != nil {
+               return res
+       }
+
+       res = append(res, cc.NewReply(t, fileNames...))
+
+       return res
+}
+
+// =================================
+//     Hotline private chat flow
+// =================================
+// 1. ClientA sends TranInviteNewChat to server with user Type to invite
+// 2. Server creates new ChatID
+// 3. Server sends TranInviteToChat to invitee
+// 4. Server replies to ClientA with new Chat Type
+//
+// A dialog box pops up in the invitee client with options to accept or decline the invitation.
+// If Accepted is clicked:
+// 1. ClientB sends TranJoinChat with FieldChatID
+
+// HandleInviteNewChat invites users to new private chat
+func HandleInviteNewChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessOpenChat) {
+               return cc.NewErrReply(t, "You are not allowed to request private chat.")
+       }
+
+       // Client to Invite
+       targetID := t.GetField(hotline.FieldUserID).Data
+
+       // Create a new chat with self as initial member.
+       newChatID := cc.Server.ChatMgr.New(cc)
+
+       // Check if target user has "Refuse private chat" flag
+       targetClient := cc.Server.ClientMgr.Get([2]byte(targetID))
+       flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(targetClient.Flags[:])))
+       if flagBitmap.Bit(hotline.UserFlagRefusePChat) == 1 {
+               res = append(res,
+                       hotline.NewTransaction(
+                               hotline.TranServerMsg,
+                               cc.ID,
+                               hotline.NewField(hotline.FieldData, []byte(string(targetClient.UserName)+" does not accept private chats.")),
+                               hotline.NewField(hotline.FieldUserName, targetClient.UserName),
+                               hotline.NewField(hotline.FieldUserID, targetClient.ID[:]),
+                               hotline.NewField(hotline.FieldOptions, []byte{0, 2}),
+                       ),
+               )
+       } else {
+               res = append(res,
+                       hotline.NewTransaction(
+                               hotline.TranInviteToChat,
+                               [2]byte(targetID),
+                               hotline.NewField(hotline.FieldChatID, newChatID[:]),
+                               hotline.NewField(hotline.FieldUserName, cc.UserName),
+                               hotline.NewField(hotline.FieldUserID, cc.ID[:]),
+                       ),
+               )
+       }
+
+       return append(
+               res,
+               cc.NewReply(t,
+                       hotline.NewField(hotline.FieldChatID, newChatID[:]),
+                       hotline.NewField(hotline.FieldUserName, cc.UserName),
+                       hotline.NewField(hotline.FieldUserID, cc.ID[:]),
+                       hotline.NewField(hotline.FieldUserIconID, cc.Icon),
+                       hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]),
+               ),
+       )
+}
+
+func HandleInviteToChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessOpenChat) {
+               return cc.NewErrReply(t, "You are not allowed to request private chat.")
+       }
+
+       // Client to Invite
+       targetID := t.GetField(hotline.FieldUserID).Data
+       chatID := t.GetField(hotline.FieldChatID).Data
+
+       return []hotline.Transaction{
+               hotline.NewTransaction(
+                       hotline.TranInviteToChat,
+                       [2]byte(targetID),
+                       hotline.NewField(hotline.FieldChatID, chatID),
+                       hotline.NewField(hotline.FieldUserName, cc.UserName),
+                       hotline.NewField(hotline.FieldUserID, cc.ID[:]),
+               ),
+               cc.NewReply(
+                       t,
+                       hotline.NewField(hotline.FieldChatID, chatID),
+                       hotline.NewField(hotline.FieldUserName, cc.UserName),
+                       hotline.NewField(hotline.FieldUserID, cc.ID[:]),
+                       hotline.NewField(hotline.FieldUserIconID, cc.Icon),
+                       hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]),
+               ),
+       }
+}
+
+func HandleRejectChatInvite(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       chatID := [4]byte(t.GetField(hotline.FieldChatID).Data)
+
+       for _, c := range cc.Server.ChatMgr.Members(chatID) {
+               res = append(res,
+                       hotline.NewTransaction(
+                               hotline.TranChatMsg,
+                               c.ID,
+                               hotline.NewField(hotline.FieldChatID, chatID[:]),
+                               hotline.NewField(hotline.FieldData, append(cc.UserName, []byte(" declined invitation to chat")...)),
+                       ),
+               )
+       }
+
+       return res
+}
+
+// HandleJoinChat is sent from a v1.8+ Hotline client when the joins a private chat
+// Fields used in the reply:
+// * 115       Chat subject
+// * 300       User Name with info (Optional)
+// * 300       (more user names with info)
+func HandleJoinChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       chatID := t.GetField(hotline.FieldChatID).Data
+
+       // Send TranNotifyChatChangeUser to current members of the chat to inform of new user
+       for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) {
+               res = append(res,
+                       hotline.NewTransaction(
+                               hotline.TranNotifyChatChangeUser,
+                               c.ID,
+                               hotline.NewField(hotline.FieldChatID, chatID),
+                               hotline.NewField(hotline.FieldUserName, cc.UserName),
+                               hotline.NewField(hotline.FieldUserID, cc.ID[:]),
+                               hotline.NewField(hotline.FieldUserIconID, cc.Icon),
+                               hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]),
+                       ),
+               )
+       }
+
+       cc.Server.ChatMgr.Join(hotline.ChatID(chatID), cc)
+
+       subject := cc.Server.ChatMgr.GetSubject(hotline.ChatID(chatID))
+
+       replyFields := []hotline.Field{hotline.NewField(hotline.FieldChatSubject, []byte(subject))}
+       for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) {
+               b, err := io.ReadAll(&hotline.User{
+                       ID:    c.ID,
+                       Icon:  c.Icon,
+                       Flags: c.Flags[:],
+                       Name:  string(c.UserName),
+               })
+               if err != nil {
+                       return res
+               }
+               replyFields = append(replyFields, hotline.NewField(hotline.FieldUsernameWithInfo, b))
+       }
+
+       return append(res, cc.NewReply(t, replyFields...))
+}
+
+// HandleLeaveChat is sent from a v1.8+ Hotline client when the user exits a private chat
+// Fields used in the request:
+//   - 114     FieldChatID
+//
+// Reply is not expected.
+func HandleLeaveChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       chatID := t.GetField(hotline.FieldChatID).Data
+
+       cc.Server.ChatMgr.Leave([4]byte(chatID), cc.ID)
+
+       // Notify members of the private chat that the user has left
+       for _, c := range cc.Server.ChatMgr.Members(hotline.ChatID(chatID)) {
+               res = append(res,
+                       hotline.NewTransaction(
+                               hotline.TranNotifyChatDeleteUser,
+                               c.ID,
+                               hotline.NewField(hotline.FieldChatID, chatID),
+                               hotline.NewField(hotline.FieldUserID, cc.ID[:]),
+                       ),
+               )
+       }
+
+       return res
+}
+
+// HandleSetChatSubject is sent from a v1.8+ Hotline client when the user sets a private chat subject
+// Fields used in the request:
+// * 114       Chat Type
+// * 115       Chat subject
+// Reply is not expected.
+func HandleSetChatSubject(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       chatID := t.GetField(hotline.FieldChatID).Data
+
+       cc.Server.ChatMgr.SetSubject([4]byte(chatID), string(t.GetField(hotline.FieldChatSubject).Data))
+
+       // Notify chat members of new subject.
+       for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) {
+               res = append(res,
+                       hotline.NewTransaction(
+                               hotline.TranNotifyChatSubject,
+                               c.ID,
+                               hotline.NewField(hotline.FieldChatID, chatID),
+                               hotline.NewField(hotline.FieldChatSubject, t.GetField(hotline.FieldChatSubject).Data),
+                       ),
+               )
+       }
+
+       return res
+}
+
+// HandleMakeAlias makes a file alias using the specified path.
+// Fields used in the request:
+// 201 File Name
+// 202 File path
+// 212 File new path   Destination path
+//
+// Fields used in the reply:
+// None
+func HandleMakeAlias(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       if !cc.Authorize(hotline.AccessMakeAlias) {
+               return cc.NewErrReply(t, "You are not allowed to make aliases.")
+       }
+       fileName := t.GetField(hotline.FieldFileName).Data
+       filePath := t.GetField(hotline.FieldFilePath).Data
+       fileNewPath := t.GetField(hotline.FieldFileNewPath).Data
+
+       fullFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, filePath, fileName)
+       if err != nil {
+               return res
+       }
+
+       fullNewFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, fileNewPath, fileName)
+       if err != nil {
+               return res
+       }
+
+       cc.Logger.Debug("Make alias", "src", fullFilePath, "dst", fullNewFilePath)
+
+       if err := cc.Server.FS.Symlink(fullFilePath, fullNewFilePath); err != nil {
+               return cc.NewErrReply(t, "Error creating alias")
+       }
+
+       res = append(res, cc.NewReply(t))
+       return res
+}
+
+// HandleDownloadBanner handles requests for a new banner from the server
+// Fields used in the request:
+// None
+// Fields used in the reply:
+// 107 FieldRefNum                     Used later for transfer
+// 108 FieldTransferSize       Size of data to be downloaded
+func HandleDownloadBanner(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+       ft := cc.NewFileTransfer(hotline.BannerDownload, []byte{}, []byte{}, make([]byte, 4))
+       binary.BigEndian.PutUint32(ft.TransferSize, uint32(len(cc.Server.Banner)))
+
+       return append(res, cc.NewReply(t,
+               hotline.NewField(hotline.FieldRefNum, ft.RefNum[:]),
+               hotline.NewField(hotline.FieldTransferSize, ft.TransferSize),
+       ))
+}
diff --git a/internal/mobius/transaction_handlers_test.go b/internal/mobius/transaction_handlers_test.go
new file mode 100644 (file)
index 0000000..7e9faef
--- /dev/null
@@ -0,0 +1,3795 @@
+package mobius
+
+import (
+       "cmp"
+       "encoding/binary"
+       "encoding/hex"
+       "errors"
+       "github.com/jhalter/mobius/hotline"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/mock"
+       "io"
+       "io/fs"
+       "log/slog"
+       "os"
+       "path/filepath"
+       "slices"
+       "strings"
+       "testing"
+       "time"
+)
+
+type mockReadWriteSeeker struct {
+       mock.Mock
+}
+
+func (m *mockReadWriteSeeker) Read(p []byte) (int, error) {
+       args := m.Called(p)
+
+       return args.Int(0), args.Error(1)
+}
+
+func (m *mockReadWriteSeeker) Write(p []byte) (int, error) {
+       args := m.Called(p)
+
+       return args.Int(0), args.Error(1)
+}
+
+func (m *mockReadWriteSeeker) Seek(offset int64, whence int) (int64, error) {
+       args := m.Called(offset, whence)
+
+       return args.Get(0).(int64), args.Error(1)
+}
+
+func NewTestLogger() *slog.Logger {
+       return slog.New(slog.NewTextHandler(os.Stdout, nil))
+}
+
+// assertTransferBytesEqual takes a string with a hexdump in the same format that `hexdump -C` produces and compares with
+// a hexdump for the bytes in got, after stripping the create/modify timestamps.
+// I don't love this, but as git does not  preserve file create/modify timestamps, we either need to fully mock the
+// filesystem interactions or work around in this way.
+// TODO: figure out a better solution
+func assertTransferBytesEqual(t *testing.T, wantHexDump string, got []byte) bool {
+       if wantHexDump == "" {
+               return true
+       }
+
+       clean := slices.Concat(
+               got[:92],
+               make([]byte, 16),
+               got[108:],
+       )
+       return assert.Equal(t, wantHexDump, hex.Dump(clean))
+}
+
+var tranSortFunc = func(a, b hotline.Transaction) int {
+       return cmp.Compare(
+               binary.BigEndian.Uint16(a.ClientID[:]),
+               binary.BigEndian.Uint16(b.ClientID[:]),
+       )
+}
+
+// TranAssertEqual compares equality of transactions slices after stripping out the random transaction Type
+func TranAssertEqual(t *testing.T, tran1, tran2 []hotline.Transaction) bool {
+       var newT1 []hotline.Transaction
+       var newT2 []hotline.Transaction
+
+       for _, trans := range tran1 {
+               trans.ID = [4]byte{0, 0, 0, 0}
+               var fs []hotline.Field
+               for _, field := range trans.Fields {
+                       if field.Type == hotline.FieldRefNum { // FieldRefNum
+                               continue
+                       }
+                       if field.Type == hotline.FieldChatID { // FieldChatID
+                               continue
+                       }
+
+                       fs = append(fs, field)
+               }
+               trans.Fields = fs
+               newT1 = append(newT1, trans)
+       }
+
+       for _, trans := range tran2 {
+               trans.ID = [4]byte{0, 0, 0, 0}
+               var fs []hotline.Field
+               for _, field := range trans.Fields {
+                       if field.Type == hotline.FieldRefNum { // FieldRefNum
+                               continue
+                       }
+                       if field.Type == hotline.FieldChatID { // FieldChatID
+                               continue
+                       }
+
+                       fs = append(fs, field)
+               }
+               trans.Fields = fs
+               newT2 = append(newT2, trans)
+       }
+
+       slices.SortFunc(newT1, tranSortFunc)
+       slices.SortFunc(newT2, tranSortFunc)
+
+       return assert.Equal(t, newT1, newT2)
+}
+
+func TestHandleSetChatSubject(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name string
+               args args
+               want []hotline.Transaction
+       }{
+               {
+                       name: "sends chat subject to private chat members",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       UserName: []byte{0x00, 0x01},
+                                       Server: &hotline.Server{
+                                               ChatMgr: func() *hotline.MockChatManager {
+                                                       m := hotline.MockChatManager{}
+                                                       m.On("Members", hotline.ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*hotline.ClientConn{
+                                                               {
+                                                                       Account: &hotline.Account{
+                                                                               Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+                                                                       },
+                                                                       ID: [2]byte{0, 1},
+                                                               },
+                                                               {
+                                                                       Account: &hotline.Account{
+                                                                               Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+                                                                       },
+                                                                       ID: [2]byte{0, 2},
+                                                               },
+                                                       })
+                                                       m.On("SetSubject", hotline.ChatID{0x0, 0x0, 0x0, 0x1}, "Test Subject")
+                                                       return &m
+                                               }(),
+                                               //PrivateChats: map[[4]byte]*PrivateChat{
+                                               //      [4]byte{0, 0, 0, 1}: {
+                                               //              Subject: "unset",
+                                               //              ClientConn: map[[2]byte]*ClientConn{
+                                               //                      [2]byte{0, 1}: {
+                                               //                              Account: &hotline.Account{
+                                               //                                      Access: AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+                                               //                              },
+                                               //                              ID: [2]byte{0, 1},
+                                               //                      },
+                                               //                      [2]byte{0, 2}: {
+                                               //                              Account: &hotline.Account{
+                                               //                                      Access: AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+                                               //                              },
+                                               //                              ID: [2]byte{0, 2},
+                                               //                      },
+                                               //              },
+                                               //      },
+                                               //},
+                                               ClientMgr: func() *hotline.MockClientMgr {
+                                                       m := hotline.MockClientMgr{}
+                                                       m.On("List").Return([]*hotline.ClientConn{
+                                                               {
+                                                                       Account: &hotline.Account{
+                                                                               Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+                                                                       },
+                                                                       ID: [2]byte{0, 1},
+                                                               },
+                                                               {
+                                                                       Account: &hotline.Account{
+                                                                               Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+                                                                       },
+                                                                       ID: [2]byte{0, 2},
+                                                               },
+                                                       },
+                                                       )
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.Transaction{
+                                       Type: [2]byte{0, 0x6a},
+                                       ID:   [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}),
+                                               hotline.NewField(hotline.FieldChatSubject, []byte("Test Subject")),
+                                       },
+                               },
+                       },
+                       want: []hotline.Transaction{
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       Type:     [2]byte{0, 0x77},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}),
+                                               hotline.NewField(hotline.FieldChatSubject, []byte("Test Subject")),
+                                       },
+                               },
+                               {
+                                       ClientID: [2]byte{0, 2},
+                                       Type:     [2]byte{0, 0x77},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}),
+                                               hotline.NewField(hotline.FieldChatSubject, []byte("Test Subject")),
+                                       },
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       got := HandleSetChatSubject(tt.args.cc, &tt.args.t)
+                       if !TranAssertEqual(t, tt.want, got) {
+                               t.Errorf("HandleSetChatSubject() got = %v, want %v", got, tt.want)
+                       }
+               })
+       }
+}
+
+func TestHandleLeaveChat(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name string
+               args args
+               want []hotline.Transaction
+       }{
+               {
+                       name: "when client 2 leaves chat",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       ID: [2]byte{0, 2},
+                                       Server: &hotline.Server{
+                                               ChatMgr: func() *hotline.MockChatManager {
+                                                       m := hotline.MockChatManager{}
+                                                       m.On("Members", hotline.ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*hotline.ClientConn{
+                                                               {
+                                                                       Account: &hotline.Account{
+                                                                               Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+                                                                       },
+                                                                       ID: [2]byte{0, 1},
+                                                               },
+                                                       })
+                                                       m.On("Leave", hotline.ChatID{0x0, 0x0, 0x0, 0x1}, [2]uint8{0x0, 0x2})
+                                                       m.On("GetSubject").Return("unset")
+                                                       return &m
+                                               }(),
+                                               ClientMgr: func() *hotline.MockClientMgr {
+                                                       m := hotline.MockClientMgr{}
+                                                       m.On("Get").Return([]*hotline.ClientConn{
+                                                               {
+                                                                       Account: &hotline.Account{
+                                                                               Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+                                                                       },
+                                                                       ID: [2]byte{0, 1},
+                                                               },
+                                                               {
+                                                                       Account: &hotline.Account{
+                                                                               Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+                                                                       },
+                                                                       ID: [2]byte{0, 2},
+                                                               },
+                                                       },
+                                                       )
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(hotline.TranDeleteUser, [2]byte{}, hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1})),
+                       },
+                       want: []hotline.Transaction{
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       Type:     [2]byte{0, 0x76},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}),
+                                               hotline.NewField(hotline.FieldUserID, []byte{0, 2}),
+                                       },
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       got := HandleLeaveChat(tt.args.cc, &tt.args.t)
+                       if !TranAssertEqual(t, tt.want, got) {
+                               t.Errorf("HandleLeaveChat() got = %v, want %v", got, tt.want)
+                       }
+               })
+       }
+}
+
+func TestHandleGetUserNameList(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name string
+               args args
+               want []hotline.Transaction
+       }{
+               {
+                       name: "replies with userlist transaction",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       ID: [2]byte{0, 1},
+                                       Server: &hotline.Server{
+                                               ClientMgr: func() *hotline.MockClientMgr {
+                                                       m := hotline.MockClientMgr{}
+                                                       m.On("List").Return([]*hotline.ClientConn{
+                                                               {
+                                                                       ID:       [2]byte{0, 1},
+                                                                       Icon:     []byte{0, 2},
+                                                                       Flags:    [2]byte{0, 3},
+                                                                       UserName: []byte{0, 4},
+                                                               },
+                                                               {
+                                                                       ID:       [2]byte{0, 2},
+                                                                       Icon:     []byte{0, 2},
+                                                                       Flags:    [2]byte{0, 3},
+                                                                       UserName: []byte{0, 4},
+                                                               },
+                                                       },
+                                                       )
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.Transaction{},
+                       },
+                       want: []hotline.Transaction{
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       IsReply:  0x01,
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(
+                                                       hotline.FieldUsernameWithInfo,
+                                                       []byte{00, 01, 00, 02, 00, 03, 00, 02, 00, 04},
+                                               ),
+                                               hotline.NewField(
+                                                       hotline.FieldUsernameWithInfo,
+                                                       []byte{00, 02, 00, 02, 00, 03, 00, 02, 00, 04},
+                                               ),
+                                       },
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       got := HandleGetUserNameList(tt.args.cc, &tt.args.t)
+                       assert.Equal(t, tt.want, got)
+               })
+       }
+}
+
+func TestHandleChatSend(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name string
+               args args
+               want []hotline.Transaction
+       }{
+               {
+                       name: "sends chat msg transaction to all clients",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessSendChat)
+                                                       return bits
+                                               }(),
+                                       },
+                                       UserName: []byte{0x00, 0x01},
+                                       Server: &hotline.Server{
+                                               ClientMgr: func() *hotline.MockClientMgr {
+                                                       m := hotline.MockClientMgr{}
+                                                       m.On("List").Return([]*hotline.ClientConn{
+                                                               {
+                                                                       Account: &hotline.Account{
+                                                                               Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+                                                                       },
+                                                                       ID: [2]byte{0, 1},
+                                                               },
+                                                               {
+                                                                       Account: &hotline.Account{
+                                                                               Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+                                                                       },
+                                                                       ID: [2]byte{0, 2},
+                                                               },
+                                                       },
+                                                       )
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.Transaction{
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldData, []byte("hai")),
+                                       },
+                               },
+                       },
+                       want: []hotline.Transaction{
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       Flags:    0x00,
+                                       IsReply:  0x00,
+                                       Type:     [2]byte{0, 0x6a},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
+                                       },
+                               },
+                               {
+                                       ClientID: [2]byte{0, 2},
+                                       Flags:    0x00,
+                                       IsReply:  0x00,
+                                       Type:     [2]byte{0, 0x6a},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "treats Chat Type 00 00 00 00 as a public chat message",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessSendChat)
+                                                       return bits
+                                               }(),
+                                       },
+                                       UserName: []byte{0x00, 0x01},
+                                       Server: &hotline.Server{
+                                               ClientMgr: func() *hotline.MockClientMgr {
+                                                       m := hotline.MockClientMgr{}
+                                                       m.On("List").Return([]*hotline.ClientConn{
+                                                               {
+                                                                       Account: &hotline.Account{
+                                                                               Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+                                                                       },
+                                                                       ID: [2]byte{0, 1},
+                                                               },
+                                                               {
+                                                                       Account: &hotline.Account{
+                                                                               Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+                                                                       },
+                                                                       ID: [2]byte{0, 2},
+                                                               },
+                                                       },
+                                                       )
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.Transaction{
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldData, []byte("hai")),
+                                               hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 0}),
+                                       },
+                               },
+                       },
+                       want: []hotline.Transaction{
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       Type:     [2]byte{0, 0x6a},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
+                                       },
+                               },
+                               {
+                                       ClientID: [2]byte{0, 2},
+                                       Type:     [2]byte{0, 0x6a},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "when user does not have required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+                                               //Accounts: map[string]*Account{},
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranChatSend, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldData, []byte("hai")),
+                               ),
+                       },
+                       want: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to participate in chat.")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "sends chat msg as emote if FieldChatOptions is set to 1",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessSendChat)
+                                                       return bits
+                                               }(),
+                                       },
+                                       UserName: []byte("Testy McTest"),
+                                       Server: &hotline.Server{
+                                               ClientMgr: func() *hotline.MockClientMgr {
+                                                       m := hotline.MockClientMgr{}
+                                                       m.On("List").Return([]*hotline.ClientConn{
+                                                               {
+                                                                       Account: &hotline.Account{
+                                                                               Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+                                                                       },
+                                                                       ID: [2]byte{0, 1},
+                                                               },
+                                                               {
+                                                                       Account: &hotline.Account{
+                                                                               Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+                                                                       },
+                                                                       ID: [2]byte{0, 2},
+                                                               },
+                                                       },
+                                                       )
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.Transaction{
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldData, []byte("performed action")),
+                                               hotline.NewField(hotline.FieldChatOptions, []byte{0x00, 0x01}),
+                                       },
+                               },
+                       },
+                       want: []hotline.Transaction{
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       Flags:    0x00,
+                                       IsReply:  0x00,
+                                       Type:     [2]byte{0, 0x6a},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldData, []byte("\r*** Testy McTest performed action")),
+                                       },
+                               },
+                               {
+                                       ClientID: [2]byte{0, 2},
+                                       Flags:    0x00,
+                                       IsReply:  0x00,
+                                       Type:     [2]byte{0, 0x6a},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldData, []byte("\r*** Testy McTest performed action")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "does not send chat msg as emote if FieldChatOptions is set to 0",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessSendChat)
+                                                       return bits
+                                               }(),
+                                       },
+                                       UserName: []byte("Testy McTest"),
+                                       Server: &hotline.Server{
+                                               ClientMgr: func() *hotline.MockClientMgr {
+                                                       m := hotline.MockClientMgr{}
+                                                       m.On("List").Return([]*hotline.ClientConn{
+                                                               {
+                                                                       Account: &hotline.Account{
+                                                                               Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+                                                                       },
+                                                                       ID: [2]byte{0, 1},
+                                                               },
+                                                               {
+                                                                       Account: &hotline.Account{
+                                                                               Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+                                                                       },
+                                                                       ID: [2]byte{0, 2},
+                                                               },
+                                                       },
+                                                       )
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.Transaction{
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldData, []byte("hello")),
+                                               hotline.NewField(hotline.FieldChatOptions, []byte{0x00, 0x00}),
+                                       },
+                               },
+                       },
+                       want: []hotline.Transaction{
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       Type:     [2]byte{0, 0x6a},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldData, []byte("\r Testy McTest:  hello")),
+                                       },
+                               },
+                               {
+                                       ClientID: [2]byte{0, 2},
+                                       Type:     [2]byte{0, 0x6a},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldData, []byte("\r Testy McTest:  hello")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "only sends chat msg to clients with AccessReadChat permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessSendChat)
+                                                       return bits
+                                               }(),
+                                       },
+                                       UserName: []byte{0x00, 0x01},
+                                       Server: &hotline.Server{
+                                               ClientMgr: func() *hotline.MockClientMgr {
+                                                       m := hotline.MockClientMgr{}
+                                                       m.On("List").Return([]*hotline.ClientConn{
+                                                               {
+                                                                       Account: &hotline.Account{
+                                                                               Access: func() hotline.AccessBitmap {
+                                                                                       var bits hotline.AccessBitmap
+                                                                                       bits.Set(hotline.AccessReadChat)
+                                                                                       return bits
+                                                                               }(),
+                                                                       },
+                                                                       ID: [2]byte{0, 1},
+                                                               },
+                                                               {
+                                                                       Account: &hotline.Account{},
+                                                                       ID:      [2]byte{0, 2},
+                                                               },
+                                                       },
+                                                       )
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.Transaction{
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldData, []byte("hai")),
+                                       },
+                               },
+                       },
+                       want: []hotline.Transaction{
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       Type:     [2]byte{0, 0x6a},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "only sends private chat msg to members of private chat",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessSendChat)
+                                                       return bits
+                                               }(),
+                                       },
+                                       UserName: []byte{0x00, 0x01},
+                                       Server: &hotline.Server{
+                                               ChatMgr: func() *hotline.MockChatManager {
+                                                       m := hotline.MockChatManager{}
+                                                       m.On("Members", hotline.ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*hotline.ClientConn{
+                                                               {
+                                                                       ID: [2]byte{0, 1},
+                                                               },
+                                                               {
+                                                                       ID: [2]byte{0, 2},
+                                                               },
+                                                       })
+                                                       m.On("GetSubject").Return("unset")
+                                                       return &m
+                                               }(),
+                                               ClientMgr: func() *hotline.MockClientMgr {
+                                                       m := hotline.MockClientMgr{}
+                                                       m.On("List").Return([]*hotline.ClientConn{
+                                                               {
+                                                                       Account: &hotline.Account{
+                                                                               Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+                                                                       },
+                                                                       ID: [2]byte{0, 1},
+                                                               },
+                                                               {
+                                                                       Account: &hotline.Account{
+                                                                               Access: hotline.AccessBitmap{0, 0, 0, 0, 0, 0, 0, 0},
+                                                                       },
+                                                                       ID: [2]byte{0, 2},
+                                                               },
+                                                               {
+                                                                       Account: &hotline.Account{
+                                                                               Access: hotline.AccessBitmap{0, 0, 0, 0, 0, 0, 0, 0},
+                                                                       },
+                                                                       ID: [2]byte{0, 3},
+                                                               },
+                                                       },
+                                                       )
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.Transaction{
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldData, []byte("hai")),
+                                               hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}),
+                                       },
+                               },
+                       },
+                       want: []hotline.Transaction{
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       Type:     [2]byte{0, 0x6a},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}),
+                                               hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
+                                       },
+                               },
+                               {
+                                       ClientID: [2]byte{0, 2},
+                                       Type:     [2]byte{0, 0x6a},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}),
+                                               hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
+                                       },
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       got := HandleChatSend(tt.args.cc, &tt.args.t)
+                       TranAssertEqual(t, tt.want, got)
+               })
+       }
+}
+
+func TestHandleGetFileInfo(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "returns expected fields when a valid file is requested",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       ID: [2]byte{0x00, 0x01},
+                                       Server: &hotline.Server{
+                                               FS: &hotline.OSFileStore{},
+                                               Config: hotline.Config{
+                                                       FileRoot: func() string {
+                                                               path, _ := os.Getwd()
+                                                               return filepath.Join(path, "/test/config/Files")
+                                                       }(),
+                                               },
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranGetFileInfo, [2]byte{},
+                                       hotline.NewField(hotline.FieldFileName, []byte("testfile.txt")),
+                                       hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x00}),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       IsReply:  0x01,
+                                       Type:     [2]byte{0, 0},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldFileName, []byte("testfile.txt")),
+                                               hotline.NewField(hotline.FieldFileTypeString, []byte("Text File")),
+                                               hotline.NewField(hotline.FieldFileCreatorString, []byte("ttxt")),
+                                               hotline.NewField(hotline.FieldFileType, []byte("TEXT")),
+                                               hotline.NewField(hotline.FieldFileCreateDate, make([]byte, 8)),
+                                               hotline.NewField(hotline.FieldFileModifyDate, make([]byte, 8)),
+                                               hotline.NewField(hotline.FieldFileSize, []byte{0x0, 0x0, 0x0, 0x17}),
+                                       },
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleGetFileInfo(tt.args.cc, &tt.args.t)
+
+                       // Clear the file timestamp fields to work around problems running the tests in multiple timezones
+                       // TODO: revisit how to test this by mocking the stat calls
+                       gotRes[0].Fields[4].Data = make([]byte, 8)
+                       gotRes[0].Fields[5].Data = make([]byte, 8)
+
+                       if !TranAssertEqual(t, tt.wantRes, gotRes) {
+                               t.Errorf("HandleGetFileInfo() gotRes = %v, want %v", gotRes, tt.wantRes)
+                       }
+               })
+       }
+}
+
+func TestHandleNewFolder(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "without required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       return bits
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranNewFolder,
+                                       [2]byte{0, 0},
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to create folders.")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "when path is nested",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessCreateFolder)
+                                                       return bits
+                                               }(),
+                                       },
+                                       ID: [2]byte{0, 1},
+                                       Server: &hotline.Server{
+                                               Config: hotline.Config{
+                                                       FileRoot: "/Files/",
+                                               },
+                                               FS: func() *hotline.MockFileStore {
+                                                       mfs := &hotline.MockFileStore{}
+                                                       mfs.On("Mkdir", "/Files/aaa/testFolder", fs.FileMode(0777)).Return(nil)
+                                                       mfs.On("Stat", "/Files/aaa/testFolder").Return(nil, os.ErrNotExist)
+                                                       return mfs
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranNewFolder, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldFileName, []byte("testFolder")),
+                                       hotline.NewField(hotline.FieldFilePath, []byte{
+                                               0x00, 0x01,
+                                               0x00, 0x00,
+                                               0x03,
+                                               0x61, 0x61, 0x61,
+                                       }),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       IsReply:  0x01,
+                               },
+                       },
+               },
+               {
+                       name: "when path is not nested",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessCreateFolder)
+                                                       return bits
+                                               }(),
+                                       },
+                                       ID: [2]byte{0, 1},
+                                       Server: &hotline.Server{
+                                               Config: hotline.Config{
+                                                       FileRoot: "/Files",
+                                               },
+                                               FS: func() *hotline.MockFileStore {
+                                                       mfs := &hotline.MockFileStore{}
+                                                       mfs.On("Mkdir", "/Files/testFolder", fs.FileMode(0777)).Return(nil)
+                                                       mfs.On("Stat", "/Files/testFolder").Return(nil, os.ErrNotExist)
+                                                       return mfs
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranNewFolder, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldFileName, []byte("testFolder")),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       IsReply:  0x01,
+                               },
+                       },
+               },
+               {
+                       name: "when Write returns an err",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessCreateFolder)
+                                                       return bits
+                                               }(),
+                                       },
+                                       ID: [2]byte{0, 1},
+                                       Server: &hotline.Server{
+                                               Config: hotline.Config{
+                                                       FileRoot: "/Files/",
+                                               },
+                                               FS: func() *hotline.MockFileStore {
+                                                       mfs := &hotline.MockFileStore{}
+                                                       mfs.On("Mkdir", "/Files/aaa/testFolder", fs.FileMode(0777)).Return(nil)
+                                                       mfs.On("Stat", "/Files/aaa/testFolder").Return(nil, os.ErrNotExist)
+                                                       return mfs
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranNewFolder, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldFileName, []byte("testFolder")),
+                                       hotline.NewField(hotline.FieldFilePath, []byte{
+                                               0x00,
+                                       }),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{},
+               },
+               {
+                       name: "FieldFileName does not allow directory traversal",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessCreateFolder)
+                                                       return bits
+                                               }(),
+                                       },
+                                       ID: [2]byte{0, 1},
+                                       Server: &hotline.Server{
+                                               Config: hotline.Config{
+                                                       FileRoot: "/Files/",
+                                               },
+                                               FS: func() *hotline.MockFileStore {
+                                                       mfs := &hotline.MockFileStore{}
+                                                       mfs.On("Mkdir", "/Files/testFolder", fs.FileMode(0777)).Return(nil)
+                                                       mfs.On("Stat", "/Files/testFolder").Return(nil, os.ErrNotExist)
+                                                       return mfs
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranNewFolder, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldFileName, []byte("../../testFolder")),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       IsReply:  0x01,
+                               },
+                       },
+               },
+               {
+                       name: "FieldFilePath does not allow directory traversal",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessCreateFolder)
+                                                       return bits
+                                               }(),
+                                       },
+                                       ID: [2]byte{0, 1},
+                                       Server: &hotline.Server{
+                                               Config: hotline.Config{
+                                                       FileRoot: "/Files/",
+                                               },
+                                               FS: func() *hotline.MockFileStore {
+                                                       mfs := &hotline.MockFileStore{}
+                                                       mfs.On("Mkdir", "/Files/foo/testFolder", fs.FileMode(0777)).Return(nil)
+                                                       mfs.On("Stat", "/Files/foo/testFolder").Return(nil, os.ErrNotExist)
+                                                       return mfs
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranNewFolder, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldFileName, []byte("testFolder")),
+                                       hotline.NewField(hotline.FieldFilePath, []byte{
+                                               0x00, 0x02,
+                                               0x00, 0x00,
+                                               0x03,
+                                               0x2e, 0x2e, 0x2f,
+                                               0x00, 0x00,
+                                               0x03,
+                                               0x66, 0x6f, 0x6f,
+                                       }),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       IsReply:  0x01,
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleNewFolder(tt.args.cc, &tt.args.t)
+
+                       if !TranAssertEqual(t, tt.wantRes, gotRes) {
+                               t.Errorf("HandleNewFolder() gotRes = %v, want %v", gotRes, tt.wantRes)
+                       }
+               })
+       }
+}
+
+func TestHandleUploadFile(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "when request is valid and user has Upload Anywhere permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Server: &hotline.Server{
+                                               FS:              &hotline.OSFileStore{},
+                                               FileTransferMgr: hotline.NewMemFileTransferMgr(),
+                                               Config: hotline.Config{
+                                                       FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(),
+                                               }},
+                                       ClientFileTransferMgr: hotline.NewClientFileTransferMgr(),
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessUploadFile)
+                                                       bits.Set(hotline.AccessUploadAnywhere)
+                                                       return bits
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranUploadFile, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldFileName, []byte("testFile")),
+                                       hotline.NewField(hotline.FieldFilePath, []byte{
+                                               0x00, 0x01,
+                                               0x00, 0x00,
+                                               0x03,
+                                               0x2e, 0x2e, 0x2f,
+                                       }),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply: 0x01,
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}), // rand.Seed(1)
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "when user does not have required access",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       return bits
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranUploadFile, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldFileName, []byte("testFile")),
+                                       hotline.NewField(hotline.FieldFilePath, []byte{
+                                               0x00, 0x01,
+                                               0x00, 0x00,
+                                               0x03,
+                                               0x2e, 0x2e, 0x2f,
+                                       }),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to upload files.")), // rand.Seed(1)
+                                       },
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleUploadFile(tt.args.cc, &tt.args.t)
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleMakeAlias(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "with valid input and required permissions",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Logger: NewTestLogger(),
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessMakeAlias)
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+                                               Config: hotline.Config{
+                                                       FileRoot: func() string {
+                                                               path, _ := os.Getwd()
+                                                               return path + "/test/config/Files"
+                                                       }(),
+                                               },
+                                               Logger: NewTestLogger(),
+                                               FS: func() *hotline.MockFileStore {
+                                                       mfs := &hotline.MockFileStore{}
+                                                       path, _ := os.Getwd()
+                                                       mfs.On(
+                                                               "Symlink",
+                                                               path+"/test/config/Files/foo/testFile",
+                                                               path+"/test/config/Files/bar/testFile",
+                                                       ).Return(nil)
+                                                       return mfs
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranMakeFileAlias, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldFileName, []byte("testFile")),
+                                       hotline.NewField(hotline.FieldFilePath, hotline.EncodeFilePath(strings.Join([]string{"foo"}, "/"))),
+                                       hotline.NewField(hotline.FieldFileNewPath, hotline.EncodeFilePath(strings.Join([]string{"bar"}, "/"))),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply: 0x01,
+                                       Fields:  []hotline.Field(nil),
+                               },
+                       },
+               },
+               {
+                       name: "when symlink returns an error",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Logger: NewTestLogger(),
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessMakeAlias)
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+                                               Config: hotline.Config{
+                                                       FileRoot: func() string {
+                                                               path, _ := os.Getwd()
+                                                               return path + "/test/config/Files"
+                                                       }(),
+                                               },
+                                               Logger: NewTestLogger(),
+                                               FS: func() *hotline.MockFileStore {
+                                                       mfs := &hotline.MockFileStore{}
+                                                       path, _ := os.Getwd()
+                                                       mfs.On(
+                                                               "Symlink",
+                                                               path+"/test/config/Files/foo/testFile",
+                                                               path+"/test/config/Files/bar/testFile",
+                                                       ).Return(errors.New("ohno"))
+                                                       return mfs
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranMakeFileAlias, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldFileName, []byte("testFile")),
+                                       hotline.NewField(hotline.FieldFilePath, hotline.EncodeFilePath(strings.Join([]string{"foo"}, "/"))),
+                                       hotline.NewField(hotline.FieldFileNewPath, hotline.EncodeFilePath(strings.Join([]string{"bar"}, "/"))),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("Error creating alias")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "when user does not have required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Logger: NewTestLogger(),
+                                       Account: &hotline.Account{
+                                               Access: hotline.AccessBitmap{},
+                                       },
+                                       Server: &hotline.Server{
+                                               Config: hotline.Config{
+                                                       FileRoot: func() string {
+                                                               path, _ := os.Getwd()
+                                                               return path + "/test/config/Files"
+                                                       }(),
+                                               },
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranMakeFileAlias, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldFileName, []byte("testFile")),
+                                       hotline.NewField(hotline.FieldFilePath, []byte{
+                                               0x00, 0x01,
+                                               0x00, 0x00,
+                                               0x03,
+                                               0x2e, 0x2e, 0x2e,
+                                       }),
+                                       hotline.NewField(hotline.FieldFileNewPath, []byte{
+                                               0x00, 0x01,
+                                               0x00, 0x00,
+                                               0x03,
+                                               0x2e, 0x2e, 0x2e,
+                                       }),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to make aliases.")),
+                                       },
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleMakeAlias(tt.args.cc, &tt.args.t)
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleGetUser(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "when account is valid",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessOpenUser)
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+                                               AccountManager: func() *MockAccountManager {
+                                                       m := MockAccountManager{}
+                                                       m.On("Get", "guest").Return(&hotline.Account{
+                                                               Login:    "guest",
+                                                               Name:     "Guest",
+                                                               Password: "password",
+                                                               Access:   hotline.AccessBitmap{},
+                                                       })
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranGetUser, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldUserLogin, []byte("guest")),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply: 0x01,
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldUserName, []byte("Guest")),
+                                               hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("guest"))),
+                                               hotline.NewField(hotline.FieldUserPassword, []byte("password")),
+                                               hotline.NewField(hotline.FieldUserAccess, []byte{0, 0, 0, 0, 0, 0, 0, 0}),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "when user does not have required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+                                               //Accounts: map[string]*Account{},
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranGetUser, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldUserLogin, []byte("nonExistentUser")),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to view accounts.")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "when account does not exist",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessOpenUser)
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+                                               AccountManager: func() *MockAccountManager {
+                                                       m := MockAccountManager{}
+                                                       m.On("Get", "nonExistentUser").Return((*hotline.Account)(nil))
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranGetUser, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldUserLogin, []byte("nonExistentUser")),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       Flags:     0x00,
+                                       IsReply:   0x01,
+                                       Type:      [2]byte{0, 0},
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("Account does not exist.")),
+                                       },
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleGetUser(tt.args.cc, &tt.args.t)
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleDeleteUser(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "when user exists",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessDeleteUser)
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+                                               AccountManager: func() *MockAccountManager {
+                                                       m := MockAccountManager{}
+                                                       m.On("Delete", "testuser").Return(nil)
+                                                       return &m
+                                               }(),
+                                               ClientMgr: func() *hotline.MockClientMgr {
+                                                       m := hotline.MockClientMgr{}
+                                                       m.On("List").Return([]*hotline.ClientConn{}) // TODO
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranDeleteUser, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       Flags:   0x00,
+                                       IsReply: 0x01,
+                                       Type:    [2]byte{0, 0},
+                                       Fields:  []hotline.Field(nil),
+                               },
+                       },
+               },
+               {
+                       name: "when user does not have required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: hotline.AccessBitmap{},
+                                       },
+                                       Server: &hotline.Server{
+                                               //Accounts: map[string]*Account{},
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranDeleteUser, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete accounts.")),
+                                       },
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleDeleteUser(tt.args.cc, &tt.args.t)
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleGetMsgs(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "returns news data",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessNewsReadArt)
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+                                               MessageBoard: func() *mockReadWriteSeeker {
+                                                       m := mockReadWriteSeeker{}
+                                                       m.On("Seek", int64(0), 0).Return(int64(0), nil)
+                                                       m.On("Read", mock.AnythingOfType("[]uint8")).Run(func(args mock.Arguments) {
+                                                               arg := args.Get(0).([]uint8)
+                                                               copy(arg, "TEST")
+                                                       }).Return(4, io.EOF)
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranGetMsgs, [2]byte{0, 1},
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply: 0x01,
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldData, []byte("TEST")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "when user does not have required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: hotline.AccessBitmap{},
+                                       },
+                                       Server: &hotline.Server{
+                                               //Accounts: map[string]*Account{},
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranGetMsgs, [2]byte{0, 1},
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to read news.")),
+                                       },
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleGetMsgs(tt.args.cc, &tt.args.t)
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleNewUser(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "when user does not have required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+                                               //Accounts: map[string]*Account{},
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranNewUser, [2]byte{0, 1},
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to create new accounts.")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "when user attempts to create account with greater access",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessCreateUser)
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+                                               AccountManager: func() *MockAccountManager {
+                                                       m := MockAccountManager{}
+                                                       m.On("Get", "userB").Return((*hotline.Account)(nil))
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranNewUser, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("userB"))),
+                                       hotline.NewField(
+                                               hotline.FieldUserAccess,
+                                               func() []byte {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessDisconUser)
+                                                       return bits[:]
+                                               }(),
+                                       ),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("Cannot create account with more access than yourself.")),
+                                       },
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleNewUser(tt.args.cc, &tt.args.t)
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleListUsers(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "when user does not have required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+                                               //Accounts: map[string]*Account{},
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranNewUser, [2]byte{0, 1},
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to view accounts.")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "when user has required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessOpenUser)
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+                                               AccountManager: func() *MockAccountManager {
+                                                       m := MockAccountManager{}
+                                                       m.On("List").Return([]hotline.Account{
+                                                               {
+                                                                       Name:     "guest",
+                                                                       Login:    "guest",
+                                                                       Password: "zz",
+                                                                       Access:   hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+                                                               },
+                                                       })
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranGetClientInfoText, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply: 0x01,
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldData, []byte{
+                                                       0x00, 0x04, 0x00, 0x66, 0x00, 0x05, 0x67, 0x75, 0x65, 0x73, 0x74, 0x00, 0x69, 0x00, 0x05, 0x98,
+                                                       0x8a, 0x9a, 0x8c, 0x8b, 0x00, 0x6e, 0x00, 0x08, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
+                                                       0x00, 0x6a, 0x00, 0x01, 0x78,
+                                               }),
+                                       },
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleListUsers(tt.args.cc, &tt.args.t)
+
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleDownloadFile(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "when user does not have required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{},
+                               },
+                               t: hotline.NewTransaction(hotline.TranDownloadFile, [2]byte{0, 1}),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to download files.")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "with a valid file",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       ClientFileTransferMgr: hotline.NewClientFileTransferMgr(),
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessDownloadFile)
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+                                               FS:              &hotline.OSFileStore{},
+                                               FileTransferMgr: hotline.NewMemFileTransferMgr(),
+                                               Config: hotline.Config{
+                                                       FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(),
+                                               },
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranDownloadFile,
+                                       [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldFileName, []byte("testfile.txt")),
+                                       hotline.NewField(hotline.FieldFilePath, []byte{0x0, 0x00}),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply: 0x01,
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}),
+                                               hotline.NewField(hotline.FieldWaitingCount, []byte{0x00, 0x00}),
+                                               hotline.NewField(hotline.FieldTransferSize, []byte{0x00, 0x00, 0x00, 0xa5}),
+                                               hotline.NewField(hotline.FieldFileSize, []byte{0x00, 0x00, 0x00, 0x17}),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "when client requests to resume 1k test file at offset 256",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       ClientFileTransferMgr: hotline.NewClientFileTransferMgr(),
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessDownloadFile)
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+                                               FS: &hotline.OSFileStore{},
+
+                                               // FS: func() *hotline.MockFileStore {
+                                               //      path, _ := os.Getwd()
+                                               //      testFile, err := os.Open(path + "/test/config/Files/testfile-1k")
+                                               //      if err != nil {
+                                               //              panic(err)
+                                               //      }
+                                               //
+                                               //      mfi := &hotline.MockFileInfo{}
+                                               //      mfi.On("Mode").Return(fs.FileMode(0))
+                                               //      mfs := &MockFileStore{}
+                                               //      mfs.On("Stat", "/fakeRoot/Files/testfile.txt").Return(mfi, nil)
+                                               //      mfs.On("Open", "/fakeRoot/Files/testfile.txt").Return(testFile, nil)
+                                               //      mfs.On("Stat", "/fakeRoot/Files/.info_testfile.txt").Return(nil, errors.New("no"))
+                                               //      mfs.On("Stat", "/fakeRoot/Files/.rsrc_testfile.txt").Return(nil, errors.New("no"))
+                                               //
+                                               //      return mfs
+                                               // }(),
+                                               FileTransferMgr: hotline.NewMemFileTransferMgr(),
+                                               Config: hotline.Config{
+                                                       FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(),
+                                               },
+                                               //Accounts: map[string]*Account{},
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranDownloadFile,
+                                       [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldFileName, []byte("testfile-1k")),
+                                       hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x00}),
+                                       hotline.NewField(
+                                               hotline.FieldFileResumeData,
+                                               func() []byte {
+                                                       frd := hotline.FileResumeData{
+                                                               ForkCount: [2]byte{0, 2},
+                                                               ForkInfoList: []hotline.ForkInfoList{
+                                                                       {
+                                                                               Fork:     [4]byte{0x44, 0x41, 0x54, 0x41}, // "DATA"
+                                                                               DataSize: [4]byte{0, 0, 0x01, 0x00},       // request offset 256
+                                                                       },
+                                                                       {
+                                                                               Fork:     [4]byte{0x4d, 0x41, 0x43, 0x52}, // "MACR"
+                                                                               DataSize: [4]byte{0, 0, 0, 0},
+                                                                       },
+                                                               },
+                                                       }
+                                                       b, _ := frd.BinaryMarshal()
+                                                       return b
+                                               }(),
+                                       ),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply: 0x01,
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}),
+                                               hotline.NewField(hotline.FieldWaitingCount, []byte{0x00, 0x00}),
+                                               hotline.NewField(hotline.FieldTransferSize, []byte{0x00, 0x00, 0x03, 0x8d}),
+                                               hotline.NewField(hotline.FieldFileSize, []byte{0x00, 0x00, 0x03, 0x00}),
+                                       },
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleDownloadFile(tt.args.cc, &tt.args.t)
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleUpdateUser(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "when action is create user without required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Logger: NewTestLogger(),
+                                       Server: &hotline.Server{
+                                               AccountManager: func() *MockAccountManager {
+                                                       m := MockAccountManager{}
+                                                       m.On("Get", "bbb").Return((*hotline.Account)(nil))
+                                                       return &m
+                                               }(),
+                                               Logger: NewTestLogger(),
+                                       },
+                                       Account: &hotline.Account{
+                                               Access: hotline.AccessBitmap{},
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranUpdateUser,
+                                       [2]byte{0, 0},
+                                       hotline.NewField(hotline.FieldData, []byte{
+                                               0x00, 0x04, // field count
+
+                                               0x00, 0x69, // FieldUserLogin = 105
+                                               0x00, 0x03,
+                                               0x9d, 0x9d, 0x9d,
+
+                                               0x00, 0x6a, // FieldUserPassword = 106
+                                               0x00, 0x03,
+                                               0x9c, 0x9c, 0x9c,
+
+                                               0x00, 0x66, // FieldUserName = 102
+                                               0x00, 0x03,
+                                               0x61, 0x61, 0x61,
+
+                                               0x00, 0x6e, // FieldUserAccess = 110
+                                               0x00, 0x08,
+                                               0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00,
+                                       }),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to create new accounts.")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "when action is modify user without required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Logger: NewTestLogger(),
+                                       Server: &hotline.Server{
+                                               Logger: NewTestLogger(),
+                                               AccountManager: func() *MockAccountManager {
+                                                       m := MockAccountManager{}
+                                                       m.On("Get", "bbb").Return(&hotline.Account{})
+                                                       return &m
+                                               }(),
+                                       },
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       return bits
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranUpdateUser,
+                                       [2]byte{0, 0},
+                                       hotline.NewField(hotline.FieldData, []byte{
+                                               0x00, 0x04, // field count
+
+                                               0x00, 0x69, // FieldUserLogin = 105
+                                               0x00, 0x03,
+                                               0x9d, 0x9d, 0x9d,
+
+                                               0x00, 0x6a, // FieldUserPassword = 106
+                                               0x00, 0x03,
+                                               0x9c, 0x9c, 0x9c,
+
+                                               0x00, 0x66, // FieldUserName = 102
+                                               0x00, 0x03,
+                                               0x61, 0x61, 0x61,
+
+                                               0x00, 0x6e, // FieldUserAccess = 110
+                                               0x00, 0x08,
+                                               0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00,
+                                       }),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to modify accounts.")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "when action is delete user without required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Logger: NewTestLogger(),
+                                       Server: &hotline.Server{},
+                                       Account: &hotline.Account{
+                                               Access: hotline.AccessBitmap{},
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranUpdateUser,
+                                       [2]byte{0, 0},
+                                       hotline.NewField(hotline.FieldData, []byte{
+                                               0x00, 0x01,
+                                               0x00, 0x65,
+                                               0x00, 0x03,
+                                               0x88, 0x9e, 0x8b,
+                                       }),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete accounts.")),
+                                       },
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleUpdateUser(tt.args.cc, &tt.args.t)
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleDelNewsArt(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "without required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       return bits
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranDelNewsArt,
+                                       [2]byte{0, 0},
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete news articles.")),
+                                       },
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleDelNewsArt(tt.args.cc, &tt.args.t)
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleDisconnectUser(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "without required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       return bits
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranDelNewsArt,
+                                       [2]byte{0, 0},
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to disconnect users.")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "when target user has 'cannot be disconnected' priv",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Server: &hotline.Server{
+                                               ClientMgr: func() *hotline.MockClientMgr {
+                                                       m := hotline.MockClientMgr{}
+                                                       m.On("Get", hotline.ClientID{0x0, 0x1}).Return(&hotline.ClientConn{
+                                                               Account: &hotline.Account{
+                                                                       Login: "unnamed",
+                                                                       Access: func() hotline.AccessBitmap {
+                                                                               var bits hotline.AccessBitmap
+                                                                               bits.Set(hotline.AccessCannotBeDiscon)
+                                                                               return bits
+                                                                       }(),
+                                                               },
+                                                       },
+                                                       )
+                                                       return &m
+                                               }(),
+                                       },
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessDisconUser)
+                                                       return bits
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranDelNewsArt,
+                                       [2]byte{0, 0},
+                                       hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("unnamed is not allowed to be disconnected.")),
+                                       },
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleDisconnectUser(tt.args.cc, &tt.args.t)
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleSendInstantMsg(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "without required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       return bits
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranDelNewsArt,
+                                       [2]byte{0, 0},
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to send private messages.")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "when client 1 sends a message to client 2",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessSendPrivMsg)
+                                                       return bits
+                                               }(),
+                                       },
+                                       ID:       [2]byte{0, 1},
+                                       UserName: []byte("User1"),
+                                       Server: &hotline.Server{
+                                               ClientMgr: func() *hotline.MockClientMgr {
+                                                       m := hotline.MockClientMgr{}
+                                                       m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{
+                                                               AutoReply: []byte(nil),
+                                                               Flags:     [2]byte{0, 0},
+                                                       },
+                                                       )
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranSendInstantMsg,
+                                       [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldData, []byte("hai")),
+                                       hotline.NewField(hotline.FieldUserID, []byte{0, 2}),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               hotline.NewTransaction(
+                                       hotline.TranServerMsg,
+                                       [2]byte{0, 2},
+                                       hotline.NewField(hotline.FieldData, []byte("hai")),
+                                       hotline.NewField(hotline.FieldUserName, []byte("User1")),
+                                       hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+                                       hotline.NewField(hotline.FieldOptions, []byte{0, 1}),
+                               ),
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       IsReply:  0x01,
+                                       Fields:   []hotline.Field(nil),
+                               },
+                       },
+               },
+               {
+                       name: "when client 2 has autoreply enabled",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessSendPrivMsg)
+                                                       return bits
+                                               }(),
+                                       },
+                                       ID:       [2]byte{0, 1},
+                                       UserName: []byte("User1"),
+                                       Server: &hotline.Server{
+                                               ClientMgr: func() *hotline.MockClientMgr {
+                                                       m := hotline.MockClientMgr{}
+                                                       m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{
+                                                               Flags:     [2]byte{0, 0},
+                                                               ID:        [2]byte{0, 2},
+                                                               UserName:  []byte("User2"),
+                                                               AutoReply: []byte("autohai"),
+                                                       })
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranSendInstantMsg,
+                                       [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldData, []byte("hai")),
+                                       hotline.NewField(hotline.FieldUserID, []byte{0, 2}),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               hotline.NewTransaction(
+                                       hotline.TranServerMsg,
+                                       [2]byte{0, 2},
+                                       hotline.NewField(hotline.FieldData, []byte("hai")),
+                                       hotline.NewField(hotline.FieldUserName, []byte("User1")),
+                                       hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+                                       hotline.NewField(hotline.FieldOptions, []byte{0, 1}),
+                               ),
+                               hotline.NewTransaction(
+                                       hotline.TranServerMsg,
+                                       [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldData, []byte("autohai")),
+                                       hotline.NewField(hotline.FieldUserName, []byte("User2")),
+                                       hotline.NewField(hotline.FieldUserID, []byte{0, 2}),
+                                       hotline.NewField(hotline.FieldOptions, []byte{0, 1}),
+                               ),
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       IsReply:  0x01,
+                                       Fields:   []hotline.Field(nil),
+                               },
+                       },
+               },
+               {
+                       name: "when client 2 has refuse private messages enabled",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessSendPrivMsg)
+                                                       return bits
+                                               }(),
+                                       },
+                                       ID:       [2]byte{0, 1},
+                                       UserName: []byte("User1"),
+                                       Server: &hotline.Server{
+                                               ClientMgr: func() *hotline.MockClientMgr {
+                                                       m := hotline.MockClientMgr{}
+                                                       m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{
+                                                               Flags:    [2]byte{255, 255},
+                                                               ID:       [2]byte{0, 2},
+                                                               UserName: []byte("User2"),
+                                                       },
+                                                       )
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranSendInstantMsg,
+                                       [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldData, []byte("hai")),
+                                       hotline.NewField(hotline.FieldUserID, []byte{0, 2}),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               hotline.NewTransaction(
+                                       hotline.TranServerMsg,
+                                       [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldData, []byte("User2 does not accept private messages.")),
+                                       hotline.NewField(hotline.FieldUserName, []byte("User2")),
+                                       hotline.NewField(hotline.FieldUserID, []byte{0, 2}),
+                                       hotline.NewField(hotline.FieldOptions, []byte{0, 2}),
+                               ),
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       IsReply:  0x01,
+                                       Fields:   []hotline.Field(nil),
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleSendInstantMsg(tt.args.cc, &tt.args.t)
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleDeleteFile(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "when user does not have required permission to delete a folder",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+                                               Config: hotline.Config{
+                                                       FileRoot: func() string {
+                                                               return "/fakeRoot/Files"
+                                                       }(),
+                                               },
+                                               FS: func() *hotline.MockFileStore {
+                                                       mfi := &hotline.MockFileInfo{}
+                                                       mfi.On("Mode").Return(fs.FileMode(0))
+                                                       mfi.On("Size").Return(int64(100))
+                                                       mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout))
+                                                       mfi.On("IsDir").Return(false)
+                                                       mfi.On("Name").Return("testfile")
+
+                                                       mfs := &hotline.MockFileStore{}
+                                                       mfs.On("Stat", "/fakeRoot/Files/aaa/testfile").Return(mfi, nil)
+                                                       mfs.On("Stat", "/fakeRoot/Files/aaa/.info_testfile").Return(nil, errors.New("err"))
+                                                       mfs.On("Stat", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil, errors.New("err"))
+
+                                                       return mfs
+                                               }(),
+                                               //Accounts: map[string]*Account{},
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranDeleteFile, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldFileName, []byte("testfile")),
+                                       hotline.NewField(hotline.FieldFilePath, []byte{
+                                               0x00, 0x01,
+                                               0x00, 0x00,
+                                               0x03,
+                                               0x61, 0x61, 0x61,
+                                       }),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete files.")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "deletes all associated metadata files",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessDeleteFile)
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+                                               Config: hotline.Config{
+                                                       FileRoot: func() string {
+                                                               return "/fakeRoot/Files"
+                                                       }(),
+                                               },
+                                               FS: func() *hotline.MockFileStore {
+                                                       mfi := &hotline.MockFileInfo{}
+                                                       mfi.On("Mode").Return(fs.FileMode(0))
+                                                       mfi.On("Size").Return(int64(100))
+                                                       mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout))
+                                                       mfi.On("IsDir").Return(false)
+                                                       mfi.On("Name").Return("testfile")
+
+                                                       mfs := &hotline.MockFileStore{}
+                                                       mfs.On("Stat", "/fakeRoot/Files/aaa/testfile").Return(mfi, nil)
+                                                       mfs.On("Stat", "/fakeRoot/Files/aaa/.info_testfile").Return(nil, errors.New("err"))
+                                                       mfs.On("Stat", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil, errors.New("err"))
+
+                                                       mfs.On("RemoveAll", "/fakeRoot/Files/aaa/testfile").Return(nil)
+                                                       mfs.On("Remove", "/fakeRoot/Files/aaa/testfile.incomplete").Return(nil)
+                                                       mfs.On("Remove", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil)
+                                                       mfs.On("Remove", "/fakeRoot/Files/aaa/.info_testfile").Return(nil)
+
+                                                       return mfs
+                                               }(),
+                                               //Accounts: map[string]*Account{},
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranDeleteFile, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldFileName, []byte("testfile")),
+                                       hotline.NewField(hotline.FieldFilePath, []byte{
+                                               0x00, 0x01,
+                                               0x00, 0x00,
+                                               0x03,
+                                               0x61, 0x61, 0x61,
+                                       }),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply: 0x01,
+                                       Fields:  []hotline.Field(nil),
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleDeleteFile(tt.args.cc, &tt.args.t)
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+
+                       tt.args.cc.Server.FS.(*hotline.MockFileStore).AssertExpectations(t)
+               })
+       }
+}
+
+func TestHandleGetFileNameList(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "when FieldFilePath is a drop box, but user does not have AccessViewDropBoxes ",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+
+                                               Config: hotline.Config{
+                                                       FileRoot: func() string {
+                                                               path, _ := os.Getwd()
+                                                               return filepath.Join(path, "/test/config/Files/getFileNameListTestDir")
+                                                       }(),
+                                               },
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranGetFileNameList, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldFilePath, []byte{
+                                               0x00, 0x01,
+                                               0x00, 0x00,
+                                               0x08,
+                                               0x64, 0x72, 0x6f, 0x70, 0x20, 0x62, 0x6f, 0x78, // "drop box"
+                                       }),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to view drop boxes.")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "with file root",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Server: &hotline.Server{
+                                               Config: hotline.Config{
+                                                       FileRoot: func() string {
+                                                               path, _ := os.Getwd()
+                                                               return filepath.Join(path, "/test/config/Files/getFileNameListTestDir")
+                                                       }(),
+                                               },
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranGetFileNameList, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldFilePath, []byte{
+                                               0x00, 0x00,
+                                               0x00, 0x00,
+                                       }),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply: 0x01,
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(
+                                                       hotline.FieldFileNameWithInfo,
+                                                       func() []byte {
+                                                               fnwi := hotline.FileNameWithInfo{
+                                                                       FileNameWithInfoHeader: hotline.FileNameWithInfoHeader{
+                                                                               Type:       [4]byte{0x54, 0x45, 0x58, 0x54},
+                                                                               Creator:    [4]byte{0x54, 0x54, 0x58, 0x54},
+                                                                               FileSize:   [4]byte{0, 0, 0x04, 0},
+                                                                               RSVD:       [4]byte{},
+                                                                               NameScript: [2]byte{},
+                                                                               NameSize:   [2]byte{0, 0x0b},
+                                                                       },
+                                                                       Name: []byte("testfile-1k"),
+                                                               }
+                                                               b, _ := io.ReadAll(&fnwi)
+                                                               return b
+                                                       }(),
+                                               ),
+                                       },
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleGetFileNameList(tt.args.cc, &tt.args.t)
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleGetClientInfoText(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "when user does not have required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+                                               //Accounts: map[string]*Account{},
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranGetClientInfoText, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to get client info.")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "with a valid user",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       UserName:   []byte("Testy McTest"),
+                                       RemoteAddr: "1.2.3.4:12345",
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessGetClientInfo)
+                                                       return bits
+                                               }(),
+                                               Name:  "test",
+                                               Login: "test",
+                                       },
+                                       Server: &hotline.Server{
+                                               ClientMgr: func() *hotline.MockClientMgr {
+                                                       m := hotline.MockClientMgr{}
+                                                       m.On("Get", hotline.ClientID{0x0, 0x1}).Return(&hotline.ClientConn{
+                                                               UserName:   []byte("Testy McTest"),
+                                                               RemoteAddr: "1.2.3.4:12345",
+                                                               Account: &hotline.Account{
+                                                                       Access: func() hotline.AccessBitmap {
+                                                                               var bits hotline.AccessBitmap
+                                                                               bits.Set(hotline.AccessGetClientInfo)
+                                                                               return bits
+                                                                       }(),
+                                                                       Name:  "test",
+                                                                       Login: "test",
+                                                               },
+                                                       },
+                                                       )
+                                                       return &m
+                                               }(),
+                                       },
+                                       ClientFileTransferMgr: hotline.ClientFileTransferMgr{},
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranGetClientInfoText, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply: 0x01,
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldData, []byte(
+                                                       strings.ReplaceAll(`Nickname:   Testy McTest
+Name:       test
+Account:    test
+Address:    1.2.3.4:12345
+
+-------- File Downloads ---------
+
+None.
+
+------- Folder Downloads --------
+
+None.
+
+--------- File Uploads ----------
+
+None.
+
+-------- Folder Uploads ---------
+
+None.
+
+------- Waiting Downloads -------
+
+None.
+
+`, "\n", "\r")),
+                                               ),
+                                               hotline.NewField(hotline.FieldUserName, []byte("Testy McTest")),
+                                       },
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleGetClientInfoText(tt.args.cc, &tt.args.t)
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleTranAgreed(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "normal request flow",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessDisconUser)
+                                                       bits.Set(hotline.AccessAnyName)
+                                                       return bits
+                                               }()},
+                                       Icon:    []byte{0, 1},
+                                       Flags:   [2]byte{0, 1},
+                                       Version: []byte{0, 1},
+                                       ID:      [2]byte{0, 1},
+                                       Logger:  NewTestLogger(),
+                                       Server: &hotline.Server{
+                                               Config: hotline.Config{
+                                                       BannerFile: "Banner.jpg",
+                                               },
+                                               ClientMgr: func() *hotline.MockClientMgr {
+                                                       m := hotline.MockClientMgr{}
+                                                       m.On("List").Return([]*hotline.ClientConn{
+                                                               //{
+                                                               //      ID:       [2]byte{0, 2},
+                                                               //      UserName: []byte("UserB"),
+                                                               //},
+                                                       },
+                                                       )
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranAgreed, [2]byte{},
+                                       hotline.NewField(hotline.FieldUserName, []byte("username")),
+                                       hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}),
+                                       hotline.NewField(hotline.FieldOptions, []byte{0, 0}),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       Type:     [2]byte{0, 0x7a},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldBannerType, []byte("JPEG")),
+                                       },
+                               },
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       IsReply:  0x01,
+                                       Fields:   []hotline.Field{},
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleTranAgreed(tt.args.cc, &tt.args.t)
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleSetClientUserInfo(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "when client does not have AccessAnyName",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       return bits
+                                               }(),
+                                       },
+                                       ID:       [2]byte{0, 1},
+                                       UserName: []byte("Guest"),
+                                       Flags:    [2]byte{0, 1},
+                                       Server: &hotline.Server{
+                                               ClientMgr: func() *hotline.MockClientMgr {
+                                                       m := hotline.MockClientMgr{}
+                                                       m.On("List").Return([]*hotline.ClientConn{
+                                                               {
+                                                                       ID: [2]byte{0, 1},
+                                                               },
+                                                       })
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranSetClientUserInfo, [2]byte{},
+                                       hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}),
+                                       hotline.NewField(hotline.FieldUserName, []byte("NOPE")),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       Type:     [2]byte{0x01, 0x2d},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+                                               hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}),
+                                               hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}),
+                                               hotline.NewField(hotline.FieldUserName, []byte("Guest"))},
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleSetClientUserInfo(tt.args.cc, &tt.args.t)
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleDelNewsItem(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "when user does not have permission to delete a news category",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: hotline.AccessBitmap{},
+                                       },
+                                       ID: [2]byte{0, 1},
+                                       Server: &hotline.Server{
+                                               ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr {
+                                                       m := hotline.MockThreadNewsMgr{}
+                                                       m.On("NewsItem", []string{"test"}).Return(hotline.NewsCategoryListData15{
+                                                               Type: hotline.NewsCategory,
+                                                       })
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranDelNewsItem, [2]byte{},
+                                       hotline.NewField(hotline.FieldNewsPath,
+                                               []byte{
+                                                       0, 1,
+                                                       0, 0,
+                                                       4,
+                                                       0x74, 0x65, 0x73, 0x74,
+                                               },
+                                       ),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       ClientID:  [2]byte{0, 1},
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete news categories.")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "when user does not have permission to delete a news folder",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: hotline.AccessBitmap{},
+                                       },
+                                       ID: [2]byte{0, 1},
+                                       Server: &hotline.Server{
+                                               ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr {
+                                                       m := hotline.MockThreadNewsMgr{}
+                                                       m.On("NewsItem", []string{"test"}).Return(hotline.NewsCategoryListData15{
+                                                               Type: hotline.NewsBundle,
+                                                       })
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranDelNewsItem, [2]byte{},
+                                       hotline.NewField(hotline.FieldNewsPath,
+                                               []byte{
+                                                       0, 1,
+                                                       0, 0,
+                                                       4,
+                                                       0x74, 0x65, 0x73, 0x74,
+                                               },
+                                       ),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       ClientID:  [2]byte{0, 1},
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete news folders.")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "when user deletes a news folder",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessNewsDeleteFldr)
+                                                       return bits
+                                               }(),
+                                       },
+                                       ID: [2]byte{0, 1},
+                                       Server: &hotline.Server{
+                                               ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr {
+                                                       m := hotline.MockThreadNewsMgr{}
+                                                       m.On("NewsItem", []string{"test"}).Return(hotline.NewsCategoryListData15{Type: hotline.NewsBundle})
+                                                       m.On("DeleteNewsItem", []string{"test"}).Return(nil)
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranDelNewsItem, [2]byte{},
+                                       hotline.NewField(hotline.FieldNewsPath,
+                                               []byte{
+                                                       0, 1,
+                                                       0, 0,
+                                                       4,
+                                                       0x74, 0x65, 0x73, 0x74,
+                                               },
+                                       ),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       IsReply:  0x01,
+                                       Fields:   []hotline.Field{},
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleDelNewsItem(tt.args.cc, &tt.args.t)
+
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleTranOldPostNews(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "when user does not have required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: hotline.AccessBitmap{},
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranOldPostNews, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldData, []byte("hai")),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to post news.")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "when user posts news update",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessNewsPostArt)
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+                                               Config: hotline.Config{
+                                                       NewsDateFormat: "",
+                                               },
+                                               ClientMgr: func() *hotline.MockClientMgr {
+                                                       m := hotline.MockClientMgr{}
+                                                       m.On("List").Return([]*hotline.ClientConn{})
+                                                       return &m
+                                               }(),
+                                               MessageBoard: func() *mockReadWriteSeeker {
+                                                       m := mockReadWriteSeeker{}
+                                                       m.On("Seek", int64(0), 0).Return(int64(0), nil)
+                                                       m.On("Read", mock.AnythingOfType("[]uint8")).Run(func(args mock.Arguments) {
+                                                               arg := args.Get(0).([]uint8)
+                                                               copy(arg, "TEST")
+                                                       }).Return(4, io.EOF)
+                                                       m.On("Write", mock.AnythingOfType("[]uint8")).Return(3, nil)
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranOldPostNews, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldData, []byte("hai")),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply: 0x01,
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleTranOldPostNews(tt.args.cc, &tt.args.t)
+
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleInviteNewChat(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "when user does not have required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       return bits
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(hotline.TranInviteNewChat, [2]byte{0, 1}),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to request private chat.")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "when userA invites userB to new private chat",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       ID: [2]byte{0, 1},
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessOpenChat)
+                                                       return bits
+                                               }(),
+                                       },
+                                       UserName: []byte("UserA"),
+                                       Icon:     []byte{0, 1},
+                                       Flags:    [2]byte{0, 0},
+                                       Server: &hotline.Server{
+                                               ClientMgr: func() *hotline.MockClientMgr {
+                                                       m := hotline.MockClientMgr{}
+                                                       m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{
+                                                               ID:       [2]byte{0, 2},
+                                                               UserName: []byte("UserB"),
+                                                       })
+                                                       return &m
+                                               }(),
+                                               ChatMgr: func() *hotline.MockChatManager {
+                                                       m := hotline.MockChatManager{}
+                                                       m.On("New", mock.AnythingOfType("*hotline.ClientConn")).Return(hotline.ChatID{0x52, 0xfd, 0xfc, 0x07})
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranInviteNewChat, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldUserID, []byte{0, 2}),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       ClientID: [2]byte{0, 2},
+                                       Type:     [2]byte{0, 0x71},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}),
+                                               hotline.NewField(hotline.FieldUserName, []byte("UserA")),
+                                               hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+                                       },
+                               },
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       IsReply:  0x01,
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}),
+                                               hotline.NewField(hotline.FieldUserName, []byte("UserA")),
+                                               hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+                                               hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}),
+                                               hotline.NewField(hotline.FieldUserFlags, []byte{0, 0}),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "when userA invites userB to new private chat, but UserB has refuse private chat enabled",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       ID: [2]byte{0, 1},
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessOpenChat)
+                                                       return bits
+                                               }(),
+                                       },
+                                       UserName: []byte("UserA"),
+                                       Icon:     []byte{0, 1},
+                                       Flags:    [2]byte{0, 0},
+                                       Server: &hotline.Server{
+                                               ClientMgr: func() *hotline.MockClientMgr {
+                                                       m := hotline.MockClientMgr{}
+                                                       m.On("Get", hotline.ClientID{0, 2}).Return(&hotline.ClientConn{
+                                                               ID:       [2]byte{0, 2},
+                                                               Icon:     []byte{0, 1},
+                                                               UserName: []byte("UserB"),
+                                                               Flags:    [2]byte{255, 255},
+                                                       })
+                                                       return &m
+                                               }(),
+                                               ChatMgr: func() *hotline.MockChatManager {
+                                                       m := hotline.MockChatManager{}
+                                                       m.On("New", mock.AnythingOfType("*hotline.ClientConn")).Return(hotline.ChatID{0x52, 0xfd, 0xfc, 0x07})
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranInviteNewChat, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldUserID, []byte{0, 2}),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       Type:     [2]byte{0, 0x68},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldData, []byte("UserB does not accept private chats.")),
+                                               hotline.NewField(hotline.FieldUserName, []byte("UserB")),
+                                               hotline.NewField(hotline.FieldUserID, []byte{0, 2}),
+                                               hotline.NewField(hotline.FieldOptions, []byte{0, 2}),
+                                       },
+                               },
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       IsReply:  0x01,
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}),
+                                               hotline.NewField(hotline.FieldUserName, []byte("UserA")),
+                                               hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+                                               hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}),
+                                               hotline.NewField(hotline.FieldUserFlags, []byte{0, 0}),
+                                       },
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+
+                       gotRes := HandleInviteNewChat(tt.args.cc, &tt.args.t)
+
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleGetNewsArtData(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "when user does not have required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{Account: &hotline.Account{}},
+                               t: hotline.NewTransaction(
+                                       hotline.TranGetNewsArtData, [2]byte{0, 1},
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to read news.")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "when user has required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessNewsReadArt)
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+                                               ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr {
+                                                       m := hotline.MockThreadNewsMgr{}
+                                                       m.On("GetArticle", []string{"Example Category"}, uint32(1)).Return(&hotline.NewsArtData{
+                                                               Title:         "title",
+                                                               Poster:        "poster",
+                                                               Date:          [8]byte{},
+                                                               PrevArt:       [4]byte{0, 0, 0, 1},
+                                                               NextArt:       [4]byte{0, 0, 0, 2},
+                                                               ParentArt:     [4]byte{0, 0, 0, 3},
+                                                               FirstChildArt: [4]byte{0, 0, 0, 4},
+                                                               DataFlav:      []byte("text/plain"),
+                                                               Data:          "article data",
+                                                       })
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranGetNewsArtData, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldNewsPath, []byte{
+                                               // Example Category
+                                               0x00, 0x01, 0x00, 0x00, 0x10, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x20, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79,
+                                       }),
+                                       hotline.NewField(hotline.FieldNewsArtID, []byte{0, 1}),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply: 1,
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldNewsArtTitle, []byte("title")),
+                                               hotline.NewField(hotline.FieldNewsArtPoster, []byte("poster")),
+                                               hotline.NewField(hotline.FieldNewsArtDate, []byte{0, 0, 0, 0, 0, 0, 0, 0}),
+                                               hotline.NewField(hotline.FieldNewsArtPrevArt, []byte{0, 0, 0, 1}),
+                                               hotline.NewField(hotline.FieldNewsArtNextArt, []byte{0, 0, 0, 2}),
+                                               hotline.NewField(hotline.FieldNewsArtParentArt, []byte{0, 0, 0, 3}),
+                                               hotline.NewField(hotline.FieldNewsArt1stChildArt, []byte{0, 0, 0, 4}),
+                                               hotline.NewField(hotline.FieldNewsArtDataFlav, []byte("text/plain")),
+                                               hotline.NewField(hotline.FieldNewsArtData, []byte("article data")),
+                                       },
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleGetNewsArtData(tt.args.cc, &tt.args.t)
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleGetNewsArtNameList(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "when user does not have required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+                                               //Accounts: map[string]*Account{},
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranGetNewsArtNameList, [2]byte{0, 1},
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       Flags:     0x00,
+                                       IsReply:   0x01,
+                                       Type:      [2]byte{0, 0},
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to read news.")),
+                                       },
+                               },
+                       },
+               },
+               //{
+               //      name: "when user has required access",
+               //      args: args{
+               //              cc: &hotline.ClientConn{
+               //                      Account: &hotline.Account{
+               //                              Access: func() hotline.AccessBitmap {
+               //                                      var bits hotline.AccessBitmap
+               //                                      bits.Set(hotline.AccessNewsReadArt)
+               //                                      return bits
+               //                              }(),
+               //                      },
+               //                      Server: &hotline.Server{
+               //                              ThreadedNewsMgr: func() *mockThreadNewsMgr {
+               //                                      m := mockThreadNewsMgr{}
+               //                                      m.On("ListArticles", []string{"Example Category"}).Return(NewsArtListData{
+               //                                              Name:        []byte("testTitle"),
+               //                                              NewsArtList: []byte{},
+               //                                      })
+               //                                      return &m
+               //                              }(),
+               //                      },
+               //              },
+               //              t: NewTransaction(
+               //                      TranGetNewsArtNameList,
+               //                      [2]byte{0, 1},
+               //                      //  00000000  00 01 00 00 10 45 78 61  6d 70 6c 65 20 43 61 74  |.....Example Cat|
+               //                      //  00000010  65 67 6f 72 79                                    |egory|
+               //                      NewField(hotline.FieldNewsPath, []byte{
+               //                              0x00, 0x01, 0x00, 0x00, 0x10, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x20, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79,
+               //                      }),
+               //              ),
+               //      },
+               //      wantRes: []hotline.Transaction{
+               //              {
+               //                      IsReply: 0x01,
+               //                      Fields: []hotline.Field{
+               //                              NewField(hotline.FieldNewsArtListData, []byte{
+               //                                      0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
+               //                                      0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
+               //                                      0x09, 0x74, 0x65, 0x73, 0x74, 0x54, 0x69, 0x74, 0x6c, 0x65, 0x0a, 0x74, 0x65, 0x73, 0x74, 0x50,
+               //                                      0x6f, 0x73, 0x74, 0x65, 0x72, 0x0a, 0x74, 0x65, 0x78, 0x74, 0x2f, 0x70, 0x6c, 0x61, 0x69, 0x6e,
+               //                                      0x00, 0x08,
+               //                              },
+               //                              ),
+               //                      },
+               //              },
+               //      },
+               //},
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleGetNewsArtNameList(tt.args.cc, &tt.args.t)
+
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleNewNewsFldr(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "when user does not have required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       return bits
+                                               }(),
+                                       },
+                                       Server: &hotline.Server{
+                                               //Accounts: map[string]*Account{},
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranGetNewsArtNameList, [2]byte{0, 1},
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       Flags:     0x00,
+                                       IsReply:   0x01,
+                                       Type:      [2]byte{0, 0},
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to create news folders.")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "with a valid request",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessNewsCreateFldr)
+                                                       return bits
+                                               }(),
+                                       },
+                                       Logger: NewTestLogger(),
+                                       ID:     [2]byte{0, 1},
+                                       Server: &hotline.Server{
+                                               ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr {
+                                                       m := hotline.MockThreadNewsMgr{}
+                                                       m.On("CreateGrouping", []string{"test"}, "testFolder", hotline.NewsBundle).Return(nil)
+                                                       return &m
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranGetNewsArtNameList, [2]byte{0, 1},
+                                       hotline.NewField(hotline.FieldFileName, []byte("testFolder")),
+                                       hotline.NewField(hotline.FieldNewsPath,
+                                               []byte{
+                                                       0, 1,
+                                                       0, 0,
+                                                       4,
+                                                       0x74, 0x65, 0x73, 0x74,
+                                               },
+                                       ),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       ClientID: [2]byte{0, 1},
+                                       IsReply:  0x01,
+                                       Fields:   []hotline.Field{},
+                               },
+                       },
+               },
+               //{
+               //      Name: "when there is an error writing the threaded news file",
+               //      args: args{
+               //              cc: &hotline.ClientConn{
+               //                      Account: &hotline.Account{
+               //                              Access: func() hotline.AccessBitmap {
+               //                                      var bits hotline.AccessBitmap
+               //                                      bits.Set(hotline.AccessNewsCreateFldr)
+               //                                      return bits
+               //                              }(),
+               //                      },
+               //                      logger: NewTestLogger(),
+               //                      Type:     [2]byte{0, 1},
+               //                      Server: &hotline.Server{
+               //                              ConfigDir: "/fakeConfigRoot",
+               //                              FS: func() *hotline.MockFileStore {
+               //                                      mfs := &MockFileStore{}
+               //                                      mfs.On("WriteFile", "/fakeConfigRoot/ThreadedNews.yaml", mock.Anything, mock.Anything).Return(os.ErrNotExist)
+               //                                      return mfs
+               //                              }(),
+               //                              ThreadedNews: &ThreadedNews{Categories: map[string]NewsCategoryListData15{
+               //                                      "test": {
+               //                                              Type:     []byte{0, 2},
+               //                                              Count:    nil,
+               //                                              NameSize: 0,
+               //                                              Name:     "test",
+               //                                              SubCats:  make(map[string]NewsCategoryListData15),
+               //                                      },
+               //                              }},
+               //                      },
+               //              },
+               //              t: NewTransaction(
+               //                      TranGetNewsArtNameList, [2]byte{0, 1},
+               //                      NewField(hotline.FieldFileName, []byte("testFolder")),
+               //                      NewField(hotline.FieldNewsPath,
+               //                              []byte{
+               //                                      0, 1,
+               //                                      0, 0,
+               //                                      4,
+               //                                      0x74, 0x65, 0x73, 0x74,
+               //                              },
+               //                      ),
+               //              ),
+               //      },
+               //      wantRes: []hotline.Transaction{
+               //              {
+               //                      ClientID:  [2]byte{0, 1},
+               //                      Flags:     0x00,
+               //                      IsReply:   0x01,
+               //                      Type:      [2]byte{0, 0},
+               //                      ErrorCode: [4]byte{0, 0, 0, 1},
+               //                      Fields: []hotline.Field{
+               //                              NewField(hotline.FieldError, []byte("Error creating news folder.")),
+               //                      },
+               //              },
+               //      },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleNewNewsFldr(tt.args.cc, &tt.args.t)
+
+                       TranAssertEqual(t, tt.wantRes, gotRes)
+               })
+       }
+}
+
+func TestHandleDownloadBanner(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               // TODO: Add test cases.
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotRes := HandleDownloadBanner(tt.args.cc, &tt.args.t)
+
+                       assert.Equalf(t, tt.wantRes, gotRes, "HandleDownloadBanner(%v, %v)", tt.args.cc, &tt.args.t)
+               })
+       }
+}
+
+func TestHandlePostNewsArt(t *testing.T) {
+       type args struct {
+               cc *hotline.ClientConn
+               t  hotline.Transaction
+       }
+       tests := []struct {
+               name    string
+               args    args
+               wantRes []hotline.Transaction
+       }{
+               {
+                       name: "without required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       return bits
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranPostNewsArt,
+                                       [2]byte{0, 0},
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 1},
+                                       Fields: []hotline.Field{
+                                               hotline.NewField(hotline.FieldError, []byte("You are not allowed to post news articles.")),
+                                       },
+                               },
+                       },
+               },
+               {
+                       name: "with required permission",
+                       args: args{
+                               cc: &hotline.ClientConn{
+                                       Server: &hotline.Server{
+                                               ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr {
+                                                       m := hotline.MockThreadNewsMgr{}
+                                                       m.On("PostArticle", []string{"www"}, uint32(0), mock.AnythingOfType("hotline.NewsArtData")).Return(nil)
+                                                       return &m
+                                               }(),
+                                       },
+                                       Account: &hotline.Account{
+                                               Access: func() hotline.AccessBitmap {
+                                                       var bits hotline.AccessBitmap
+                                                       bits.Set(hotline.AccessNewsPostArt)
+                                                       return bits
+                                               }(),
+                                       },
+                               },
+                               t: hotline.NewTransaction(
+                                       hotline.TranPostNewsArt,
+                                       [2]byte{0, 0},
+                                       hotline.NewField(hotline.FieldNewsPath, []byte{0x00, 0x01, 0x00, 0x00, 0x03, 0x77, 0x77, 0x77}),
+                                       hotline.NewField(hotline.FieldNewsArtID, []byte{0x00, 0x00, 0x00, 0x00}),
+                               ),
+                       },
+                       wantRes: []hotline.Transaction{
+                               {
+                                       IsReply:   0x01,
+                                       ErrorCode: [4]byte{0, 0, 0, 0},
+                                       Fields:    []hotline.Field{},
+                               },
+                       },
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       TranAssertEqual(t, tt.wantRes, HandlePostNewsArt(tt.args.cc, &tt.args.t))
+               })
+       }
+}