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